Session 2026-03-30: SOPS vault, SC-Syncro sync, Syncro scripts

- SOPS+age credential vault created (59 encrypted files, separate repo)
- Updated CLAUDE.md credential access to reference SOPS vault
- Updated memory for ACG-5070 (Windows 11, replaces CachyOS)
- SC-Syncro sync script: enriched 410 SC sessions with company/device data
- Syncro scripts: SC property updater, SC deployer, rogue SC killer
- Session log with full details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 19:38:38 -07:00
parent 505bc12355
commit e34f51fe5d
8 changed files with 1274 additions and 37 deletions

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env node
'use strict';
const https = require('https');
const { execSync } = require('child_process');
const { URL } = require('url');
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const FLAG_FORCE = args.includes('--force');
const FLAG_DRY_RUN = args.includes('--dry-run') || !FLAG_FORCE;
const FLAG_VERBOSE = args.includes('--verbose');
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node sync-sc-from-syncro.js [--dry-run] [--force] [--verbose]
Options:
--dry-run List what would be updated without making SC API calls (default)
--force Actually perform the ScreenConnect updates
--verbose Show detailed output for each asset
--help Show this help message
Without --force, the script runs in dry-run mode.`);
process.exit(0);
}
// ---------------------------------------------------------------------------
// Credential loading via vault
// ---------------------------------------------------------------------------
const VAULT_PATH = process.env.VAULT_PATH || 'D:/vault';
function vaultGet(file, field) {
try {
const result = execSync(
`bash "${VAULT_PATH}/scripts/vault.sh" get-field "${file}" "${field}"`,
{ encoding: 'utf8', timeout: 30000 }
);
return result.trim();
} catch (err) {
console.error(`[ERROR] Failed to read vault field "${field}" from "${file}": ${err.message}`);
process.exit(1);
}
}
let SYNCRO_API_KEY;
let SC_SECRET;
function loadCredentials() {
console.log('Loading credentials from vault...');
SYNCRO_API_KEY = vaultGet('msp-tools/syncro.sops.yaml', 'credentials.credential');
SC_SECRET = vaultGet('msp-tools/screenconnect.sops.yaml', 'credentials.api_secret');
if (!SYNCRO_API_KEY) {
console.error('[ERROR] Syncro API key is empty.');
process.exit(1);
}
if (!SC_SECRET) {
console.error('[ERROR] ScreenConnect API secret is empty.');
process.exit(1);
}
console.log('Credentials loaded successfully.');
}
// ---------------------------------------------------------------------------
// HTTP helpers (built-in https module)
// ---------------------------------------------------------------------------
/**
* Make an HTTPS request and return the parsed JSON response.
* @param {object} options
* @param {string} options.url - Full URL
* @param {string} [options.method='GET'] - HTTP method
* @param {object} [options.headers={}] - Request headers
* @param {string|null} [options.body=null] - Request body (string)
* @returns {Promise<{statusCode: number, data: any}>}
*/
function httpsRequest({ url, method = 'GET', headers = {}, body = null }) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const reqOptions = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method,
headers,
};
const req = https.request(reqOptions, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
let data;
try {
data = JSON.parse(raw);
} catch (_) {
data = raw;
}
resolve({ statusCode: res.statusCode, data });
});
});
req.on('error', (err) => reject(err));
req.setTimeout(30000, () => {
req.destroy(new Error('Request timed out'));
});
if (body !== null) {
const buf = Buffer.from(body, 'utf8');
req.setHeader('Content-Length', buf.length);
req.write(buf);
}
req.end();
});
}
/**
* Sleep for a given number of milliseconds.
* @param {number} ms
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ---------------------------------------------------------------------------
// Syncro API - paginate all assets
// ---------------------------------------------------------------------------
/**
* Fetch all customer assets from Syncro, paginating until no more results.
* @returns {Promise<Array<{scGuid: string, company: string, deviceName: string, customerId: number}>>}
*/
async function fetchAllSyncroAssets() {
const baseUrl = 'https://computerguru.syncromsp.com/api/v1/customer_assets';
const perPage = 100;
let page = 1;
const results = [];
let totalRaw = 0;
console.log('Fetching Syncro assets...');
while (true) {
const url = `${baseUrl}?page=${page}&per_page=${perPage}`;
if (FLAG_VERBOSE) {
console.log(` Fetching page ${page}...`);
}
let response;
try {
response = await httpsRequest({
url,
method: 'GET',
headers: {
'Authorization': SYNCRO_API_KEY,
'Accept': 'application/json',
},
});
} catch (err) {
console.error(`[ERROR] Syncro API request failed on page ${page}: ${err.message}`);
process.exit(1);
}
if (response.statusCode !== 200) {
console.error(`[ERROR] Syncro API returned status ${response.statusCode} on page ${page}.`);
if (typeof response.data === 'string') {
console.error(` Response: ${response.data.substring(0, 500)}`);
}
process.exit(1);
}
const assets = response.data.assets || response.data;
if (!Array.isArray(assets) || assets.length === 0) {
if (FLAG_VERBOSE) {
console.log(` Page ${page} returned 0 assets, done paginating.`);
}
break;
}
totalRaw += assets.length;
for (const asset of assets) {
const props = asset.properties || {};
const scGuid = props['ScreenConnect GUID'] || '';
if (!scGuid) {
continue;
}
const customer = asset.customer || {};
results.push({
scGuid,
company: customer.business_then_name || '',
deviceName: asset.name || 'Unknown',
customerId: customer.id || 0,
deviceType: (props.form_factor || '').replace(/^Physical /, ''),
});
}
if (FLAG_VERBOSE) {
console.log(` Page ${page}: ${assets.length} assets (${results.length} with SC GUID so far)`);
}
page++;
}
console.log(`Fetched ${totalRaw} total assets, ${results.length} have ScreenConnect GUIDs.`);
return { totalRaw, assets: results };
}
// ---------------------------------------------------------------------------
// ScreenConnect API - read session custom properties
// ---------------------------------------------------------------------------
/**
* Read current session custom properties from ScreenConnect to check tagging.
* Uses the same extension endpoint with GetSessionDetails or similar.
*
* Note: ScreenConnect does not have a clean REST API for reading session
* properties by GUID through the extension endpoint. We attempt to read
* session details. If reading fails or is unsupported, we return null
* and let the caller decide whether to update.
*
* @param {string} scGuid
* @returns {Promise<string[]|null>} Array of custom property values, or null if unreadable
*/
async function readScSessionProperties(scGuid) {
const url = 'https://computerguru.screenconnect.com/App_Extensions/2d558935-686a-4bd0-9991-07539f5fe749/Service.ashx/GetSessionDetails';
try {
const response = await httpsRequest({
url,
method: 'POST',
headers: {
'CTRLAuthHeader': SC_SECRET,
'Content-Type': 'application/json',
'Origin': 'https://computerguru.screenconnect.com',
},
body: JSON.stringify([scGuid]),
});
if (response.statusCode === 200 && response.data) {
// The response structure may vary; attempt to extract custom properties
const session = response.data;
if (Array.isArray(session.CustomPropertyValues)) {
return session.CustomPropertyValues;
}
if (Array.isArray(session)) {
const first = session[0];
if (first && Array.isArray(first.CustomPropertyValues)) {
return first.CustomPropertyValues;
}
}
}
} catch (_) {
// Reading failed - will attempt update anyway (unless --force is off)
}
return null;
}
// ---------------------------------------------------------------------------
// ScreenConnect API - update session custom properties
// ---------------------------------------------------------------------------
/**
* Update ScreenConnect session custom properties for a given GUID.
* @param {string} scGuid
* @param {string} company
* @param {string} site
* @returns {Promise<{success: boolean, error?: string}>}
*/
async function updateScSession(scGuid, company, deviceType) {
const url = 'https://computerguru.screenconnect.com/App_Extensions/2d558935-686a-4bd0-9991-07539f5fe749/Service.ashx/UpdateSessionCustomProperties';
// CP1=Company, CP2=Site (blank), CP3=Department (blank), CP4=Device Type, CP5=Tag, CP6-8=blank
const bodyPayload = [
scGuid,
[company, '', '', deviceType, 'Syncro-Matched', '', '', ''],
];
try {
const response = await httpsRequest({
url,
method: 'POST',
headers: {
'CTRLAuthHeader': SC_SECRET,
'Content-Type': 'application/json',
'Origin': 'https://computerguru.screenconnect.com',
},
body: JSON.stringify(bodyPayload),
});
if (response.statusCode >= 200 && response.statusCode < 300) {
return { success: true };
}
const detail = typeof response.data === 'string'
? response.data.substring(0, 200)
: JSON.stringify(response.data).substring(0, 200);
return { success: false, error: `HTTP ${response.statusCode}: ${detail}` };
} catch (err) {
return { success: false, error: err.message };
}
}
// ---------------------------------------------------------------------------
// Main processing
// ---------------------------------------------------------------------------
async function main() {
console.log('=== SC-Syncro Session Sync ===');
console.log(`Mode: ${FLAG_FORCE ? 'LIVE (--force)' : 'DRY-RUN'}`);
console.log('');
loadCredentials();
console.log('');
const { totalRaw, assets } = await fetchAllSyncroAssets();
console.log('');
const stats = {
totalSyncroAssets: totalRaw,
assetsWithScGuid: assets.length,
alreadyTagged: 0,
updated: 0,
errors: 0,
wouldUpdate: 0,
};
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
const shortGuid = asset.scGuid.length > 12
? asset.scGuid.substring(0, 12) + '...'
: asset.scGuid;
// Check if already tagged
const currentProps = await readScSessionProperties(asset.scGuid);
if (currentProps !== null && currentProps.length >= 5 && currentProps[4] === 'Syncro-Matched') {
stats.alreadyTagged++;
if (FLAG_VERBOSE) {
console.log(`[SKIP] ${asset.deviceName} (${shortGuid}) - already tagged`);
}
await sleep(100);
continue;
}
if (FLAG_DRY_RUN && !FLAG_FORCE) {
stats.wouldUpdate++;
if (FLAG_VERBOSE) {
console.log(`[DRY-RUN] ${asset.deviceName} (${shortGuid}) -> Company: "${asset.company}", Type: "${asset.deviceType}"`);
}
await sleep(100);
continue;
}
// Perform the update
const result = await updateScSession(asset.scGuid, asset.company, asset.deviceType);
if (result.success) {
stats.updated++;
if (FLAG_VERBOSE) {
console.log(`[UPDATE] ${asset.deviceName} (${shortGuid}) -> Company: "${asset.company}", Type: "${asset.deviceType}"`);
}
} else {
stats.errors++;
if (FLAG_VERBOSE) {
console.log(`[ERROR] ${asset.deviceName} (${shortGuid}): ${result.error}`);
}
}
// Rate limiting: 200ms between SC API calls
await sleep(200);
// Progress indicator for non-verbose mode
if (!FLAG_VERBOSE && (i + 1) % 50 === 0) {
console.log(` Progress: ${i + 1}/${assets.length} processed...`);
}
}
// Print summary
console.log('');
console.log('SC-Syncro Sync Results:');
console.log(` Total Syncro assets: ${stats.totalSyncroAssets}`);
console.log(` Assets with SC GUID: ${stats.assetsWithScGuid}`);
console.log(` Already tagged (skipped): ${stats.alreadyTagged}`);
if (FLAG_DRY_RUN && !FLAG_FORCE) {
console.log(` Would update: ${stats.wouldUpdate}`);
console.log('');
console.log('Run with --force to apply updates.');
} else {
console.log(` Updated: ${stats.updated}`);
console.log(` Errors: ${stats.errors}`);
}
}
main().catch((err) => {
console.error(`[FATAL] Unhandled error: ${err.message}`);
if (err.stack) {
console.error(err.stack);
}
process.exit(1);
});

View File

@@ -0,0 +1,60 @@
# Syncro Script: Install ScreenConnect with Company Properties
#
# Syncro Platform Variables (set in script editor):
# $MachineName - platform - {{asset_name}}
# $OrgName - platform - {{customer_business_name_or_customer_name}}
#
# Required File:
# ScreenConnect.ClientSetup.msi -> c:\screenconnectinstaller.msi
# (Download from SC Admin > Build Installer > Access > Windows MSI)
#
# File Type: PowerShell | Run as: System | Max Run Time: 10 minutes
Import-Module $env:SyncroModule
$InstallerLocation = 'C:\screenconnectinstaller.msi'
# URL-encode spaces in names for SERVICE_ARGUMENTS
$MachineName = $MachineName -replace '\s','%20'
$OrgName = $OrgName -replace '\s','%20'
# Detect device type from chassis
$DeviceType = "Desktop"
try {
$chassis = (Get-CimInstance -ClassName Win32_SystemEnclosure).ChassisTypes
if ($chassis | Where-Object { $_ -in @(8,9,10,11,12,14,18,21,31,32) }) { $DeviceType = "Laptop" }
if ($chassis | Where-Object { $_ -in @(17,23) }) { $DeviceType = "Server" }
$model = (Get-CimInstance -ClassName Win32_ComputerSystem).Model
if ($model -match "Virtual|VMware|VirtualBox|Hyper-V|KVM|Xen") { $DeviceType = "Virtual%20$DeviceType" }
} catch {}
# Check if already installed
$SCService = Get-Service -Name "ScreenConnect Client*" -ErrorAction SilentlyContinue
if ($SCService) {
Write-Host "ScreenConnect already installed: $($SCService.Name)"
Log-Activity -Message "SC Deploy: Already installed ($($SCService.Name))" -EventName "ScreenConnect"
exit 0
}
# Build SERVICE_ARGUMENTS with custom properties
# c=Company&c=Site&c=Department&c=DeviceType&c=Tag&c=&c=&c=
$ServiceArgs = "SERVICE_ARGUMENTS=""?e=Access&y=Guest&h=computerguru.screenconnect.com&p=443&k=BgIAAACkAABSU0ExAAgAAAEAAQCZmJAsjX2QoAvu/VF8SV7ggAxARlSj/TwgdqA8NcZgw9q+6G/FWwABU2WOeGPvRu6rA+sECP+u11d1BOp16iWA+KbkJPT93TIctreTy/BegdplEL5Bq0L3ZJcim++PLZjwYLDaIotdnOl+24JqkV75DxC1MV9dKNkz5DqS1+jNVMBvpOLY8UgPc9Io71pNIMo/rakJNlT4ofNeJiKIfuwRtgNNYKb51vSHGyFPYtHVNjDNYlJeu320yNJdN0zWwSQst/2GR3hAX8SnzJcZeROZ3HJuJc63uT0KS4ie4+4ExKaUimtfl8oAqIp4vBwiEXhm8T5RKhx9hLiJj/5shza8&c=$OrgName&c=&c=&c=$DeviceType&c=Syncro-Deploy&c=&c=&c="""
$installparams = '/i', $InstallerLocation, $ServiceArgs
$uninstallparams = '/uninstall', $InstallerLocation, '/qb'
# Install
Write-Host "Installing ScreenConnect for $OrgName ($DeviceType)..."
$exitCode = (Start-Process -FilePath msiexec.exe -ArgumentList $installparams -Wait -Passthru).ExitCode
if ($exitCode -eq 0 -or $exitCode -eq 3010) {
Write-Host "ScreenConnect installed successfully (exit code: $exitCode)"
Log-Activity -Message "SC Deploy: Installed for '$OrgName' ($DeviceType)" -EventName "ScreenConnect"
} else {
Write-Host "Installation failed with exit code: $exitCode"
Log-Activity -Message "SC Deploy FAILED: exit code $exitCode" -EventName "ScreenConnect"
exit 1
}
# Cleanup
Remove-Item -Path $InstallerLocation -Force -ErrorAction SilentlyContinue

View File

@@ -0,0 +1,439 @@
# Syncro Script: Find and Remove Rogue ScreenConnect Instances
#
# Detects ScreenConnect/ConnectWise Control services that do NOT connect
# to our instance (computerguru.screenconnect.com). Collects forensic
# evidence, creates a security ticket with attachments, then removes
# the unauthorized instance.
#
# Syncro Platform Variables (set in script editor):
# $OrgName - platform - {{customer_business_name_or_customer_name}}
#
# File Type: PowerShell | Run as: System | Max Run Time: 10 minutes
Import-Module $env:SyncroModule
$OurHost = "computerguru.screenconnect.com"
$Found = 0
$Killed = 0
$EvidenceDir = "$env:TEMP\RogueSC_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
$RogueInstances = @()
Write-Host "=== Rogue ScreenConnect Detection ==="
Write-Host "Legitimate host: $OurHost"
Write-Host ""
# ============================================================================
# Find all ScreenConnect services
# ============================================================================
$scServices = Get-Service -Name "ScreenConnect Client*" -ErrorAction SilentlyContinue
if (-not $scServices) {
Write-Host "No ScreenConnect services found."
exit 0
}
foreach ($svc in $scServices) {
$serviceName = $svc.Name
Write-Host "Found service: $serviceName (Status: $($svc.Status))"
# Get the ImagePath from registry to find the install location
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName"
$imagePath = (Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue).ImagePath
if (-not $imagePath) {
Write-Host " [WARNING] Could not read ImagePath - skipping"
continue
}
# Clean up the image path (remove quotes, get directory)
$exePath = ($imagePath -replace '"', '').Split(' ')[0]
$installDir = Split-Path -Parent $exePath
# Check the service launch parameters for the host
$connectedHost = "unknown"
# Method 1: Check registry ImagePath for host parameter
if ($imagePath -match 'h=([^&\s"]+)') {
$connectedHost = $Matches[1]
}
# Method 2: Check config/xml files in install directory
if ($connectedHost -eq "unknown" -and (Test-Path $installDir)) {
foreach ($pattern in @("*.xml", "*.config")) {
Get-ChildItem -Path $installDir -Filter $pattern -ErrorAction SilentlyContinue | ForEach-Object {
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
if ($content -match 'h=([^&\s<"]+)') {
$connectedHost = $Matches[1]
}
}
if ($connectedHost -ne "unknown") { break }
}
}
# Method 3: Check binary strings
if ($connectedHost -eq "unknown" -and (Test-Path $exePath)) {
try {
$content = [System.IO.File]::ReadAllText($exePath)
if ($content -match 'h=([a-zA-Z0-9\.\-]+\.screenconnect\.com)') {
$connectedHost = $Matches[1]
} elseif ($content -match 'h=([a-zA-Z0-9\.\-]+)') {
$connectedHost = $Matches[1]
}
} catch {}
}
Write-Host " Connected to: $connectedHost"
# Determine if this is ours
if ($connectedHost -like "*$OurHost*" -or $connectedHost -eq $OurHost) {
Write-Host " [OK] This is our instance - keeping."
continue
}
# ======================================================================
# ROGUE INSTANCE DETECTED - Collect forensic evidence before removal
# ======================================================================
$Found++
Write-Host " [ROGUE] Unauthorized ScreenConnect instance!"
Write-Host " Collecting forensic evidence..."
# Create evidence directory
if (-not (Test-Path $EvidenceDir)) {
New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null
}
$instanceDir = Join-Path $EvidenceDir "instance_$Found"
New-Item -ItemType Directory -Path $instanceDir -Force | Out-Null
# --- Evidence Collection ---
$evidence = [ordered]@{
Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" -AsUTC)
ComputerName = $env:COMPUTERNAME
ServiceName = $serviceName
ServiceStatus = $svc.Status.ToString()
ServiceStartType = $svc.StartType.ToString()
ConnectedHost = $connectedHost
ImagePath = $imagePath
InstallDirectory = $installDir
ExePath = $exePath
}
# Service account info
$svcWmi = Get-CimInstance Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue
if ($svcWmi) {
$evidence["ServiceAccount"] = $svcWmi.StartName
$evidence["ServicePID"] = $svcWmi.ProcessId
$evidence["ServicePath"] = $svcWmi.PathName
}
# File details
if (Test-Path $exePath) {
$fileInfo = Get-Item $exePath -ErrorAction SilentlyContinue
$evidence["ExeSize"] = "$([math]::Round($fileInfo.Length/1KB, 2)) KB"
$evidence["ExeCreated"] = $fileInfo.CreationTimeUtc.ToString("yyyy-MM-dd HH:mm:ss UTC")
$evidence["ExeModified"] = $fileInfo.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss UTC")
# Digital signature
$sig = Get-AuthenticodeSignature $exePath -ErrorAction SilentlyContinue
if ($sig) {
$evidence["ExeSignatureStatus"] = $sig.Status.ToString()
$evidence["ExeSignerSubject"] = $sig.SignerCertificate.Subject
$evidence["ExeSignerIssuer"] = $sig.SignerCertificate.Issuer
$evidence["ExeSignerThumbprint"] = $sig.SignerCertificate.Thumbprint
}
# File hash
$hash = Get-FileHash $exePath -Algorithm SHA256 -ErrorAction SilentlyContinue
if ($hash) {
$evidence["ExeSHA256"] = $hash.Hash
}
}
# Install date from registry
$uninstallPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
)
foreach ($uPath in $uninstallPaths) {
if (-not (Test-Path $uPath)) { continue }
Get-ChildItem $uPath -ErrorAction SilentlyContinue | ForEach-Object {
$props = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
if ($props.DisplayName -match "ScreenConnect|ConnectWise Control" -and
$props.UninstallString -notmatch [regex]::Escape($OurHost)) {
$evidence["InstallerDisplayName"] = $props.DisplayName
$evidence["InstallerVersion"] = $props.DisplayVersion
$evidence["InstallerPublisher"] = $props.Publisher
$evidence["InstallerInstallDate"] = $props.InstallDate
$evidence["InstallerProductCode"] = $_.PSChildName
$evidence["InstallerUninstallCmd"] = $props.UninstallString
}
}
}
# Save evidence summary
$evidenceTxt = ($evidence.GetEnumerator() | ForEach-Object { "$($_.Key): $($_.Value)" }) -join "`r`n"
$evidenceTxt | Out-File (Join-Path $instanceDir "evidence-summary.txt") -Encoding UTF8
# Copy install directory contents (configs, logs -- not the binary itself to save space)
if (Test-Path $installDir) {
$artifactDir = Join-Path $instanceDir "install_files"
New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null
Get-ChildItem -Path $installDir -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.Length -lt 5MB) {
Copy-Item $_.FullName -Destination $artifactDir -Force -ErrorAction SilentlyContinue
} else {
# For large files, just log metadata
"SKIPPED (too large): $($_.Name) - $([math]::Round($_.Length/1MB,2)) MB" |
Out-File (Join-Path $artifactDir "_skipped_files.txt") -Append -Encoding UTF8
}
}
}
# Export relevant Windows Event Logs
Write-Host " Collecting event logs..."
# Application log - SC install/service events
try {
$appEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Application'
StartTime = (Get-Date).AddDays(-30)
} -ErrorAction SilentlyContinue | Where-Object {
$_.Message -match "ScreenConnect|ConnectWise Control" -or
$_.ProviderName -match "MsiInstaller"
} | Select-Object TimeCreated, Id, ProviderName, LevelDisplayName, Message -First 100
if ($appEvents) {
$appEvents | Export-Csv (Join-Path $instanceDir "eventlog-application.csv") -NoTypeInformation -Encoding UTF8
}
} catch {}
# System log - service start/stop events
try {
$sysEvents = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Id = @(7034, 7035, 7036, 7040, 7045)
StartTime = (Get-Date).AddDays(-30)
} -ErrorAction SilentlyContinue | Where-Object {
$_.Message -match "ScreenConnect"
} | Select-Object TimeCreated, Id, ProviderName, LevelDisplayName, Message -First 100
if ($sysEvents) {
$sysEvents | Export-Csv (Join-Path $instanceDir "eventlog-system.csv") -NoTypeInformation -Encoding UTF8
}
} catch {}
# Security log - logon events around install time
if ($evidence["InstallerInstallDate"]) {
try {
$installDate = [DateTime]::ParseExact($evidence["InstallerInstallDate"], "yyyyMMdd", $null)
$secEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = @(4624, 4625, 4648, 4672)
StartTime = $installDate.AddHours(-2)
EndTime = $installDate.AddHours(2)
} -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, Message -First 200
if ($secEvents) {
$secEvents | Export-Csv (Join-Path $instanceDir "eventlog-security-around-install.csv") -NoTypeInformation -Encoding UTF8
}
} catch {}
}
# Active network connections at time of detection
try {
$netConns = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
Select-Object LocalAddress, LocalPort, RemoteAddress, RemotePort, OwningProcess,
@{N='ProcessName';E={(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName}}
$netConns | Export-Csv (Join-Path $instanceDir "active-connections.csv") -NoTypeInformation -Encoding UTF8
} catch {}
# Running processes at time of detection
try {
Get-Process | Select-Object Id, ProcessName, Path, StartTime, Company |
Export-Csv (Join-Path $instanceDir "running-processes.csv") -NoTypeInformation -Encoding UTF8
} catch {}
# Recent user logins
try {
$loginEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = @(4624)
StartTime = (Get-Date).AddDays(-7)
} -MaxEvents 50 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, @{N='LogonType';E={$_.Properties[8].Value}},
@{N='User';E={"$($_.Properties[6].Value)\$($_.Properties[5].Value)"}},
@{N='SourceIP';E={$_.Properties[18].Value}}
$loginEvents | Export-Csv (Join-Path $instanceDir "recent-logins.csv") -NoTypeInformation -Encoding UTF8
} catch {}
# Scheduled tasks (attackers sometimes add persistence)
try {
Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object {
$_.Actions.Execute -match "ScreenConnect|ConnectWise" -or
$_.TaskPath -match "ScreenConnect|ConnectWise"
} | Select-Object TaskName, TaskPath, State,
@{N='Action';E={$_.Actions.Execute}},
@{N='Arguments';E={$_.Actions.Arguments}} |
Export-Csv (Join-Path $instanceDir "scheduled-tasks.csv") -NoTypeInformation -Encoding UTF8
} catch {}
# Collect the full service registry export
try {
reg export "HKLM\SYSTEM\CurrentControlSet\Services\$serviceName" (Join-Path $instanceDir "service-registry.reg") /y 2>&1 | Out-Null
} catch {}
# Add to rogue list for ticket
$RogueInstances += $evidence
# ======================================================================
# REMOVE the rogue instance
# ======================================================================
Write-Host " Stopping and removing rogue instance..."
# Stop the service
try {
Stop-Service -Name $serviceName -Force -ErrorAction Stop
} catch {
Get-Process -Name "ScreenConnect.ClientService" -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
}
# Disable it
Set-Service -Name $serviceName -StartupType Disabled -ErrorAction SilentlyContinue
# Uninstall via MSI
$uninstalled = $false
foreach ($uPath in $uninstallPaths) {
if (-not (Test-Path $uPath)) { continue }
Get-ChildItem $uPath -ErrorAction SilentlyContinue | ForEach-Object {
$props = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
if ($props.DisplayName -match "ScreenConnect|ConnectWise Control" -and
$props.UninstallString -and
$props.UninstallString -notmatch [regex]::Escape($OurHost)) {
if ($props.UninstallString -match "\{[0-9A-Fa-f\-]+\}") {
$productCode = $Matches[0]
Start-Process msiexec.exe -ArgumentList "/x $productCode /qn /norestart" -Wait | Out-Null
$uninstalled = $true
}
}
}
}
# Fallback: nuke install directory
if (-not $uninstalled -and (Test-Path $installDir)) {
Remove-Item -Path $installDir -Recurse -Force -ErrorAction SilentlyContinue
}
# Remove service registration
sc.exe delete $serviceName 2>&1 | Out-Null
$Killed++
Write-Host " [REMOVED] Rogue instance cleaned up."
Write-Host ""
}
# ============================================================================
# Package evidence and create ticket if rogue instances found
# ============================================================================
if ($Found -gt 0) {
Write-Host "=== Packaging Evidence ==="
# Build ticket body
$ticketBody = @"
## SECURITY INCIDENT: Rogue ScreenConnect Detected
**Computer:** $env:COMPUTERNAME
**Detection Time:** $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
**Customer:** $OrgName
**Instances Found:** $Found
**Instances Removed:** $Killed
### Rogue Instance Details
"@
foreach ($inst in $RogueInstances) {
$ticketBody += @"
---
**Service:** $($inst.ServiceName)
**Connected To:** $($inst.ConnectedHost)
**Install Directory:** $($inst.InstallDirectory)
**Exe Created:** $($inst.ExeCreated)
**Exe SHA256:** $($inst.ExeSHA256)
**Signature:** $($inst.ExeSignatureStatus) ($($inst.ExeSignerSubject))
**Installer Date:** $($inst.InstallerInstallDate)
**Installer Product:** $($inst.InstallerDisplayName) v$($inst.InstallerVersion)
**Service Account:** $($inst.ServiceAccount)
"@
}
$ticketBody += @"
### Actions Taken
- Forensic evidence collected (event logs, network connections, processes, install files)
- Service stopped and disabled
- Software uninstalled / install directory removed
- Service registration deleted
- Evidence package attached to this ticket
### Recommended Follow-Up
1. Review the attached evidence package for indicators of compromise
2. Check the security event logs around the install date for unauthorized access
3. Verify no other persistence mechanisms remain (scheduled tasks, startup items)
4. Consider password resets for accounts on this machine
5. Check other machines at this customer for similar rogue instances
6. Determine if this was an authorized install by another vendor or an actual breach
"@
# Create zip of evidence
$zipPath = "$EvidenceDir.zip"
try {
Compress-Archive -Path "$EvidenceDir\*" -DestinationPath $zipPath -Force
Write-Host "Evidence packaged: $zipPath"
} catch {
Write-Host "[WARNING] Could not create zip: $_"
}
# Create Syncro ticket
Write-Host "Creating security ticket..."
$ticketResult = Create-Syncro-Ticket -Subject "SECURITY: Rogue ScreenConnect on $env:COMPUTERNAME" -IssueType "Security" -Status "New" -Body $ticketBody
# Upload evidence zip to the ticket
if ($ticketResult -and (Test-Path $zipPath)) {
try {
Upload-File -FilePath $zipPath -TicketIdOrNumber $ticketResult.ticket.id
Write-Host "Evidence attached to ticket."
} catch {
Write-Host "[WARNING] Could not attach evidence to ticket: $_"
# Fallback: upload to asset files
try {
Upload-File -FilePath $zipPath
Write-Host "Evidence uploaded to asset files instead."
} catch {
Write-Host "[WARNING] Could not upload evidence at all. Local copy at: $zipPath"
}
}
}
# RMM Alert
Rmm-Alert -Category "Security" -Body "ROGUE SCREENCONNECT on $env:COMPUTERNAME - $Found instance(s) connecting to: $(($RogueInstances | ForEach-Object { $_.ConnectedHost }) -join ', '). Evidence collected. Ticket created. Instances removed."
# Activity log
Log-Activity -Message "SECURITY: Removed $Killed rogue ScreenConnect instance(s). Ticket created with forensic evidence." -EventName "Security"
Write-Host ""
Write-Host "[ALERT] Security ticket created with full evidence package."
# Cleanup local evidence (zip is uploaded, raw files no longer needed)
Remove-Item -Path $EvidenceDir -Recurse -Force -ErrorAction SilentlyContinue
} else {
Write-Host ""
Write-Host "=== Summary ==="
Write-Host "Total SC services found: $($scServices.Count)"
Write-Host "All clear - no rogue instances."
}

View File

@@ -0,0 +1,94 @@
# Syncro Script: Update ScreenConnect Session Properties via RESTful API
#
# Runs on each machine to self-report its company/device info to ScreenConnect.
# Designed to run on a schedule (e.g., daily) alongside other maintenance scripts.
#
# Syncro Platform Variables (set in script editor):
# $OrgName - platform - {{customer_business_name_or_customer_name}}
#
# File Type: PowerShell | Run as: System | Max Run Time: 5 minutes
Import-Module $env:SyncroModule
# ============================================================================
# Configuration
# ============================================================================
$SCBaseUrl = "https://computerguru.screenconnect.com"
$SCExtGuid = "2d558935-686a-4bd0-9991-07539f5fe749"
$SCAuthSecret = "FTnl15dK1uaKCOeFzkO1UnjGqpgtqCA5vRExWeXT38LjAV4vF9W/mYf8GpCyqlAv"
# ============================================================================
# Check if ScreenConnect is installed
# ============================================================================
$SCService = Get-Service -Name "ScreenConnect Client*" -ErrorAction SilentlyContinue
if (-not $SCService) {
Write-Host "ScreenConnect not installed - skipping."
exit 0
}
# ============================================================================
# Extract session GUID from service name
# ============================================================================
# Service name format: "ScreenConnect Client (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)"
$guidMatch = $SCService.Name | Select-String -Pattern '\(([0-9a-f\-]{36})\)' -AllMatches
if (-not $guidMatch -or -not $guidMatch.Matches) {
Write-Host "Could not extract session GUID from service: $($SCService.Name)"
exit 1
}
$SessionGuid = $guidMatch.Matches[0].Groups[1].Value
Write-Host "SC Session GUID: $SessionGuid"
# ============================================================================
# Determine device type
# ============================================================================
$DeviceType = "Desktop"
try {
$chassis = (Get-CimInstance -ClassName Win32_SystemEnclosure).ChassisTypes
if ($chassis | Where-Object { $_ -in @(8,9,10,11,12,14,18,21,31,32) }) { $DeviceType = "Laptop" }
if ($chassis | Where-Object { $_ -in @(17,23) }) { $DeviceType = "Server" }
$model = (Get-CimInstance -ClassName Win32_ComputerSystem).Model
if ($model -match "Virtual|VMware|VirtualBox|Hyper-V|KVM|Xen") { $DeviceType = "Virtual $DeviceType" }
} catch {
Write-Host "Could not detect device type, defaulting to Desktop"
}
# ============================================================================
# Build and send API request
# ============================================================================
$Company = if ([string]::IsNullOrWhiteSpace($OrgName)) { "Unassigned" } else { $OrgName }
Write-Host "Updating SC: Company='$Company', DeviceType='$DeviceType', Tag='Syncro-Matched'"
# CP1=Company, CP2=Site, CP3=Department, CP4=DeviceType, CP5=Tag, CP6-8=blank
$body = ConvertTo-Json @(
$SessionGuid,
@($Company, "", "", $DeviceType, "Syncro-Matched", "", "", "")
) -Compress
$url = "$SCBaseUrl/App_Extensions/$SCExtGuid/Service.ashx/UpdateSessionCustomProperties"
try {
$response = Invoke-WebRequest -Uri $url -Method POST -Body $body -ContentType "application/json" -Headers @{
"CTRLAuthHeader" = $SCAuthSecret
"Origin" = $SCBaseUrl
} -UseBasicParsing -ErrorAction Stop
if ($response.StatusCode -eq 200) {
Write-Host "Success - SC session updated."
Log-Activity -Message "SC Properties: $Company / $DeviceType" -EventName "ScreenConnect"
} else {
Write-Host "Unexpected status: $($response.StatusCode)"
Write-Host $response.Content
}
} catch {
$err = $_.Exception.Message
Write-Host "API call failed: $err"
Log-Activity -Message "SC Properties FAILED: $err" -EventName "ScreenConnect"
exit 1
}