<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>CtrlAltUpgrade: Timothy Alexander</title>
    <link>https://www.ctrlaltupgrade.com/blog/</link>
    <atom:link href="https://www.ctrlaltupgrade.com/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <description>K-12 IT, automation, security and home-lab write-ups by Timothy Alexander.</description>
    <language>en-us</language>
    <lastBuildDate>Wed, 11 Nov 2026 12:00:00 -0500</lastBuildDate>
    <item>
      <title>A phishing triage score that explains itself</title>
      <link>https://www.ctrlaltupgrade.com/blog/phishing-triage-score/</link>
      <guid isPermaLink="true">https://www.ctrlaltupgrade.com/blog/phishing-triage-score/</guid>
      <pubDate>Wed, 11 Nov 2026 12:00:00 -0500</pubDate>
      <category>Security</category>
      <description>How I score user-reported phishing emails in n8n with plain-language rules instead of AI, so technicians see the reasons behind every score.</description>
      <content:encoded><![CDATA[<p>Every school district has a "Report phishing" button, and every school district has the same problem with it: once people actually use it, someone has to look at every report. Most are marketing mail or a vendor newsletter. A few are real. The real ones need attention fast, and they're easy to miss in a pile of the others.</p>
<p>This fall I rebuilt how our reports get handled. The goal wasn't to let a machine decide what's phishing. It was to put each report in front of a technician with a score and, more importantly, the reasons behind the score, so they can make the call in seconds instead of digging through headers.</p>
<h2>What you need</h2>

<ul> 
<li>A self-hosted n8n instance (the community edition is fine)</li> 
<li>A shared mailbox where reported messages land, readable through Microsoft Graph or Gmail</li> 
<li>Somewhere to post results for your techs (we use a Google Chat space)</li> 
<li>A list of the domains and vendors your organization really uses</li> </ul>
<h2>Why rules instead of AI</h2>
<p>I considered handing each message to a language model and asking "is this phishing?" I didn't, for two reasons. First, the reported messages contain staff email and sometimes student information, and I didn't want that leaving our systems for a yes/no answer. Second, a model's answer is hard to argue with. When a tech disagrees with "73% likely phishing", there's nothing to fix.</p>
<p>A rule-based score is boring, and that's the point. Every point comes with a sentence a person can read, and when the score is wrong, I can see exactly which rule caused it and tune it.</p>
<h2>How the score works</h2>
<p>A small Code node in n8n runs a list of checks against the reported message. Each check that fires adds (or subtracts) points and records why. The total is clamped to 0–100 and mapped to a band:</p>

<pre tabindex="0"><code>const factors = [];
const add = (points, reason) =&gt; factors.push({ points, reason });

if (spoofsOurDomain)   add(35, 'Claims to be from our domain, but Microsoft says we did not send it');
if (lookAlikeDomain)   add(25, 'Uses a look-alike of our name: ' + lookAlike);
if (replyToFreemail)   add(20, 'Replies go to a personal email account');
if (riskyAttachment)   add(30, 'Has a risky attachment type (' + ext + ')');
if (usesShortener)     add(15, 'Uses a link shortener that hides the real destination');
if (fromInside)        add(-10, 'Sent from inside the organization');

const score = Math.max(0, Math.min(100, factors.reduce((s, f) =&gt; s + f.points, 0)));
const band = score &gt;= 60 ? 'Likely phishing'
           : score &gt;= 35 ? 'Suspicious, look closer'
           : score &gt;= 15 ? 'Low risk, a few flags'
           : 'Probably fine';</code></pre>
 <p>The full rubric, sanitized with a CONFIG block for your own domain and names, is on GitHub: <a href="https://github.com/taalexander0614/CtrlAltUpgrade/tree/main/n8n/Phishing%20Triage%20Score" rel="noopener">Phishing Triage Score</a>.</p>
<p>The checks fall into a few groups:</p>

<ul> 
<li><strong>Sender authentication.</strong> I only trust the verdicts Microsoft itself stamped on the message (its own Authentication-Results header), not whatever an attacker might have added further down. Spoofing our own domain is the heaviest single signal.</li> 
<li><strong>Look-alike names.</strong> Domains are decoded from punycode, common look-alike characters (Cyrillic "а" for Latin "a", "rn" for "m") are folded, and anything within a couple of edits of our name scores high. Other organizations that legitimately share part of our name score lower.</li> 
<li><strong>Where replies and links go.</strong> Reply-to a personal Gmail, links to free hosting or form builders, raw IP addresses, and link shorteners all add points.</li> 
<li><strong>Lures.</strong> Mentions of leadership from an outside address, "document" PDFs from strangers, messages with almost no readable text.</li> 
<li><strong>History.</strong> If mail from the same sender was reported before, the earlier verdict nudges the score up or down.</li> </ul>
<h2>What the tech sees</h2>
<p>The result posts as a card: the band, the score, the top three reasons in one line, and the full list below. A typical card reads something like "Suspicious, look closer · Replies go to a personal email account · Links go to free website hosting · Mentions leadership but isn't from a district address." From the card, a tech marks it Malicious, Legitimate or Needs Investigation. Malicious reports can kick off a follow-up workflow that finds and pulls the same campaign from other mailboxes.</p>
<p>We're also piloting an email back to the person who reported it, with the result and the reasons. My bet is that closing that loop matters as much as the score: people keep reporting when they hear back, and the reasons teach them what to look for.</p>
<h2>Lessons so far</h2>

<ul> 
<li><strong>Use the real address, not the display name.</strong> My first version read the sender from the display name, which an attacker controls completely. "Helpdesk" can be anyone.</li> 
<li><strong>Don't double count.</strong> "Outside sender" plus "outside link" plus "outside reply-to" can stack into a scary score for an ordinary newsletter. Signals that describe the same thing should share points.</li> 
<li><strong>Keep the lists editable.</strong> Trusted vendors, risky top-level domains and file-sharing senders are plain arrays at the top of the script, so tuning doesn't mean touching logic.</li> 
<li><strong>Run new pieces in shadow or test mode first.</strong> New workflows run quietly, or send only to me, until their output has been checked against what a person would have decided.</li> </ul>
<p>It's still being tuned, and it always will be. But the techs now start from a short, readable list of reasons instead of a raw message, which is the whole point.</p>]]></content:encoded>
    </item>
    <item>
      <title>Letting Home Assistant fill in my mileage form</title>
      <link>https://www.ctrlaltupgrade.com/blog/home-assistant-mileage-log/</link>
      <guid isPermaLink="true">https://www.ctrlaltupgrade.com/blog/home-assistant-mileage-log/</guid>
      <pubDate>Mon, 09 Nov 2026 12:00:00 -0500</pubDate>
      <category>Home Assistant</category>
      <description>Connecting Home Assistant to a Google Sheets copy of a district expense form so work trips log themselves at the official chart distances.</description>
      <content:encoded><![CDATA[<p>I drive between school sites most days, and the district reimburses mileage using an official chart of distances between sites. For years I logged trips in a paid mileage app, then retyped them into the district's expense form at the end of the month. Both halves of that were tedious, and the app's GPS miles never matched the chart anyway.</p>
<p>My car already reports its location to Home Assistant, and the form is just a spreadsheet. So I'm connecting them. The spreadsheet side and the link from Home Assistant are working; the last piece, the zone automation that spots each trip on its own, is what I'm finishing now.</p>
<h2>What you need</h2>

<ul> 
<li>Home Assistant, with a way to see where your car or phone is (I use the car maker's integration; the Companion app on your phone works too)</li> 
<li>A Google Sheet copy of your expense form, with your organization's mileage chart on its own tab</li> 
<li>Google Apps Script, published as a small web app, so Home Assistant has somewhere to send trips</li> </ul>
<h2>The rules come first</h2>
<p>Before writing any automation, I wrote down the rules in plain language, because that's where all the edge cases live:</p>

<ul> 
<li>A claimable leg is one work site to the next work site. Stopping at home or for lunch in between doesn't break the leg, and detours aren't claimed.</li> 
<li>Driving from home to my regular office, and from my last stop back home, is commuting and isn't claimed.</li> 
<li>If I drive from home straight to a different site, I claim it, using the shorter of home-to-site or office-to-site, which is what the form's policy says.</li> 
<li>Miles always come from the official chart, not from GPS.</li> 
<li>Anything odd gets marked <em>Pending</em> for me to approve: a work stop that isn't a school (a hardware store run), or a trip outside normal hours (evening games where I'm running audio and video).</li> </ul>
<h2>Home Assistant: zones and one REST call</h2>
<p>The plan for Home Assistant is simple: each school is a zone, and an automation watches the car arrive at a zone and remembers the last work site it left. When it arrives at a different site, it sends the leg to the spreadsheet. The sending part is already in place, a single <code>rest_command</code>:</p>

<pre tabindex="0"><code># configuration.yaml
rest_command:
  mileage_log_trip:
    url: !secret mileage_url
    method: POST
    content_type: "application/json"
    payload: !secret mileage_payload</code></pre>
 <p>The web app's address and its shared token live in <code>secrets.yaml</code>, not in the automation, so the config can be shared or backed up without leaking them.</p>
<h2>Google Sheets: the form does the math</h2>
<p>The Apps Script web app checks the token, looks up the two sites on the mileage chart tab, and appends a row to a Trip Log tab: date, description, chart miles, status, and where the trip came from. The expense form tab never gets typed into directly. Its trip lines are formulas that pull <em>approved</em> trips for the pay period from the log, so the totals, rates and meal allowances calculate exactly as they did on the paper form.</p>
<p>That split matters. The log is messy and editable: I can fix a description, mark a pending trip approved, or add a meal. The form stays clean and always matches the official layout.</p>
<h2>Closing out the month</h2>
<p>At the end of the month, one menu item in the sheet does the paperwork:</p>

<ol> 
<li>Makes a copy of the form and freezes the formulas into plain values, so it can't change later.</li> 
<li>Fills in the date and adds my signature image.</li> 
<li>Saves a PDF to a Drive folder and emails it to me.</li> 
<li>Includes a one-time link that sends it on to my supervisor once I've checked it.</li> </ol>
<p>There's also a "preview" option that only emails me, which I used a lot while getting the layout right.</p>
<h2>What I'd tell you before you build one</h2>

<ul> 
<li><strong>Write the rules down first.</strong> Every bug I hit was a rule I hadn't stated, not a code problem.</li> 
<li><strong>Keep a human in the loop for the odd cases.</strong> Pending-for-approval is what makes it trustworthy enough to sign.</li> 
<li><strong>Never type into the official form.</strong> Feed it from a log with formulas, and it will always match the original.</li> 
<li><strong>Back up the sheet.</strong> It's now the record of money I'm owed.</li> </ul>
<p>Once the zones are in, the miles will match the chart without retyping, and month-end becomes one menu click and a quick check.</p>]]></content:encoded>
    </item>
    <item>
      <title>One Ansible playbook to baseline every Linux server</title>
      <link>https://www.ctrlaltupgrade.com/blog/linux-server-baseline-ansible/</link>
      <guid isPermaLink="true">https://www.ctrlaltupgrade.com/blog/linux-server-baseline-ansible/</guid>
      <pubDate>Sat, 07 Nov 2026 12:00:00 -0500</pubDate>
      <category>Ansible</category>
      <description>A re-runnable Ansible baseline, run from Semaphore, that sets up certificates, SSH, time, updates and monitoring agents on every Ubuntu server and doubles as a drift check.</description>
      <content:encoded><![CDATA[<p>For a long time, setting up a new Linux server in our district meant following a checklist from memory: add the admin accounts, lock down SSH, point time at our internal servers, trust our certificate authority, turn on security updates, install the monitoring agents. Each server came out a little different, and nobody could say for sure what "set up" meant.</p>
<p>Now it's one Ansible playbook run from Semaphore. It's safe to run again at any time, and running it again is how we check for drift.</p>
<h2>What you need</h2>

<ul> 
<li>Ansible, and ideally a UI to run it from (we use <a href="https://semaphoreui.com" rel="noopener">Semaphore</a>, self-hosted in Docker)</li> 
<li>An inventory of your servers, grouped however makes sense for you</li> 
<li>Secrets (API tokens, enrollment keys) stored in your runner, never in the repo</li> </ul>
<h2>One playbook made of small ones</h2>
<p>The baseline itself has no tasks. It just imports a series of focused playbooks, each of which can also be run on its own:</p>

<pre tabindex="0"><code>---
# Universal Ubuntu server baseline. Safe to re-run; also used for drift checks.
- ansible.builtin.import_playbook: configure-certificates.yml
- ansible.builtin.import_playbook: configure-ssh.yml
- ansible.builtin.import_playbook: configure-time.yml
- ansible.builtin.import_playbook: configure-updates.yml
- ansible.builtin.import_playbook: provision-monitoring-agent.yml
- ansible.builtin.import_playbook: provision-security-agent.yml</code></pre>
 <p>Splitting it up this way means each piece can be fixed or re-run on its own. If a server only needs its certificates refreshed or its time settings corrected, that's one small job, not the whole baseline.</p>
<h2>What each piece does</h2>

<ul> 
<li><strong>Certificates.</strong> Installs our internal root CA into the system trust store, then verifies against it before moving on, so internal HTTPS services work from the server.</li> 
<li><strong>SSH.</strong> Key-based access only. The new settings go in a drop-in file, the whole SSH configuration is validated with <code>sshd -t</code>, and only then is SSH reloaded. A typo can't lock you out.</li> 
<li><strong>Time.</strong> Sets the time zone, installs chrony, points it at our internal time servers, and then waits until the clock actually syncs instead of assuming it will.</li> 
<li><strong>Updates.</strong> Turns on unattended security upgrades, but never reboots on its own. Reboots happen when we choose.</li> 
<li><strong>Monitoring and security agents.</strong> Installs and enrolls the Zabbix and Wazuh agents (and our endpoint protection), using keys that come from Semaphore's secret store at run time.</li> </ul>
<h2>Patterns worth stealing</h2>
<p><strong>Validate before you apply.</strong> Anything that could break access gets tested first. SSH config goes through <code>sshd -t</code>; Zabbix config goes through <code>zabbix_agent2 -T</code>. If validation fails, the play stops before anything changes.</p>
<p><strong>Assert what you expect.</strong> The monitoring maintenance playbook checks that each server's agent config files exist and aren't empty before it touches anything, and fails with a message that says exactly which step to run first:</p>

<pre tabindex="0"><code>- name: Require existing configuration and enrollment
  ansible.builtin.assert:
    that:
      - item.stat.exists
      - (item.stat.size | default(0)) &gt; 0
    fail_msg: "Missing or empty {{ item.item }}. Run the install/enrollment playbook first."
  loop: "{{ monitoring_files.results }}"</code></pre>
 <p><strong>Pin and hold versions.</strong> Agents are installed at an approved version and held with <code>dpkg_selections</code>, so a routine <code>apt upgrade</code> can't move an agent ahead of its server.</p>
<p><strong>Go one at a time when it matters.</strong> Playbooks that restart services use <code>serial: 1</code>. If something goes wrong, it goes wrong on one server.</p>
<h2>Running it</h2>
<p>In Semaphore, the baseline is a template with an inventory and a variable group holding the secrets. A brand-new server gets one earlier job that sets up administrator access; after that, it's "run the baseline" and wait for green. Because every step is safe to repeat, re-running it doubles as a drift check: anything that reports <em>changed</em> on a server that was already baselined is worth a look.</p>
<p>I've put a sanitized copy of the playbooks, with organization-specific values moved into an example variables file, on GitHub: <a href="https://github.com/taalexander0614/CtrlAltUpgrade/tree/main/Ansible/Linux%20Server%20Baseline" rel="noopener">Linux Server Baseline</a>.</p>
<p>The biggest win isn't speed, though that's nice. It's that "a properly set up server" now has a definition, and the definition lives in git where anyone on the team can read it.</p>]]></content:encoded>
    </item>
    <item>
      <title>Using Microsoft Graph to integrate Autopilot into SCCM task sequences</title>
      <link>https://www.ctrlaltupgrade.com/blog/autopilot-in-sccm-task-sequences/</link>
      <guid isPermaLink="true">https://www.ctrlaltupgrade.com/blog/autopilot-in-sccm-task-sequences/</guid>
      <pubDate>Tue, 30 May 2023 12:00:00 -0500</pubDate>
      <category>ConfigMgr</category>
      <description>How I used PowerShell and Microsoft Graph to register devices with Windows Autopilot during an SCCM task sequence in a mixed SCCM and Intune school district.</description>
      <content:encoded><![CDATA[<p>A lot of IT work in a school district is balancing the old with the new: keeping legacy systems running while bringing in modern ones. This post is about a PowerShell script I used to bring Windows Autopilot into our imaging process, in an environment where some devices were managed by SCCM and others by Intune.</p>
<h2>What you need</h2>

<ul> 
<li>SCCM (Configuration Manager) task sequences for imaging</li> 
<li>Devices registered in Windows Autopilot</li> 
<li>An Entra ID (Azure AD) app registration with Intune read/write permissions, so the script can update devices and trigger a sync</li> </ul>
<h2>The old way: fat images and provisioning packages</h2>
<p>For years we used "fat" images, with every application preinstalled. When Intune first came in, for high school student laptops during the Covid lockdowns, technicians applied provisioning packages by hand on top of that fat image. They had to pick the right task sequence too: the standard one, or an Intune one that skipped the ConfigMgr client and rebooted to the out-of-box experience (OOBE).</p>
<p>The first two things I changed were dropping the fat image, so software installed during the task sequence, and merging the two task sequences, so the variables chosen in the UI++ step decided whether a device was set up for Intune and got the provisioning package before rebooting.</p>
<p>Some of that had to be walked back. Intune devices weren't ready when they were handed to people, because software was still installing after the first sign-in, and imaging took longer because software installed during the task sequence. I kept the merged task sequence but went back to the fat image, so imaging stayed fast and Intune devices had the basics installed before anyone signed in.</p>
<h2>The new way: Autopilot and clean images</h2>
<p>Later we moved all staff laptops, and student laptops at more schools, to Intune, and I wanted to make real use of Autopilot. My first idea was to run the Autopilot hash upload script from a flash drive at OOBE on new devices. I wrote a Windows Forms front end so techs could set the location and asset number for the device name, then upload the hash, wait about six minutes for the profile to be assigned, and reboot. It worked, but it added too many steps for the techs.</p>
<p>So it had to happen during imaging. The catch is that you can't upload a device hash from Windows PE, because it doesn't have the WMI namespaces the hash upload needs.</p>
<p>There was a second catch. We have a wide mix of models, some of them consumer devices, and having TPM 2.0 doesn't guarantee a device supports attestation for Autopilot self-deploying mode. (Our HP 255 G7 laptops were the main problem there.) I tested every student laptop model, and since they all worked, I added a task sequence step that sets a <code>TPMSupport</code> variable to true for those models. I've added other models as I've tested them. Devices that can't self-deploy still get the fat image, but we're moving in the right direction.</p>
<h2>What the script decides</h2>
<p>The script runs inside the task sequence and does two things:</p>

<ol> 
<li>Checks whether the device is already registered in our tenant, which means it has an Autopilot profile.</li> 
<li>Based on that, the task sequence either lays down a clean image and boots to OOBE for the enrollment status page, or applies the fat image and uses a provisioning package to join Azure AD with a bulk enrollment token.</li> </ol>
<p><a href="https://github.com/taalexander0614/CtrlAltUpgrade/blob/139fa01/SCCM/Task%20Sequences/CheckAutoPilotAndRename.ps1" rel="noopener">See the original script on GitHub</a></p>
<h2>How the script works</h2>

<ul> 
<li><strong>Graph app credentials.</strong> The script starts with the client ID, client secret and tenant ID for an Entra ID app with Intune read/write permissions. Replace the placeholders with your own, and keep the real values out of source control.</li> 
<li><strong>Environment setup.</strong> It sets <code>LOCALAPPDATA</code>, which isn't defined when running as SYSTEM, and works from the system's temp folder.</li> 
<li><strong>Modules.</strong> It makes sure PowerShellGet and PackageManagement are present, installs them if not, then installs and imports the Autopilot and Intune modules it needs.</li> 
<li><strong>Task sequence variables.</strong> It opens the task sequence environment through its COM object and reads values like <code>TPMSupport</code>, the computer name and the Autopilot profile.</li> 
<li><strong>Serial number.</strong> It reads the device's serial number through WMI. If that fails, it sets the <code>ProfileAssigned</code> task sequence variable to <code>False</code> and stops.</li> 
<li><strong>Connect to Microsoft Graph</strong> with the app credentials. On failure, <code>ProfileAssigned</code> is set to <code>False</code>.</li> 
<li><strong>Find and update the device.</strong> It looks the device up in Autopilot by serial number, updates its details, and starts an Autopilot sync. If the device isn't found or the update fails, <code>ProfileAssigned</code> is set to <code>False</code>.</li> </ul>
<p>The rest of the task sequence can then use <code>ProfileAssigned</code> to choose between the clean-image path and the fat-image path.</p>
<h2>Update: a version without modules</h2>
<p>A few weeks after this post I replaced the script with <a href="https://github.com/taalexander0614/CtrlAltUpgrade/blob/main/SCCM/Imaging/UpdateAutopilotGraph.ps1" rel="noopener">UpdateAutopilotGraph.ps1</a>. Windows PE can't install modules natively, so instead of adding steps to make the PowerShell Gallery work there, the new version calls the Microsoft Graph REST API directly to find the device by serial number and update its display name and group tag. It reads <code>OSDComputerName</code> and <code>GroupTag</code> from the task sequence, and can also run outside one with those passed as parameters.</p>
<p>If you're working through a similar mix of SCCM and Intune, feel free to use and adapt either version. Happy scripting!</p>]]></content:encoded>
    </item>
    <item>
      <title>Tackling orphaned user folders: a PowerShell solution</title>
      <link>https://www.ctrlaltupgrade.com/blog/orphaned-user-folders/</link>
      <guid isPermaLink="true">https://www.ctrlaltupgrade.com/blog/orphaned-user-folders/</guid>
      <pubDate>Sun, 28 May 2023 12:00:00 -0500</pubDate>
      <category>PowerShell</category>
      <description>A PowerShell remediation script that finds and removes orphaned user profile folders that were filling up 64 GB student laptops.</description>
      <content:encoded><![CDATA[<p>Some IT problems don't come from anything breaking. They come from two normal processes not quite lining up. Orphaned user folders were one of those for us.</p>
<h2>The problem</h2>
<p>In our domain, user accounts are deleted after a period of inactivity, which is standard practice. But deleting the account left the user's folder behind in <code>C:\Users</code>.</p>
<p>When that person signed in again later, Windows didn't reuse the leftover folder. It created a new profile folder next to it, so the same user ended up with duplicate folders. On student laptops with 64&nbsp;GB drives, that filled the disk quickly.</p>
<h2>What you need</h2>

<ul> 
<li>Intune Remediations (formerly Proactive Remediations), or any way to run a detection and remediation script as SYSTEM</li> 
<li>The two scripts linked below, with the variables at the top adjusted for your organization</li> </ul>
<h2>The solution</h2>
<p>I wrote a detection script and a remediation script. Detection finds orphaned folders; remediation removes them. Deleting a folder whose owner no longer exists often runs into broken permissions, so the remediation tries several approaches, from simplest to most forceful.</p>
<p><a href="https://github.com/taalexander0614/CtrlAltUpgrade/tree/main/Intune/Proactive%20Remediations/Windows%20Issues/Orphaned%20User%20Folders" rel="noopener">See the scripts on GitHub</a></p>
<h2>How the remediation works</h2>

<ol> 
<li><strong>Set up.</strong> It sets its variables, creates its log and cleanup paths if they don't exist, and logs which accounts are on the device before it starts.</li> 
<li><strong>Find orphans.</strong> It compares the folders in <code>C:\Users</code> with the user profiles registered in Windows. Any folder without a matching profile is orphaned. (The <code>Public</code> folder and its own cleanup folder are skipped.)</li> 
<li><strong>Try a normal delete.</strong> It attempts to delete each orphaned folder.</li> 
<li><strong>Fix permissions and try again.</strong> If that fails, it gives the SYSTEM account ownership and full control, then deletes the folder recursively, continuing past errors.</li> 
<li><strong>Go file by file.</strong> If the folder is still there, it collects the remaining files into a list and fixes permissions on each one and deletes it individually, then does the same for the remaining subfolders.</li> 
<li><strong>Move it out of the way.</strong> If anything still survives, it moves the folder into a cleanup directory, so <code>C:\Users</code> stays clean and the duplication doesn't keep happening.</li> </ol>
<p>The script works out whether it's running as SYSTEM or as a user and puts its log file in the right place, under an organization folder in ProgramData or AppData.</p>
<h2>Results</h2>
<p>This fixed our orphaned folder problem: student laptops no longer fill up with duplicate profile folders. It's a good example of how a little scripting can solve a problem that looks bigger than it is.</p>
<p>If you're seeing the same thing, feel free to use and modify the scripts. Happy scripting!</p>]]></content:encoded>
    </item>
  </channel>
</rss>
