# Limehawk > Enterprise IT security and managed services for regulated industries in Knoxville, Tennessee. Limehawk specializes in compliance-driven IT (FTC Safeguards, HIPAA, SEC/FINRA, NAIC, GLBA) and supports legacy industrial systems (Windows 98-7) that other managed service providers walk away from. Every published incident report follows, newest first. Site index: https://limehawk.io/llms.txt --- # Windows 10 Extended Security Updates: What Businesses Get, and When to Stop Paying URL: https://limehawk.io/reports/windows-10-extended-security-updates Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Windows 10 support ended Oct 14, 2025. Business ESU buys up to three years of security-only patches, one year at a time, and Year 1 ends Oct 13, 2026. > **TL;DR** Windows 10 reached end of support on October 14, 2025. Extended Security Updates (ESU) is Microsoft's paid program that keeps critical and important security patches coming to Windows 10 PCs after that date. Businesses buy it one year at a time, for a maximum of three years, through volume licensing. Year 1 coverage ends October 13, 2026, Year 2 ends October 12, 2027, and Year 3 ends October 10, 2028. ESU is security fixes only. No features, no bug fixes, no general support. Microsoft calls it a temporary bridge, and that's how to use it: buy time for PCs you can't replace yet, and isolate anything that has to stay on Windows 10 past that. - ESU Year 1 ends: Oct 13, 2026 ## Is Windows 10 still supported? No. Microsoft ended support for Windows 10 on October 14, 2025. Version 22H2 was the last release, according to the [Windows 10 Home and Pro lifecycle page](https://learn.microsoft.com/en-us/lifecycle/products/windows-10-home-and-pro). The PCs still turn on and run your software. What stopped is the monthly security patching, along with technical support and feature updates. Microsoft's [ESU program page](https://learn.microsoft.com/en-us/windows/whats-new/extended-security-updates) puts it plainly: Windows 10 PCs "will continue to work." But after that date, Microsoft no longer provides technical support, feature updates, or "quality updates (including security and reliability fixes)." A Windows 10 PC that isn't enrolled in ESU has gone without patches since the October 2025 release. Every Windows flaw found since then stays open on that machine. LTSC editions are the exception. Those have their own lifecycles and aren't part of this program. ## What are Windows 10 Extended Security Updates? Extended Security Updates (ESU) is a paid, annual subscription that keeps sending critical and important security updates to enrolled Windows 10 PCs after end of support. Microsoft's [lifecycle FAQ](https://learn.microsoft.com/en-us/lifecycle/faq/extended-security-updates) calls ESU "a last resort paid option" and "not intended as a long-term solution, but rather as a temporary bridge." It doesn't extend the product's lifecycle. Here's what ESU leaves out, per Microsoft: - New features. - Customer-requested non-security updates. - Design change requests. - General technical support. Microsoft only supports activation, installation, and regressions caused by the ESU updates themselves. Even that requires an active Microsoft support plan. So ESU fixes security holes. It won't fix a printer driver, a broken update, or anything else that goes wrong on the PC. ## How long do Windows 10 ESU updates last for businesses? Business ESU runs one year at a time, for a maximum of three years after end of support. Microsoft's [lifecycle FAQ table](https://learn.microsoft.com/en-us/lifecycle/faq/extended-security-updates) lists the end dates: - **Year 1** ends October 13, 2026. - **Year 2** ends October 12, 2027. - **Year 3** ends October 10, 2028. You can't buy partial periods. ESU is also cumulative. Microsoft says that if you buy in Year 2, "you have to pay for Year 1 too." Skipping a year to save money doesn't work, and joining late means paying for the time you missed. As of this writing, Year 1 ends Oct 13, 2026. If you enrolled PCs last fall, Year 2 has to be bought and activated on every machine, or patching stops again. ## Which PCs and editions qualify? Business ESU covers Windows 10 Pro, Enterprise, and Education "in Commercial use." Microsoft lists those editions in its lifecycle FAQ. The minimum purchase is one license, so a 10-person office can buy exactly what it needs. There's also a consumer ESU program. Don't confuse the two. Microsoft's [consumer ESU page](https://www.microsoft.com/en-us/windows/extended-security-updates) says it "can't be used in commercial scenarios." A business PC goes through volume licensing, not the Settings app. Some PCs get ESU without a separate purchase. Microsoft lists Windows 10 virtual machines in Windows 365, Azure Virtual Desktop, and Azure as covered at no additional cost. Windows 10 PCs that connect to Windows 365 Cloud PCs are covered for up to three years with an active Windows 365 license. ## How do you enroll a business PC in ESU? You buy ESU licenses through Microsoft volume licensing, then activate a key on every PC. Microsoft's [enablement guide](https://learn.microsoft.com/en-us/windows/whats-new/enable-extended-security-updates) lists the steps. Each machine has to meet these prerequisites first: - Windows 10, version 22H2, with KB5066791 or a later update installed. - The ESU Licensing Preparation Package, KB5072653, installed after KB5066791. - Admin rights on the device. Then: 1. **Get the key.** The Multiple Activation Key (MAK) is in the Microsoft 365 admin center under Billing, Your Products, Volume licensing. Your account needs the Product Key Reader or VL Administrator role to see it. 2. **Install the key** on each PC from an elevated prompt with `slmgr.vbs /ipk`. 3. **Activate the right year.** Each year has its own Activation ID, and you run `slmgr.vbs /ato` with it. A Year 1 activation doesn't carry the PC into Year 2. 4. **Verify.** `slmgr.vbs /dlv` should show the ESU program name with a status of Licensed. PCs that can't reach the internet can activate by phone. Large offline fleets can use VAMT proxy activation. Reimaging a PC can use up activations, and Microsoft lets you request more. Run this by hand on three PCs and it's fine. On forty PCs, every year, it's a scripted job that needs a report of which machines activated and which didn't. That's the kind of work our [managed IT service](/services/managed-it-security) handles. ## When does ESU make sense, and when should you replace the PC? ESU makes sense when a PC can't move to Windows 11 yet and you need it patched while you work out a plan. It doesn't make sense as a way to avoid buying hardware. You'd be paying every year to keep a machine on an operating system that's already done. Start by sorting your Windows 10 PCs into three groups: - **Can upgrade.** The PC meets [Windows 11's hardware requirements](https://learn.microsoft.com/en-us/windows/whats-new/windows-11-requirements): a compatible 64-bit processor with two or more cores, 4 GB of RAM, 64 GB of storage, UEFI with Secure Boot, and TPM 2.0. Upgrade it. ESU on this machine is money spent on a problem you can just fix. - **Can't upgrade, but nothing ties it to Windows 10.** Usually older hardware without TPM 2.0 or a supported CPU. Replace it. ESU can cover the gap until the new PC shows up, and the move doesn't have to be painful. In our [USMT profile migration](/reports/usmt-profile-migration), a CFO's documents, app settings, and browser data moved to a new machine with zero data loss, and USMT works across Windows versions, Windows 10 to 11 included. - **Has to stay on Windows 10.** A vendor app or a piece of equipment only runs on it. ESU buys up to three years of patches. Use that time to get a real answer from the vendor. An ESU patch also still has to install. Patching failures don't stop because you paid for the patches. Our [Windows Update reset report](/reports/reset-windows-update) shows what a failed update looks like on a live machine, and an enrolled PC needs the same monitoring as any other. ## What do you do with a PC that has to stay on Windows 10? Isolate it. A PC you can't patch, or won't be able to after October 10, 2028, shouldn't sit on the same network as everything else. We handle it the same way we handle the Windows 7 and XP machines on our [legacy systems service](/services/legacy-systems): - Put it on its own network segment, away from the rest of the office. - Write firewall rules that only allow the traffic it actually needs. - Air-gap it where the workflow allows. - Watch it at the network layer so problems show up even when the box can't run modern tools. The goal is containment. If the old PC gets compromised, it can't reach anything else. ESU and isolation work together, not as either/or. Patch it while you can, isolate it regardless, and plan for the day the patches stop. ## Key takeaways - Windows 10 support ended October 14, 2025. The PCs still boot, but without ESU they get no more security patches. - Business ESU runs one year at a time for up to three years: Year 1 ends October 13, 2026, Year 2 ends October 12, 2027, and Year 3 ends October 10, 2028. - ESU is cumulative. Join in Year 2 and you pay for Year 1 too. The consumer ESU program can't be used in commercial scenarios. - Each PC needs Windows 10 22H2, two prerequisite updates, an ESU key from the Microsoft 365 admin center, and activation with the right year's Activation ID. - ESU is a bridge, not a plan. Replace or upgrade what you can, and isolate anything that has to stay on Windows 10. ## What this looks like when we do it We start with an inventory: every Windows 10 PC, its edition, its version, and whether its hardware can run Windows 11. Upgradable machines get upgraded. Old hardware gets a replacement date, and the profile moves over when the new PC arrives. ESU goes only on the machines that truly need the bridge. For the PCs on ESU, we track activation by year, so nobody finds out in October that half the office stopped getting patches. For anything that has to stay on Windows 10, we segment it and document why it's there. That's routine work on our [legacy systems service](/services/legacy-systems), where machines a lot older than Windows 10 still run in production. Still have Windows 10 PCs and no plan for Year 2? Book a call and we'll sort the list with you. --- # What Is MDM? Mobile Device Management for Regulated Small Firms URL: https://limehawk.io/reports/what-is-mdm Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min MDM is software that enrolls your laptops and phones so IT can push settings, require encryption, control apps, and lock or wipe a lost device remotely. > **TL;DR** Mobile device management (MDM) is how IT controls a device it doesn't have in its hands. A laptop or phone gets enrolled once. After that, IT can push settings, require a passcode and encryption, decide which apps go on it, and lock or erase it if it's lost or stolen. Company-owned devices usually get full management. Personal phones usually get a lighter mode that only touches work apps and work data. Regulators and insurers don't ask for "MDM" by name. They ask for encrypted devices, an inventory, and a plan for lost hardware, and MDM is how a small firm proves all three. - Theft report to wipe: 16 minutes ## What is MDM? MDM, or mobile device management, is software that lets IT manage a laptop, tablet, or phone remotely. You enroll the device once. From then on, IT can change its settings, check that it's still configured correctly, install or remove work apps, and lock or erase it from anywhere it has a connection. Nobody has to touch it. Apple's [deployment guide](https://support.apple.com/guide/deployment/intro-to-mobile-device-management-depc0aadd3fe/web) puts it plainly: a device management service lets IT "securely and remotely configure devices by sending configurations, profiles, and commands to the device." Microsoft's [Intune overview](https://learn.microsoft.com/en-us/intune/fundamentals/what-is-intune) says the same thing in its own words: once a device is enrolled, the service "manages the whole device, including settings, security, and apps. If a device is lost or stolen, you can wipe it." You'll also see the term EMM (enterprise mobility management) or UEM (unified endpoint management). NIST's [SP 800-124 Rev. 2](https://csrc.nist.gov/pubs/sp/800/124/r2/final), its guide to managing mobile devices in organizations, treats those as the bigger umbrella. An EMM suite "may encompass" MDM, app management, and mobile threat defense. For a small firm, the label doesn't matter much. What matters is what the tool controls. ## What does MDM actually control? MDM controls five things: how a device joins your company, the security settings on it, whether its storage is encrypted, which apps it runs, and what happens to it when it goes missing. Here's each one in plain terms, using the capabilities NIST lists in section 4.2 of SP 800-124 Rev. 2. - **Enrollment.** The device signs up with your management service. On company hardware this can happen automatically during first setup, through programs like Apple Automated Device Enrollment, Windows Autopilot, or Android Enterprise. On a personal phone, the user enrolls it themselves. - **Policies.** A passcode to unlock the device, an auto-lock after a short idle time, a limit on wrong passcode attempts, and blocks on things like the camera, removable storage, or copy and paste out of work apps. MDM can also cut off access for devices running an old OS version or devices that have been jailbroken. - **Encryption.** NIST's list includes "strongly encrypt stored data" on the device and on removable media. On a Windows laptop that usually means BitLocker, with the recovery key stored somewhere other than the laptop. - **App control.** Push the apps people need, remove the ones they shouldn't have, and protect the data inside work apps like Outlook and Teams. - **Remote lock and wipe.** If a device is lost, lock it. If it's stolen, erase it. NIST says to wipe "if it is suspected that the device has been lost, stolen, or otherwise fallen into untrusted hands." MDM also gives you an inventory as a side effect. Every enrolled device shows up in one console with its owner, model, OS version, and encryption status. That list is what an auditor will ask for. ## How is MDM different on company-owned and personal devices? On a company-owned device, IT manages the whole thing and can factory reset it. On a personal phone (BYOD, "bring your own device"), IT should manage only the work apps and work data, and a wipe removes only those. Everything else on the phone stays the employee's. Microsoft calls the lighter mode mobile application management (MAM). Its [Intune overview](https://learn.microsoft.com/en-us/intune/fundamentals/what-is-intune) says MAM manages "only the work apps and the data inside them, not the rest of the device," and that when someone leaves "you can selectively wipe organization data without touching personal content." Apple's version is [User Enrollment](https://support.apple.com/guide/deployment/user-enrollment-and-device-management-dep23db2037d/web), where IT can manage "only an organization's accounts, settings, and information... never a user's personal account." Google's [Android work profile](https://support.google.com/work/android/answer/6191949) separates work apps from personal ones so "your personal apps, data, and usage remain private." NIST draws the same line. It describes a full-device wipe for company-owned devices and an "enterprise wipe" that deletes only work data for personal ones. The practical rule for a small office: if the device holds client data and the company paid for it, manage all of it. If it's an employee's phone that only reads email, use the work-only mode and get the employee's agreement in writing before you enroll it. ## Why do regulators and insurers care about MDM? They care because a lost laptop full of client records can turn into a reportable incident. No rule we know of says "you must buy MDM." The rules ask for device controls, encryption, and an inventory, and MDM is how you show those exist. - **HIPAA.** The Security Rule's [device and media controls standard](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.310) requires policies for hardware that holds ePHI coming into and out of your office, plus procedures for disposal and re-use. HHS [breach guidance](https://www.hhs.gov/hipaa/for-professionals/breach-notification/guidance/index.html) says PHI encrypted to the standard it describes is not "unsecured," as long as the key "has not been breached." That's why a lost encrypted laptop is a very different day from a lost unencrypted one. Medical practices can see how we handle this on our [medical practices page](/industries/medical-practices). - **FTC Safeguards Rule.** [16 CFR 314.4(c)](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.4) requires firms covered by the rule to "identify and manage the data, personnel, devices, systems, and facilities" they rely on and to encrypt customer information "at rest." - **Cyber insurance.** Carriers ask for encryption status and a device inventory. In one case we published, a client's renewal required [full-disk encryption on all 127 endpoints within 72 hours](/reports/bitlocker-encryption-automation) or the policy would lapse. ## What happens when a managed laptop gets stolen? When a managed laptop is stolen, IT confirms the theft, records who approved the action, and sends a lock or wipe command the next time the device is online. Encryption is supposed to protect the data in the meantime. Our [stolen-laptop report](/reports/mdm-remote-wipe) shows how that plays out, and where it doesn't go by the book. A sales rep's laptop was taken at an airport gate on a Friday afternoon. It was BitLocker-encrypted, but it was asleep, not powered off, so the encryption wasn't gating anything yet. The thief already had it on their own network. Waiting for the device to check in with the cloud MDM service could have taken hours. Our RMM agent was online, so we got the sales director's authorization, logged it in the ticket, and pushed a factory reset through the same Windows wipe mechanism that MDM tools use. It went from theft report to wipe in 16 minutes, and we found no evidence that any data left the laptop. NIST warns about exactly this. In SP 800-124 Rev. 2 it calls remote wipe "a fundamentally unreliable security control" when used by itself, because an attacker can read the device first or just keep it offline. Wipe is the backup. Encryption, a short auto-lock, and a ready plan are what actually protect you. ## Key takeaways - MDM enrolls a device so IT can configure it, require encryption and a passcode, manage apps, and lock or wipe it from anywhere. - Company-owned devices get full management. Personal phones get a work-only mode that can remove work data without touching photos or texts. - HIPAA and the FTC Safeguards Rule don't say "MDM." They ask for device controls, encryption, and an inventory, and MDM is how you produce the evidence. - Remote wipe is a backup, not the plan. NIST calls it unreliable on its own because the device has to be on and connected. Encryption is what protects a powered-off device. - Set it up before the theft. In our stolen-laptop case, the agent and a tested wipe script were already in place, so the wipe took 16 minutes. ## What this looks like when we do it Under our [managed IT and security](/services/managed-it-security) service, every machine gets a security baseline before anything goes wrong. In practice that means an RMM agent on each laptop and desktop, BitLocker turned on with the recovery keys escrowed away from the device, and a hardware inventory that shows each device's encryption status. We keep a wipe procedure tested and written down, so nobody is writing PowerShell at 5 PM on a Friday. For phones, we start with a question about who owns them. Company phones get full management. Personal phones that only need email get the work-only mode, so you can remove client data when someone leaves without touching their photos. Then we write down what's in place, because HIPAA and the Safeguards Rule both want to see records, not just good intentions. Our [managed IT and security page](/services/managed-it-security) covers the rest of the baseline. Not sure which of your devices would survive being left at an airport gate? [Book a call](/contact) and we'll go through it with you. --- # What Is FCI? Federal Contract Information for Small DoD Suppliers URL: https://limehawk.io/reports/what-is-fci Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min FCI is non-public information provided by or generated for the government under a contract. If your systems hold it, FAR 52.204-21 and CMMC Level 1 apply. > **TL;DR** Federal Contract Information (FCI) is information that isn't meant for public release and is provided by or generated for the government under a contract to deliver a product or service. The definition lives in FAR 4.1901. It leaves out two things: information the government has already made public, and simple transactional information like what's needed to process payments. For a machine shop on a defense subcontract, FCI is mostly ordinary paperwork: the purchase order, the contract terms, delivery schedules, and the emails about them. If FCI sits on your computers, FAR 52.204-21 applies, and on DoD work that means CMMC Level 1. CUI is a separate, stricter category. - Safeguards FCI triggers: 15 ## What is FCI? FCI, or Federal Contract Information, is information that is "not intended for public release" and is "provided by or generated for the Government under a contract to develop or deliver a product or service to the Government." That's the definition in [FAR 4.1901](https://www.acquisition.gov/far/4.1901). It's broad on purpose. If it came to you because of a government contract, or you made it for one, and it isn't public, assume it's FCI. The CMMC rule doesn't write its own version. [32 CFR 170.4](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) just says FCI "is defined in 48 CFR 4.1901." So the FAR definition is the one that counts for CMMC too. One housekeeping note. Under the government's FAR overhaul, the same definition now also appears at [FAR 40.301](https://www.acquisition.gov/far-overhaul/far-part-deviation-guide/far-overhaul-part-40), and the safeguarding clause is numbered 52.240-93 in agencies using that deviation. The DoD's [Part 40 class deviation](https://www.acquisition.gov/sites/default/files/page_file_uploads/DoD_RFO_Deviation_Part-40.pdf) took effect February 1, 2026. The wording is the same. You'll see both numbers in contracts for a while. ## What is not FCI? The FAR definition carves out exactly two things. FCI does not include "information provided by the Government to the public (such as that on public Web sites)" or "simple transactional information, such as that necessary to process payments." Anything else that is non-public and tied to the contract stays in. In practice: - **Public information isn't FCI.** If the government already posted it, like a solicitation on a public website, it isn't FCI just because a copy is on your server. - **Payment processing details aren't FCI.** The FAR's own example is the information "necessary to process payments." Don't stretch it. The definition doesn't say every invoice or every email that mentions money is exempt. - **Your own commercial work isn't FCI.** The definition only covers information tied to a government contract. A job for a private customer with no government contract behind it doesn't qualify. The carve-outs are narrow. When people get FCI wrong, it's usually because they decided their contract paperwork is "just business stuff." It usually isn't. ## What does FCI look like at a machine shop? At a small machine shop supplying a defense prime, FCI is mostly ordinary contract paperwork that isn't public. The purchase order for the parts, the contract or subcontract terms, the delivery schedule, and the email back and forth with the buyer about quantities and dates all fit the definition. So do the quotes and status updates you generate for that contract. That paperwork doesn't live in one place. It's in: - The office manager's inbox and the shared mailbox the buyer emails. - The ERP or job-tracking system where the PO gets entered. - A file share or cloud folder where the paperwork gets saved. - Laptops and phones that sync that mail. - The printer or scanner if it stores jobs. That's why FCI turns into an IT question. The FAR calls any contractor-owned system that "processes, stores, or transmits Federal contract information" a [covered contractor information system](https://www.acquisition.gov/far/4.1901). Every one of those is in scope. FCI also flows down. [FAR 52.204-21](https://www.acquisition.gov/far/52.204-21), paragraph (c), requires the prime to put the clause in subcontracts, including subcontracts for commercial products and services, other than commercially available off-the-shelf (COTS) items. If you're two tiers down from the prime, the clause can still reach you. ## What's the difference between FCI and CUI? FCI is the floor. CUI is a narrower, more sensitive category with its own legal basis and much heavier requirements. Controlled Unclassified Information is defined at [32 CFR 2002.4(h)](https://www.ecfr.gov/current/title-32/subtitle-B/chapter-XX/part-2002) as information that "a law, regulation, or Government-wide policy requires or permits an agency to handle using safeguarding or dissemination controls." We cover CUI in detail in [What Is CUI?](/reports/what-is-cui) | | FCI | CUI | |---|---|---| | Defined in | FAR 4.1901 | 32 CFR 2002.4(h) | | What it is | Non-public information provided by or generated for the government under a contract | Information that law, regulation, or government-wide policy says needs safeguarding or dissemination controls | | Federal safeguards | FAR 52.204-21, 15 basic requirements | NIST SP 800-171 (Rev 2 under the CMMC rule), 110 requirements | | CMMC level on DoD work | Level 1 | Level 2 (some programs Level 3) | | How you spot it | By the definition. There's no FCI banner marking in the FAR. | Usually marked by the government or the prime | The practical test for a shop: if a drawing or spec shows up marked CUI, you've left Level 1. That's [CMMC Level 2](/reports/cmmc-level-2-requirements) territory. FAR 52.204-21 says so itself in paragraph (b)(2). It "does not relieve the Contractor" of the separate CUI safeguarding requirements. ## What does FCI require you to do? If FCI lives on your systems, you have to apply the 15 basic safeguarding requirements in [FAR 52.204-21](https://www.acquisition.gov/far/52.204-21), paragraph (b)(1), to those systems. They're the basics: limit access to authorized users, know who and what is on the network, authenticate logins, sanitize old media, lock the doors, protect the network boundary, patch, and run malware protection. On DoD contracts, FCI is also what triggers CMMC Level 1. The CMMC rule at [32 CFR 170.14](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) sets the Level 1 requirements as the ones in 52.204-21(b)(1)(i) through (xv). You assess yourself every year, post the result in SPRS, and a senior official affirms it. We walk through all 15 and the SPRS steps in our [CMMC Level 1 guide](/reports/cmmc-level-1). Where the rollout stands: the DoD suspended the Phase 2 transition on July 13, 2026, and that pause was about third-party Level 2 assessments. Level 1 self-assessments remain in place. If you hold FCI on a DoD contract, Level 1 applies now. ## Key takeaways - FCI is non-public information provided by or generated for the government under a contract. The definition is in FAR 4.1901. - Two things are excluded: information the government already made public, and simple transactional information such as what's needed to process payments. - At a machine shop, FCI is everyday contract paperwork: the PO, the terms, delivery schedules, and the email threads about them. - Any system that processes, stores, or transmits FCI is a covered contractor information system, and FAR 52.204-21's 15 safeguards apply to it. - On DoD work, FCI alone means CMMC Level 1. CUI is a separate, stricter category that means Level 2. ## What this looks like when we do it We're not a C3PAO or a Registered Practitioner Organization, and we don't do CMMC assessments. What we do is the IT work under them. For a shop with FCI, the first job is finding where it lives. We trace the PO from the buyer's email to the inbox, the ERP, the file share, and every laptop and phone that syncs them. That map becomes your scope. Then we close the gaps the 15 safeguards look for: shared logins, former employees still active, a firewall nobody has reviewed, patching that quietly stopped. The evidence comes out of the same tools that run the controls, so your affirming official signs based on reports, not a hunch. On the plant floor, we isolate older controllers and legacy Windows instead of forcing replacements. More on that on our [manufacturing IT support page](/industries/manufacturing). If you're a supplier figuring out what counts as FCI in your office, our [manufacturing IT page](/industries/manufacturing) covers how we set up shops like yours. [Book a call](/contact) and we'll map it with you. --- # What Is DMARC? SPF, DKIM, and DMARC Explained for Small Firms URL: https://limehawk.io/reports/what-is-dmarc Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min DMARC is a DNS record that tells receiving mail servers what to do with mail that fails SPF and DKIM checks for your domain, and sends you reports on it. > **TL;DR** DMARC is a DNS record that tells the rest of the internet what to do with email that claims to be from your domain but can't prove it. SPF and DKIM are the two checks. DMARC is the policy on top: p=none (watch only), p=quarantine (send it to spam), or p=reject (refuse it). It also sends you reports on who's mailing as you. Google and Yahoo require bulk senders to publish at least p=none. Most small firms publish p=none and stop there, which means a forged email from their domain still gets delivered. DMARC also won't stop a look-alike domain or a criminal sending from your real, compromised inbox. That's the BEC case, and it takes other controls. - Gmail bulk-sender line: 5,000 a day ## What is DMARC? DMARC (Domain-based Message Authentication, Reporting, and Conformance) is a text record you publish in your domain's DNS. It tells receiving mail servers what to do when a message claims to be from your domain but fails authentication, and where to send reports about it. It's defined in [RFC 7489](https://www.rfc-editor.org/rfc/rfc7489.html), published in March 2015. The point is simple. Anyone can type your domain into the From line of an email. Without DMARC, the receiving server has no instruction from you about what to do when that message doesn't check out. With DMARC at enforcement, a forged email from `yourfirm.com` gets sent to spam or bounced before your client ever sees it. ## How do SPF, DKIM, and DMARC work together? SPF and DKIM are the two checks. DMARC is the rulebook that reads both results and decides. As [dmarc.org](https://dmarc.org/overview/) puts it, DMARC "builds upon both" of them. You need all three, and they each do a different job. - **SPF** (Sender Policy Framework) is a DNS list of the servers allowed to send mail for your domain. Microsoft 365, Google Workspace, your billing system, your closing software. If a message comes from a server not on the list, SPF fails. - **DKIM** (DomainKeys Identified Mail) is a digital signature. The sending server signs each message with a private key, and you publish the matching public key in DNS. If the signature checks out, the message wasn't altered and really came through a system that holds your key. - **DMARC** adds the part SPF and DKIM skip: does the domain that passed actually match the domain in the From line the reader sees? RFC 7489 calls this "identifier alignment." A message passes DMARC if at least one of SPF or DKIM passes *and* lines up with the From domain. That alignment check matters. Without it, a scammer can pass SPF on their own throwaway domain while showing your name in the From line. DMARC closes that gap. ## What's the difference between p=none, p=quarantine, and p=reject? The `p=` tag is your policy, and it's the whole ballgame. Per [RFC 7489](https://www.rfc-editor.org/rfc/rfc7489.html): - **p=none**: "no specific action." Failing mail is delivered as usual. You just get reports. - **p=quarantine**: failing mail should be "treated as suspicious." In practice, it goes to spam or junk. - **p=reject**: receivers should "reject email that fails the DMARC mechanism check." It never reaches the inbox. There's also a `pct=` tag that applies the policy to a percentage of failing mail, so you can step up gradually. And the `rua=` tag tells receivers where to send aggregate reports. Those reports are how you find out that your payroll provider, your e-signature tool, or a scammer in another country is sending mail as you. p=none is a starting point for collecting data. It protects nobody. A spoofed email from a p=none domain lands right in the inbox. ## Why do most small firms get stuck at p=none? They're afraid of blocking their own mail, and that fear is reasonable. A small law firm or title company sends as its domain from more places than anyone remembers: the mailbox platform, the website contact form, accounting software sending invoices, the closing or practice management platform, a marketing tool, a scanner that emails PDFs. If any of those isn't covered by SPF or signed with DKIM, moving to p=reject bounces real client mail. So somebody publishes p=none to check a box, points the reports at an inbox nobody reads, and never comes back. The record exists. The protection doesn't. Getting to enforcement is inventory work, not magic: 1. Publish p=none with a `rua=` address someone (or some tool) actually reads. 2. Use the reports to list every service sending as your domain. 3. Fix each one: add it to SPF, turn on DKIM signing, or move it to a subdomain. 4. Move to p=quarantine, watch for legitimate failures, then move to p=reject. The federal government did exactly this. CISA's [Binding Operational Directive 18-01](https://www.cisa.gov/news-events/directives/bod-18-01-enhance-email-and-web-security), issued October 16, 2017, gave agencies 90 days to publish at least p=none with a report address, and one year to get to "reject." If federal agencies can be told to reach reject in a year, a 20-person firm can get there too. ## What are the Google and Yahoo sender requirements? Since February 1, 2024, [Google's sender guidelines](https://support.google.com/a/answer/81126) require every sender to Gmail personal accounts to set up SPF or DKIM. Bulk senders, those sending more than 5,000 a day to Gmail accounts, need SPF and DKIM both, a DMARC record (p=none is allowed), From-domain alignment, and one-click unsubscribe on marketing mail. Google's [FAQ](https://support.google.com/a/answer/14229414) says that "starting November 2025," non-compliant mail sees "temporary and permanent rejections," and that bulk sender status, once assigned, doesn't expire. [Yahoo's requirements](https://senders.yahooinc.com/best-practices/) match closely: SPF or DKIM for everyone, and for bulk senders both SPF and DKIM plus "a valid DMARC policy with at least p=none" that passes. Most professional firms aren't bulk senders. But if you send newsletters or client announcements from your main domain, you might be. And notice what both companies set as the minimum: p=none. That gets your mail delivered. It doesn't protect your clients from someone forging you. ## What doesn't DMARC stop? DMARC protects your exact domain and nothing else. RFC 7489 says so plainly: it "does not address the use of visually similar domain names ('cousin domains')." If a scammer registers `yourfirm-title.com` or swaps an `l` for a `1`, that domain can pass its own SPF, DKIM, and DMARC perfectly. Your record has nothing to say about it. DMARC also can't help when the email really is from you. In [our BEC wire fraud incident report](/reports/bec-wire-fraud-incident-response), the attacker logged into a title closer's actual inbox and sent wire instructions from it. Every authentication check passed, because it was the real account on the real servers. What stopped the loss was a phone call to the other side of the deal, and what closed the hole was enforcing the second sign-in step across the whole tenant. The same goes for the reverse problem. Sometimes legitimate mail gets flagged and nobody knows which layer made the call. We sorted out one of those in [Front sending a bank's wire confirmations to spam](/reports/front-app-gmail-spam-sender-whitelist), where the classification happened in Gmail underneath the app. And unexpected verification codes in your inbox, like the ones in [our OTP spam triage](/reports/email-address-enumeration-otp-spam), are someone typing your address into a form. DMARC has nothing to do with those either. So DMARC is one layer. For a firm that moves money or privileged documents by email, it sits next to enforced MFA, mailbox rule monitoring, and a verification call before any wire change. ## Key takeaways - SPF and DKIM are the two checks. DMARC is the policy that says what to do when both fail, plus the reports that show who's sending as you. - p=none only watches. A forged email from your domain still lands. The protection starts at p=quarantine and is complete at p=reject. - Google and Yahoo require bulk senders to publish DMARC at p=none or stronger. That's the floor, not the goal. - DMARC doesn't stop look-alike domains or a criminal sending from your real, compromised inbox. Enforced MFA and a phone call before any wire change cover those. - Getting from p=none to p=reject is mostly inventory work: find every service that sends as you, fix its SPF or DKIM, then tighten. ## What this looks like when we do it When we take on a firm, we read the DNS before we touch anything else: what SPF says, whether DKIM is actually signing, and what the DMARC policy is (or whether there is one). Then we use the reports to build the list of every service sending as the domain, fix each one, and move the policy toward reject. That's part of the email hardening we do under [Microsoft 365 and Google Workspace administration](/services/cloud-solutions), not a separate project. For [title companies](/industries/title-companies), DMARC, SPF, and DKIM are part of the baseline, along with tenant-wide MFA and monitoring for the mailbox rules attackers leave behind. DMARC keeps a stranger from forging your domain. The rest keeps a stranger from using your real one. Not sure what your domain's DMARC record says right now? Book a call and we'll look at it with you. --- # What Is CUI? Controlled Unclassified Information for Machine Shops URL: https://limehawk.io/reports/what-is-cui Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min CUI is government information that a law or policy says must be protected, like DoD drawings marked CUI//SP-CTI. Holding it puts you under NIST 800-171. > **TL;DR** Controlled Unclassified Information (CUI) is information the government creates or owns, or that you create for it, that a law, regulation, or government-wide policy says has to be protected. It isn't classified. The rules are in 32 CFR Part 2002, and the National Archives keeps the official list of categories in the CUI Registry. For a machine shop on DoD work, the CUI you'll actually see is usually controlled technical information: engineering drawings, specs, and process sheets, often marked CUI//SP-CTI with a distribution statement. Export-controlled data is the other common one. CUI is a step above Federal Contract Information (FCI), and once it lands on your systems, your contract expects the 110 requirements in NIST SP 800-171 and CMMC Level 2. - Banner on DoD technical drawings: CUI//SP-CTI ## What is CUI? CUI is government information that has to be protected but isn't classified. The federal rule at [32 CFR 2002.4](https://www.ecfr.gov/current/title-32/subtitle-B/chapter-XX/part-2002) defines it as information the government "creates or possesses, or that an entity creates or possesses for or on behalf of the Government," that a law, regulation, or government-wide policy "requires or permits an agency to handle using safeguarding or dissemination controls." Two parts of that definition matter to a shop owner. First, CUI includes what you create for the government, not only what it sends you. Second, the definition leaves out information you hold on your own systems that never came from the government and wasn't created for it. Your own commercial product drawings aren't CUI just because you also do defense work. The program comes from Executive Order 13556. The National Archives (NARA) is the CUI Executive Agent, and it keeps the [CUI Registry](https://www.archives.gov/cui/registry/category-list). The Registry lists every approved category, the law behind it, and how it gets marked. If a type of information isn't in the Registry, it isn't CUI. ## What's the difference between CUI Basic and CUI Specified? CUI Basic gets one standard set of handling rules. CUI Specified has its own extra rules, written into the law or regulation that created it. Under [32 CFR 2002.4](https://www.ecfr.gov/current/title-32/subtitle-B/chapter-XX/part-2002), CUI Basic is the subset where the authority "does not set out specific handling or dissemination controls," so the standard rules apply. CUI Specified is where the authority "contains specific handling controls" that differ from Basic. Those can be stricter, or just different. The practical difference for you shows up in the marking. Category markings are optional on CUI Basic. On CUI Specified, the banner has to name the category, with an "SP-" in front of it. For safeguarding on your own systems, the rule points the same direction either way. [Section 2002.14(h)](https://www.ecfr.gov/current/title-32/subtitle-B/chapter-XX/part-2002) says agencies "must use NIST SP 800-171" to set the requirements for protecting CUI on non-federal systems, unless the law behind a category sets its own. DoD adds a wrinkle. [DoDI 5200.48](https://web.archive.org/web/20260531063810/https://www.esd.whs.mil/Portals/54/Documents/DD/issuances/dodi/520048p.PDF), DoD's CUI instruction (effective March 6, 2020), says that during the department's phased rollout, "there is no required distinction" between Basic and Specified. DoD information gets Basic protection unless a law or policy says otherwise. Don't be surprised if markings on DoD paper vary. ## How is CUI marked? CUI gets a banner marking at the top of the page and a designation indicator that says who controls it. Under [32 CFR 2002.20](https://www.ecfr.gov/current/title-32/subtitle-B/chapter-XX/part-2002), the banner must include the control marking, which is either "CUI" or the word "CONTROLLED." It must also list any CUI Specified categories, plus any limited dissemination controls. The banner has to be the same on every page that has CUI. The designation indicator is mandatory too. It names the agency that designated the information, either through letterhead or a "Controlled by" line, and it can sit on the first page or cover only. DoD goes further. [DoDI 5200.48](https://web.archive.org/web/20260531063810/https://www.esd.whs.mil/Portals/54/Documents/DD/issuances/dodi/520048p.PDF) calls for a "CUI" marking at the top and bottom of each page. Its designation block has five lines: - Controlled by: the DoD component (if it's not already on the letterhead) - Controlled by: the office - CUI Category - Distribution/Dissemination Control - POC: a phone number or email You might also see portion markings like "(CUI)" in front of a paragraph. The rule permits and encourages them, but it doesn't require them. Older DoD documents may still carry the "old FOUO marking," as DoDI 5200.48 calls it. Part 2002 calls anything marked before the CUI program legacy material, and expects agencies to re-mark it when it qualifies as CUI. ## What CUI does a machine shop actually touch? Usually controlled technical information (CTI), and sometimes export-controlled data. The [CUI Registry entry for CTI](https://www.archives.gov/cui/registry/category-detail/controlled-technical-info) defines it as "technical information with military or space application" that's subject to controls on its use, release, or dissemination. Its examples read like a job folder: "engineering drawings, and associated lists, specifications, standards, process sheets, manuals, technical reports," plus software source code. The Registry lists CTI as CUI Specified, authorized under DFARS 252.204-7012, with the banner **CUI//SP-CTI**. It also says CTI is marked with one of the distribution statements B through F under DoDI 5230.24. So a DoD part drawing that came down from a prime with a CUI//SP-CTI banner and a Distribution D statement in the title block is CUI. That's true whether it came as a PDF, a STEP file, or a printout on the shop floor. The [Export Control category](https://www.archives.gov/cui/registry/category-detail/export-control) (marking EXPT) covers information whose export could hurt national security, including items under the EAR and ITAR. Depending on the authority behind it, the banner is CUI, CUI//EXPT, or CUI//SP-EXPT. If your work involves ITAR parts, expect this one. Don't rely only on the stamp. The [DFARS 252.204-7012](https://www.acquisition.gov/dfars/252.204-7012-safeguarding-covered-defense-information-and-cyber-incident-reporting.) clause protects "covered defense information." That means CTI or other Registry information that's "marked or otherwise identified in the contract" and provided to you, or that's "collected, developed, received, transmitted, used, or stored" by you in support of the contract. Read the contract and the flow-down from your prime. If you aren't sure whether something is CUI, ask the prime in writing. ## How is CUI different from FCI? FCI is the broader, lower tier. The FAR defines Federal Contract Information as information "not intended for public release" that's provided by or generated for the government under a contract. Purchase orders, delivery schedules, and contract emails are typical. CUI is the narrower set that a specific law or policy says needs protection, and it's listed in the Registry. All CUI on a contract is also FCI, but most FCI isn't CUI. We break FCI down in [what is FCI](/reports/what-is-fci). The tier you're in changes what you owe. FCI alone puts you at [CMMC Level 1](/reports/cmmc-level-1), which is 15 basic safeguards. CUI puts you at Level 2. ## Why does having CUI pull you into NIST 800-171 and CMMC Level 2? Because the rules say CUI on a contractor's systems gets NIST SP 800-171 protection, and CMMC Level 2 is how DoD checks it. Section 2002.14(h) makes NIST SP 800-171 the standard for CUI on non-federal systems. DFARS 252.204-7012 requires it on any contractor system that "processes, stores, or transmits covered defense information." The CMMC rule at 32 CFR 170 ties Level 2 to the 110 requirements in NIST SP 800-171 Revision 2. That's a real project: MFA for every user, FIPS-validated encryption, logging, a system security plan, and a score in SPRS. We walk through it in [CMMC Level 2 requirements](/reports/cmmc-level-2-requirements), including where the program's rollout stands after the July 2026 Phase 2 suspension. The cheapest move is to shrink where CUI lives. Every PC, server, and cloud folder that touches a CUI drawing is in scope. If CUI drawings land in a shared inbox and get copied to six workstations and a USB stick for the CNC, all of that is in scope. On a plant floor, that scope question is most of the work. We cover how we handle it on our [manufacturing IT page](/industries/manufacturing). ## Key takeaways - CUI is government information that a law, regulation, or government-wide policy says must be protected. It's defined in 32 CFR 2002.4, and NARA's CUI Registry lists the categories. - CUI Basic uses one standard set of controls. CUI Specified has extra rules from its underlying law and has to carry its category in the banner, like CUI//SP-CTI. - For a machine shop, the usual CUI is controlled technical information (drawings, specs, process sheets with a distribution statement B through F) and export-controlled data. - Check the contract as well as the stamp. DFARS 252.204-7012 covers information marked or otherwise identified in the contract, plus what you develop in support of it. - FCI puts you at CMMC Level 1. CUI on your systems puts you at Level 2 and all 110 NIST SP 800-171 requirements, so keep CUI on as few machines as you can. ## What this looks like when we do it We're not a C3PAO, a Registered Practitioner Organization, or an assessor, and we don't decide what's CUI. Your prime and your contract do that. Our part is the IT. For a manufacturer, we start by finding where CUI actually goes. Which inbox gets the drawing packages? Where do they get saved? Which machines open them, and how do they get to the shop floor? Then we pull that path into as small a footprint as we can, put MFA and encryption on it, and segment it from the plant floor and the rest of the office. Legacy Windows boxes running machines get documented and walled off instead of being torn out. More on that on our [manufacturing IT support page](/industries/manufacturing). Got a drawing package with a CUI banner and no plan for where it should live? Book a call and we'll map it with you. --- # What Is CMMC? The DoD Cybersecurity Rule, Explained for Small Shops URL: https://limehawk.io/reports/what-is-cmmc Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min CMMC is the DoD's program for proving contractors protect FCI and CUI. Three levels, phased in from November 10, 2025, and paused in Phase 1 since July 2026. > **TL;DR** CMMC (Cybersecurity Maturity Model Certification) is how the Department of Defense checks that contractors and subcontractors actually protect the contract information they handle. The rule is 32 CFR Part 170. "CMMC 2.0" is the November 2021 rework that cut the original five levels to three. Level 1 covers Federal Contract Information with 15 FAR safeguards. Level 2 covers CUI with the 110 requirements in NIST SP 800-171 Rev 2. Level 3 adds 24 requirements from NIST SP 800-172. Phase 1 started November 10, 2025. On July 13, 2026, the department suspended Phase 2, so contracts can only ask for Level 1 or Level 2 self-assessments for now. The security requirements themselves didn't change. - Phase 1 in effect since: November 10, 2025 ## What is CMMC? CMMC is the Department of Defense's program for verifying that companies in its supply chain protect two kinds of information: Federal Contract Information (FCI) and Controlled Unclassified Information (CUI). The rule lives at [32 CFR Part 170](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170). Your contract names a CMMC level, and you have to meet it to be awarded the work. Most of the security rules aren't new. The [program rule](https://www.federalregister.gov/documents/2024/10/15/2024-22905/cybersecurity-maturity-model-certification-cmmc-program) says the CMMC safeguarding requirements "are the same as those required by FAR Subpart 4.19 and DFARS clause 252.204-7012," plus some NIST SP 800-172 requirements at the top level. Those clauses have been in contracts for years. What CMMC adds is proof: an assessment, a score in the Supplier Performance Risk System (SPRS), and a senior official at your company signing off every year. If you make parts for a DoD prime, the prime flows the requirement down to you. That's how most small manufacturers meet CMMC for the first time. It shows up as a line in a subcontract, or as a request for your SPRS entry. ## What does "CMMC 2.0" mean? CMMC 2.0 is the revised program DoD announced in November 2021. It replaced the original model, which the 2024 rule describes as "five progressively advanced levels" that every contractor would have had certified. After more than 750 public comments and an internal review that began in March 2021, DoD cut the model to three levels and took out the CMMC-only practices. According to the [final program rule](https://www.federalregister.gov/documents/2024/10/15/2024-22905/cybersecurity-maturity-model-certification-cmmc-program), the main changes were: - **"Streamlining the model from five to three certification levels."** Old Levels 2 and 4 went away. Old Level 3 became the new Level 2, and old Level 5 became the new Level 3. - **Self-assessment** for all of Level 1 and some of Level 2, instead of third-party certification for everyone. - **Requirements tied directly to NIST and the FAR**, with no extra CMMC-only practices or maturity processes. - **A time-limited POA&M process** for closing a few gaps after the assessment. The rule that finally made it law was published October 15, 2024 and took effect December 16, 2024. People still say "CMMC 2.0" to mean this version. The regulation itself just says CMMC. ## What are the three CMMC levels? The level depends on the information you handle. FCI only means Level 1. CUI means Level 2. Level 3 is for a small set of contracts with the most sensitive CUI. Here's how [32 CFR 170.14](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) lays them out: | Level | Data it protects | Requirements | Assessment | |---|---|---|---| | 1 | FCI | 15 safeguards in FAR 52.204-21 | Self-assessment every year | | 2 | CUI | 110 requirements in NIST SP 800-171 Rev 2 | Self-assessment or C3PAO, every 3 years | | 3 | CUI on high-priority programs | Level 2, plus 24 from NIST SP 800-172 | Government-led (DIBCAC) | All three need an annual affirmation in SPRS from a senior official. Not sure which kind of data you have? Start with our explainers on [what FCI is](/reports/what-is-fci) and [what CUI is](/reports/what-is-cui). Then go to the level guide that fits: [CMMC Level 1](/reports/cmmc-level-1) or [CMMC Level 2 requirements](/reports/cmmc-level-2-requirements). One version note. The rule incorporates NIST SP 800-171 **Revision 2**, even though NIST has since published Revision 3. We explain why that matters in our [NIST 800-171 Rev 3 guide](/reports/nist-800-171-rev-3). ## What is the CMMC timeline? CMMC went into contracts on November 10, 2025, when the [DFARS acquisition rule](https://www.federalregister.gov/documents/2025/09/10/2025-17359/defense-federal-acquisition-regulation-supplement-assessing-contractor-implementation-of) took effect. That started Phase 1. Section 170.3 planned four phases, each beginning "one calendar year" after the last. Phase 2 was due November 10, 2026. It has been suspended. Here's the short history: - **2019:** DoD announces CMMC. - **November 30, 2020:** The first CMMC rule (the "1.0" version) takes effect in the DFARS. - **November 2021:** DoD announces the revised program, now called CMMC 2.0. - **December 16, 2024:** The 32 CFR 170 program rule takes effect. - **November 10, 2025:** The DFARS rule takes effect. Phase 1 begins, with Level 1 (Self) and Level 2 (Self) as conditions of award. - **July 13, 2026:** The department [suspends the Phase 2 transition](https://dowcio.war.gov/Portals/0/Documents/Library/ImplementingSuspensionCMMC-PhaseII.pdf) and starts a 60-day review. Under that memo, programs "must only include the need for CMMC Level 1 (Self) or Level 2 (Self)." They can't require C3PAO or DIBCAC assessments during the suspension, and existing contracts with those requirements get them removed at the next option or administrative modification. The memo also says "the clause at DFARS 252.204-7012 ... remain[s] in effect," and that "further guidance will be promulgated at the conclusion of the CIO's 60-day review." ## Where does CMMC stand as of October 2026? CMMC is paused in Phase 1. The DoW CIO's [About CMMC page](https://dowcio.war.gov/cmmc/About/) still says, as of October 2, 2026, that "CMMC implementation is paused in Phase 1" and that "All Phase I self-assessment requirements remain firmly in place." The 60-day review window has closed, but I haven't seen any new guidance or replacement schedule published. So, for now: - Level 1 and Level 2 self-assessments still apply where a contract calls for them. - Third-party C3PAO certification is off the table as a contract requirement until DoD says otherwise. - [DFARS 252.204-7012](/reports/dfars-252-204-7012) still applies to anyone handling covered defense information, including its 72-hour incident reporting. Check the solicitation in front of you. Don't rely on a blog's timeline, including this one. ## What should a small shop do this quarter? Figure out what data you actually have, then get an honest score. The pause gives you time. It doesn't lower the bar. Your prime can still ask for your SPRS entry tomorrow, and DoD can still run a government-led assessment on a self-assessed contractor. A practical order of work for the next 90 days: 1. **Classify the data.** Pull a few recent purchase orders and drawing packages. Are any marked CUI? That one answer decides Level 1 or Level 2. 2. **Draw the boundary.** List every computer, account, and cloud service that touches that data. Many shops shrink the problem by putting CUI in a [CMMC enclave](/reports/cmmc-enclave) instead of securing the whole office to Level 2. For email and files, look at whether [GCC High](/reports/gcc-high-cmmc) is actually required for you. 3. **Score yourself honestly.** Use the DoD methodology and post the result to SPRS. Our [SPRS score guide](/reports/sprs-score) walks through it. 4. **Write it down.** At Level 2 you need a [system security plan](/reports/system-security-plan-template) that describes systems that really exist. Gaps that qualify go on a [POA&M](/reports/poam-cmmc), within the rule's limits. 5. **Fix the five-point items first.** MFA, account control, logging, incident response, and malware protection carry the heaviest point values at Level 2. If any of those are missing, start there. Plant-floor equipment usually counts as a Specialized Asset, so an old CNC controller doesn't have to be replaced. It does have to be documented and separated from the office network. We cover that on our [manufacturing IT page](/industries/manufacturing). ## Key takeaways - CMMC doesn't invent new security rules. It makes you prove you meet FAR 52.204-21 (Level 1) or NIST SP 800-171 Rev 2 (Level 2), and puts that proof in SPRS. - CMMC 2.0 is the November 2021 revision: five levels became three, and self-assessment became an option at Level 1 and part of Level 2. - The data decides the level. FCI only means Level 1. CUI means Level 2. Your contract or your prime tells you which applies. - Phase 1 has been in effect since November 10, 2025. Phase 2 was suspended on July 13, 2026, and the program is paused in Phase 1. - The pause moved the third-party audits, not the requirements. Self-assessments, SPRS entries, annual affirmations, and DFARS 252.204-7012 all still apply. ## What this looks like when we do it Limehawk isn't a C3PAO, a Registered Practitioner Organization, or an assessor. We don't certify anyone, and we don't sign your affirmation. A senior official at your company does that. What we do is the IT underneath it. We inventory and scope the environment, segment the plant floor from the office, enforce MFA, run patching and endpoint protection through our RMM, and pull evidence from the same tools that run the controls. On the shop floor, we isolate legacy Windows and controllers instead of forcing replacements. Our [manufacturing IT support](/industries/manufacturing) page has the details. Have a prime asking about CMMC and not sure which level applies to you? Book a call and we'll sort it out. --- # What Is a Managed Service Provider? What an MSP Actually Does URL: https://limehawk.io/reports/what-is-a-managed-service-provider Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min A managed service provider (MSP) runs your IT for a flat monthly fee: monitoring, patching, security, help desk. It's paid to prevent problems, not fix them. > **TL;DR** A managed service provider (MSP) is an outside company that runs your IT on an ongoing basis for a recurring fee. Day to day that means monitoring every machine, patching on a schedule, running the security tools, and answering the help desk. The big difference from break-fix is the incentive. A break-fix shop bills by the hour, so it makes money when things break. An MSP charges the same whether anything breaks or not, so it makes money by keeping things from breaking. If you're shopping for one, watch out for multi-year contracts, tiered plans that hide the basics, and providers who can't show you any of their work. - Contract term: Month-to-month ## What is a managed service provider? A managed service provider (MSP) is a company you hire to run your IT on an ongoing basis, usually for a recurring monthly fee. Instead of calling someone when a computer breaks, you pay one provider to watch, maintain, secure, and support everything all the time. For a business without its own IT department, the MSP effectively becomes that department. That's the whole definition. The rest of this guide is what that looks like in practice, how it compares to the other ways a 5-100 user business can get IT handled, and how to tell a good MSP from one that'll lock you in. I run Limehawk, a managed service provider in Knoxville, so I have a side in this. I'll tell you where we fit and where we don't. ## What does an MSP do day to day? An MSP does the boring, constant work that keeps problems from turning into tickets. It monitors every machine, applies patches on a schedule, runs the security tools, answers your staff's help desk calls, and handles onboarding and offboarding. Most of the work happens before anyone at your office notices something was wrong. Here's what that covers on [our managed IT service](/services/managed-it-security): - **Monitoring.** Servers, workstations, and the network are watched around the clock, with alerts when something drifts out of normal range. A server quietly filling its disk gets caught before it stops. - **Patching.** Windows, third-party apps, and firmware get updated on a schedule, and somebody checks that the updates actually landed. In [one patch audit](/reports/uptime-based-reboot), 49% of endpoints were missing critical patches. The updates were installed. The machines just never rebooted. - **Security.** The same baseline on every machine: EDR, anti-malware, email filtering, and password management. - **Help desk.** Your staff call, email, or chat when something's wrong. Password resets, new hires, and departing employees are part of the job, not extra charges. - **Planning.** Telling you which machines are aging out, what your cyber insurance questionnaire is going to ask, and what to fix first. ## What's the difference between break-fix and managed services? Break-fix means you call an IT company when something breaks and pay by the hour to fix it. Managed services means you pay a recurring fee and the provider keeps things from breaking in the first place. Both can be done well. The real difference is what each model rewards. A break-fix shop makes more money when you have more problems. Nobody has to be dishonest for that to matter. It just means nobody is paid to notice the laptop that stopped patching three months ago, or the former employee whose account is still active. Those aren't billable until they turn into an emergency. A managed provider charges the same whether you have a quiet month or a bad one. Every outage, every infection, every 2am call comes out of the provider's own margin. So the provider's money is on prevention: patch before the exploit, block the phish before someone clicks it, flag the dying drive before it fails. Break-fix can still make sense for a very small office with simple needs and a high tolerance for downtime. If you handle client financial data, health records, or wire transfers, it's a hard model to defend. Your regulator and your cyber insurer both expect controls that someone maintains all the time, not after something breaks. That's why we don't offer break-fix. We're a managed and compliance provider, not hourly tech support. ## Managed vs co-managed vs in-house IT: which one fits? The answer mostly depends on whether you already have IT staff. No internal IT person: managed IT, where the MSP runs everything. One or two internal IT people: co-managed, where your person stays and the MSP covers what they can't. A full in-house team only makes sense once you can staff help desk, security, and after-hours coverage yourself. - **Managed IT.** The MSP is your IT department. This fits most 5-100 user firms with nobody on staff whose job is IT. - **Co-managed IT.** Your internal person keeps the seat and the relationships. The MSP takes after-hours coverage, overflow tickets, security tooling, and compliance paperwork. Our [co-managed IT page](/industries/co-managed-it) covers when that model works and when it doesn't. - **In-house only.** You hire everything yourself. One generalist can keep a network running, but one person can't be on call around the clock, take vacations, and keep up with security tooling all at once. ## What should you look for in an MSP, and what should you avoid? Look for short contracts, a flat scope you can read, published response times, and proof of real work. Avoid multi-year lock-ins, tiered plans that hold back the basics, and providers who can't show you anything they've actually done. Here's each of those, and where we stand on it. **Avoid multi-year contracts.** A provider that's confident in its work doesn't need to lock you in for three years. We're month-to-month, with a 3-user minimum. If we stop earning the work, you can leave, and our [guide to switching IT providers](/reports/switching-it-providers-first-30-days) walks through how to do it cleanly, including with us. **Avoid tiered upsells.** Watch for bronze, silver, and gold plans where EDR, email filtering, or fast response only come with the top tier. Security shouldn't be an upgrade. Our [managed IT plan](/services/managed-it-security) puts the same security baseline on every machine, with no tiers to sort through. **Look for response times in writing.** Ask what happens when you call with a real emergency. Ours are published: under 15 minutes for critical issues, 1-2 hours for standard requests. **Look for published proof.** Anybody can put "proactive" and "secure" on a website. Ask to see actual work. Every notable problem we solve gets a written incident report with the symptom, the fix, and the outcome, signed by me. Those reports include what we found on [day one with a new client](/reports/open-rdp-onboarding-discovery): Remote Desktop open to the internet, closed before anyone found it. If you're comparing local options, our [roundup of Knoxville IT companies](/reports/knoxville-it-companies) lists the shops I'd consider and who each one fits. ## Key takeaways - An MSP runs your IT on a recurring fee: monitoring, patching, security tools, and help desk, every month, whether anything broke or not. - Break-fix bills by the hour, so it earns more when things break. Managed IT earns more when they don't. Pick the incentive you want working for you. - No IT staff means managed IT. One or two internal IT people usually means co-managed. In-house only makes sense once you can staff it fully. - Avoid multi-year contracts and tiered plans that hold back security or response time for the top tier. - Ask any MSP for proof: written-up incidents, real response times, and what they found at their last onboarding. ## What this looks like when we do it Limehawk is a managed service provider for regulated small and mid-sized firms in Knoxville and East Tennessee: accounting, financial advisory, insurance, law, medical, mortgage, title, and manufacturing, usually 5-100 users. We run the monitoring, patching, security baseline, and help desk described above, and we map the controls to the rules your firm actually answers to, whether that's FTC Safeguards, HIPAA, SEC Reg S-P, NAIC, or GLBA. The terms are simple. Month-to-month, no multi-year contract, a 3-user minimum, and rates scoped on a call instead of a rate sheet. Critical issues get a response in under 15 minutes. You work with the people doing the work, not a rotating tier-1 queue. We're not the right fit if you want the cheapest hourly tech support, or if you're an enterprise that needs a dedicated NOC and SOC staff. For everyone in between, a short call is the fastest way to find out. Book a 15-minute call and bring your current setup. We'll tell you straight where the gaps are. --- # System Security Plan Template: What a CMMC Level 2 SSP Has to Say URL: https://limehawk.io/reports/system-security-plan-template Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min An SSP describes your CUI system's boundary, environment, how each NIST 800-171 requirement is met, and its connections. CMMC Level 2 needs one. > **TL;DR** A System Security Plan (SSP) is the document that describes your CUI environment: where the boundary is, what's inside it, how each of the 110 NIST SP 800-171 requirements is met, and what it connects to. It's requirement 3.12.4 of NIST SP 800-171 Rev 2. Under the CMMC rule, if you don't have an up-to-date SSP at assessment time, the assessment can't be completed, and the SSP can never go on a POA&M. NIST publishes a free Word template. The template is the easy part. The common failure is an SSP that describes what you plan to do, or what a policy says, instead of what's actually configured. - NIST 800-171 SSP requirement: 3.12.4 ## What is a System Security Plan? A System Security Plan is the written description of the system that holds your Controlled Unclassified Information and how it's protected. In NIST SP 800-171 Rev 2 it's requirement 3.12.4: "Develop, document, and periodically update system security plans that describe system boundaries, system environments of operation, how security requirements are implemented, and the relationships with or connections to other systems." That one sentence is the whole spec. Four things: the boundary, the environment, how each requirement is met, and the connections. The CMMC rule at [32 CFR 170.4](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) defines the SSP the same way, as the document that "describes the security controls in place or planned" for the system. NIST is clear that format is up to you. A footnote in [SP 800-171 Rev 2](https://csrc.nist.gov/pubs/sp/800/171/r2/upd1/final) says "there is no prescribed format or specified level of detail." It also says the plan doesn't have to be one document. It can point to policies, procedures, and other documents you already keep. A 40-page Word file is fine. So is a shorter plan that references your inventory and diagram. ## Why does CMMC Level 2 require an SSP? Because the assessment can't happen without one. [Section 170.24](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.24(c)(2)(i)(B)(5)) says you must have an SSP "in place at the time of assessment to describe each information system within the CMMC Assessment Scope." If it's missing or out of date, the finding is that "an assessment could not be completed due to incomplete information and noncompliance with 48 CFR 252.204-7012." It's also one of the few requirements you can't defer. [Section 170.21](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.21(a)(2)) lists CA.L2-3.12.4, the SSP, among the requirements that can never go on a [POA&M](/reports/poam-cmmc). Every other gap can at least be argued about. This one can't. The SSP is also where the rest of the rule points. The [scoping table in 170.19](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.19(c)(1)) says to "document asset treatment in the SSP" for CUI assets, security protection assets, contractor risk managed assets, and specialized assets. If you use a cloud provider or an outside IT provider for in-scope work, that relationship and its customer responsibility matrix belong in the SSP too. And for a C3PAO assessment, the name, date, and version of your SSP get recorded with the results. That holds whether your contract asks for Level 2 (Self) or Level 2 (C3PAO). As we covered in our [Level 2 requirements guide](/reports/cmmc-level-2-requirements), the July 13, 2026 suspension memo limits new contracts to self-assessments for now. A self-assessment still needs the SSP. ## Is there an official SSP template? Yes. NIST publishes a free CUI SSP template as a Word document. It's listed under supporting materials on the [SP 800-171 Rev 2 page](https://csrc.nist.gov/pubs/sp/800/171/r2/upd1/final). It has sections for system identification, the system environment, and each requirement, with checkboxes for implemented, planned, or not applicable. It's a reasonable starting point. Two notes. First, its requirements section still cites the December 2016 version of 800-171, so check the wording against Rev 2, which is what CMMC scores you against. Second, a template only gives you headings. Nobody fails an assessment for using the wrong headings. They fail because what's under them isn't true. ## What sections does a usable SSP need? A usable SSP covers the four things 3.12.4 asks for, plus enough context that an assessor can find the evidence. Here's the outline we'd work from. No download, because the content has to be yours. 1. **System identification.** System name, what it's for, the CUI it handles, and the company and CAGE codes it covers. 2. **Roles.** Who owns the system, who's responsible for security, and who administers it. Include how many regular and privileged users there are. 3. **Boundary and scope diagram.** A network diagram that shows where CUI lives and moves, where the boundary is, and every connection out of it: internet, cloud services, remote access, the plant floor. If you've built a [CUI enclave](/reports/cmmc-enclave), the enclave is your boundary, and the diagram should make that obvious. 4. **Environment description.** A narrative that matches the diagram: sites, networks, operating systems, servers, cloud tenants, and what each component does. 5. **Asset inventory reference.** Point to your hardware and software inventory instead of pasting it in. Every in-scope asset gets its CMMC category. The inventory, the diagram, and the SSP have to agree. We built a live one for an insurer in our [automated hardware inventory report](/reports/automated-hardware-inventory). 6. **External service providers.** Each cloud provider and outside IT provider in scope, what they do, and a reference to their customer responsibility matrix. 7. **Requirement-by-requirement implementation.** All 110 requirements. For each: status (implemented, not implemented, or not applicable with the reason), how it's met, the system or tool that does it, and where the evidence is. 8. **Specialized assets.** OT, test equipment, and anything else that can't be fully secured, with how it's isolated and managed. For a machine shop that's often an old CNC controller. More on that on our [manufacturing IT page](/industries/manufacturing). 9. **Links to the POA&M.** Every requirement marked not implemented points to its POA&M item and target date. 10. **Version history and review.** Date, version, who approved it, and when it was last updated. ## What's the most common SSP mistake? Describing intentions instead of configuration. "Users are required to use MFA" is a policy statement. "MFA is enforced by Conditional Access on every account in the tenant, with no exclusions" is a configuration, and it's one an assessor can check in a few minutes. The rule backs this up. Under [170.24](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170), a requirement is MET only if its objectives are satisfied "based on evidence," and that evidence has to be in final form. Drafts, working papers, and "unofficial or unapproved policies" don't count. Here's how the gap usually shows up: - **The SSP says "all laptops are encrypted."** The assessor asks for the encryption report. Three laptops aren't encrypted. Now the requirement is NOT MET, and the SSP is wrong too. - **The diagram is from two years ago.** The new file server isn't on it. Neither is the vendor's remote support tool. - **Implementation text is copied from a template.** It names tools you don't own. - **Something planned is marked implemented.** Planned work goes on the POA&M with a date. Don't write it up as done. The fix is boring. Write each implementation statement from the screen that runs the control, then save that screenshot or export as the evidence. If you can't point to a setting, don't call the requirement implemented. ## Key takeaways - The SSP is NIST SP 800-171 requirement 3.12.4: boundary, environment, how each requirement is implemented, and connections to other systems. - Under 32 CFR 170.24, no up-to-date SSP means the assessment can't be completed. It also can't go on a POA&M. - NIST publishes a free CUI SSP template. Use it or don't. NIST says there's no prescribed format. - Every asset category in scope has to show up in the inventory, the network diagram, and the SSP. Those three have to agree. - Write what's configured today. Anything still planned belongs on the POA&M with a date, not in the SSP as if it's done. ## What this looks like when we do it We're not a C3PAO or a Registered Practitioner Organization, and we don't perform CMMC assessments or sign your affirmation. The SSP is your document, and a senior person at your company stands behind it. Our part is making the IT sections true. We build the inventory and the network diagram from live data, configure the controls, and pull the evidence from the same tools that run them: MFA status, encryption reports, patch history, the asset list. Then the implementation statements describe settings, not hopes. If we manage in-scope systems, we're an external service provider in your SSP, and we write our part of it with that in mind. For shops with CUI drawings and a plant floor full of older equipment, our [manufacturing IT support page](/industries/manufacturing) covers how we handle the OT side. Have an SSP that describes the network you meant to build? Book a call and we'll walk through it. --- # SPRS Score: How the DoD Scores Your NIST 800-171 Work, and How to Calculate Yours URL: https://limehawk.io/reports/sprs-score Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Your SPRS score starts at 110 and loses 1, 3, or 5 points for each NIST SP 800-171 requirement you haven't met. It can go as low as -203. > **TL;DR** SPRS is the Supplier Performance Risk System, the DoD database where your NIST SP 800-171 assessment score lives. The math is simple. Start at 110, one point for each requirement, and subtract 1, 3, or 5 points for every requirement you haven't fully met. MFA and FIPS encryption are the only two that give partial credit. The score can go negative, all the way to -203. A self-assessed score has to be current, which means no more than three years old, and under CMMC a senior official affirms it every year. Post a number you can back up. The Justice Department uses the False Claims Act against contractors who misstate their cybersecurity. - Lowest possible SPRS score: -203 ## What is an SPRS score? An SPRS score is the summary number from your NIST SP 800-171 assessment, posted in the DoD's Supplier Performance Risk System. It tells a contracting officer how much of the 110-requirement standard you've actually put in place. A perfect score is 110. Contracting officers check SPRS before award, so a missing or stale score can cost you the contract. SPRS holds a lot more than cyber scores, but this number is the part that matters to most small defense suppliers. If your contract includes DFARS 252.204-7012, you're required to implement NIST SP 800-171 on the systems that handle covered defense information. The score is how you prove it. It's the same 110 requirements that [CMMC Level 2](/reports/cmmc-level-2-requirements) is built on. ## How do you calculate an SPRS score? Start at 110 and subtract points for every requirement that isn't fully met. Each requirement is worth 1, 3, or 5 points depending on how much damage its absence could do. Partly done counts as not met, except in two places. The rules come from the [DoD Assessment Methodology](https://www.acq.osd.mil/asda/dpc/cp/cyber/docs/safeguarding/NIST-SP-800-171-Assessment-Methodology-Version-1.2.1-6.24.2020.pdf) (version 1.2.1) and the CMMC version of the same method in [32 CFR 170.24](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.24). The weights: - **5 points (42 requirements).** These are the ones that, if missing, "could lead to significant exploitation of the network, or exfiltration of CUI." Examples: limiting system access to authorized users (3.1.1), security training (3.2.1), audit logging (3.3.1), baseline configurations (3.4.1), user identification and authentication (3.5.1, 3.5.2), incident response (3.6.1, 3.6.2), controlling removable media (3.8.7), and malware protection (3.14.2). - **3 points (14 requirements).** These have "a specific and confined effect," like encrypting CUI on mobile devices (3.1.19) or protecting CUI on media (3.8.2). - **1 point (the remaining 51).** Things like preventing reuse of identifiers (3.5.5) and password complexity (3.5.7). Then there's the SSP. Requirement 3.12.4, the system security plan, isn't scored at all. If you don't have one, the methodology says "an assessment could not be completed due to incomplete information and noncompliance with DFARS clause 252.204-7012." No SSP, no score. Our [SSP template guide](/reports/system-security-plan-template) covers what goes in one. ## What's the lowest possible SPRS score? The lowest possible SPRS score is -203. Both the methodology and 170.24 say the subtraction "may result in a negative score." Neither prints a floor. Add up every deduction in the rule's own lists, though, and you get 313 points: 42 five-pointers, 14 three-pointers, the two adjustable requirements at their full 5, and 51 one-pointers. 110 minus 313 is -203. Nobody with a working IT setup scores that low. But a shop with no MFA, no logging, no written plan, and no training can be deep in the negatives without knowing it. The number moves fast because the heavy requirements are the basic ones. ## Which requirements get partial credit? Only two. The 170.24 text says the method credits partial implementation "only in limited cases," and it names these: - **MFA (3.5.3).** If MFA covers remote and privileged users but not everyone else, you lose 3 points. With no MFA for anyone, you lose 5. The methodology also gives an example: an MFA rollout that's 75% done is still "not implemented." Partial credit is for the remote-and-privileged split, not for being partway through. We wrote about [MFA that holds up against phishing](/reports/phishing-resistant-mfa) separately. - **FIPS-validated encryption (3.13.11).** If you encrypt CUI but the cryptography isn't FIPS-validated, you lose 3 points. With no encryption, you lose 5. Turning on BitLocker is the easy half. Checking that the module is validated is a separate step. That's what we cover in our [BitLocker rollout report](/reports/bitlocker-encryption-automation). Everything else is all or nothing. A plan of action doesn't earn points either. The methodology says requirements not implemented are "not implemented" whether "a plan of action is in place or not." If you're using a POA&M to get a conditional CMMC status, our [POA&M guide](/reports/poam-cmmc) covers the 88-point floor and the 180-day clock. ## What do DFARS 252.204-7019 and 7020 require you to post? [DFARS 252.204-7019](https://www.acquisition.gov/dfars/252.204-7019-notice-nist-sp-800-171-dod-assessment-requirements.) says an offeror needs a "current assessment (i.e., not more than 3 years old unless a lesser time is specified in the solicitation)" in SPRS to be considered for award. [DFARS 252.204-7020](https://www.acquisition.gov/dfars/252.204-7020-nist-sp-800-171dod-assessment-requirements.) defines three assessment levels. A **Basic** assessment is your own self-assessment. **Medium** and **High** are done by the government. It also lists what goes into SPRS for a Basic score: - The version of NIST SP 800-171 you assessed against. - Who did the assessment. - Every CAGE code tied to the system security plan. - A short description of the architecture, if you have more than one SSP. - The date you finished the assessment. - The summary score, "e.g., 95 out of 110," not the score for each requirement. - The date you expect to reach 110. 7020 also requires you to give the government access for a Medium or High assessment. You get 14 business days to rebut the findings. One thing has changed in 2026. Under [Class Deviation 2026-O0025](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf), part of the FAR overhaul and effective February 1, 2026, new DoD solicitations use a rewritten DFARS Part 240. The 7020 text now lives on as 252.240-7997, which covers only the government-run Medium and High assessments. 7019 isn't in the new clause list. For new contracts, your self-assessed score goes into SPRS through the CMMC clause. Older contracts and solicitations can still carry 7019 and 7020, so read the clauses in yours. ## How do CMMC Level 2 self-assessments get entered and affirmed? You enter a CMMC Level 2 self-assessment in SPRS, and then a senior official at your company affirms it. Under [32 CFR 170.16](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.16), the entry has to include the CMMC level, status date, assessment scope, CAGE codes, your overall score ("e.g., 105 out of 110"), and POA&M status if you have one. You redo the self-assessment every three years. The affirmation is separate. [Section 170.22](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.22) says an Affirming Official, the "senior level representative" responsible for compliance, affirms in SPRS after each assessment, after a POA&M closeout, and every year after that. The affirmation states that you have implemented, and will keep in place, every requirement that applies. This is still live. The July 13, 2026 memo suspended the Phase 2 move to C3PAO certification. The September 3, 2026 revision of the class deviation still lets programs ask for Level 1 (Self) or Level 2 (Self). The DoD's 60-day review results weren't public as of October 2, 2026. ## Can a wrong SPRS score get you in legal trouble? Yes. On October 6, 2021, the Justice Department announced its Civil Cyber-Fraud Initiative. It uses the False Claims Act against contractors "knowingly misrepresenting their cybersecurity practices or protocols." An SPRS score is that kind of statement. The clearest example is Georgia Tech. When DOJ [joined a whistleblower suit](https://www.justice.gov/archives/opa/pr/united-states-files-suit-against-georgia-institute-technology-and-georgia-tech-research), it alleged that a summary score of 98 reported in December 2020 was false because it covered a "fictitious" or "virtual" environment, not a real system that handled covered defense information. The release notes that a False Claims Act violation carries "three times the government's losses, plus applicable penalties." Georgia Tech Research Corporation [settled on September 30, 2025](https://www.justice.gov/opa/pr/georgia-tech-research-corporation-agrees-pay-875000-resolve-civil-cyber-fraud-litigation). Per DOJ, the claims are allegations only, with no determination of liability. The lesson isn't that a low score is dangerous. A low, honest score with a real plan is fine. What gets contractors in trouble is a score that doesn't match the systems it describes. ## Key takeaways - Your SPRS score starts at 110 and drops by 1, 3, or 5 points for each NIST SP 800-171 requirement that isn't fully met. The lowest possible score is -203. - Partial credit exists in exactly two places: MFA for remote and privileged users only (minus 3, not 5) and encryption that isn't FIPS-validated (minus 3, not 5). - No SSP means no score. Without one, the methodology says the assessment can't be completed. - A self-assessment has to be no more than three years old, and under CMMC Level 2 (Self) your Affirming Official re-affirms it in SPRS every year. - The score is a statement to the government. DOJ's Civil Cyber-Fraud Initiative uses the False Claims Act against contractors who knowingly misstate their cybersecurity. ## What this looks like when we do it Limehawk isn't a C3PAO, an RPO, or an assessor, and we don't post your score or sign your affirmation. Your Affirming Official does that. What we do is the IT that makes the number true. We scope the systems that touch CUI, enforce MFA for everyone instead of just admins, roll out encryption and check the validation status, turn on logging, and keep patching and baselines on record. Then we pull the evidence from the same tools that run those controls. Shrinking the scope helps too. Our [CMMC enclave guide](/reports/cmmc-enclave) covers that option. On the plant floor, we document and segment OT instead of pretending it isn't there. Our [manufacturing IT page](/industries/manufacturing) explains how. If a prime is asking for your SPRS score and you're not sure what it really is, our [manufacturing IT support](/industries/manufacturing) team can help you count it. Book a call and we'll go through it. --- # SocGholish Fake Browser Update: What to Do If Someone Clicked It URL: https://limehawk.io/reports/socgholish-fake-browser-update Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min SocGholish shows a fake browser update on a real, hacked website. If someone ran the file, unplug that PC and call IT. It's an entry point for ransomware. > **TL;DR** SocGholish, also called FakeUpdates, is malware that hides on real websites that have been hacked. A visitor sees a page saying their browser is out of date and gets a download, usually a .js file or a .zip with one inside. If someone opens it, the attackers get a foothold on that PC and can sell it to ransomware crews. Microsoft, Proofpoint, and Europol have all tied it to ransomware. If it ran, unplug the machine from the network, leave it on, and call IT. Real browsers update themselves and never ask you to download a file from a web page. Europol announced a takedown in June 2026, but copycat groups still use the same trick. - Hacked sites cleaned, June 2026: 14,971 ## Someone clicked the fake Chrome update. What do I do right now? Get that computer off the network, leave it powered on, and call whoever runs your IT. Don't keep using it, don't wipe it, and don't try to clean it up yourself. The file is a door, not the whole attack. The goal in the first hour is to shut the door before someone walks through it. In order: 1. **Disconnect it.** Unplug the network cable and turn off Wi-Fi. If you can't do either, shut it down. CISA's [#StopRansomware Guide](https://www.cisa.gov/stopransomware/ransomware-guide) says to isolate affected systems right away and to power down only if you can't disconnect them, because a running machine holds evidence. 2. **Don't wipe it or run random cleaners.** IT needs to see what ran and when. A reinstall before anyone looks destroys the trail that shows whether the attacker moved to other machines. 3. **Call IT now, not tomorrow.** Tell them the website, roughly what time it happened, and the file name if you saw it. 4. **Change passwords from a different device.** Start with email, your agency management system, carrier portals, and banking. Assume anything saved in that browser is exposed. Turn on MFA anywhere it isn't on yet. 5. **Write it down.** Who clicked, which site, what time, what they saw. Your [incident response plan](/reports/incident-response-plan-template) should say who gets this list. If you run an [insurance agency](/industries/insurance-agencies), check your notice duties too. Tennessee's version of the NAIC model law has a [three-business-day clock to notify the commissioner](/reports/tennessee-insurance-data-security-law) once you determine a qualifying cybersecurity event happened. Your IT provider's findings are what tell you whether you've hit that bar. ## What is SocGholish? SocGholish is a JavaScript downloader spread through legitimate websites that criminals have hacked. The [Center for Internet Security (MS-ISAC)](https://www.cisecurity.org/insights/blog/ctas-leveraging-fake-browser-updates-in-malware-campaigns) describes it as using "fake software updates, specifically browser updates, to trick users into downloading the malware." Security firms also call it FakeUpdates. Proofpoint tracks the group behind it as TA569. Microsoft calls it Mustard Tempest, which used to be DEV-0206. The site you were on wasn't fake. That's what makes this one work. [Europol's June 2026 announcement](https://www.europol.europa.eu/media-press/newsroom/news/global-cyber-strike-disrupts-socgholish-amadey-and-stealc-malware-networks) says the attackers mostly hack WordPress sites, and the sites it cleaned up included "restaurants, auto repair shops, and other everyday services." Your staff could be looking up a client's business or a local vendor when the page goes gray and says the browser needs an update. The download is a script, not a program. In 2022 [Microsoft described](https://www.microsoft.com/en-us/security/blog/2022/05/09/ransomware-as-a-service-understanding-the-cybercrime-gig-economy-and-how-to-protect-yourself/) the lure delivering "a ZIP file" that "contains a JavaScript file (.js), which in most environments runs when double-clicked." [Proofpoint says](https://www.proofpoint.com/us/blog/threat-insight/ta569-socgholish-and-beyond) the file type varies and "includes .js, .zip, or .iso files among others." ## How does a fake browser update turn into ransomware? The person who infects your PC usually isn't the one who encrypts it. Proofpoint calls TA569 an [initial access broker](https://www.proofpoint.com/us/blog/threat-insight/ta569-socgholish-and-beyond), an actor who "infiltrates major targets and then sells access to other groups to deliver follow-on payloads such as ransomware." Europol says the same thing in plainer words: once the fake update runs, "the malware opens a connection to the hackers," and "even more dangerous software can then be installed." The ransomware links are documented: - Microsoft [wrote in 2022](https://www.microsoft.com/en-us/security/blog/2022/10/27/raspberry-robin-worm-part-of-larger-ecosystem-facilitating-pre-ransomware-activity/) that FakeUpdates infections led to activity from DEV-0243, a ransomware group that "overlaps with actions tracked as EvilCorp," which had deployed LockBit. - Microsoft's [profile of Manatee Tempest](https://www.microsoft.com/en-us/security/security-insider/threat-landscape/manatee-tempest), its name for Evil Corp, says the group used WastedLocker ransomware in its early partnership with Mustard Tempest. - Proofpoint's [June 2026 writeup](https://www.proofpoint.com/us/blog/threat-insight/sayonara-socgholish-operation-endgame-disrupts-major-cybercrime-operation) ties TA569's injects to WastedLocker, LockBit, and RansomHub. One more reason not to go it alone: the U.S. Treasury [sanctioned Evil Corp](https://home.treasury.gov/news/press-releases/sm845) on December 5, 2019. If this ever reaches a ransom note, that call involves your lawyer and your cyber insurer, not the office. ## Wasn't SocGholish taken down? It was disrupted, not erased. On June 24, 2026, [Europol announced](https://www.europol.europa.eu/media-press/newsroom/news/global-cyber-strike-disrupts-socgholish-amadey-and-stealc-malware-networks) an Operation Endgame action in which 14,971 infected websites were remediated and the SocGholish botnet was disabled "by taking over domain names and taking servers offline." [Proofpoint's account](https://www.proofpoint.com/us/blog/threat-insight/sayonara-socgholish-operation-endgame-disrupts-major-cybercrime-operation) names the Netherlands, Canada, the United States, and Germany as the countries that acted, and puts it at over 100 servers and domains taken down. The trick is still around. The same Proofpoint post counts "nearly a dozen different threat actor clusters" running web-inject campaigns and expects others to fill the gap. Treat any "your browser is out of date" page as hostile, whatever name is behind it this month. The same goes for fake CAPTCHA pages that ask you to paste a command, which we cover in our [ClickFix guide](/reports/clickfix-fake-captcha-malware), and for [malvertising that pushes fake software downloads](/reports/malvertising-fake-software-downloads). ## How can staff tell a fake browser update from a real one? A real browser update never comes from a web page. Chrome and Edge update themselves in the background. [Google says](https://support.google.com/chrome/answer/95414?hl=en) Chrome updates usually "happen in the background when you close and reopen" the browser. [Microsoft says](https://support.microsoft.com/en-us/topic/microsoft-edge-update-settings-af8aaca2-1b69-4870-94fe-18822dbb7ef1) Edge "automatically updates when you restart your browser." Teach one rule: if a website tells you to update your browser, close the tab. To check for real, go to the browser's own menu. In Chrome that's Help, then About Google Chrome. In Edge it's Help and feedback, then About Microsoft Edge. Europol's advice is the same: "A genuine update always comes from the official source." A file ending in .js, or a .zip with a .js inside, is never a browser update. ## How do we stop this from running next time? Training helps, but it shouldn't be the only thing standing between a click and a ransomware crew. Three technical controls cover most of it: - **Turn off Windows Script Host where nobody needs it.** Microsoft documents a [registry setting that disables WSH](https://learn.microsoft.com/en-us/previous-versions/tn-archive/ee198684(v=technet.10)) for one user or for every user on a machine. After that, a double-clicked .js file gets an error instead of running. Have IT test it first, because some line-of-business software still uses scripts. Then push it to every machine at once. - **Turn on the ASR rules for scripts.** Microsoft Defender has attack surface reduction rules called [Block JavaScript or VBScript from launching downloaded executable content](https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-reference) and Block execution of potentially obfuscated scripts. Microsoft says malware written in JavaScript or VBScript "often acts as a downloader to fetch and launch other malware." - **Run EDR and have someone watching it.** EDR is what catches the follow-on activity if a script gets through. Antivirus alone isn't the same thing, which we explain in [EDR vs antivirus](/reports/edr-vs-antivirus). Microsoft notes that some access-broker ads specifically mention a system "isn't managed by an antivirus or endpoint detection and response (EDR) product." Unwatched machines sell for more. Behind all three, keep backups the attacker can't reach. The [3-2-1 backup rule](/reports/3-2-1-backup-rule) with an offline copy is what turns a ransomware event into a restore instead of a negotiation. MFA on email and carrier portals limits what stolen passwords are worth. ## Key takeaways - A real browser updates itself. A web page that hands you an "update" file is lying, even when the site is one you trust. - If someone ran it: pull the network cable or turn off Wi-Fi, leave the PC powered on, and call IT. Change passwords from a different device. - SocGholish is an access broker's tool. The person who infects the PC sells the access, and ransomware crews buy it. - Europol announced on June 24, 2026 that 14,971 infected websites were cleaned up. Copycat groups still run the same fake-update trick. - Stop the script from running: turn off Windows Script Host where nobody needs it, turn on Microsoft's script-blocking ASR rules, and make sure someone watches the EDR alerts. ## What this looks like when we do it On [our managed IT and security plan](/services/managed-it-security), every machine gets the same baseline: EDR, 24/7 monitoring, patching, and email filtering. When EDR flags a machine, it can cut that machine off from the network remotely, which gets you step one without anyone hunting for a cable. Critical issues get a response in under 15 minutes. Then we work the incident: what ran, whether it reached other machines, which passwords need to change, and what you need to document. We push settings like the script controls above across a fleet through our RMM tooling, so they land on every machine, not just the ones someone remembered. For [insurance agencies](/industries/insurance-agencies), that work also produces the records the NAIC model law and carrier questionnaires ask for: what happened, when you found it, and what you did about it. If someone in your office clicked a fake update and you're not sure what happened next, [see what our managed security covers](/services/managed-it-security), or book a call below and we'll walk through it with you. --- # How to Know if Your Computer Is Hacked: Real Signs vs. False Alarms URL: https://limehawk.io/reports/signs-your-computer-is-hacked Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Real signs a work computer is hacked: remote-access apps you didn't install, new inbox rules, MFA prompts you didn't trigger, antivirus off. What to do next. > **TL;DR** The signs that actually mean trouble on a work computer are specific: a remote-access program nobody on your team installed, inbox rules you didn't make, MFA prompts you didn't trigger, password-reset emails you didn't ask for, antivirus that turned itself off, and people telling you about emails you never sent. A slow PC usually isn't one of them, and a browser pop-up screaming "virus detected" with a phone number is almost always a scam page, not a real infection. If you see a real sign, take the computer off the network, don't power it off or wipe it, call IT, and change your passwords from a different device. - Real virus alerts that ask you to call: None ## How do I know if my computer is hacked? Look for things that only happen when someone else is in control. A remote-access app you didn't install. Inbox rules you didn't create. MFA prompts on your phone when you weren't signing in. Password-reset emails you didn't request. Antivirus that's suddenly off. Colleagues or clients asking about an email you never sent. Any one of those is worth a call to IT today. Here's each one in plain terms: - **A remote-access program you don't recognize.** AnyDesk, ScreenConnect, and similar tools are legitimate, and IT teams use them every day. That's why attackers like them too. CISA's January 2023 advisory, [Protecting Against Malicious Use of Remote Monitoring and Management Software](https://www.cisa.gov/news-events/cybersecurity-advisories/aa23-025a), describes criminals tricking people into installing them and then using that access as a backdoor. If one shows up and your IT provider didn't put it there, treat it as an intruder. - **New inbox rules or forwarding.** Microsoft's guide to [responding to a compromised email account](https://learn.microsoft.com/en-us/defender-office-365/responding-to-a-compromised-email-account) lists rules that forward mail to unknown addresses, or quietly move messages into folders like RSS Subscriptions or Junk, as common symptoms. Attackers do this so the replies to their fake emails never reach you. - **MFA prompts you didn't trigger.** If your phone asks you to approve a sign-in and you weren't signing in, someone has your password. CISA calls the repeated version "MFA fatigue" or "push bombing" in its [number matching fact sheet](https://www.cisa.gov/sites/default/files/publications/fact-sheet-implement-number-matching-in-mfa-applications-508c.pdf). The attacker sends prompt after prompt until you tap approve by accident or just to make it stop. Deny it and call IT. - **Password-reset or "your password was changed" emails you didn't ask for.** The FTC lists this, along with sign-in alerts from devices or places you don't recognize, in its [hacked email guidance](https://consumer.ftc.gov/articles/how-recover-your-hacked-email-or-social-media-account). One caveat: a stray signup or verification code is often just someone typing your address into a form. We walked through how to tell the two apart in [our report on verification codes you didn't request](/reports/email-address-enumeration-otp-spam). - **Antivirus or Windows Security turned off.** You didn't turn it off, and now it's off. Microsoft's [tamper protection overview](https://learn.microsoft.com/en-us/defender-endpoint/tamper-protection-overview) says it plainly: "During cyberattacks, attackers often try to disable security features." Task Manager suddenly being disabled is on the FTC's list too. - **Email going out that you didn't write.** Messages in your Sent folder you don't remember sending, or a client asking why you emailed them a strange link or new payment instructions. That's the setup for [business email compromise](/reports/business-email-compromise), and it's urgent. ## What usually isn't hacking? A slow computer and a scary browser pop-up are the two things that bring most people to this page, and usually neither one means you've been hacked. Slowness is most often a full disk, a pending update, or too much running at startup. A pop-up with a phone number is a scam web page, not a message from your computer. To be fair to the FTC, its [malware guide](https://consumer.ftc.gov/articles/how-recognize-remove-avoid-malware) does list slowing down, freezing, and crashing as possible signs. They are. They're also signs of a dozen boring problems. In [one of our cases](/reports/disk-space-analysis), a "my computer is slow" ticket turned out to be 2 GB free on a 256 GB drive. Slowness plus one of the strong signs above is worth a call. Slowness alone usually isn't. The pop-up is the bigger trap. It takes over the browser, maybe beeps, and tells you to call "Microsoft support" right away. The FTC's [tech support scam page](https://consumer.ftc.gov/articles/how-spot-avoid-and-report-tech-support-scams) is clear: "Real security pop-up warnings and messages will never ask you to call a phone number." How many real virus alerts ask you to call a number? None. The person on the other end wants you to install a remote-access tool so they can "fix" the problem, which is exactly how a fake alert becomes a real compromise. Close the browser (Task Manager or a restart works if it won't close) and don't call. Changed homepages, new toolbars, and search results going to strange sites are a middle case. That's usually a browser hijacker: annoying, worth removing, rarely a hands-on attacker. See [our browser hijacker removal guide](/reports/browser-hijacker-removal) and the [OneStart case](/reports/onestart-malware-removal) where 30 machines had the same one. ## What should I do right now if my work computer was hacked? Contain it, don't destroy evidence, and get IT involved fast. In order: 1. **Disconnect it from the network.** Unplug the network cable and turn off Wi-Fi. CISA's [ransomware response checklist](https://www.cisa.gov/stopransomware/ive-been-hit-ransomware) says to isolate affected systems immediately. 2. **Leave it powered on.** CISA only recommends powering down if you can't disconnect, because shutting off loses evidence in memory. Don't restart it over and over either. 3. **Don't wipe it, reinstall Windows, or run cleanup tools you found online.** That erases what IT needs to figure out how they got in and what they touched. Removal is IT's job on a work machine. 4. **Call IT, by phone.** Don't email from the affected account. Tell them what you saw and when. If you already let someone connect remotely, or approved an MFA prompt you didn't start, say so first. 5. **Change your passwords from a different, clean device.** Start with email, then anything that touches money. Changing them on the infected computer can hand the new password straight to the attacker. Microsoft's guidance also says to sign the account out everywhere and check MFA methods; your IT team should do both. 6. **Don't pay anyone and don't move money.** If a payment or wire is involved, call your bank now, and verify any changed payment instructions by phone first. [Here's how to verify wire instructions](/reports/how-to-verify-wire-instructions). 7. **Report it.** IT will tell you whether to file with the [FBI's IC3](https://www.ic3.gov) or [CISA](https://www.cisa.gov/report). If your firm is regulated, notice deadlines may apply. That's what an [incident response plan](/reports/incident-response-plan-template) is for. ## If you saw something specific, which guide should you read? - Clicked "I'm not a robot," then got told to paste something into Run or a terminal: [ClickFix fake CAPTCHA malware](/reports/clickfix-fake-captcha-malware). - Your browser said it needed an update, and you downloaded it from the page: [SocGholish fake browser updates](/reports/socgholish-fake-browser-update). - You installed software from a search ad and it wasn't what you expected: [malvertising and fake software downloads](/reports/malvertising-fake-software-downloads). - Saved passwords or session logins were used somewhere else: [infostealer malware](/reports/infostealer-malware). - You signed in through an email "document to review" link: [fake DocuSign emails](/reports/fake-docusign-email). ## How do you stop it from happening again? Most of these signs are things the right tools catch before you do. Endpoint detection and response flags a new remote-access tool or a disabled antivirus and can isolate the machine on its own. We explain the difference in [EDR vs antivirus](/reports/edr-vs-antivirus). Phishing-resistant MFA stops push bombing because there's no prompt to approve by accident; see [phishing-resistant MFA](/reports/phishing-resistant-mfa). Monitoring for new inbox rules and odd sign-ins catches the email side. And a tested [3-2-1 backup](/reports/3-2-1-backup-rule) means a bad day doesn't turn into lost data. ## Key takeaways - The strong signs are specific: unknown remote-access apps, inbox rules or forwarding you didn't set up, MFA prompts you didn't trigger, password resets you didn't request, and antivirus that's been switched off. - A pop-up "virus alert" with a phone number is a scam page. Real security warnings never ask you to call. Close the browser and don't call. - Slowness alone is a weak signal. It's usually a full disk or a pending update. Look for a second sign before you panic. - On a work computer: unplug it from the network, leave it powered on, don't wipe or "clean" it yourself, call IT, and reset passwords from a different device. - If you already let a stranger in or approved a prompt you didn't start, say so right away. Minutes matter more than embarrassment. ## What this looks like when we do it On our [managed IT and security service](/services/managed-it-security), EDR runs on every managed endpoint with 24/7 monitoring behind it. On the [Microsoft 365 side](/services/cloud-solutions), we enforce MFA and audit sign-ins and forwarding rules, which is where the email signs above show up first. When a client calls with "I think I got hacked," critical issues get a response in under 15 minutes. We'd rather hear about a false alarm than miss a real one, so call even if you're not sure. If you're not sure what's running on your office computers right now, our [managed IT security page](/services/managed-it-security) covers the baseline, or book a call and we'll go through it with you. --- # QuickBooks Scam Email: How to Spot a Fake Intuit Invoice or Support Call URL: https://limehawk.io/reports/quickbooks-scam-email Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min A QuickBooks scam email fakes an invoice, failed payment, or suspension notice. Don't click or call the number in it. Sign in to QuickBooks yourself to check. > **TL;DR** A QuickBooks scam email is a fake Intuit message, usually an invoice, a "payment failed" notice, or an "account suspended" warning. It wants you to click a link, sign in on a fake page, or call a fake support number. Intuit says it will never ask for your password or banking details by email. Don't use any link or phone number in the message. Open QuickBooks the way you normally do and check there. If someone already clicked, typed a password, or let a "support agent" onto their computer, unplug that machine from the network, change the password from a different device, and call IT. - Times Intuit asks for your password by email: Never ## What should I do right now if I clicked a QuickBooks scam email? Stop and keep the evidence. Don't delete the email, because IT will want the headers and the link. What you do next depends on how far it went. Clicked only? Close the tab. Typed a password? Change it from another device. Let someone onto the computer? Unplug it from the network and call IT before anything else. Here's the order we'd work it in: 1. **Remote access given to a "support agent":** pull the network cable or turn off Wi-Fi on that computer. Don't shut it down and don't wipe it. Call your IT people. Assume anything that computer could reach was exposed, including saved browser passwords and the bank portal. 2. **Password typed into a sign-in page:** from a different, clean device, change your Intuit password. Then change it anywhere else you used the same one. Turn on multi-factor authentication if it isn't on already. 3. **Card or bank details given:** call your bank or card issuer on the number printed on your card. 4. **Report it.** Forward the email to Intuit at security@intuit.com, file at [ReportFraud.ftc.gov](https://reportfraud.ftc.gov/), and file with the FBI at [ic3.gov](https://www.ic3.gov/) if money moved. Intuit's own [guidance on suspicious activity and phishing](https://quickbooks.intuit.com/learn-support/en-us/help-article/fraud/identify-suspicious-activity-phishing-scams-fraud/L2NzZQnZu_US_en_US) says the same things for a clicked link: change your password, turn on MFA or a passkey, review account activity, run antivirus, and contact Intuit Support if you see transactions you didn't make. The [FTC's tech support scam page](https://consumer.ftc.gov/articles/how-spot-avoid-and-report-tech-support-scams) adds the step people forget: if you reused that password anywhere else, change it there too. ## What do QuickBooks scam emails look like? They usually take one of three shapes, and all three want you to act before you think. One is a fake invoice or receipt for something you never bought, with a "Pay now" or "View invoice" button. Another says your payment failed or your card was declined. The third says your account is suspended or about to be closed. Intuit lists the warning signs: a misspelled or altered sender address, a generic greeting, attachments you didn't expect, requests for passwords or verification codes, poor grammar, and urgent lines like "Immediate action required." Intuit says it will never ask for your password, sign-in details, or banking information by email. It also says it will never email you a "software update" or "software download" attachment. Any email that asks you to install something is fake. A lot of these don't even want a click. They want a call. The invoice says you've been charged for antivirus or a subscription, and there's a number to call to cancel it. The person who answers is the scammer. ## Can a scam email really come from intuit.com? Yes, and that's the part that trips people up. Intuit says its emails come from addresses that end in @intuit.com. That's true, but it doesn't prove the email is safe. QuickBooks sends invoices on behalf of whatever business sends them. An Intuit staff answer says invoices go out from [quickbooks@notification.intuit.com by default](https://quickbooks.intuit.com/learn-support/en-us/reports-and-accounting/why-are-invoice-notifications-coming-from-qbo-intuit-com-and-not/00/1475961). Anyone with a QuickBooks account can send one, and a criminal can open an account like anyone else. So the sender address tells you which system sent it, not who's behind it. The real test is simpler: do you know this vendor, and did you expect this bill? If not, don't pay it and don't call the number on it. If it's a vendor you do work with, call them on a number you already had. We walk through that callback in [how to verify wire instructions](/reports/how-to-verify-wire-instructions). It works the same way for a changed bank account on a vendor invoice. ## How do I tell a real QuickBooks support call from a fake one? Hang up and call back on a number you found yourself. Intuit's community team says a real call is one where you called them, or they're following up on something you already asked for. In its [post on verifying support calls](https://quickbooks.intuit.com/community/getting-the-most-out-of-quickbooks-11/is-that-really-quickbooks-support-calling-how-to-verify-and-report-a-scam-call-376256), real support never asks for your password or a one-time code. It never asks you to pay with gift cards, wire transfers, or crypto. And it doesn't install remote-access software unless you started the request. To reach real support, use the Help menu inside QuickBooks, or type quickbooks.intuit.com into the browser yourself. Don't search for "QuickBooks support number" and call the first result. Criminals [buy search ads that impersonate brands](https://www.ic3.gov/PSA/2022/PSA221221), the FBI warned in December 2022. We cover that trick in [malvertising and fake software downloads](/reports/malvertising-fake-software-downloads). The fake support call does the most damage, because it ends with a stranger in control of a computer that has your books open. The FBI's advice on [tech support scams](https://www.ic3.gov/PSA/2023/PSA230929) is blunt: never let someone who contacted you take control of your computer, and never call a number from a pop-up, text, or email. ## Why does this matter more for CPA firms? An accountant's computer isn't one company's books. It's dozens. Staff at an [accounting firm](/industries/accounting-firms) are often signed in to client QuickBooks files, the tax software, and the client portal all at once. A fake support agent on that screen, or a stolen Intuit password with no MFA, can reach client financial data at every one of them. That's customer information under the FTC Safeguards Rule. If a breach involves at least 500 consumers, the rule requires notice to the FTC within 30 days of discovery. We cover that in [the GLBA Safeguards Rule for small firms](/reports/glba-safeguards-rule-small-firms). It's also where a "check your QuickBooks payment" email turns into [business email compromise](/reports/business-email-compromise): someone reads your threads and sends your clients new payment instructions. ## What controls stop QuickBooks scam emails? No filter catches all of them, especially the ones sent through Intuit's own system. The controls that matter are the ones that limit what a click or a phone call can do. - **Enforced MFA on Intuit and email accounts.** A stolen password alone shouldn't be enough to sign in. [Phishing-resistant MFA](/reports/phishing-resistant-mfa) goes further, because a fake sign-in page can't relay it. - **EDR on every machine.** If someone installs a remote-access tool, endpoint detection and response is how you see it and cut it off. See [EDR vs. antivirus](/reports/edr-vs-antivirus). It's part of our [managed IT and security](/services/managed-it-security) baseline. - **No local admin rights for daily work.** It's harder for a caller to install software on an account that can't install software. - **A written callback rule.** No vendor bank change or "past due" payment goes out without a call to a number you already had. - **An incident response plan people can find.** The first ten minutes go better when someone already decided who unplugs what. Start from our [incident response plan template](/reports/incident-response-plan-template). - **A five-minute talk with staff** about the three shapes above, and the rule that real Intuit support doesn't call out of the blue. If the email came as a fake e-signature request instead of an invoice, the same rules apply. See our guide to the [fake DocuSign email](/reports/fake-docusign-email). ## Key takeaways - Never use a link or phone number from the email. Type the QuickBooks address yourself or use Help inside the product. - Intuit says it will never ask for your password, sign-in details, or banking information by email, and a real support call never asks for your password or a one-time code. - A real QuickBooks invoice email can still carry a scam. Anyone with a QuickBooks account can send one through Intuit's own system. - If someone gave a fake support agent remote access, disconnect the machine and treat it as compromised. Changing the QuickBooks password alone isn't enough. - Enforced MFA, EDR, and a callback rule for payment changes turn most of these into a non-event. ## What this looks like when we do it On our [managed IT and security](/services/managed-it-security) baseline, MFA is enforced on Microsoft 365, not left optional. Every machine runs EDR and is monitored. When someone calls about a suspicious email or a caller who got onto their screen, critical issues get a response in under 15 minutes. We isolate the machine, reset the accounts from a clean device, and look for persistence before anyone goes back to work. For [CPA firms](/industries/accounting-firms), we also keep the Safeguards Rule paperwork current, so an incident like this gets logged, reviewed, and documented the way the rule expects. We also handle day-to-day QuickBooks problems, like the [Intuit ID login hang](/reports/quickbooks-desktop-intuit-id-login-hang) we wrote up. That way staff call us first, not a number from a search result. If your team gets these emails and you aren't sure what would happen after a click, [book a call](/contact) and we'll walk through it. --- # Quishing: What to Do When Someone Scans a QR Code Phishing Email URL: https://limehawk.io/reports/qr-code-phishing Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Quishing is phishing with a QR code. It moves the login to a phone your email filter cannot see. If someone signed in, revoke sessions, not just the password. > **TL;DR** Quishing is phishing that hides the link inside a QR code. The email says "scan to review the document" or "scan to keep your account active," and the person scans it with their phone. That moves the login off the protected work computer and onto a phone your email filter and endpoint tools never see. The FBI says these attacks often end with a stolen session token, which gets around MFA without a single failed prompt. If someone scanned and signed in, call IT, revoke every session on the account, reset the password from a clean computer, and check the mailbox for rules and new MFA methods. To stop the next one, require phishing-resistant MFA and only let managed devices sign in. - QR phishing attacks Microsoft saw, March 2026: 18.7 million ## What should I do if someone scanned a QR code in an email? If they only scanned it and closed the page, have them report the email and delete it. If they typed a password, approved an MFA prompt, or downloaded anything, treat the account as compromised and call IT now. Do these in order: 1. **Don't sign in again from that phone.** Leave it alone and set it aside for IT. 2. **Call IT or your MSP.** Say "someone entered their password on a phishing page from a QR code." That sentence gets the right response. 3. **Revoke every session on the account.** In Microsoft 365, an admin opens the user in the Entra admin center and selects **Revoke sessions**. Microsoft's [emergency access revocation guide](https://learn.microsoft.com/en-us/entra/identity/users/users-revoke-access) covers it. Have IT do this. 4. **Reset the password from a clean computer**, not the phone that scanned the code. 5. **Check the mailbox.** IT should look for new inbox rules, forwarding, MFA methods the user didn't add, and sent mail the user didn't send. 6. **Warn anyone with money in motion.** If the user handles wires or closings, call the other parties at numbers you already had. Don't email them. Our [wire verification guide](/reports/how-to-verify-wire-instructions) walks through that call. 7. **Don't delete the email.** IT needs it to find everyone else who got it. If money already moved, call your bank first, then file at [ic3.gov](https://www.ic3.gov). Our [incident response plan template](/reports/incident-response-plan-template) has the call list to keep ready. ## What is quishing? Quishing is phishing with a QR code instead of a clickable link. The FBI's [January 8, 2026 FLASH alert](https://www.ic3.gov/CSA/2026/260108.pdf) defines it as embedding malicious URLs inside QR codes "to force victims to pivot from their corporate endpoint to a mobile device, bypassing traditional email security controls." The code usually arrives as an image in the email or inside an attached PDF. It's growing fast. Microsoft Threat Intelligence [reported](https://www.microsoft.com/en-us/security/blog/2026/04/30/email-threat-landscape-q1-2026-trends-and-insights/) QR code phishing volume went from 7.6 million attacks in January 2026 to 18.7 million in March, a 146% increase over the quarter. The FTC has been warning about this since its [December 2023 consumer alert](https://consumer.ftc.gov/consumer-alerts/2023/12/scammers-hide-harmful-links-qr-codes-steal-your-information): don't scan a QR code in an email or text you weren't expecting, especially if it pushes you to act right away. ## Why do QR codes get past email security? A QR code is a picture, not a link. Email filters are built to read, rewrite, and test links before anyone clicks them. The FBI alert says QR images "evading URL inspection, rewriting, and sandboxing." Then the scan happens on a phone, which is the real problem. The work laptop has your EDR, your web filtering, and your monitoring. The personal phone usually has none of that. The FBI puts it plainly: "the compromise path originates on unmanaged mobile devices outside normal Endpoint Detection and Response (EDR) and network inspection boundaries." The attacker picked the one device you can't see. The pages are built for phones, too. The FBI describes redirectors that check the device type, operating system, and screen size, then serve a mobile login page made to look like Microsoft 365, Okta, or a VPN portal. On a small screen, the address bar is easy to miss. ## Does MFA stop QR code phishing? Not the usual kind. Codes and push prompts can be relayed by a proxy page that sits between the user and the real login. The user types their password and approves their prompt like normal. The attacker keeps the session token that comes back. The FBI says quishing "frequently end[s] with session token theft and replay," which bypasses MFA "without triggering typical 'MFA failed' alerts." That's why a password reset alone doesn't close it. The stolen token is the attacker's key, and they don't need the password while it's valid. Revoking sessions forces every device, including theirs, to sign in again. Microsoft notes that access tokens it issues last one hour by default, so expect a short tail after revocation. What does stop it is [phishing-resistant MFA](/reports/phishing-resistant-mfa): passkeys, FIDO2 security keys, or smart cards. Those check which site they're talking to before they answer, so a fake page gets nothing it can replay. The FBI's recommendations include it: "Require phishing-resistant MFA for all remote access and sensitive systems." ## How do you spot a QR code phishing email? The tell is a QR code in an email at all. Real business email rarely needs one. You're already at a computer, so a sender who wants you on your phone is a red flag. Watch for: - **"Scan to review" or "scan to sign."** A document, invoice, or closing package you have to scan to open. The FBI's examples include a QR code that "purported to provide access to a secure drive." - **"Scan to keep your account active."** Fake MFA re-enrollment, password expiry, or mailbox storage notices. - **A PDF attachment with almost no text** besides a QR code and a deadline. - **Urgency.** The FTC calls out scammers who say there's a problem with your account or suspicious activity "to create a sense of urgency." - **A login page on your phone** right after you scanned a code from your work email. If the message might be real, go to the site the way you normally would, or call the sender at a number you already had. That's the FBI's advice too: verify through "secondary means." ## Why are title companies a target? Title companies run on e-signature requests and wire instructions, so a "document ready to sign" lure looks like Tuesday. A closer who scans one on their phone hands over the mailbox where wire instructions live. From there it's [business email compromise](/reports/business-email-compromise). The FBI notes attackers use the compromised mailbox to send more phishing to everyone it talks to. Our [managed IT for title companies](/industries/title-companies) page covers the wire fraud side. If your closers get e-signature requests all day, also read our guide to [fake DocuSign emails](/reports/fake-docusign-email). ## What controls stop quishing next time? No single setting stops it. You stack a few so one mistake doesn't become a breach: - **Phishing-resistant MFA** for anyone who moves money or has admin rights first, then everyone. - **Only managed devices can sign in.** In Microsoft 365, a Conditional Access policy can [require a compliant device](https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-device-compliance), so a password and token phished onto someone else's phone don't open the mailbox. - **Mail filtering that reads images and attachments.** Microsoft recommends turning on Safe Links and Safe Attachments in Defender for Office 365. - **Monitoring on sign-ins and mailboxes**, so new inbox rules, new MFA methods, and odd sign-in locations raise an alert. - **Training with one rule:** never scan a QR code from an email. Report it instead. If a scan also downloaded something to a computer, our [infostealer malware guide](/reports/infostealer-malware) covers what that malware takes and why it's a bigger cleanup. ## Key takeaways - A QR code in an email is a link your email filter can't easily read. Treat it like any unexpected link: don't scan it. - Scanning moves the login to a personal phone, outside the EDR and network monitoring that protect the work computer. - If someone signed in, a password reset isn't enough. Revoke every session, check inbox rules and MFA methods, and look at sent mail. - Phishing-resistant MFA and a Conditional Access rule that only allows managed devices make a stolen password or token much less useful. - If a deal or a payment was in flight, call the other side at a number you already had and tell them not to trust new instructions. ## What this looks like when we do it On our [managed IT and security](/services/managed-it-security) baseline, MFA is enforced across the tenant, not left optional per user. We push the people who handle money and admin rights toward phishing-resistant methods, and we set up Microsoft 365 email hardening and endpoint detection and response on the computers we manage. We also watch the things a quished account leaves behind: new inbox rules, new MFA methods, and sign-ins that don't fit. When someone calls to say they scanned a code and typed their password, we revoke the sessions, reset from a clean device, and walk the mailbox before the attacker uses it. Critical calls get a response in under 15 minutes. If you want to know whether a stolen password and token would open your mailbox today, our [managed IT security page](/services/managed-it-security) is the place to start, or book a call and we'll look at it with you. --- # Qilin Ransomware: What to Do First, and Why It's a Breach Question URL: https://limehawk.io/reports/qilin-ransomware Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Qilin (formerly Agenda) is ransomware that steals files before encrypting them. Isolate machines, don't pay or wipe, call IT, and plan for breach notice. > **TL;DR** Qilin is a ransomware-as-a-service operation that started as "Agenda" in July 2022 and renamed itself Qilin that September. HHS's health sector security center profiled it in June 2024, and the FBI ranked it the #2 most reported ransomware variant in 2025. It gets in through phishing, stolen VPN passwords with no MFA, and exposed remote access. It copies your files out before it encrypts, so a medical practice or a firm holding customer financial data has a breach question, not just an outage. If you see a Qilin note: disconnect the affected machines, don't pay, don't wipe anything, call IT from a phone, and get breach counsel and your insurer involved early. - FBI IC3 rank among 2025 ransomware reports: #2 ## What should I do right now if I see a Qilin ransom note? Pull the network cable or turn off Wi-Fi on every machine showing the note, and leave those machines powered on. Don't pay, don't reply to the attackers, and don't wipe or rebuild anything. Call your IT provider from a personal phone, then your cyber insurance carrier, then report it to the FBI. In order: 1. **Disconnect, don't shut down.** CISA's [#StopRansomware Guide](https://www.cisa.gov/stopransomware/ransomware-guide) says to unplug affected devices or take them off Wi-Fi, and to power a device down only if you can't get it off the network. A running machine still holds evidence. 2. **Don't pay.** The [FBI](https://www.fbi.gov/how-we-can-help-you/scams-and-safety/common-frauds-and-scams/ransomware) says it "does not support paying a ransom," and that paying "doesn't guarantee" you get anything back. HHS's Qilin profile says the same about this group's decryptors. 3. **Don't clean up.** Deleting the note, reimaging a PC, or running a free removal tool destroys the logs that tell you what was taken. That answer drives your breach notice decision. 4. **Talk out of band.** CISA warns attackers may watch your email and chat to see if they've been spotted. Use phones until IT says the network is clean. 5. **Call your insurer early,** before you hire outside help, so you know what your policy covers. Your [incident response plan](/reports/incident-response-plan-template) should have the number on page one. 6. **Report it** to the [FBI's IC3](https://www.ic3.gov), whether or not you pay. 7. **Reset passwords from a clean device,** email and admin accounts first, once IT confirms the attacker is cut off. ## What is Qilin ransomware? Qilin is ransomware rented out to criminal "affiliates" who do the break-ins and split the money with the developers. The U.S. Department of Health and Human Services' HC3 [threat profile from June 18, 2024](https://www.hhs.gov/sites/default/files/qilin-threat-profile-tlpclear.pdf) says it launched as "Agenda" in July 2022, rebranded as Qilin by that September, and likely originates from Russia. A few facts from that profile worth knowing: - Affiliates use Qilin's tools and infrastructure "in exchange for 15-20% of the proceeds." - The ransomware is written in Go and Rust, and mainly hits Windows. A Linux version that targets VMware ESXi servers showed up in December 2023. - It practices **double extortion**. Victims are posted on a leak site, and the payment is to keep the data from going public. It hasn't slowed down. The [FBI's 2025 Internet Crime Report](https://www.ic3.gov/AnnualReport/Reports/2025_IC3Report.pdf) lists Qilin #2 among the ransomware variants most reported to IC3 in 2025, behind Akira. The FBI says that top 10 hit critical manufacturing, healthcare, and government facilities hardest. As of this writing, we found no law enforcement takedown and no joint CISA/FBI advisory on Qilin. ## Who does Qilin go after? Mostly whoever has an open door. HHS calls its targeting "opportunistic rather than targeted." By leak-site count, manufacturing and professional services lead, and healthcare is a smaller share that does outsized damage. The industries that hold the most sensitive data are the ones that end up in the headlines. The numbers, by source: - **HHS, June 2024:** of more than 100 leak-site victims, manufacturing was about 21%, legal and professional services 15%, financial services 14%, and healthcare just over 7%. HHS counted at least 15 healthcare incidents worldwide since October 2022, about half in the U.S. The U.S. ones included dental clinics, a radiology company, a home health provider, a neurology center, and a cardiology clinic. - **Cisco Talos, October 2025:** in its [writeup of Qilin cases](https://blog.talosintelligence.com/uncovering-qilin-attack-methods-exposed-through-multiple-cases/), Talos put manufacturing at about 23% and professional and scientific services at about 18%, with Qilin posting more than 40 victims a month in the second half of 2025. The healthcare cases are the ones people remember. HHS notes that on June 3, 2024, Qilin hit a UK pathology and diagnostic services provider and disrupted services at several major London hospitals. If you run a [medical practice](/industries/medical-practices), a small clinic is squarely in the victim profile. ## How does Qilin get into a small office? Through the same doors as everyone else: phishing emails, stolen passwords, and remote access left open. HHS lists spear phishing plus exposed Citrix and Remote Desktop (RDP). Both vendor writeups below came down to a VPN login with no second factor. Two real cases, from the vendors who worked them: - **Sophos, July 2024.** Sophos X-Ops [described an intrusion](https://www.sophos.com/en-us/blog/qilin-ransomware-caught-stealing-credentials-stored-in-google-chrome) that started with stolen credentials on a VPN portal that "lacked multifactor authentication." The attackers sat quiet for 18 days, then used a Group Policy script to grab passwords saved in Chrome on every machine at logon. That's every website login your staff saved, not just the network. - **Cisco Talos, October 2025.** Talos assessed that attackers likely used admin credentials leaked on the dark web to log into a VPN that "had no multi-factor authentication (MFA) configured." They copied data out with an ordinary file transfer tool, deleted Windows shadow copies, and went after backup software before encrypting. Once inside, HHS says Qilin uses remote management tools and spreads with PsExec and SSH. That's software that's supposed to be on a network, which is why plain antivirus often misses it and [EDR](/reports/edr-vs-antivirus) is the tool insurers ask about. ## Is a Qilin attack a HIPAA or GLBA breach? Usually you have to assume yes until an investigation says otherwise. Qilin copies data out before encrypting, and HHS's [ransomware guidance](https://www.hhs.gov/sites/default/files/RansomwareFactSheet.pdf) says that when ePHI is encrypted by ransomware, "a breach has occurred" and is presumed unless you can show a low probability the PHI was compromised. That "low probability" has to come from a documented four-factor risk assessment. You can't fill it out without logs. That's another reason not to wipe machines in the first hour. The clocks, by rule: - **HIPAA:** notify affected patients "without unreasonable delay and in no case later than 60 calendar days after discovery" ([45 CFR 164.404](https://www.ecfr.gov/current/title-45/part-164/section-164.404)). You also notify HHS, and the media too if it's over 500 people. - **GLBA / FTC Safeguards Rule:** firms covered by the FTC rule, like mortgage brokers and tax preparers, must tell the FTC within 30 days of discovery when unencrypted data on 500 or more consumers is taken ([16 CFR 314.4(j)](https://www.ecfr.gov/current/title-16/part-314/section-314.4)). - **State law** may add its own deadlines. That's a question for your breach counsel. We're not your lawyer. What we can do is make sure the evidence exists to answer the question honestly. ## How do I stop Qilin next time? Close the doors the writeups describe, and keep a copy of your data the attacker can't reach. For a 5-50 person office, that's a short list. None of it is new. It's the baseline we put in place as part of [managed IT and security](/services/managed-it-security). - **MFA on every remote login.** VPN, email, remote desktop gateways, admin accounts. Both vendor cases above were a VPN with no MFA. Use the [phishing-resistant kind](/reports/phishing-resistant-mfa) where you can. - **No RDP or Citrix open to the internet.** We've [found RDP exposed](/reports/open-rdp-onboarding-discovery) on a new client's first day. - **Patch the edge first.** Firewalls and VPN appliances face the internet. Both vendor cases above started at the VPN. - **Stop saving work passwords in the browser.** Sophos recommends a real password manager instead. - **EDR with someone watching it.** The alert at 2 a.m. only helps if a person reads it. - **One remote support tool.** Remove the ones old vendors left behind. - **Offline, tested backups.** HHS passes along the FBI's advice to keep copies the infected system can't modify or delete. Follow the [3-2-1 rule](/reports/3-2-1-backup-rule) and test the restore. Backups get your files back. They don't un-steal them. That's why prevention and backups both matter. The same list covers [Akira](/reports/akira-ransomware) and [Medusa](/reports/medusa-ransomware) too. ## Key takeaways - Qilin and Agenda are the same ransomware. It launched as Agenda in July 2022 and rebranded as Qilin by September 2022. - First hour: disconnect, don't shut down, don't pay, don't wipe, call IT from a phone, call your insurer, report to the FBI. - Qilin steals first. Under HHS guidance, ransomware that encrypts PHI is presumed a breach unless you can show a low probability of compromise. - The doors it uses are ordinary: phishing, VPN logins with no MFA, exposed RDP and Citrix, and unpatched firewalls. - The fix list is boring on purpose: MFA everywhere, no open RDP, EDR someone watches, no passwords saved in the browser, and offline backups you've test-restored. ## What this looks like when we do it On our [managed IT and security](/services/managed-it-security) baseline, every machine gets EDR and 24/7 monitoring, MFA is enforced across the tenant, internet-facing RDP gets closed, and backups are kept and test-restored so recovery is a known quantity. For a [medical practice](/industries/medical-practices), that ties back to the HIPAA documentation an auditor or insurer will ask for. When something goes wrong, critical issues get a response in under 15 minutes. We isolate the affected machines, keep the evidence your breach counsel needs, and work with your insurer instead of around them. Want to know how your office holds up against what Qilin actually does? Book a call and we'll walk through it. --- # POA&M for CMMC: What a Plan of Action and Milestones Can and Can't Cover URL: https://limehawk.io/reports/poam-cmmc Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min A POA&M lists the security gaps you haven't closed and how you'll close them. CMMC allows one at Level 2 only with 88 of 110 and 180 days to close. > **TL;DR** A POA&M (plan of action and milestones) is a document that lists the security requirements you haven't met yet, what you'll do about each one, who does it, and by when. NIST SP 800-171 Rev 2 requires one in 3.12.2. CMMC puts hard limits on it in 32 CFR 170.21. You can't use one at all at Level 1. At Level 2 you need a score of at least 88 of 110, only 1-point requirements can go on it (plus non-FIPS encryption), six specific requirements can never go on it, and you have 180 days to close it out. Do that and your Conditional status becomes Final. Miss it and the Conditional status expires. - Time to close a CMMC POA&M: 180 days ## What is a POA&M? A POA&M, or plan of action and milestones, is a document that lists security gaps and the plan to close each one. NIST's glossary, quoting [SP 800-53 Rev 5](https://csrc.nist.gov/glossary/term/plan_of_action_and_milestones), defines it as "a document that identifies tasks that need to be accomplished. It details resources required to accomplish the elements of the plan, milestones for meeting the tasks, and the scheduled completion dates for the milestones." For a defense contractor, the requirement comes from [NIST SP 800-171 Rev 2](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-171r2.pdf), requirement 3.12.2: "Develop and implement plans of action designed to correct deficiencies and reduce or eliminate vulnerabilities in organizational systems." The discussion under it says plans of action "describe how any unimplemented security requirements will be met and how any planned mitigations will be implemented." You can keep it separate from your system security plan or combine them, "in any chosen format." The SSP describes what you have. The POA&M describes what you don't have yet and when you will. We cover the SSP side in our [system security plan template guide](/reports/system-security-plan-template). ## Can I use a POA&M at CMMC Level 1? No. [32 CFR 170.21(a)(1)](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.21) is one sentence: "A POA&M is not permitted at any time for Level 1 self-assessments." The scoring section, [170.24(c)(1)](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.24), says the same thing and adds that Level 1 results are scored MET or NOT MET "in their entirety." So at Level 1, all 15 safeguards have to be done before your Affirming Official signs in SPRS. There's no conditional status to fall back on. Our [CMMC Level 1 guide](/reports/cmmc-level-1) walks through the 15. ## How does CMMC limit a POA&M at Level 2? At Level 2, a POA&M gets you a Conditional status only if it meets every condition in [170.21(a)(2)](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.21). There are three, and they apply the same way whether you self-assess or a C3PAO assesses you. **1. A minimum score.** Your score divided by the number of Level 2 requirements has to be at least 0.8. With 110 requirements, that's 88 of 110. Your score starts at 110, and [170.24](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.24) subtracts 1, 3, or 5 points for each requirement NOT MET. It can go negative. If you're not sure where you stand, start with our [SPRS score guide](/reports/sprs-score). **2. One-point requirements only.** Nothing on the POA&M can be worth more than 1 point. The one exception is SC.L2-3.13.11, CUI encryption. If you encrypt but the module isn't FIPS-validated, that's a 3-point deduction, and the rule lets it go on the POA&M anyway. That rules out every 5-point requirement, which includes account control, security training, audit logging, baseline configurations, incident response, boundary protection, and malware protection. It also rules out partial MFA. MFA for remote and privileged users only is a 3-point deduction, so it can't go on a POA&M. **3. Six requirements that can never be on it:** - AC.L2-3.1.20 External Connections - AC.L2-3.1.22 Control Public Information - CA.L2-3.12.4 System Security Plan - PE.L2-3.10.3 Escort Visitors - PE.L2-3.10.4 Physical Access Logs - PE.L2-3.10.5 Manage Physical Access The SSP is on that list for a reason. Under 170.24, if you don't have an up-to-date SSP at assessment time, the assessment "could not be completed." One more line in 170.24 matters here. "A POA&M addressing NOT MET security requirements is not a substitute for a completed requirement." Anything on the POA&M still scores NOT MET. The POA&M doesn't raise your score. It only buys you time. ## What happens in the 180 days? You have 180 days from your Conditional CMMC Status Date to fix every item and pass a POA&M closeout assessment. Per [170.21(b)](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.21), the closeout only looks at the requirements that were on the POA&M. If you don't close it out in time, "the Conditional CMMC Status for the information system will expire." Who does the closeout depends on how you were assessed: - **Level 2 (Self):** you do it yourself, the same way as the original self-assessment, and post the results in SPRS. - **Level 2 (C3PAO):** an authorized C3PAO has to do it. ## What's the difference between Conditional and Final status? Conditional means your assessment left a POA&M open and that POA&M meets the 170.21 rules. Final means you scored a passing result with nothing open. [32 CFR 170.16](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.16) says you can reach Final on the first self-assessment or through a POA&M closeout. The rule also spells out what happens if a Conditional Level 2 (Self) status expires during a contract: "standard contractual remedies will apply," and you can't get new awards that require Level 2 (Self) or higher for that system until you get a new status. On the contracting side, the DoD's current class deviation for DFARS Part 240 says Level 2 and 3 "can be in a conditional level for a period not to exceed 180 days" and "award can occur with a conditional CMMC level." Level 1 "requires a final CMMC level for award." ## Does the Phase 2 suspension change any of this? No. The July 13, 2026 memo suspended the Phase 2 move to C3PAO assessments, so new contracts ask for Level 1 (Self) or Level 2 (Self). On September 3, 2026, the DoD signed [Class Deviation 2026-O0025, Revision 3](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf), which writes that memo into contracting guidance and keeps "baseline compliance with NIST SP 800-171 Rev 2" under DFARS 252.204-7012. The POA&M rules in 170.21 apply to Level 2 (Self), so they're as live as ever. Our [Level 2 requirements guide](/reports/cmmc-level-2-requirements) has the full timeline. ## What goes in a usable POA&M row? One row per NOT MET requirement, with enough detail that someone else could pick it up and finish it. Start from NIST's definition (tasks, resources, milestones, dates) and add what CMMC scoring needs. We'd want these columns: 1. **Requirement ID** in CMMC format, like IA.L2-3.5.6. 2. **Point value** from the 170.24 table. If it's more than 1, it doesn't belong on a CMMC POA&M (except non-FIPS encryption). 3. **The gap**, in plain words. "Accounts aren't disabled after a set period of inactivity," not "partially implemented." 4. **Affected systems.** Which assets in your scope are short. 5. **Planned fix.** The specific change, and any interim mitigation. 6. **Owner.** A named person, not "IT." 7. **Resources.** Licenses, hardware, outside help, hours. 8. **Milestones** with dates, each one checkable. 9. **Scheduled completion date** that falls well inside the 180 days. 10. **Status and evidence.** Open or closed, and where the proof lives. 170.24 says evidence has to be "in final form and not draft." A spreadsheet works fine. NIST doesn't require a format, and neither does the CMMC rule. What matters is that every row ties to a requirement, an owner, and a date you can hit. One more distinction from 170.24: something that breaks after you've implemented it is different. "Temporary deficiencies" covered by an operational plan of action that shows progress are assessed as MET. That's ordinary upkeep. The assessment POA&M under 170.21 is for things you never finished. ## Key takeaways - A POA&M is a list of unmet requirements with tasks, owners, resources, milestones, and due dates. NIST SP 800-171 Rev 2 requires one under 3.12.2. - No POA&Ms at CMMC Level 1. Every one of the 15 requirements has to be MET before you post. - At Level 2, a POA&M needs a score of 88 of 110 or better, and only 1-point requirements can go on it. The one exception is encryption that's in place but not FIPS-validated. - The SSP, external connections, public information, visitor escorts, physical access logs, and physical access devices can never be on a POA&M. - You have 180 days from the Conditional status date to close every item and pass a closeout assessment, or the Conditional status expires. ## What this looks like when we do it We're not a C3PAO or a Registered Practitioner Organization, and we don't perform CMMC assessments. Our job is the IT work that closes POA&M rows, and keeping the evidence that proves it. For a manufacturer, that usually means doing the 5-point items first, since they can't go on a POA&M at all. MFA for every user, named accounts, logging, patching, and endpoint protection go in before anyone scores anything. The smaller items that are left get a row, an owner, and a date, and we work them down from the same RMM and Microsoft 365 tooling that produces the evidence. On the plant floor, we isolate legacy controllers instead of forcing replacements. Our [manufacturing IT page](/industries/manufacturing) covers how we handle OT. Sitting near 88 and not sure which gaps fit on a POA&M? Book a call and we'll go through the list with you, or see how we run [CMMC compliance for defense contractors](/industries/defense-contractors) first. --- # Phishing-Resistant MFA: Why Text Codes and Push Prompts Aren't Enough URL: https://limehawk.io/reports/phishing-resistant-mfa Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Phishing-resistant MFA ties the login to the real site, so a fake page gets nothing it can use. That means FIDO2 security keys, passkeys, or smart cards. > **TL;DR** Phishing-resistant MFA is a second factor a fake login page can't steal or replay, because the authenticator checks which site it's talking to before it answers. FIDO2 security keys, passkeys, and smart cards (PKI, like the government's PIV card) qualify. Text codes, authenticator-app codes, and push prompts don't: a proxy phishing page can relay the code, or wear the user down with prompts until they tap Approve. CISA calls phishing-resistant MFA the "gold standard." Start with the people who move money and the people with admin rights, then enforce it in Microsoft 365 or Google Workspace so nobody can fall back to a weaker method. - Orgs targeted, one AiTM campaign: 10,000+ ## What is phishing-resistant MFA? Phishing-resistant MFA is a second factor that won't work on a fake website. The authenticator signs a challenge that's tied to the real site's address, so a look-alike page gets nothing it can reuse. NIST's [SP 800-63B-4](https://pages.nist.gov/800-63-4/sp800-63b.html) (August 2025) calls this "verifier name binding" and "channel binding." Codes you type in by hand don't count. NIST is blunt about it: authenticators that involve "manual entry of an authenticator output (e.g., out-of-band and OTP authenticators) SHALL NOT be considered phishing-resistant." That covers the six-digit code from a text message and the six-digit code from an authenticator app. If a person can read it and type it, a person can type it into the wrong page. CISA's fact sheet [Implementing Phishing-Resistant MFA](https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf) (October 2022) says "any form of MFA is better than no MFA," and then calls phishing-resistant MFA "the gold standard." It says organizations "should make migrating to it a high priority effort." ## Why do text codes and push prompts get phished? Because the attacker doesn't need to break them. They just ask for them. The two common ways in are adversary-in-the-middle (AiTM) phishing pages and push fatigue. Both get past MFA that's turned on and working. Neither works against a FIDO2 key or a passkey. **Adversary-in-the-middle.** The user clicks a link and lands on a page that looks exactly like their Microsoft or Google sign-in. Behind it, the attacker's server is passing everything back and forth to the real sign-in page. The user types the password, then the code, and the real site issues a session. The attacker keeps that session cookie. Microsoft [documented one AiTM campaign](https://www.microsoft.com/en-us/security/blog/2022/07/12/from-cookie-theft-to-bec-attackers-use-aitm-phishing-sites-as-entry-point-to-further-financial-fraud/) that tried to target more than 10,000 organizations starting in September 2021. In some cases it took "as little time as five minutes" from the stolen session to payment fraud. Microsoft says the attacker gets in "regardless of the sign-in method" the user picks, as long as that method can be relayed. **Push fatigue.** CISA calls this "push bombing." The attacker already has the password and keeps triggering sign-in prompts on the user's phone "until they press the 'Accept' button." Number matching helps here. CISA lists push with number matching as resistant to push bombing, but still "vulnerable to phishing attacks." **SMS on its own** is the weakest option. CISA lists text and voice codes as open to phishing, SIM swap, and SS7 attacks. It says they "should only be used as a last resort." NIST's current guidance classifies phone-network codes as "restricted." That's the problem in plain terms. Business email compromise is mostly a login problem, and our guide to [business email compromise](/reports/business-email-compromise) walks through how one turns into a redirected payment. ## What counts as phishing-resistant? Three families of authenticator qualify. Both CISA and OMB name the same ones: FIDO2/WebAuthn and PKI-based smart cards. Passkeys are FIDO2 credentials, so they count too. CISA puts it this way: "The only widely available phishing-resistant authentication is FIDO/WebAuthn authentication." - **FIDO2 security keys.** A small USB or NFC key. CISA calls these "roaming" authenticators. You tap it to sign in. It's tied to one person, not to a phone number. - **Passkeys.** The [FIDO Alliance](https://fidoalliance.org/passkeys/) defines a passkey as "an authentication credential based on FIDO standards" stored on a phone, a computer, or a hardware key. Some sync across your devices through a cloud account. Others never leave one device, like a passkey on a security key. Windows Hello for Business is the platform version that lives in the laptop itself. - **PKI smart cards.** Certificate-based sign-in, like the federal PIV card. CISA says it "requires highly mature identity management practices." For most small firms, keys and passkeys are the easier path. The federal government already made this call for its own staff. OMB memo [M-22-09](https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf) (January 26, 2022) says that for agency staff, contractors, and partners, "phishing-resistant MFA is required." It also tells agencies to drop methods "that fail to resist phishing, including protocols that register phone numbers for SMS or voice calls, supply one-time codes, or receive push notifications." It names PIV and Web Authentication as the approaches that work. ## Who should get phishing-resistant MFA first? Start with the accounts an attacker would pick: people who move money and people with admin rights. CISA says to ask "Which users are high-value targets?" and suggests help desk and IT administrators for a first phase. It also lists attorneys with e-discovery access and HR staff as high-value targets. For a firm that wires money or holds client data, I'd add one group to CISA's list: 1. **Anyone who sends wires, approves payments, or can change payment instructions.** At a title company that's the funding desk and closers. At an accounting or advisory firm it's whoever can move client money. 2. **Every admin account** in Microsoft 365, Google Workspace, and your line-of-business software. An admin can turn off everyone else's protection. 3. **Owners and partners.** Their names carry the authority attackers want to borrow. 4. **Everyone else**, once the first groups are working and you've sorted out recovery. This is what we did after the [BEC wire fraud case at a title company](/reports/bec-wire-fraud-incident-response). Org-wide 2-Step Verification enforcement went on the same day. Hardware security keys for funding staff followed over the next week. Under our baseline, funding staff get hardware keys on top of enforced MFA. If you run a title shop, our page on [managed IT for title companies](/industries/title-companies) covers the rest of the wire fraud controls. CISA also says to start with "the services that do support phishing-resistant MFA," and notes that "most hosted mail and SSO systems support FIDO." Your email is the right place to begin. ## How do you enforce it in Microsoft 365 or Google Workspace? Turning it on isn't enough. You have to require it, or users will fall back to the code their attacker can relay. Both platforms can do this. The setting lives in a different place in each one, and you should test it on a small group before you apply it to everyone. **Microsoft 365 (Entra ID).** Use a Conditional Access policy with the **Require authentication strength** control, set to the built-in **Phishing-resistant MFA strength**. Per [Microsoft's documentation](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-strengths), that strength allows FIDO2 security keys, Windows Hello for Business or platform credentials, and multifactor certificate-based authentication. Text messages, push, and app codes don't satisfy it. Conditional Access needs a Microsoft Entra ID P1 license, so check what your plan includes. Scope the first policy to your money and admin groups, and keep a break-glass admin account out of it. **Google Workspace.** In the 2-Step Verification settings, Google's [admin guide](https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification) offers three choices for allowed methods. Pick **Only security key** for the groups that move money and the admins. Google notes that this option "now supports both security keys and passkeys" and that the two "have the same level of phishing protection." If you can't go that far yet, **Any except verification codes via text, phone call** at least takes SMS off the table. Before you flip either switch, plan for the lost key. Give each person two keys, register both, and keep the spare somewhere safe. Write down how someone gets back in that doesn't involve a text message, because that's the door attackers will knock on next. Our [Microsoft 365 administration](/services/cloud-solutions) work treats recovery as part of the rollout, not something to figure out the first time someone loses a key. ## Key takeaways - Any MFA beats none, but codes and push prompts can be phished. A proxy page relays the code and keeps the session. - Phishing-resistant means the authenticator is bound to the real site. FIDO2 security keys, passkeys, and PKI smart cards qualify. - Start with the people who can move money or change payment details, and with every admin account. - Enrolled isn't enforced. In Microsoft 365 that's a Conditional Access policy; in Google Workspace it's the 2SV allowed-methods setting. - Plan for lost keys before rollout: two keys per person and a recovery process that isn't a text message. ## What this looks like when we do it We start by listing who can move money, who can change payment details, and who has admin rights. Those people get FIDO2 keys or passkeys first. Then we put the enforcement policy on that group in Microsoft 365 or Google Workspace, so the weaker methods stop working for them. MFA is enforced across the whole tenant before any of that, not left as something each user can opt into. Once the first group is stable, we widen the policy. We also look at what's already in the tenant: users still on text-message codes and admin accounts with no MFA at all. Our [Microsoft 365 administration](/services/cloud-solutions) baseline covers tenant security, enforced MFA, mail-flow rules, and sign-in monitoring. Keys are one control. Out-of-band wire verification still matters, because a perfect login doesn't stop a spoofed email from outside your firm. Want to know which of your people should be on keys first? Book a call and we'll walk your tenant with you. --- # NIST 800-171 Rev 3: What Changed, and Why DoD Contractors Still Build to Rev 2 URL: https://limehawk.io/reports/nist-800-171-rev-3 Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min NIST SP 800-171 Rev 3 cut the list to 97 requirements and added fill-in-the-blank parameters. DoD contracts and CMMC still assess against Rev 2. Build to Rev 2. > **TL;DR** NIST SP 800-171 is the list of security requirements for protecting Controlled Unclassified Information (CUI) on a contractor's systems. NIST published Revision 3 in May 2024 and withdrew Revision 2 the same month. Rev 3 has 97 requirements in 17 families, adds organization-defined parameters you fill in yourself, and drops the old basic/derived split. But the CMMC rule at 32 CFR 170.2 incorporates Rev 2, and a DoD class deviation holds DFARS 252.204-7012 to Rev 2 until it's rescinded. The September 2026 deviation that carries the CMMC pause says the same. If you hold DoD work, build to Rev 2's 110 requirements today. - Rev 3 security requirements: 97 ## What is NIST SP 800-171? NIST SP 800-171 is the National Institute of Standards and Technology's list of security requirements for protecting Controlled Unclassified Information on systems the government doesn't own. If you're a contractor and CUI lands on your computers, this is the rulebook for how those computers are secured. NIST says it's meant for use in federal contracts with nonfederal organizations. For DoD contractors, two things point at it. DFARS 252.204-7012 requires "adequate security" on contractor systems that hold covered defense information, and NIST SP 800-171 sets the minimum. And CMMC Level 2 is an assessment against it. Our [CMMC Level 2 requirements guide](/reports/cmmc-level-2-requirements) covers that assessment, and our [DFARS 252.204-7012 guide](/reports/dfars-252-204-7012) covers the clause. This page is about the standard itself, and which version you should be building to. ## What changed in NIST 800-171 Rev 3? NIST published [Revision 3 in May 2024](https://csrc.nist.gov/pubs/sp/800/171/r3/final). It has 97 security requirements in 17 families, down from 110 requirements in 14 families in Rev 2. The requirements now track the SP 800-53 moderate baseline more closely, and many of them are more specific about what you have to do. The [Rev 3 document's change log](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-171r3.pdf) (Appendix E) lists the big moves: - **No more basic vs. derived.** Rev 2 split requirements into two types. Rev 3 dropped that distinction. - **Organization-defined parameters (ODPs).** Some requirements now have blanks you fill in. More on that below. - **Grouped and withdrawn requirements.** NIST grouped related requirements where it could and "removed outdated and redundant security requirements." The old numbers stay in the document marked "Withdrawn," so the numbering doesn't shift. - **New requirements, and titles.** Every requirement now has a name, like "03.01.08 Unsuccessful Logon Attempts." - **Three new families.** Planning, System and Services Acquisition, and Supply Chain Risk Management. Those new families are where a small shop will feel the change. Planning includes 03.15.02, System Security Plan, and 03.15.03, Rules of Behavior. System and Services Acquisition includes 03.16.02, Unsupported System Components. Supply Chain Risk Management asks for a written supply chain risk management plan. If you run old Windows on the plant floor, note that unsupported-components requirement. It's not part of the standard CMMC scores you against today, but it's the direction NIST is headed. ## What are organization-defined parameters? Organization-defined parameters are blanks inside a requirement that you have to fill in with your own value. NIST doesn't set the values. Rev 3 says that if a federal agency doesn't assign a value, the contractor "must assign the value or values to complete the security requirement." Here's 03.01.08 from Rev 3. It says to "enforce a limit of [Assignment: organization-defined number] consecutive invalid logon attempts by a user during a [Assignment: organization-defined time period]." Then it gives you a list of actions to pick from when the limit is hit: lock the account for a set time, lock it until an admin releases it, delay the next prompt, and so on. Password management works the same way. Rev 3 requires a list of commonly used or compromised passwords, a check against that list whenever someone sets a password, and an update to the list on a schedule you define. In practice, ODPs move decisions out of an assessor's head and into your policy. You pick the number, write it down, and configure your systems to match. That's good discipline under Rev 2 as well. ## Does CMMC use Rev 2 or Rev 3? Rev 2. The CMMC program rule incorporates a specific edition by reference, and it isn't the current one. [32 CFR 170.2](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/subpart-A/section-170.2) lists "SP 800-171, Protecting Controlled Unclassified Information in Nonfederal Systems and Organizations, Revision 2, February 2020 (includes updates as of January 28, 2021)." It also incorporates the June 2018 edition of SP 800-171A, the assessment procedures that go with Rev 2. That holds even though NIST [withdrew Revision 2](https://csrc.nist.gov/pubs/sp/800/171/r2/upd1/final) on May 14, 2024 and lists Rev 3 as its replacement. A regulation that incorporates a dated document keeps pointing at that document until the regulation is amended. Moving CMMC to Rev 3 would take a rule change. So a CMMC Level 2 assessment, whether self or C3PAO, checks the 110 Rev 2 requirements. Your score in SPRS is on the Rev 2 scale. Our [SPRS score guide](/reports/sprs-score) walks through how that number works. ## Is there a DoD class deviation for Rev 3? Yes. On May 2, 2024, DoD issued Class Deviation 2024-O0013, and on May 22, 2024 it issued [Revision 1](https://web.archive.org/web/2026/https://www.acq.osd.mil/dpap/policy/policyvault/USA001074-24-DPC.pdf). The standard DFARS 252.204-7012 clause points to the version of 800-171 "in effect at the time the solicitation is issued," which would have meant Rev 3. The deviation changes that. In DoD's words, the deviation clause "requires contractors, who are subject to 252.204-7012, to comply with NIST SP 800-171 Revision 2." It also says the deviation "remains in effect until rescinded." It was still on DoD's list of active class deviations in [the April 2026 archive of that page](https://web.archive.org/web/20260413014341/https://www.acq.osd.mil/dpap/dars/class_deviations.html). I haven't found a rescission. DoD repeated the point more recently. [Class Deviation 2026-O0025, Revision 3](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf), signed in September 2026, tells contracting officers to follow the July 13, 2026 memo that suspended CMMC Phase 2. Per the deviation, that memo "requires baseline compliance with NIST SP 800-171 Rev 2 in accordance with the clause at DFARS 252.204-7012." Our [CMMC Level 1 guide](/reports/cmmc-level-1) covers the pause itself. ## Which revision should I build to today? If you hold or want DoD work, build to Rev 2. All 110 requirements, scored the Rev 2 way, documented in a system security plan written against Rev 2. That's what DFARS 7012 requires under the deviation and what a CMMC assessment checks. Our [system security plan template](/reports/system-security-plan-template) is set up for that. Then borrow from Rev 3 where it's cheap and makes you safer. Some examples: 1. **Write down your parameters.** Lockout threshold, session timeout, log review interval, patch windows. Rev 2 assessors ask how you decided anyway. 2. **Keep a banned-password list** and check new passwords against it. 3. **Inventory unsupported systems** and document how each one is isolated. That's also what CMMC scoping wants for specialized assets under Rev 2. 4. **Know your suppliers.** List the vendors and service providers that touch your CUI environment, including us if we manage it. Don't rewrite your SSP to Rev 3 numbering. An assessor scoring you against Rev 2 needs to find each Rev 2 requirement where they expect it. Moving CMMC to Rev 3 would take a change to 32 CFR 170, and the 7012 deviation would have to be rescinded. If that happens, you'll be starting from a working system instead of a blank page. ## Key takeaways - NIST SP 800-171 is the CUI security standard. Rev 3 (May 2024) replaced Rev 2 at NIST, with 97 requirements in 17 families. - CMMC Level 2 is scored against Rev 2. 32 CFR 170.2 incorporates the February 2020 Rev 2, not Rev 3. - Class Deviation 2024-O0013 makes DFARS 252.204-7012 contractors comply with Rev 2 and stays in effect until rescinded. - Rev 3's organization-defined parameters make you write down the numbers: lockout thresholds, review intervals, password rules. - Build to Rev 2 now. Where Rev 3 is stricter and it costs little, do it anyway so a future switch is smaller. ## What this looks like when we do it Limehawk isn't a C3PAO, an RPO, or an assessor, and we don't score or certify anyone. We do the IT work under the requirements. For a manufacturer with CUI drawings coming in, that means scoping first, then building to Rev 2: MFA everywhere, encryption with recovery keys escrowed (we've written up [a BitLocker rollout across a fleet](/reports/bitlocker-encryption-automation)), logging, patching, and an [asset inventory built from live data](/reports/automated-hardware-inventory). Where Rev 3 is a cheap upgrade, like a written lockout threshold or a list of unsupported machines, we do that too. The plant floor is usually where the real work is. If you're a [manufacturer handling CUI](/industries/manufacturing), that's where we start. We keep legacy Windows and controllers running, isolated and documented, instead of forcing replacements. Our [manufacturing IT support page](/industries/manufacturing) covers how we segment OT from the office. Not sure which revision your contract points at? Book a call and we'll read the clause with you. --- # Microsoft 365 Business Premium vs E3: Which License Fits a Regulated Firm URL: https://limehawk.io/reports/microsoft-365-business-premium-vs-e3 Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Under 300 users, Business Premium gives most regulated firms the security they need. E3 fits past 300 seats; E5 adds risk-based access and deeper compliance. > **TL;DR** Microsoft 365 Business Premium is built for organizations up to 300 users, and Microsoft caps the whole Business family at 300 seats per tenant. Under that line, Business Premium is the right license for most regulated firms. It includes Microsoft Entra ID P1 (Conditional Access), Intune Plan 1, Defender for Business, Defender for Office 365 Plan 1, and Purview sensitivity labels and DLP for email and files. Microsoft 365 E3 has no 300-seat cap, but it doesn't include Defender for Office 365 Plan 1. E5 is for firms that need risk-based sign-in policies, Premium eDiscovery and Audit, or Insider Risk Management. And no license secures anything until someone configures it. - Business plan seat cap: 300 ## What's the difference between Business Premium and E3? Business Premium is Microsoft's security bundle for small and mid-sized firms, and it stops at 300 users. Microsoft 365 E3 is the enterprise plan, and it has no cap. Under 300 seats, Business Premium actually includes more email security than E3. E3's edge is room to grow and Windows 11 Enterprise. Microsoft describes [Microsoft 365 for enterprise](https://learn.microsoft.com/en-us/microsoft-365/enterprise/microsoft-365-overview?view=o365-worldwide) as "designed for large organizations" that can also be used by smaller firms "that need the most advanced security and productivity capabilities." Its components are the Microsoft 365 Apps, Windows 11 Enterprise, Intune, and Defender for Endpoint. E5 includes all of E3 plus the Defender Suite and Purview Suite. Business Premium is described as "designed especially for small and medium-sized businesses (up to 300 users)" on Microsoft's [Business Premium overview](https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/m365bp-security-benefits?view=o365-worldwide). For a regulated firm of 5 to 100 people, that's the plan we start from. If you're still choosing a platform and not a plan, start with our [Google Workspace vs Microsoft 365 guide](/reports/google-workspace-vs-microsoft-365). ## What is the 300-user limit on Business plans? The limit is 300 seats total across all Business plans in one tenant, not 300 per plan. Microsoft's [Business Premium FAQ](https://learn.microsoft.com/en-us/microsoft-365/business-premium/microsoft-365-business-faqs?view=o365-worldwide) says organizations "may only provision up to 300 seats total across all of our Business family of plans (Business Basic, Business Standard, Business Premium)." Microsoft's own example: if you have 250 seats of Business Premium, you can add only 50 more seats across all Business plans. Microsoft "reserves the right to enforce the tenant limit," and it tells organizations with more than 300 users to look at the enterprise plans. The cap counts provisioned seats, not just Premium seats. A part-time assistant on Business Basic still uses one of the 300. If you're at 220 and hiring, plan the move to E3 before you hit the wall, not the week you do. ## What security features does Business Premium include? Business Premium is Business Standard plus the security stack. Microsoft's [business security overview](https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/m365b-security-overview?view=o365-worldwide) and the [Business Premium security FAQ](https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/m365bp-security-faq?view=o365-worldwide) list what you get: - **Microsoft Entra ID P1.** This is what unlocks Conditional Access: rules like "require MFA everywhere," "block legacy sign-ins," or "only allow compliant devices to open email." The [Conditional Access overview](https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview) confirms Business Premium customers can use it. - **Microsoft Intune Plan 1.** Full device and app management for Windows, Mac, iOS, and Android, including app protection for personal phones. - **Microsoft Defender for Business.** Endpoint protection that Microsoft calls equivalent to Defender for Endpoint Plan 1 with some Plan 2 features, including endpoint detection and response. - **Microsoft Defender for Office 365 Plan 1.** Safe Links, Safe Attachments (including for SharePoint, OneDrive, and Teams), and impersonation protection in anti-phishing policies. - **Microsoft Purview.** Per Microsoft's [Purview service description](https://learn.microsoft.com/en-us/office365/servicedescriptions/microsoft-365-service-descriptions/microsoft-365-tenantlevel-services-licensing-guidance/microsoft-purview-service-description), Business Premium covers manual sensitivity labels, Message Encryption, DLP for Exchange, SharePoint, and OneDrive, retention policies and labels, and an archive mailbox up to 1.5 TB. For a CPA firm, a title agency, or a medical practice, that list covers the controls an auditor or cyber insurer asks about: MFA, managed devices, endpoint detection, encryption, and email protection. ## What does E3 have that Business Premium doesn't? E3's main advantage is scale. There's no 300-seat ceiling. It also includes Windows 11 Enterprise, and Microsoft sells the Defender Suite and Purview Suite as add-ons on top of E3. On security, E3 and Business Premium overlap more than they differ. Both include Entra ID P1, Intune, manual sensitivity labels, Message Encryption, and DLP for email and files. One gap runs the other direction. Microsoft's [Defender service description](https://learn.microsoft.com/en-us/office365/servicedescriptions/microsoft-365-service-descriptions/microsoft-365-tenantlevel-services-licensing-guidance/microsoft-defender-service-description) lists Business Premium and E5 for Defender for Office 365 Plan 1, and E3 isn't on that list. E3 includes Defender for Endpoint Plan 1 for devices, but no Safe Links or Safe Attachments unless you add them. For a firm that loses money through its inbox, that matters. A 150-person firm moving from Business Premium to E3 for "more security" can actually lose email protection. ## When does E3 or E5 make sense? Move to E3 when you pass 300 users or you're about to. Look at E5 when your regulator, your insurer, or your own risk calls for features Business Premium doesn't have: - **Risk-based Conditional Access.** Sign-in risk and user risk policies need Microsoft Entra ID Protection, a P2 feature. Microsoft's [Entra licensing page](https://learn.microsoft.com/en-us/entra/fundamentals/licensing) lists P2 in E5, not in E3 or Business Premium. P2 also unlocks Privileged Identity Management for time-limited admin roles. - **Premium eDiscovery and Audit.** E3 covers eDiscovery (Standard) and Audit (Standard). Premium eDiscovery and Audit (Premium) come with E5. - **Insider Risk Management, Customer Lockbox, Endpoint DLP, and automatic sensitivity labeling.** All E5-level in the Purview service description. There's a middle path under 300 seats. Microsoft sells a Defender Suite add-on and Purview Suite add-ons for Business Premium. The Defender Suite adds Entra ID P2, Defender for Endpoint Plan 2, Defender for Office 365 Plan 2, Defender for Identity, and Defender for Cloud Apps. The Purview add-ons, per Microsoft, "require a Microsoft 365 Business Premium base license and are capped at 300 seats total." ## Does buying Business Premium make you secure? No. A license is permission to use a feature, not the feature working. Microsoft says so itself. After setup, "you need to review and configure the security settings," per the [business security overview](https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/m365b-security-overview?view=o365-worldwide). Some protection is on by default. New tenants get [security defaults](https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults), which require MFA registration and block legacy authentication. But Microsoft also says organizations with Entra ID P1 "security defaults are probably not right for you." To use Conditional Access, you have to turn security defaults off, and Microsoft says to "immediately enable Conditional Access policies" after that. If someone flips the switch and never builds the policies, the tenant ends up with less protection than security defaults gave it. The same goes everywhere else. Intune does nothing until devices are enrolled and compliance policies exist. Defender for Office 365's defaults are a start, but Microsoft recommends turning on the Standard or Strict preset policies. Sensitivity labels don't exist until someone creates them. We've seen what "enrolled but not enforced" costs. In our [business email compromise guide](/reports/business-email-compromise), the first fix is enforcing MFA at the tenant level on every Microsoft 365 or Google Workspace tenant you inherit. And MFA alone may not be enough. Our [phishing-resistant MFA guide](/reports/phishing-resistant-mfa) covers why. Intune and Entra also matter when a laptop won't boot: we wrote up how to find a [BitLocker recovery key](/reports/bitlocker-recovery-key-lookup) that was escrowed to Entra ID. ## Key takeaways - Microsoft limits the Business family (Basic, Standard, Premium) to 300 seats per tenant combined. Past that, you're on Enterprise plans. - Business Premium includes Entra ID P1, Intune Plan 1, Defender for Business, Defender for Office 365 Plan 1, and Purview sensitivity labels and DLP for email and files. - Microsoft 365 E3 doesn't include Defender for Office 365 Plan 1. That's Safe Links, Safe Attachments, and impersonation protection, which only come with E5 or an add-on. - Pick E5, or Business Premium plus the Defender or Purview add-ons, when you need risk-based Conditional Access, Premium eDiscovery and Audit, or Insider Risk Management. - A license is permission to use a feature. Conditional Access, device compliance, and email policies all have to be built and maintained. ## What this looks like when we do it Most tenants we take over were set up years ago and never touched again: default settings, no Conditional Access, and licenses nobody has audited. Whatever plan you're paying for, our [Microsoft 365 administration](/services/cloud-solutions) work starts with identity. That means Conditional Access policies that enforce MFA, with security defaults turned off only after those policies are live. Then we harden email with mail-flow rules that stop silent forwarding, and we watch for odd sign-ins. License management is part of the same job. We check who has what, who left and still holds a seat, and whether you're headed for the 300-seat cap. If E3 or an add-on is the right call, we'll tell you why. If it isn't, we'll tell you that too. More on how we run tenants is on our [cloud solutions page](/services/cloud-solutions). Not sure which license you need, or whether the one you have is set up? [Book a call](/contact) and we'll look at your tenant with you. --- # Medusa Ransomware: What the CISA/FBI Advisory Means for a Small Firm URL: https://limehawk.io/reports/medusa-ransomware Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Medusa is ransomware-as-a-service that steals your files, then encrypts them. Isolate machines, don't pay or wipe anything, call IT, and report it to the FBI. > **TL;DR** Medusa is a ransomware-as-a-service operation the FBI has tracked since June 2021. CISA, the FBI, and MS-ISAC published joint advisory AA25-071A on it on March 12, 2025, and the FBI, CISA, and HHS updated it on August 18, 2026. Medusa gets in through phishing and unpatched internet-facing software, uses ordinary remote support tools to move around, copies your files out, then encrypts. If you see a Medusa ransom note: disconnect the affected machines from the network, don't pay, don't wipe or rebuild anything, call your IT provider, and report it to the FBI or CISA. Prevention is boring and it works: patching, phishing-resistant MFA, locked-down remote access, and offline backups. - Medusa victims (FBI, as of Apr 2026): 500+ ## What should I do right now if I see a Medusa ransom note? Pull the network cable or turn off Wi-Fi on every machine showing the note, and leave them powered on. Don't pay, don't reply to the attackers, and don't wipe or rebuild anything yet. Call your IT provider from a phone, not a work computer, then report it to the FBI or CISA. Here's the order we'd want a client to follow: 1. **Disconnect, don't shut down.** CISA's advisory says to isolate compromised hosts "by quarantining or taking them offline." CISA's [#StopRansomware Guide](https://www.cisa.gov/stopransomware/ransomware-guide) says to power a device down only if you can't get it off the network. Unplugging stops the spread and keeps evidence for whoever investigates. 2. **Don't pay and don't negotiate.** The [advisory](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-071a) says the FBI, CISA, and HHS "do not encourage paying ransom as payment does not guarantee victim files will be recovered." The note will push you to make contact within 48 hours. That deadline is pressure, not a reason to skip steps. 3. **Don't wipe or "clean up."** Reimaging a PC, deleting the note, or running a random cleanup tool destroys the logs your IT team and your insurer need. Rebuilding is a later step, and it's IT's job. 4. **Call IT from a clean device.** Use a personal phone. Assume work email and chat are being watched until someone checks. 5. **Call your cyber insurance carrier** if you have one, early. Your [incident response plan](/reports/incident-response-plan-template) should have the number written down. 6. **Report it.** The advisory asks victims to report to the [FBI's IC3](https://www.ic3.gov), a local FBI field office, or CISA at 1-844-729-2472, whether or not you pay. 7. **Reset passwords from a clean device,** starting with email and admin accounts, once IT says the attacker's access is cut off. If your firm holds client files under a confidentiality duty, like a [law firm](/industries/law-firms), loop in whoever handles breach notice early. A Medusa attack is a data theft, not just an outage. ## What is Medusa ransomware? Medusa is a ransomware-as-a-service (RaaS) operation, first identified in June 2021. According to [CISA advisory AA25-071A](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-071a), it started as a closed group and has used an affiliate model since at least early 2023. The developers rent the ransomware to affiliates and sometimes run the ransom negotiation themselves. The advisory first came out on March 12, 2025, from the FBI, CISA, and the Multi-State Information Sharing and Analysis Center (MS-ISAC). At that point the FBI counted over 300 victims as of February 2025. The August 18, 2026 update, now co-sealed by the FBI, CISA, and HHS, puts the count at 500+ victims as of April 2026. The sectors it names include medical, education, legal, insurance, technology, and manufacturing. **Medusa is not MedusaLocker.** The FBI says the Medusa ransomware variant "is unrelated to the MedusaLocker variant and the Medusa mobile malware variant." If you're reading guidance online, check which one it's about. Same name, different crews. ## How does Medusa get into a small business? Two ways, mostly: phishing and unpatched software facing the internet. The advisory says Medusa affiliates buy access from "initial access brokers" and use phishing "as a primary method for stealing victim credentials." They also exploit known holes in internet-facing products, and they pick victims by what's unpatched, not by who they are. The software named in the advisory is the kind small firms actually run: ConnectWise ScreenConnect, Fortinet EMS, Fortra GoAnywhere, and BeyondTrust. The advisory says Medusa actors "operate opportunistically" and watch for vulnerability announcements. Translation: when a patch comes out for something on your perimeter, the clock starts for you too. Phishing is the other door. A stolen password with no second factor is all they need for webmail or a VPN. That's the same path we see in [business email compromise](/reports/business-email-compromise), and it's why the advisory asks for phishing-resistant MFA, not just any MFA. ## Why does Medusa use normal IT tools? Because they blend in. Once inside, Medusa affiliates install or reuse ordinary remote support software. The FBI saw them use AnyDesk, Atera, ConnectWise, eHorus, N-able, BeyondTrust, SimpleHelp, and Splashtop, and the advisory says they "may tailor their choice based on remote access tools already present in the victim environment." That last part matters. If your office already has three remote tools from three past IT vendors, one more session looks normal. They pair those tools with Remote Desktop (RDP), PsExec, and PowerShell to move from machine to machine. They're using things that are supposed to be there, so plain antivirus often has nothing to flag. That's the gap [EDR](/reports/edr-vs-antivirus) fills: it watches what a tool does, not just what file it is. ## What is double extortion? Double extortion means they steal your data first, then encrypt it, and charge you for both. Medusa's leak site posts victims with a countdown to publication and links to a payment wallet. The advisory says Medusa also advertises the data for sale before the timer runs out, and "there is no way to verify" they delete anything. It gets worse. In one FBI case, after a victim paid, a separate Medusa actor said the negotiator had stolen the payment and asked for half of it again for the "true decryptor." The advisory calls it "potentially" a triple-extortion scheme. This is why backups alone don't solve Medusa. A good backup gets your files back. It doesn't un-steal them. ## What does the advisory say to do, in small-firm terms? The [mitigations section](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-071a) is written for big networks. Here's how it reads for a 5-50 person office: - **Patch internet-facing things first.** Firewall, VPN, remote support portal, file transfer server. The advisory says to prioritize known exploited vulnerabilities. - **Phishing-resistant MFA** on email, VPN, and admin accounts. Security keys or passkeys, covered in our [phishing-resistant MFA guide](/reports/phishing-resistant-mfa). - **One approved remote access tool.** Remove the leftovers and block the rest. The advisory says to require a VPN or jump host for remote access. - **No RDP open to the internet.** We've [found it exposed](/reports/open-rdp-onboarding-discovery) on a new client's first day. - **Least privilege.** Staff don't use admin accounts for daily work. Check for accounts you don't recognize. - **EDR plus someone watching it.** The advisory says EDR is "particularly useful for detecting lateral connections." - **Segment the network** so one infected PC can't reach every server. - **Offline, encrypted, immutable backups,** tested. Follow the [3-2-1 rule](/reports/3-2-1-backup-rule) with one copy the network can't reach. - **Long passwords.** The advisory points to NIST and says to consider dropping forced password changes every few months. None of that is exotic. It's the same baseline we'd point to for [Akira](/reports/akira-ransomware) and [Qilin](/reports/qilin-ransomware). ## Key takeaways - Medusa is ransomware-as-a-service. It's unrelated to MedusaLocker and to the Medusa mobile malware, per the FBI. - First hour: disconnect, don't pay, don't wipe, don't use the infected network to talk about it, call IT, report to the FBI or CISA. - It gets in through phishing and unpatched internet-facing software, then hides inside normal remote support tools like AnyDesk, ScreenConnect, and Splashtop. - Double extortion means a backup gets your files back but doesn't stop the leak. You need both prevention and recovery. - The advisory's fixes for a small firm: patch fast, phishing-resistant MFA, one approved remote tool, no open RDP, EDR, and offline backups you've test-restored. ## What this looks like when we do it On our [managed IT and security](/services/managed-it-security) baseline, every machine gets EDR and 24/7 monitoring, MFA is enforced across the tenant, and backups are kept and tested so a restore is a known quantity. We standardize on one remote support tool and pull out the ones left behind by past vendors, because a stray AnyDesk install is exactly what the advisory describes Medusa hiding in. Internet-facing RDP gets closed on day one. When something does go wrong, critical issues get a response in under 15 minutes. We isolate the affected machines, preserve what the investigators need, and work with your insurer instead of around them. If you're a [law firm](/industries/law-firms) or another office holding client files, the [managed IT and security](/services/managed-it-security) page covers what's in the baseline. Want to know how your firm stacks up against the Medusa advisory? Book a call and we'll walk through it. --- # Malvertising: When the Top Search Result for Zoom or Teams Is Malware URL: https://limehawk.io/reports/malvertising-fake-software-downloads Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Malvertising is a paid search ad that impersonates real software and serves malware. If someone installed one, unplug the PC and call IT before anything else. > **TL;DR** Malvertising is criminals buying real search ads that look like Zoom, Teams, AnyDesk, or whatever you typed, then sending you to a lookalike site with a malware installer. The FBI warned about it in December 2022, and Microsoft disrupted a malware-signing service in May 2026 whose customers used paid ads for fake Teams downloads to deliver ransomware. If someone in your office ran one of these installers, unplug that computer from the network, don't wipe it, and call IT. Then change passwords from a different, clean device. Long term, the fix is boring: staff aren't local admins, IT installs the software, and every machine runs EDR. - Malware-signing certs revoked: 1,000+ ## What should I do if someone already installed it? Treat the computer as compromised and stop using it. Pull the network cable or turn off Wi-Fi, but leave the machine powered on so IT can see what ran. Don't run a "cleaner" you also found on Google, and don't wipe it yourself. Call your IT provider right away, then change passwords from a different, clean device. Here's the order we'd want it done in: 1. **Disconnect it.** Unplug the network cable or turn off Wi-Fi. Don't shut it down unless IT tells you to. 2. **Don't wipe or reinstall.** The evidence of what ran, and what it reached, is on that disk. IT decides when it gets reimaged. 3. **Call IT now.** Say what was searched, what was clicked, and roughly when. A screenshot of the ad helps if anyone still has it. 4. **Change passwords from a different device.** Start with email, banking, payroll, and anything saved in that computer's browser. A lot of this malware steals saved passwords and logged-in sessions, so assume those are gone. Our [infostealer guide](/reports/infostealer-malware) covers why. 5. **Watch the money.** If that PC touches banking or wires, call the bank and tell them. Verify any new payment instructions by phone, the way our [wire verification guide](/reports/how-to-verify-wire-instructions) describes. 6. **Don't pay anyone** who pops up asking for money to "fix" it. If you don't have a plan written down for this yet, start with our [incident response plan template](/reports/incident-response-plan-template). ## What is malvertising? Malvertising is malicious advertising: a criminal buys a real ad on a search engine so it shows up above the real result. The ad looks like the brand you searched for. It links to a copycat site with a download button, and the file is malware. Nothing about the ad itself is hacked. They paid for the spot. The FBI described exactly this in a December 21, 2022 public service announcement, [Cyber Criminals Impersonating Brands Using Search Engine Advertisement Services to Defraud Users](https://www.ic3.gov/PSA/2022/PSA221221). It says criminals "purchase advertisements that appear within internet search results using a domain that is similar to an actual business or service," and that the fake page "has a link to download software that is actually malware." The PSA calls out ransomware as one of the payloads. The FBI repeated the warning in an [April 24, 2025 PSA](https://www.ic3.gov/PSA/2025/PSA250424), this time about fake ads for employee self-service and payroll logins that steal credentials. Same trick, different bait. ## Which software do they impersonate? Anything people search for by name instead of getting from IT. Microsoft has documented fake installers for Zoom, AnyDesk, TeamViewer, Teams, PuTTY, and Webex across two campaigns. Remote access tools and meeting apps show up a lot because people need them in a hurry, usually because a client just sent a meeting link. - In November 2022, Microsoft wrote that a group it tracks as DEV-0569 ran a [malvertising campaign using Google Ads](https://www.microsoft.com/en-us/security/blog/2022/11/17/dev-0569-finds-new-ways-to-deliver-royal-ransomware-various-payloads/). It pushed fake installers for TeamViewer, Zoom, AnyDesk, and Adobe Flash Player, and the chain led to Royal ransomware. - On May 19, 2026, Microsoft published its [disruption of Fox Tempest](https://www.microsoft.com/en-us/security/blog/2026/05/19/exposing-fox-tempest-a-malware-signing-service-operation/), a service that sold criminals code-signing so malware would look like legitimate software. Microsoft says one customer, the ransomware group it calls Vanilla Tempest, spread signed fake Teams installers "through legitimately purchased advertisements that redirected users searching for Microsoft Teams" to fraudulent download pages. The post names fake AnyDesk, Teams, PuTTY, and Webex files. Microsoft revoked 1,000+ certificates tied to the service and, per its [legal announcement](https://blogs.microsoft.com/on-the-issues/2026/05/19/disrupting-fox-tempest-a-cybercrime-service/), seized the service's website and filed suit in federal court in New York. That second one matters for small offices. The installer had a valid-looking signature. "It was signed" is not proof it's safe. For an accounting firm, think about what gets searched in a hurry: a screen-sharing tool for a client, a tax software update, QuickBooks. Nobody has published a campaign tied to those exact searches that we're citing here, but the method works for any name you'd type into a search box. That's why our [accounting firm IT page](/industries/accounting-firms) treats software installs as an IT job, not a staff job. ## How do I spot a fake software ad? Look at the label and the address before you click. Paid results are marked "Sponsored" or "Ad." The real vendor's domain should match exactly, not with an extra word or a different ending. If you're about to download software, the safest move is not to search at all. Type the vendor's address or use a bookmark. Warning signs: - **The top result is marked Sponsored**, and you were looking for a download. - **The domain is close but wrong.** An extra word like "download" or "install," a misspelling, or a different ending than the vendor's real site. - **The page pushes a download immediately** and looks a little off. - **The installer asks for admin rights** for something that normally doesn't, or the program doesn't seem to do anything after it runs. The FBI's own advice in the [2022 PSA](https://www.ic3.gov/PSA/2022/PSA221221) is to check the URL before clicking an ad, type the address directly instead of searching, and use an ad blocker when searching. For businesses, it says to teach users where legitimate program downloads come from. ## How do we stop this next time? Make it so staff don't need to download software at all, and so a fake installer can't do much if they try. That comes down to four controls. None of them is exotic, and together they shut down most of this. - **Staff aren't local administrators.** A standard user account can't install system-wide software or change security settings, so a lot of fake installers stall and somebody calls IT. Some malware runs fine without admin rights, which is why this isn't the only layer. Microsoft lists least-privilege access and restricted admin accounts in its DEV-0569 mitigations. - **IT installs software through an approved channel.** If someone needs Zoom, IT pushes it from a known source. We do this with RMM tools and package managers like winget, the way we describe in our [winget deployment report](/reports/winget-rmm-automation). Nobody goes shopping on a search engine. - **Ad and web filtering.** DNS or web filtering blocks known-bad domains before the page loads, and blocking ads removes the bait. Neither is perfect, since new domains show up daily. That's why you want the next layer too. - **EDR on every machine, with someone watching it.** Endpoint detection and response watches what programs do, not just what they're named, and can cut an infected PC off the network. Our [EDR vs antivirus guide](/reports/edr-vs-antivirus) explains the difference and its blind spots. Add MFA on email and banking so a stolen password alone doesn't get anyone in, and tested backups so a worst case is a restore and not a ransom conversation. Our [3-2-1 backup rule guide](/reports/3-2-1-backup-rule) covers that part. A related trick to know: fake "your browser needs an update" pages work the same way, just without the ad. See our [SocGholish fake browser update guide](/reports/socgholish-fake-browser-update). ## Key takeaways - Malvertising is a paid search ad that impersonates real software. The ad sits above the real result and the download is malware. - If someone ran a fake installer: unplug the PC from the network, leave it on, call IT, and change passwords from a different device. - A digital signature isn't proof. Microsoft revoked 1,000+ fraudulently obtained code-signing certificates used to make malware look legitimate. - Staff shouldn't be local admins. If IT installs the software, a fake installer has nowhere to land. - Bookmark the real download pages and the real login pages. Typing or clicking a bookmark skips the ad entirely. ## What this looks like when we do it On our [managed IT and security service](/services/managed-it-security), every machine gets the same baseline: EDR, monitoring, patching, email filtering, and web filtering. When someone needs a new app, the answer we want is a ticket, not a search. We push software from known sources through our RMM, the same way we do fleet installs. If something still gets through, our critical response time is under 15 minutes. We isolate the machine, look at what ran, reset what needs resetting, and restore from backup if it comes to that. We've written up what unwanted software looks like after it's already on a fleet in our [OneStart removal report](/reports/onestart-malware-removal), and why it can get past EDR when it's signed. If you run a CPA or bookkeeping office, our [accounting firm page](/industries/accounting-firms) covers how this fits with your WISP and the FTC Safeguards Rule. Our [managed IT and security page](/services/managed-it-security) has the full baseline. Someone just clicked a sponsored download and you're not sure what happened? Book a call and we'll walk through it with you. --- # Infostealer Malware: What Lumma, RedLine, and Vidar Steal and What to Do Next URL: https://limehawk.io/reports/infostealer-malware Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Lumma, RedLine, and Vidar steal saved passwords and login cookies. Isolate the PC, reimage it, then reset passwords and sessions from a clean device. > **TL;DR** An infostealer is malware built to copy what your browser remembers: saved passwords, autofill data, crypto wallets, and the login cookies that keep you signed in. A stolen cookie can let someone walk into your email without ever seeing an MFA prompt. Lumma, RedLine, and Vidar are the big names. All three have been hit by law enforcement or replaced, and all three kept going in some form. If a machine is infected: pull it off the network, have IT reimage it, then reset passwords and sign the account out everywhere from a different, clean device. Next time, keep passwords out of the browser, run EDR someone watches, and block unknown downloads. - PCs infected with Lumma in 2 months: 394,000+ ## What should I do right now if a PC has an infostealer? Disconnect the PC from the network, stop using it, and call IT. Don't log in to anything else on that machine, and don't wipe it yourself before IT has looked. Then, from a different device you trust, change your email password and have IT sign you out of every session. Everything typed or saved on that PC should be treated as stolen. Here's the order we'd follow: 1. **Unplug it.** Pull the network cable or turn off Wi-Fi. Leave it powered on so IT can see what ran. 2. **Don't sign in to anything on it.** Every new login on an infected machine is another password handed over. 3. **Call IT.** They'll want to know what was downloaded or clicked and when. If you're a client of ours, this is a critical ticket. 4. **Use a clean device.** A different computer or your phone, not the infected one. 5. **Reset email first, then banking, then everything else** that was saved in that browser. 6. **Revoke sessions.** Have IT sign the account out everywhere. A new password alone doesn't kill a stolen cookie (more on that below). 7. **Reimage the PC.** Wipe and reinstall Windows, then restore files from backup. That's an IT job, after they've looked at what happened. If you're not sure the PC is infected, see [signs your computer is hacked](/reports/signs-your-computer-is-hacked). If it started with a fake "verify you are human" page, read our [ClickFix fake CAPTCHA guide](/reports/clickfix-fake-captcha-malware) too. ## What is infostealer malware? An infostealer is malware that copies your stored credentials and sends them to an attacker. It goes after browser passwords, autofill, credit cards, crypto wallets, and session cookies. The stolen bundle is called a "log," and the [Justice Department says](https://www.justice.gov/usao-wdtx/pr/us-joins-international-action-against-redline-and-meta-infostealers) logs are "sold on cybercrime forums and used for further fraudulent activity and other hacks." Most infostealers are sold as a service. One crew writes and runs the malware. Paying customers, called affiliates, run their own campaigns to infect people. That's why taking down one tool rarely ends anything. The affiliates move to the next one. They don't need to break anything to get in. Somebody has to run them. [Microsoft documented](https://www.microsoft.com/en-us/security/blog/2025/05/21/lumma-stealer-breaking-down-the-delivery-techniques-and-capabilities-of-a-prolific-infostealer/) Lumma arriving through phishing emails, fake ads in search results for things like "Notepad++ download," and fake CAPTCHA pages. Bitdefender says Lumma infections are "primarily driven by social engineering" rather than exploits. Fake downloads are their own topic. See our guide on [malvertising and fake software downloads](/reports/malvertising-fake-software-downloads). ## How can a stolen cookie get past MFA? When you sign in and pass MFA, the site gives your browser a session cookie so it doesn't ask again on every click. An infostealer copies that cookie. The attacker loads it into their own browser and shows up already signed in, past the password and the MFA prompt. The DOJ put it plainly: RedLine and META can "bypass multi-factor authentication (MFA) through the theft of authentication cookies." That changes the cleanup. Resetting the password stops new sign-ins, but a session that's already open can keep working until it expires or someone kills it. Microsoft's [emergency access revocation guide](https://learn.microsoft.com/en-us/entra/identity/users/users-revoke-access) has admins disable the account and select **Revoke sessions**, and it notes that access tokens last an hour by default. Google Workspace admins have a matching control: **Security > Sign-in cookies > Reset**, which [signs the user out of browsers and mobile devices](https://knowledge.workspace.google.com/admin/devices/sign-a-user-out-of-a-managed-google-account). Google notes it doesn't sign them out of desktop apps like Drive for desktop. Then IT should read the sign-in logs in Microsoft 365 or Google. They're looking for sign-ins from places you weren't, new MFA methods you didn't add, and new inbox rules. That last one is how an infostealer turns into [business email compromise](/reports/business-email-compromise). If your email was opened, the next risk is a fake wire request, so [verify wire instructions](/reports/how-to-verify-wire-instructions) by phone. ## What happened to Lumma Stealer? Lumma was disrupted in May 2025 and came back within weeks. On May 21, 2025, [Microsoft announced](https://blogs.microsoft.com/on-the-issues/2025/05/21/microsoft-leads-global-action-against-favored-cybercrime-tool/) a court-ordered action against roughly 2,300 malicious domains behind Lumma. It said it found over 394,000 Windows computers infected with Lumma between March 16 and May 16, 2025. The same day, the [Justice Department seized five domains](https://www.justice.gov/opa/pr/justice-department-seizes-domains-behind-major-information-stealing-malware-operation) that hosted Lumma's control panels, and said the FBI had identified at least 1.7 million instances of Lumma stealing information. CISA and the FBI published a [joint advisory on LummaC2](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-141b) that day too. It didn't stay down. [Trend Micro reported](https://www.trendaisecurity.com/en-us/resources-insights/trendai-security-blog/lumma-stealer-returns) on July 22, 2025 that Lumma activity was climbing again from June to July. On February 11, 2026, [Bitdefender wrote](https://www.bitdefender.com/en-us/blog/labs/lummastealer-second-life-castleloader) that "LummaStealer is back at scale," often delivered alongside a loader called CastleLoader and through fake CAPTCHA pages. ## What was RedLine Stealer, and is it gone? RedLine was one of the most common infostealers in the world until Operation Magnus. On October 29, 2024, the [Justice Department announced](https://www.justice.gov/usao-wdtx/pr/us-joins-international-action-against-redline-and-meta-infostealers) it had joined the Netherlands, Belgium, Eurojust, and other partners to seize domains, servers, and Telegram accounts used by RedLine and the closely related META stealer. It charged Maxim Rudometov, whom it described as one of RedLine's developers and administrators. The DOJ said RedLine spread through malvertising, phishing emails, fake software downloads, and fake Windows update lures. It also said investigators found millions of stolen usernames and passwords in the logs. That's the useful part for you: logs stolen years ago still get sold and reused. Any password that was saved on an infected machine should be considered burned. ## What is Vidar Stealer? Vidar is an older infostealer, around since 2018, that got a full rewrite in 2025. [Trend Micro reports](https://www.trendaisecurity.com/en-us/resources-insights/trendai-security-blog/how-vidar-stealer-2-upgrades-infostealer-capabilities) that the developer announced Vidar 2.0 on October 6, 2025. It goes after browser credentials, crypto wallets, cloud credentials, FTP and SSH accounts, Steam, Discord, and Telegram. Trend Micro tied the release to a drop in Lumma activity and expected criminals looking for a Lumma replacement to move to Vidar and StealC. The names change, but the cleanup and prevention are the same for all of them. ## How do we stop the next infostealer? You stop the next one by having less in the browser to steal and fewer ways for the malware to run. Three controls do most of the work. - **Don't save passwords in the browser.** Use a password manager instead. An infostealer's first stop is the browser's saved logins, and a password manager isn't the browser's built-in store. It also makes "reset everything" a list instead of a guessing game. - **Run EDR that someone watches.** EDR flags the behavior of a stealer grabbing browser data and calling home, and lets IT cut the PC off the network remotely. Here's [the difference between EDR and antivirus](/reports/edr-vs-antivirus). On our [managed IT and security](/services/managed-it-security) plan, EDR runs on every machine and alerts go to a person. - **Block unknown downloads.** Standard users shouldn't be admins, and Windows can be set to block executables that don't meet a prevalence, age, or trusted list criterion. That's one of Microsoft's [attack surface reduction rules](https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-reference). It stops a lot of fake installers. On the identity side, [phishing-resistant MFA](/reports/phishing-resistant-mfa) won't stop a stolen cookie, but Microsoft's [token protection guidance](https://learn.microsoft.com/en-us/entra/identity/devices/protecting-tokens-microsoft-entra-id) shows how risky-sign-in policies and device-bound tokens make a stolen session much less useful. Write all of this into your [incident response plan](/reports/incident-response-plan-template) so nobody is figuring out the order at 4 p.m. on a Friday. ## Key takeaways - Infostealers copy saved browser passwords, autofill data, crypto wallets, and session cookies. A stolen cookie can skip MFA entirely. - Lumma was disrupted in May 2025 and came back within weeks. RedLine was hit by Operation Magnus in October 2024. Vidar 2.0 shipped in October 2025. Takedowns slow these tools down. They don't end them. - If a PC is infected: disconnect it, don't keep using it, and have IT reimage it. Cleaning an infostealer in place isn't worth the risk. - Reset passwords and revoke sessions from a clean device, starting with email. A password reset alone doesn't kill a stolen cookie. - Prevention is boring and works: a password manager instead of the browser, monitored EDR, and blocking unknown downloads. ## What this looks like when we do it For our managed clients, an infostealer alert gets the same treatment as any critical ticket: a human on it in under 15 minutes. EDR lets us isolate the PC remotely, so step one doesn't depend on someone finding the network cable. From there we pull the M365 or Google sign-in logs, revoke sessions, check for new MFA methods and inbox rules, and reimage the machine from a known-good build. Files come back from backup. For a [financial advisory firm](/industries/financial-advisors), a stolen login is a Regulation S-P question, not just a reset. That's why the baseline for those firms includes EDR, email filtering, and password management, so passwords aren't sitting in the browser to begin with. The [financial advisors page](/industries/financial-advisors) covers the rest of that baseline, and our [managed IT and security page](/services/managed-it-security) covers how monitoring and response work for every client. Worried a machine in your office already ran something it shouldn't have? Book a call and we'll walk through it. --- # Incident Response Plan Template for Small Regulated Firms URL: https://limehawk.io/reports/incident-response-plan-template Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min A small-firm incident response plan needs eight sections: roles, declaring an incident, containment, outside calls, notices, evidence, recovery, and review. > **TL;DR** An incident response plan is a short written document that says who does what when something goes wrong. For a 5 to 100 user firm it needs eight sections: roles and contacts, how to declare an incident, containment steps, who calls the bank, insurer, and counsel, notification duties, evidence, recovery, and an after-action review. The FTC Safeguards Rule and amended Reg S-P both require a written plan or program. Below is an outline you can copy into your own doc, plus how our published BEC response lines up with it. - Areas FTC 314.4(h) names: 7 ## What is an incident response plan? An incident response plan is a written document that tells your people what to do before, during, and after a security incident. It names who is in charge, who gets called, how you stop the damage, and how you write it all down. CISA's [Incident Response Plan Basics](https://www.cisa.gov/sites/default/files/publications/Incident-Response-Plan-Basics_508c.pdf) describes it as a written document "formally approved by the senior leadership team." It doesn't need to be long. A small firm's plan fits on a few pages. What matters is that it exists before the bad morning, that the people named in it have read it, and that it's printed somewhere you can reach when the network isn't working. The federal reference is NIST [SP 800-61 Revision 3](https://csrc.nist.gov/pubs/sp/800/61/r3/final), published April 2025. It replaced Revision 2 from 2012 and now ties incident response to the NIST Cybersecurity Framework 2.0. It's written for organizations much bigger than yours, so use it as background, not as a form to fill out. ## Which rules require a written incident response plan? If you're a financial institution under the FTC Safeguards Rule, [16 CFR 314.4(h)](https://www.ecfr.gov/current/title-16/part-314/section-314.4#p-314.4(h)) requires a written incident response plan. It names 7 areas the plan has to address: goals, internal response processes, roles and decision authority, communications, fixing identified weaknesses, documentation and reporting, and revising the plan after an event. That covers tax preparers and many [accounting firms](/industries/accounting-firms). Firms holding data on fewer than 5,000 consumers are exempt from that paragraph under 314.6. Our [FTC Safeguards guide](/reports/glba-safeguards-rule-small-firms) covers the exemption and the 30-day FTC notice. SEC-registered advisers fall under amended Regulation S-P. [17 CFR 248.30(a)(3)](https://www.ecfr.gov/current/title-17/part-248/section-248.30#p-248.30(a)(3)) requires a written program to assess an incident, contain it, and notify affected individuals. We broke down the deadlines and vendor rules in our [Reg S-P guide](/reports/regulation-s-p-amendments-ria). Even if no rule requires a plan, write one anyway. You'll want it the first time an inbox gets taken over. ## What sections does a small-firm incident response plan need? A small-firm plan needs eight sections. Copy this outline into your own document and fill in the blanks. Keep each section short enough that a stressed person can follow it. 1. **Roles and contacts.** Name an incident lead who runs the response and makes decisions, a technical lead (your IT provider or in-house IT), and someone who handles communications. Name a backup for each one. List phone numbers for your IT provider, bank, cyber insurance carrier, attorney, and key vendors. Note who can approve destructive steps like wiping a device or shutting down a system. CISA's advice is to print the list and give a copy to everyone with a role, because "your internal email, chat, and document storage services may be down or inaccessible." 2. **How to declare an incident.** Say what staff should report (odd sign-in prompts, a missing laptop, a client asking about changed wire instructions) and who they tell, by phone. Say who decides it's an incident, and that a false alarm is fine. 3. **Containment.** Write the first moves for your likely scenarios: a compromised email account, a lost or stolen device, ransomware. For an account, that's reset the password, end every session, and check for mail rules and forwarding. For a device, it's locate, lock, or wipe. 4. **Who calls the bank, insurer, and counsel.** Assign each call to a named person. If money moved, call the bank first. The FBI's IC3 says to [contact the originating financial institution](https://www.ic3.gov/CrimeInfo/BEC) "as soon as fraud is recognized to request a recall or reversal," then file a complaint at ic3.gov. Check your cyber policy for when and how the carrier wants to hear from you. CISA also suggests reviewing the plan with your attorney, who may have preferences about outside responders and law enforcement. 5. **Notification obligations.** List which notices could apply to you and who decides: clients, regulators, and state breach laws. Write down where the deadlines come from. Don't guess them during the incident. 6. **Evidence.** Say what to preserve before anything gets cleaned up: sign-in logs, the suspicious emails, mail rules, ticket notes, and who approved what and when. Keep a running timeline as you go. 7. **Recovery.** Define what "back to normal" means: accounts reset and verified, devices rebuilt or restored from a tested backup, and the gap that let the attacker in closed. 8. **After-action review.** Meet within a couple of weeks. Walk the timeline, decide what to change, and update the plan. CISA says these reviews "must be blameless." Under 314.4(h), revising the plan after an event is one of the required areas. At the top of the document, add a line for the approval date and the next review date. CISA suggests reviewing the plan quarterly and running a tabletop exercise, where you talk through a fake incident as a team. ## How does a real incident map to the template? Our [BEC wire fraud report](/reports/bec-wire-fraud-incident-response) covers almost every section. An attacker took over a title company closer's inbox and sent fake wire instructions on a real closing. - **Declare:** the owner called us right away instead of trying to sort it out internally. That call is the reason we had time. - **Contain:** we forced a password change, ended every active session, and removed the two mail rules the attacker had set up to hide replies. - **Outside calls:** we phoned the closing agent on the other side and told them to disregard the fraudulent instructions. That call stopped the loss. The wire was never sent, so no bank recall was needed. - **Evidence:** we traced the sign-in, then checked for and ruled out connected apps, app passwords, forwarding rules, and malware on the laptop. - **Recovery:** the same day, we enforced 2-Step Verification across the whole tenant. - **After-action:** the firm adopted a written rule that any change to wire instructions gets verified by phone to a number on file. Our [stolen laptop report](/reports/mdm-remote-wipe) shows the roles section in action. Before we wiped the device, we got verbal authorization from the sales director and recorded it in the ticket. Decision authority was clear, and the paper trail was there afterward. The wipe ran 16 minutes after the theft was reported. ## Key takeaways - A small-firm plan needs eight sections: roles and contacts, declaring an incident, containment, outside calls, notification, evidence, recovery, and after-action review. - FTC Safeguards 16 CFR 314.4(h) requires a written plan covering 7 areas. Firms with data on fewer than 5,000 consumers are exempt from that paragraph, but the plan is still worth having. - Amended Reg S-P requires a written incident response program that assesses, contains, and notifies affected clients. - Print the contact list. During an incident, the email and file shares you'd look it up in may be the thing that's down. - Write down who can authorize destructive steps like a remote wipe, and record that approval in the ticket before you act. ## What this looks like when we do it We write the IT half of the plan with you, as part of our [compliance and backup work](/services/compliance-backup): who calls us, how we contain a compromised account or a lost device, which logs we pull, and where the evidence lands. You own the business half, including the bank, the insurer, your attorney, and the decision on client notices. We'll go through both halves with you before you need them. When something does happen, critical issues get a response in under 15 minutes, and every decision goes into the ticket so the after-action review has a real timeline to work from. The [compliance and backup page](/services/compliance-backup) covers the rest of the documentation insurers ask for. If you need a plan written, or you want a second set of eyes on the one you have, book a call. --- # HIPAA Compliant Texting: What Staff Can Send, and on Which Phone URL: https://limehawk.io/reports/hipaa-compliant-texting Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min HIPAA doesn't ban texting. Patients can ask for texts, and staff can text PHI to the care team, but only through an app with a BAA on a managed phone. > **TL;DR** HIPAA doesn't ban texting. Patients can ask you to reach them by text, and HHS says you should honor a reasonable request for an alternative way to communicate. Texting PHI to other providers is allowed too, as long as the Security Rule safeguards are in place. CMS says hospitals can now text patient orders through a HIPAA compliant secure texting platform, in a memo dated Feb 8, 2024. The real risk isn't the text. It's the personal phone it lives on: no BAA with the messaging service, no remote wipe when it's lost, and no record of who saw what. - CMS secure-texting memo: Feb 8, 2024 ## Is texting HIPAA compliant? Texting can be HIPAA compliant. Nothing in HIPAA names text messaging or bans it. The Security Rule asks what protects the PHI once it's in a message: who can open it, whether there's a log, whether it can be altered, and whether it's protected in transit. Plain SMS from a personal phone usually fails most of that. A managed app can pass it. The rules that matter are the [technical safeguards at 45 CFR 164.312](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.312). Here's what they mean for texting: - **Unique user identification.** Each person signs in as themselves. A shared front-desk phone with one login doesn't work. - **Automatic logoff.** The app locks after a period of inactivity. - **Audit controls.** Some record of activity on the systems that hold PHI. - **Integrity.** Messages can't be quietly altered or deleted. - **Transmission security.** Encryption in transit. It's "addressable," which means you either do it or document why not and what you did instead. It doesn't mean optional. Plain SMS gives you none of that on your terms. Whoever holds either phone can read the message, and there's no admin console or log you control. If you want the full Security Rule picture beyond texting, start with our [HIPAA compliance checklist](/reports/hipaa-compliance-checklist). ## Can you text patients? Yes. Patients can ask you to reach them by text, and you should go along with it when it's reasonable. Under [45 CFR 164.522(b)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.522), a provider "must accommodate reasonable requests" to communicate "by alternative means or at alternative locations." You can't make the patient explain why. HHS's [FAQ on emailing patients](https://www.hhs.gov/hipaa/for-professionals/faq/570/does-hipaa-permit-health-care-providers-to-use-email-to-discuss-health-issues-with-patients/index.html) is the closest official guidance, and the same rules apply to text. It says unencrypted messages to patients aren't prohibited, but you should use reasonable safeguards, like confirming the address and "limiting the amount or type of information disclosed." It also says that if a patient isn't aware of the risk, you can warn them and let them choose. If a patient says no to unencrypted messages, offer something more secure, or the phone, or the mail. Records requests are stricter. HHS says patients [have a right to their records in the form they ask for](https://www.hhs.gov/hipaa/for-professionals/faq/2060/do-individuals-have-the-right-under-hipaa-to-have/index.html), even an unsecure one, after a brief warning about the risk. Other unsecure modes depend on what you can produce and on your own risk analysis. And you can never require a patient to accept an unsecure method. In practice, for the front desk: - **Reminders and logistics by text are fine.** "Appointment Tuesday at 2. Reply C to confirm." Leave out the diagnosis. - **Warn once, then document.** If a patient wants results or details by text, read them a short script about the risk and note their answer in the chart. - **Verify the number.** A typo sends PHI to a stranger. That's on you, not the patient. Email follows the same logic. Our [HIPAA compliant email guide](/reports/hipaa-compliant-email) covers that side. ## Can staff text PHI to other providers? Yes, with the right setup. HIPAA lets you [disclose PHI for treatment](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.506), and texting a referring doctor about a shared patient is treatment. The method still has to meet the Security Rule. That's where personal SMS falls down, and where a secure messaging app earns its keep. CMS has the official word for hospitals. Its memo [QSO-24-05-Hospital/CAH](https://www.cms.gov/medicare/health-safety-standards/quality-safety-oversight-general-information/policy-memos-states-and-cms-locations/texting-patient-information-and-orders-hospitals-and-cahs), dated Feb 8, 2024, says texting patient information and orders "among members of the health care team is permissible, if accomplished through a HIPAA compliant secure texting platform." It replaced 2018 guidance that said texting orders wasn't compliant with the Conditions of Participation. Two caveats are in there too. Computerized provider order entry "continues to be the preferred method," and the platform has to be secure, encrypted, and able to identify who wrote each message. That memo is addressed to hospitals and critical access hospitals, not independent clinics. But its standard is a good one to borrow. If the app can't tell you who sent an order and can't get it into the record, don't use it for orders. ## Do you need a BAA with your messaging app? Yes, if the service stores your messages. A vendor that creates, receives, maintains, or transmits PHI for you is a business associate, and [45 CFR 164.502(e)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.502) requires satisfactory assurances, in writing, before PHI goes to them. That's the BAA. Some vendors claim they're just a "conduit," like the post office. HHS's [cloud computing guidance](https://www.hhs.gov/hipaa/for-professionals/special-topics/health-information-technology/cloud-computing/index.html) limits that exception to "transmission-only services" with temporary storage. A messaging app that keeps your conversation history is storing PHI, so it's a business associate. That holds even if the vendor says it can't read the messages because they're encrypted. Check three things before staff use any messaging app for PHI: 1. **A signed BAA** that covers the messaging product you're using, not just the vendor's other products. 2. **An admin console** where you control accounts, so you can shut off someone who leaves the same day. 3. **Logs and retention settings** you can actually see. If there's no BAA, there's no PHI in that app, whatever the marketing says about encryption. ## Why are personal phones the real problem? Personal phones are the problem because you don't control them. The texts aren't the weak point. When a staff member's own phone holds patient texts, you can't enforce a passcode, can't wipe it when it's lost, can't pull the messages when they quit, and can't show anyone who read what. Walk through the lost-phone case. Under the [breach notification rule](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-D/section-164.404), you have to notify patients after a breach of *unsecured* PHI. HHS's [breach guidance](https://www.hhs.gov/hipaa/for-professionals/breach-notification/guidance/index.html) says PHI encrypted to its standard, with the key not compromised, doesn't count as unsecured. So a lost phone that's encrypted, locked, and wiped is a bad afternoon. A lost personal phone with years of patient texts and a four-digit passcode can mean letters to patients. The Security Rule also has [device and media controls](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.310) for hardware that carries ePHI in and out of the building. A phone in a coat pocket is that hardware. We've had to act fast on a stolen device before. In our [stolen laptop remote wipe report](/reports/mdm-remote-wipe), the device was online on someone else's network within minutes, and we wiped it 16 minutes from the theft. That worked because the laptop was already managed. You can't enroll a phone after it's gone. ## What does a sane texting setup look like? A sane setup is boring. Practice-managed phones, or personal phones with a managed work profile. One approved messaging app with a BAA. Plain SMS only for reminders. A written rule for how long messages are kept and where clinical content ends up. The pieces: - **Managed devices.** Passcode, encryption, auto-lock, and remote wipe enforced by policy, not by asking nicely. - **One approved app.** Covered by a BAA, with individual logins and an admin console. Everything else is off-limits for PHI, in writing. - **Offboarding that includes the phone.** Account disabled and work data wiped on the last day. - **A retention rule.** HIPAA itself doesn't set how long [medical records are kept](https://www.hhs.gov/hipaa/for-professionals/faq/580/does-hipaa-require-covered-entities-to-keep-medical-records-for-any-period/index.html). State law does. HIPAA does require safeguards for as long as you keep the data, including disposal. Decide whether clinical texts get copied into the chart, and how long the app keeps the rest. - **A patient script.** One short warning, used every time, with the answer noted. - **Training.** Staff need to know which app to use and what never goes in plain SMS. Put all of it in your risk analysis. ## Key takeaways - HIPAA allows texting. The Security Rule cares about access control, audit logs, integrity, and transmission security, not the word "SMS." - Patients can ask to be reached by text. Honor reasonable requests, warn them once about plain text, and keep the details short. - Any messaging service that stores your messages is a business associate. No BAA, no PHI in that app. - CMS's Feb 8, 2024 memo lets hospitals and CAHs text orders through a secure texting platform. Order entry in the EHR is still preferred. - The personal phone is the breach. Managed devices, one approved app, and a retention rule fix most of it. ## What this looks like when we do it For a [medical practice](/industries/medical-practices), texting is part of the device and Security Rule work, not a separate project. We start by finding out where texting already happens, because it's almost always on personal phones. Then we put the phones that touch PHI under management with encryption, passcodes, and remote wipe. We check that the messaging app you pick has a BAA on file, tie its accounts to your user list so offboarding shuts them off, and write down the policy and retention rule so your risk analysis matches reality. The clinical side stays yours: what goes in the chart and what your providers text each other about. Our job is making sure the phone in the parking lot isn't a breach notice. More on how we support practices is on our [medical practices page](/industries/medical-practices). If your staff are texting patients from their own phones right now, [book a call](/contact) and we'll map out the fix. --- # HIPAA Compliance Checklist for Small Medical and Dental Practices URL: https://limehawk.io/reports/hipaa-compliance-checklist Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 8 min A HIPAA compliance checklist covers the Security Rule's three safeguard groups, Privacy Rule basics, and breach notice. IT owns about half of it. > **TL;DR** HIPAA compliance for a small practice comes down to three rule sets. The Security Rule covers electronic patient data with administrative, physical, and technical safeguards. The Privacy Rule covers how you use and share patient information. The Breach Notification Rule says who you tell, and how fast, when something leaks. Below is every item in one line, what "done" looks like, and who owns it. Your IT owns most of the technical list and the evidence for the rest. You still own the policies, the training, and the decisions. - Breach notice deadline, at most: 60 days ## What goes on a HIPAA compliance checklist? A HIPAA compliance checklist has five parts: the Security Rule's administrative, physical, and technical safeguards, the Privacy Rule basics, and breach notification. The Security Rule lives in [45 CFR Part 164, Subpart C](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C). It only covers electronic PHI. The Privacy Rule ([Subpart E](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E)) covers PHI in any form, paper included. Each section links the regulation it comes from. The owner tag says who does the work: **You** (the practice), **IT** (whoever runs your systems), or **Both**. One rule before you start. Some Security Rule items are "Required" and some are "Addressable." [Section 164.306(d)](https://www.ecfr.gov/current/title-45/section-164.306) says addressable means you implement it if it's reasonable and appropriate. If it isn't, you document why and implement an equivalent alternative. Addressable is not a skip button. The first item, the [HIPAA risk assessment](/reports/hipaa-risk-assessment-it), is the foundation the rest hangs on. ## What are the administrative safeguards? Administrative safeguards are the policies, people, and planning side of the Security Rule, in [45 CFR 164.308](https://www.ecfr.gov/current/title-45/section-164.308). It's the biggest group. The work is mostly decisions and paperwork, but the evidence comes from your systems. - **Risk analysis** (Required). Done: a written assessment of where ePHI lives and what threatens it. Owner: Both. - **Risk management** (Required). Done: a list of fixes from that assessment, with dates and who did them. Owner: Both. - **Sanction policy** (Required). Done: a written policy for staff who break the rules, applied when they do. Owner: You. - **Activity review** (Required). Done: someone regularly reviews audit logs, access reports, and incident reports. Owner: IT. - **Security official** (Required standard). Done: one named person is responsible for the security policies. Owner: You. - **Workforce access and termination** (Addressable). Done: access is approved before it's granted and removed the day someone leaves. Owner: Both. - **Access authorization** (Addressable). Done: each role gets only the systems it needs, documented and reviewed. Owner: IT. - **Security awareness training** (Required standard). Done: all staff, including management, are trained, with records kept. Owner: Both. - **Malware protection, login monitoring, password management** (Addressable). Done: endpoint protection on every machine, failed logins watched, a written password standard. Owner: IT. - **Incident response** (Required). Done: a written procedure, and every incident logged with its outcome. Owner: Both. - **Backup, disaster recovery, emergency mode** (Required). Done: retrievable copies of ePHI, a restore procedure, and a plan to keep seeing patients during an outage. Owner: IT. - **Contingency testing** (Addressable). Done: a restore test on file, with the date. Owner: IT. - **Periodic evaluation** (Required standard). Done: a recorded review of your safeguards, repeated when things change. Owner: Both. - **Business associate agreements** (Required). Done: a signed BAA with every vendor that creates, receives, stores, or sends your ePHI. Owner: You. That includes your IT provider, EHR host, and email provider. Our [HIPAA-compliant email guide](/reports/hipaa-compliant-email) covers what a BAA does and doesn't cover for email, and our [HIPAA-compliant texting guide](/reports/hipaa-compliant-texting) covers patient texts. ## What are the physical safeguards? Physical safeguards control who can touch the hardware and the building, in [45 CFR 164.310](https://www.ecfr.gov/current/title-45/section-164.310). For a small practice, that's the front desk screen, the network closet, and every laptop that leaves. - **Facility access** (Addressable). Done: locked server and network gear, visitor control, and a record of security repairs like lock changes. Owner: You. - **Workstation use** (Required standard). Done: a written rule for how and where workstations that access ePHI are used. Owner: You. - **Workstation security** (Required standard). Done: screens patients can't read, machines that lock, and access limited to authorized users. Owner: IT. - **Disposal** (Required). Done: drives wiped or destroyed before a device leaves for good, with a record. Owner: IT. - **Media re-use** (Required). Done: ePHI removed before a device is reassigned. Owner: IT. - **Device tracking** (Addressable). Done: an inventory showing which device is where and who has it. Owner: IT. Laptops are where this fails loudest. Our [stolen laptop remote wipe report](/reports/mdm-remote-wipe) shows what acting in minutes looks like. ## What are the technical safeguards? Technical safeguards are the controls built into your systems, in [45 CFR 164.312](https://www.ecfr.gov/current/title-45/section-164.312). IT owns this part outright. If your IT can't show you each one, you don't have it. - **Unique user IDs** (Required). Done: no shared logins on the EHR, email, or workstations. Owner: IT. - **Emergency access** (Required). Done: a written way to reach ePHI when normal access is down. Owner: IT. - **Automatic logoff** (Addressable). Done: sessions lock after a set idle time. Owner: IT. - **Encryption at rest** (Addressable). Done: full-disk encryption on every laptop and desktop, with recovery keys stored safely. Owner: IT. - **Audit controls** (Required standard). Done: logs that record activity in systems holding ePHI, kept and reviewed. Owner: IT. - **Integrity** (Required standard). Done: protections against ePHI being altered or deleted without authorization. Owner: IT. - **Authentication** (Required standard). Done: proof that each user is who they claim to be. MFA is the practical answer. Owner: IT. - **Transmission security** (Required standard). Done: ePHI encrypted when it travels over a network. Owner: IT. Encryption deserves its own line. We rolled out [BitLocker across 127 endpoints](/reports/bitlocker-encryption-automation) with recovery keys escrowed, without touching each machine by hand. Finally, [45 CFR 164.316](https://www.ecfr.gov/current/title-45/section-164.316) requires all of the above to be written down. Keep that documentation 6 years from when it was created or last in effect, whichever is later. ## What are the Privacy Rule basics? The Privacy Rule covers how you use, share, and protect PHI in every form. The administrative pieces are in [45 CFR 164.530](https://www.ecfr.gov/current/title-45/section-164.530). - **Privacy official and contact person.** Done: named in writing. Owner: You. - **Privacy training.** Done: every new hire trained within a reasonable time, and training documented. Owner: You. - **Complaint process.** Done: a way for patients to complain, and a log of complaints and outcomes. Owner: You. - **Notice of Privacy Practices.** Done: given at the first visit, acknowledgment requested, posted in the office, and on your website if you have one ([164.520](https://www.ecfr.gov/current/title-45/section-164.520)). Owner: You, with IT for the website. - **Patient right of access.** Done: record requests answered within 30 days, with one 30-day extension allowed ([164.524](https://www.ecfr.gov/current/title-45/section-164.524)). Owner: You. ## What does breach notification require? If unsecured PHI is breached, you notify each affected patient without unreasonable delay and no later than 60 days after you discover it. The rules are in [45 CFR 164.400 to 164.414](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-D). An impermissible use or disclosure is presumed a breach unless a documented risk assessment shows low probability of compromise. - **Patients.** Done: written notice, by first-class mail or email if they agreed to it. Owner: You. - **HHS.** Done: reported with the patient notices if 500 or more people are affected. Smaller breaches go in a log and are reported within 60 days of the end of the calendar year. Owner: You. - **Media.** Done: notice to prominent media outlets when more than 500 residents of one state or jurisdiction are affected. Owner: You. - **Business associates.** Done: your BAAs require vendors to tell you about breaches. Owner: You. - **Proof.** Done: records showing every notice went out on time. The burden of proof is yours. Owner: Both. [HHS guidance](https://www.hhs.gov/hipaa/for-professionals/breach-notification/guidance/index.html) says ePHI encrypted to its standard, with the key not compromised, is not "unsecured." A stolen encrypted laptop is a bad day, not a mailing to every patient. ## Is the HIPAA Security Rule changing? Maybe, but not yet. HHS published a [proposed rule](https://www.federalregister.gov/documents/2025/01/06/2024-30983/hipaa-security-rule-to-strengthen-the-cybersecurity-of-electronic-protected-health-information) on January 6, 2025. As of this writing, no final rule has been published in the Federal Register. Per the [HHS fact sheet](https://www.hhs.gov/hipaa/for-professionals/security/hipaa-security-rule-nprm/factsheet/index.html), the proposal would remove the addressable category, require encryption of ePHI at rest and in transit and MFA (both with limited exceptions), and require an asset inventory, a network map, and a compliance audit at least every 12 months. None of it is law today. ## Key takeaways - The Security Rule groups its requirements into administrative, physical, and technical safeguards. A real checklist covers all three, not just the technical ones. - Addressable does not mean optional. You implement it, or you document why not and what you did instead. - Encrypted devices are your best breach insurance. Lost PHI that was properly encrypted is not unsecured PHI, so the notification rules don't kick in. - A breach of unsecured PHI means notifying each affected patient within 60 days of discovery, plus HHS, and the media when more than 500 residents of a state are involved. - HHS proposed a stricter Security Rule in January 2025. No final rule has been published yet, so today's rule is the one you're held to. ## What this looks like when we do it On our [medical practice IT](/industries/medical-practices) baseline, we work the technical list first: MFA enforced, full-disk encryption with escrowed keys, endpoint protection, patching, monitoring, and backups we've restored on purpose. We sign a BAA with every medical practice client. The evidence comes out of the same tools: user lists, encryption status per machine, restore test dates. Your policies, training, and breach decisions stay yours. Our job is to make the IT half of this checklist true and hand you the proof when someone asks. Our [HIPAA compliant IT support page](/industries/medical-practices) covers the rest of how we work with practices. Want a second set of eyes on your checklist? Book a call and we'll go through it with you. --- # Google Workspace vs Microsoft 365 for Regulated Small Firms URL: https://limehawk.io/reports/google-workspace-vs-microsoft-365 Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Microsoft 365 fits firms that run on Outlook, Excel, and Windows. Google Workspace fits browser-first teams. Both work for regulated firms once locked down. > **TL;DR** Both platforms can carry a regulated firm. Both sign a HIPAA BAA, both can enforce MFA, and both can hold mail for retention and legal hold, on the right edition. The real difference is how your office works. If your accounting or legal software expects Outlook and Excel on a Windows desktop, and you want conditional access and device management in the same license, Microsoft 365 is the easier fit. If your team works in a browser, shares files constantly, and runs little desktop software, Google Workspace is simpler to live with. Whichever you pick, the defaults aren't enough. Google leaves 2-Step Verification enforcement off until an admin turns it on, and that gap is how we've seen a title company's inbox get taken over. - Google 2SV enforcement, by default: Off ## Is Google Workspace or Microsoft 365 better for a regulated small firm? Neither one is better for everyone. Both can meet HIPAA, FTC Safeguards, and similar rules once they're configured. Pick Microsoft 365 if your office runs Windows desktops and software that expects Outlook and Excel. Pick Google Workspace if your team works in a browser and shares documents all day. Then lock down whichever one you chose. We run both for clients. The ones that get firms in trouble aren't the wrong platform. They're the right platform left on defaults. Every point below comes from Microsoft's and Google's own admin and compliance documentation. We've left prices out on purpose, because editions and prices change more often than this page will. ## Do you need desktop Office apps, or is the browser enough? If people at your firm build real spreadsheets, redline long documents, or run software that hooks into Office, you need the desktop apps. Microsoft 365 includes them in some plans and not others. Google Workspace is built around the browser. Microsoft's [plan comparison](https://learn.microsoft.com/en-us/office365/servicedescriptions/office-365-platform-service-description/office-365-plan-options) lists the desktop client apps as included in Business Standard and Business Premium, but not in Business Basic. Basic gets the web versions only. That trips up firms that bought the cheapest plan and then wonder why Excel won't install. Google Docs, Sheets, and Slides run in the browser. For a lot of teams that's a feature: nothing to install, nothing to patch, and everyone edits the same copy. It gets harder when a CPA lives in pivot tables and macros, or a paralegal works in heavily formatted Word templates that come back from opposing counsel. ## Will your line-of-business software work with it? Ask your software vendor before you pick a platform. A lot of accounting, tax, practice management, and title software expects Outlook on the desktop. It ships an Outlook add-in, exports straight to Excel, or sends mail through Outlook or SMTP. We've seen this firsthand. QuickBooks Desktop's Send Forms feature can hand mail to Outlook or SMTP instead of Intuit's webmail flow. That's the longer-term fix we recommended when [a client's QuickBooks couldn't email invoices](/reports/quickbooks-desktop-intuit-id-login-hang). Google does offer a bridge. [Google Workspace Sync for Microsoft Outlook](https://knowledge.workspace.google.com/admin/sync/about-gwsmo) lets people keep using Outlook against a Google mailbox, syncing mail, calendar, contacts, notes, and tasks. Google's own documentation warns that some Outlook features "aren't available or might not work as you'd expect." If Outlook is central to how your firm works, that warning matters. ## Which one has better security controls? Both can enforce MFA for every user. What differs is the default, and which edition unlocks the deeper controls. **Microsoft 365.** Every Microsoft 365 for business plan includes [security defaults](https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults), which require everyone to register for MFA and block legacy sign-in protocols. Microsoft says security defaults are on by default in [Microsoft 365 for business](https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/m365b-security-overview?view=o365-worldwide). Business Premium adds Microsoft Entra ID P1, which is where Conditional Access lives. That's granular sign-in policy, such as letting only managed devices reach company mail. It also adds Intune Plan 1 for full device management and Defender for Business for endpoint protection. Basic and Standard only get Basic Mobility and Security, a limited subset of Intune. **Google Workspace.** In Google Workspace, 2-Step Verification enforcement is Off until an admin turns it on. Google's [2SV deployment guide](https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification) treats enforcement as a step an admin takes after users enroll. Users can be enrolled while the tenant still lets a password-only login through. Google's version of conditional access, [Context-Aware Access](https://knowledge.workspace.google.com/admin/security/protect-your-business-with-context-aware-access), is listed for Enterprise, Frontline, and Education editions and Cloud Identity Premium. It isn't on the list for the Business editions most small firms buy. Device control comes through [Google endpoint management](https://support.google.com/a/answer/7576736?hl=en), with basic and advanced levels for mobile devices. The off-by-default problem isn't hypothetical. In [our BEC wire fraud report](/reports/bec-wire-fraud-incident-response), an attacker got into a title closer's Google Workspace inbox and sent fake wire instructions on a live closing. Users had 2SV enrolled, but the tenant didn't enforce it. A phone call to the other side stopped the wire. Flipping the enforcement toggle would have stopped the login. ## Which one meets HIPAA and retention requirements? Both sign a HIPAA Business Associate Agreement, and both say the BAA alone doesn't make you compliant. Retention and legal hold are available on both, but only on certain editions. **BAA.** Microsoft's [HIPAA BAA](https://learn.microsoft.com/en-us/compliance/regulatory/offering-hipaa-hitech) comes by default through its Data Protection Addendum for covered entities and business associates. Exchange Online, SharePoint Online, OneDrive, and Teams are in scope. Google's BAA has to be [accepted by an administrator in the Admin console](https://knowledge.workspace.google.com/admin/compliance/hipaa-compliance-with-google-workspace-and-cloud-identity), under Account settings, then Legal and compliance. Google is blunt about it: customers without a signed BAA "must not use PHI" in Workspace, and third-party add-ons aren't covered. Our [HIPAA-compliant email guide](/reports/hipaa-compliant-email) goes deeper on what else HIPAA expects. **Retention and eDiscovery.** On Google, that's [Vault](https://knowledge.workspace.google.com/vault/getting-started/vault-overview), which lets you retain, hold, search, and export users' data. Google lists Vault licenses as included in Business Plus and the Enterprise editions. Business Starter and Business Standard aren't on that list. On Microsoft, the [Purview service description](https://learn.microsoft.com/en-us/office365/servicedescriptions/microsoft-365-service-descriptions/microsoft-365-tenantlevel-services-licensing-guidance/microsoft-purview-service-description) lists Business Premium among the licenses that cover organization-wide retention policies. Audit (Standard) logging is in Business Basic, Standard, and Premium. A mailbox hold needs Exchange Online Plan 2 or the Exchange Online Archiving add-on. A law firm that may face a litigation hold, or an advisor with records-keeping rules, should settle this before buying. Don't find out after a matter starts. ## How hard is it to switch later? It's a planned project, not a weekend. Mail, calendars, and contacts move with supported tools. Some things don't make the trip. Microsoft's [Google Workspace migration guide](https://learn.microsoft.com/en-us/exchange/mailbox-migration/perform-g-suite-migration) covers mail and rules, calendar, and contacts, moved in batches. It also lists what doesn't come across: shared calendars, room bookings, vacation and auto-reply settings, and Gmail tags on contacts. Migrated rules arrive turned off. Retention and archive policies on the source can make messages look "missing" during verification, so Microsoft recommends turning them off first. None of that is a reason to stay on the wrong platform. It's a reason to scope the move properly. If you're already leaning toward Microsoft, our [Google Workspace to Microsoft 365 migration](/services/email-migrations) page covers how we test with real mailboxes and cut over nights and weekends. ## Key takeaways - Neither platform is the compliant one. Both sign a HIPAA BAA, and both say in writing that the BAA alone doesn't make you compliant. - Microsoft 365 is the easier fit when your line-of-business software expects Outlook, Excel, and a Windows desktop. - Google Workspace is the easier fit for browser-first teams with little desktop software. - The edition matters more than the brand. Conditional access, device management, Vault, and retention policies all live in specific editions, so check before you buy. - Switching later is a real project. Mail, calendars, and contacts move cleanly. Shared calendars, room bookings, and habits don't. ## What this looks like when we do it We start with how your office actually works: the software you run, who needs desktop Excel, and what your regulator or insurer will ask for. Then we recommend a platform and an edition. Sometimes that means staying put and fixing the configuration. Either way, we set it up to a baseline, not to defaults. That means MFA enforced across the whole tenant, a signed BAA on file where PHI is involved, retention set to match your rules, mail forwarding locked down, and devices under management. On Microsoft, that's our [Microsoft 365 administration](/services/cloud-solutions) work. We handle Google Workspace tenants to the same standard. When a firm needs to move, the [email migration](/services/email-migrations) runs as a separate, tested project. Not sure which one your firm should be on? Book a call and we'll go through it with you. --- # GCC High for CMMC: When a Small Defense Shop Actually Needs It URL: https://limehawk.io/reports/gcc-high-cmmc Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min GCC High is a separate Microsoft 365 US government cloud for DoD CUI and ITAR data. Microsoft signs ITAR terms only for it, and moving in means a new tenant. > **TL;DR** Microsoft 365 GCC High is a separate US government cloud for defense contractors that hold DoD CUI or ITAR data. Microsoft assesses it at FedRAMP High with DoD IL4 controls, and it's the only Microsoft 365 environment where Microsoft agrees to ITAR contract language. DFARS 252.204-7012 doesn't name a product. It says any cloud that holds covered defense information has to meet security requirements equivalent to the FedRAMP Moderate baseline. ITAR data pushes you to GCC High. CUI alone is a scoping decision. Either way, GCC High is a new tenant, not a license switch: eligibility validation, a migration, apps that may not work there, and licenses bought only through approved channels. - DFARS 7012 cloud baseline: FedRAMP Moderate ## What is Microsoft 365 GCC High? GCC High is a Microsoft 365 environment Microsoft built for the Department of Defense's contractors. Its [service description](https://learn.microsoft.com/en-us/office365/servicedescriptions/office-365-platform-service-description/office-365-us-government/gcc-high-and-dod) says it's for "contractors holding or processing DoD controlled unclassified information (CUI) or subject to International Traffic in Arms Regulations (ITAR)." It runs Exchange, SharePoint, OneDrive, and Teams, but in a government cloud that's separate from the one most businesses use. Microsoft assesses GCC High against NIST SP 800-53 at the FIPS 199 High level, and its [compliance pages](https://learn.microsoft.com/en-us/compliance/regulatory/offering-itar) say it's designed to DoD Security Requirements Guide Impact Level 4 controls. There are no trials. You go through a validation process before Microsoft builds the environment. If you're still working out whether you hold CUI at all, start with [what CUI is](/reports/what-is-cui). This guide assumes you've answered that. ## How is GCC High different from Commercial and GCC? There are three Microsoft 365 clouds a contractor might land in, and they differ in who can use them, what Microsoft commits to, and where they run. - **Commercial** is the Microsoft 365 most businesses have. It's global, and anyone can buy it. - **GCC** is for government agencies and contractors holding data on behalf of the US government. Microsoft's [how-to-buy page](https://learn.microsoft.com/en-us/office365/servicedescriptions/office-365-platform-service-description/office-365-us-government/microsoft-365-government-how-to-buy) lists its commitments as FedRAMP High, DFARS, CJIS, IRS 1075, and DISA SRG L2. - **GCC High** lists FedRAMP High, ITAR, DFARS, and DISA SRG L4 controls. Some of what people say sets GCC High apart is actually true of GCC too. Both store [customer content at rest only in US datacenters](https://learn.microsoft.com/en-us/office365/servicedescriptions/office-365-platform-service-description/office-365-us-government/office-365-us-government). Both restrict access to customer content to Microsoft staff who passed US citizenship verification and background checks. Both are FedRAMP High. The real differences are these: 1. **ITAR.** Microsoft "will only agree to ITAR contract language for the GCC High environment." Its [ITAR page](https://learn.microsoft.com/en-us/compliance/regulatory/offering-itar) adds that customers sign extra agreements notifying Microsoft they intend to store ITAR data. There's no ITAR certification from anyone. Microsoft says it designs these services to support your ITAR program. 2. **A separate cloud.** GCC High runs on Azure Government. Microsoft's [Intune government overview](https://learn.microsoft.com/en-us/intune/fundamentals/government-service) describes the government cloud as "a datacenter that's physically separate from the commercial instances." For Intune, GCC is the same instance as commercial. GCC High isn't. 3. **Who you can collaborate with.** GCC High users can share SharePoint and OneDrive files "only with other organizations in GCC-High." One thing that isn't different: the support desk. Microsoft says support for both GCC and GCC High sits outside the accreditation boundary. Don't paste CUI into a support ticket in either one. ## Does CMMC or DFARS require GCC High? No. Neither rule names a product. [DFARS 252.204-7012(b)(2)(ii)(D)](https://www.acquisition.gov/dfars/252.204-7012-safeguarding-covered-defense-information-and-cyber-incident-reporting.) says that if you use an external cloud service provider to store, process, or transmit covered defense information, you "shall require and ensure" that the provider "meets security requirements equivalent to those established by the Government for the Federal Risk and Authorization Management Program (FedRAMP) Moderate baseline." The provider also has to comply with the clause's rules on cyber incident reporting, malicious software, media preservation, forensic access, and damage assessment. We cover the rest of that clause in our [DFARS 7012 guide](/reports/dfars-252-204-7012). So the bar is FedRAMP Moderate equivalent, and the choice of how to meet it is yours. Microsoft's [DFARS page](https://learn.microsoft.com/en-us/compliance/regulatory/offering-dfars) names its US government clouds, Office 365 U.S. Government and U.S. Government Defense, as having FedRAMP Moderate authorization and being "adequate for DFARS." It doesn't make that statement about commercial Microsoft 365. If you want to keep CUI in Commercial, you'd have to show your assessor that it meets the FedRAMP Moderate equivalent requirement yourself. That's an argument to have before an assessment, not during one. The CMMC rule itself still stands on NIST SP 800-171 Rev 2, as we explain in our [CMMC Level 2 requirements guide](/reports/cmmc-level-2-requirements). Since that guide published, DoD issued [Class Deviation 2026-O0025, Revision 3](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf), signed September 3, 2026. It carries forward the July 13 suspension: Level 1 (Self) and Level 2 (Self) only, baseline compliance with NIST SP 800-171 Rev 2 under 7012, and the November 2026 Phase 2 transition suspended. None of that changes the 7012 cloud requirement. ## When does CUI push you to GCC High, and when can GCC or Commercial work? ITAR is the clearest line. If export-controlled technical data under ITAR lives in your email and files, GCC High is the Microsoft 365 environment Microsoft supports for it. Validation can require proof of registration with the State Department for ITAR data. For DoD CUI without ITAR, Microsoft's own guidance leans the same way. Its how-to-buy page calls GCC "the hero offering for all customers that don't hold FedRAMP High or DoD controlled unclassified information (CUI)." Read the other way, Microsoft expects DoD CUI to sit a step up, in GCC High. GCC still carries DFARS in its commitment list, so it's not ruled out. Ask your prime what they expect, and get the answer in writing. The other option is to shrink the problem. If CUI only touches a few people, you don't have to move the whole company. Put those people in a [CUI enclave](/reports/cmmc-enclave) and leave everyone else in Commercial. For a lot of [small manufacturers](/industries/manufacturing), the CUI is the drawing package, and only a handful of people ever open it. ## What does GCC High cost in effort? We won't put a number on it here. Licensing goes through Microsoft's channels and depends on your seat count. But the effort is easy to list: - **Eligibility and validation.** You prove you hold CUI or ITAR data before Microsoft builds the tenant. Revalidation is required at renewal. - **Licensing channel.** GCC High is sold only through an Enterprise Agreement or Microsoft's AOS-G partners. Web Direct and the general CSP channel aren't options for it. - **A new tenant.** It's a separate cloud, so it's a migration. Mail, files, and Teams move across. Microsoft says devices "need to unenroll from the current tenant, and then re-enroll," and there "isn't a built-in way" to move them. - **Feature gaps.** Intune's government overview lists Windows Autopilot and Remediations as not available, with no plans to add them. Viva Engage isn't in GCC High. Some Defender anti-phishing features aren't there yet. Teams calling goes through Direct Routing. - **Third-party apps.** Your CRM, ERP, backup tool, and email security add-on each need a version that connects to the government cloud. Microsoft notes that third-party services sit outside its compliance commitments, so each one is its own scoping question. - **Outside collaboration.** Customers and suppliers in Commercial can't open a shared SharePoint link the way they could before. Plan for how drawings go in and out. Check every line-of-business app before you sign anything. The licenses are the easy part. ## Key takeaways - DFARS 252.204-7012 sets the cloud bar at security equivalent to the FedRAMP Moderate baseline. It doesn't name GCC High or any other product. - If you hold ITAR data, GCC High is the Microsoft 365 answer. Microsoft only agrees to ITAR contract language for GCC High. - GCC and GCC High are both FedRAMP High and both restrict content access to screened US citizens. The differences are ITAR terms, DoD IL4 controls, and a physically separate government cloud. - Moving to GCC High means a new tenant. Devices unenroll and re-enroll, mail and files migrate, and some features and third-party apps aren't there. - You can't buy GCC High on a card. Microsoft validates eligibility first, then sells it through an Enterprise Agreement or an AOS-G partner. ## What this looks like when we do it Limehawk isn't a Microsoft AOS-G partner, a C3PAO, or an assessor. We can't sell you GCC High licenses or certify you. What we do is the IT work on either side of that decision. First we scope. We map where CUI and ITAR data actually go: which mailboxes, which file shares, which machines on the plant floor. That tells you whether the answer is a whole-company move, an enclave, or staying put. Then we check every app you rely on against the government cloud before anyone buys anything. If GCC High is the answer, we handle the tenant work our [Microsoft 365 administration](/services/cloud-solutions) covers in any tenant: enforced MFA, conditional access, email hardening, and documentation that matches what's configured. We run [tenant-to-tenant migrations](/services/email-migrations) tested first and cut over on nights and weekends. Our [manufacturing IT page](/industries/manufacturing) covers how we keep the OT side walled off while the office moves. Got a prime asking where your CUI lives and no clean answer yet? Book a call and we'll map it with you. --- # Fake DocuSign Email? How to Spot It and What to Do If You Signed In URL: https://limehawk.io/reports/fake-docusign-email Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min A fake DocuSign email leads to a page that steals your password. Open envelopes via Access Documents on docusign.com. If you typed a password, call IT. > **TL;DR** A fake DocuSign email looks like a "Please review and sign" envelope, but the button leads to a fake login page that takes your email password. Title, law, and real estate offices get hit hardest because they sign things in DocuSign all day. Don't click the link. Copy the security code from the bottom of the email and open it through Access Documents on docusign.com instead. If it isn't there, it's fake. If someone already typed their password, change it from a different device, have IT kill every session and check the inbox rules, and call anyone you're waiting on a wire from. - Phish to payment fraud (Microsoft): 5 min ## What should I do right now if I clicked a fake DocuSign email? Stop, and don't type anything else into the page. If you only clicked and closed it, report it and you're probably fine. If you entered a password, treat the account as taken. Change it from a different device, call IT, and warn anyone you're mid-transaction with. Here's the order: 1. **Close the tab.** Don't download or "unblur" anything it offers. 2. **Don't delete the email.** IT needs it to see who else got it. Forward it as an attachment to IT, and to verify@docusign.com, the address [DocuSign gives for reporting](https://www.docusign.com/safety/fraud-resources). 3. **If you typed a password, change it from a different device**, like your phone or a coworker's PC. Use a new password you haven't used anywhere. 4. **Call IT, don't email them.** If the attacker is in your mailbox, they can read your email to IT too. 5. **If a wire is pending, phone the other side** at a number you already had on file. Tell them not to trust any emailed instruction change until you call back. 6. **If money already moved, call your bank first.** The FBI says to [ask your bank to recall the funds](https://www.ic3.gov/PSA/2024/PSA240911) and file at ic3.gov "as soon as possible," whatever the amount. That last step is about the real danger. The fake envelope isn't the attack. Your inbox is what they're after. ## How do I tell a real DocuSign email from a fake one? A real envelope notification comes from the @docusign.net domain and has a 32-character security code at the bottom, under "Alternate Signing Method." [DocuSign's fraud resources page](https://www.docusign.com/safety/fraud-resources) says both of those things. The safest test is to skip the email. Go to docusign.com, use Access Documents, and paste the code. If the envelope opens there, it's real. If the code doesn't work, or there's no code at all, treat it as fake. Some other tells, all from DocuSign's own guidance: - **Links that don't go to DocuSign.** DocuSign's [phishing guide](https://www.docusign.com/blog/tools-to-protect-your-data-phishing) says a real signing link begins with https://www.docusign.net, sometimes with a server prefix like na2 or eu. Hover before you click. A link to a random domain, a file-sharing site, or a "secure viewer" is wrong. - **Attachments.** DocuSign says it "will never ask you to open an office document or zip file in an email." - **A login wall.** DocuSign's [safety alerts](https://www.docusign.com/trust/safety-alerts) say: "Do not enter your username or password on a page you reached from an email or QR code." A "sign in to view the document" page asking for your Microsoft 365 or Google password is the phish. - **Off branding.** A [June 2025 DocuSign alert](https://www.docusign.com/trust/alerts/fraud-alert-spoofed-docusign-emails-june-1-2025) warns that fakes "may use color schemes or branding that do not match Docusign's official look and feel." ## Can a fake DocuSign email come from a real DocuSign address? Yes. DocuSign's own [safety alerts page](https://www.docusign.com/trust/safety-alerts) says "attackers use registered Docusign accounts to send envelopes" with legitimate-looking documents. In that August 20, 2026 alert, the envelope showed a blurred document with a prompt to unblur it. Clicking it led to a malicious site that wanted the person to download a file, and that download gave the attacker access to the device. So the sender domain checks out and the email passes your filters, and it's still an attack. That's why "it came from docusign.net" doesn't settle it. Ask two things instead. Were you expecting this document from this sender? Does the page it opens want anything besides a signature? If the answer to the first is no, call the sender at a number you already have. The same alerts page lists other fakes too, including [QuickBooks impersonation](/reports/quickbooks-scam-email) and fake invoices. ## Why do title companies and law firms keep getting these? They work in DocuSign every day, so one more "Please review and sign" email looks normal. A [title company](/industries/title-companies) might get dozens of envelopes a week from lenders, agents, and buyers. A [law firm](/industries/law-firms) sends engagement letters and settlement papers the same way. Nobody stops to wonder about one more. The other reason is money. Those same inboxes coordinate large wires on a schedule everyone can guess. Take over a closer's mailbox and you can see which deals fund this week and who's on the other side. ## What happens after someone enters their password? The attacker logs in to your mailbox and gets ready to redirect a payment. Microsoft documented this in a [2022 campaign against more than 10,000 organizations](https://www.microsoft.com/en-us/security/blog/2022/07/12/from-cookie-theft-to-bec-attackers-use-aitm-phishing-sites-as-entry-point-to-further-financial-fraud/). The fake page stole the session after MFA, and in one case payment fraud started within 5 min of the theft. The attackers made inbox rules that sent replies from their targets to Archive and marked them read, so the real user never saw them. That's [business email compromise](/reports/business-email-compromise). We've seen the inbox half of it firsthand. In [our BEC case at a title company](/reports/bec-wire-fraud-incident-response), the attacker sent fake wire instructions on a real closing from the closer's own account, then built filters to hide the thread from coworkers. A phone call to the other side stopped the wire. Microsoft's [guide to a compromised mailbox](https://learn.microsoft.com/en-us/defender-office-365/responding-to-a-compromised-email-account) lists what IT should do. Have IT do this part: - Disable the account or reset the password, and don't send the new password by email. - Revoke every active session so a stolen cookie stops working. - Remove MFA methods and devices the user doesn't recognize. - Review apps the user consented to. - Check for mailbox forwarding and inbox rules, including hidden ones. - Read the sign-in logs and Sent Items from the start of the incident. A password reset alone is not enough. Without revoking sessions and pulling the rules, the attacker can stay in. ## How do we stop fake DocuSign emails from working next time? You won't stop every one from landing. The point is to make the click cheap. These controls do that, roughly in order of value: - **Enforce MFA on every mailbox.** Enrolled isn't enforced. That gap is how our BEC case got in. - **Use phishing-resistant MFA for anyone who touches wires.** [Phishing-resistant MFA](/reports/phishing-resistant-mfa) won't sign in to a fake page, so a stolen password plus a fake login page gets the attacker nothing. - **Alert on new inbox rules, forwarding, and odd sign-ins.** That's where a takeover shows up first. - **Run endpoint detection on every PC**, for the variants that push a download instead of a login page. - **Have a callback rule for wire instructions.** Our [wire verification procedure](/reports/how-to-verify-wire-instructions) lays out the call, and it works even when everything else fails. - **Teach one habit**: open envelopes through Access Documents, never from the link. - **Write down who does what** in an [incident response plan](/reports/incident-response-plan-template), so step 4 above isn't figured out live. ## Key takeaways - Real DocuSign envelope notifications come from @docusign.net, but attackers also send real envelopes from paid accounts. The sender address alone doesn't prove anything. - Never sign in from an envelope link. Use the security code with Access Documents on docusign.com. If the envelope isn't there, the email is fake. - DocuSign says not to enter a username or password on any page you reached from an email. A page that asks for your Microsoft 365 or Google password is the phish. - If someone typed their password: reset it from a clean device, revoke sessions, check inbox rules and forwarding, and pick up the phone on any pending wire. - Enforced MFA, phishing-resistant MFA for the people who move money, and a callback rule for wire instructions stop the loss even when the click happens. ## What this looks like when we do it On our [managed IT and security](/services/managed-it-security) baseline, MFA is enforced across the whole tenant, not left up to each user. We monitor for the new inbox rules, forwarding, and sign-ins that come after a phish. Every machine gets endpoint detection and response. Critical issues get a response in under 15 minutes, so "I think I typed my password into something" turns into revoked sessions and a clean mailbox fast. For [title companies](/industries/title-companies), we start with the wire. The people who send and confirm wires get MFA that a fake page can't capture, and the callback procedure gets written down and tested. The same [managed security](/services/managed-it-security) baseline covers law firms and the other regulated offices we support. Your staff will still see fake DocuSign emails. We want the click to cost you a password reset and nothing more. Got a "please review and sign" email you're not sure about, or one someone already clicked? Book a call below and we'll go through it. --- # Employee Offboarding Checklist: The IT Side, in Order URL: https://limehawk.io/reports/employee-offboarding-it-checklist Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Offboarding IT in order: disable sign-in, revoke sessions, hand off mail and files, rotate shared passwords, recover the laptop, and write down what you did. > **TL;DR** The IT side of an employee offboarding checklist is nine steps, and the order matters. Disable sign-in first, then revoke the sessions that are already open, because a disabled account doesn't kill a token that's still live. Then hand off the mailbox and files, rotate every shared password the person knew, remove their MFA devices, get the laptop back or wipe it, pull them from vendor portals and the bank, route their email, and write it all down. If the person leaving is your IT person or anyone with admin rights, change the order: lock down admin access before you tell them. - Entra access token default life: 1 hour ## What goes on an employee offboarding checklist for IT? An IT offboarding checklist covers nine things, in this order: disable sign-in, revoke open sessions and tokens, transfer the mailbox and files, rotate shared passwords, remove MFA devices, recover or wipe devices, remove the person from vendor portals and banking, forward or monitor their email, and document what you did. HR runs the rest of the exit. This part is ours. NIST's federal control catalog says the same thing in five lines. Control [PS-4, Personnel Termination, in NIST SP 800-53 Rev. 5](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final) says that when someone leaves you should disable system access within a set time, "terminate or revoke any authenticators and credentials associated with the individual," hold an exit interview, retrieve company property, and "retain access to organizational information and systems formerly controlled by terminated individual." That last one is the step people skip. You need their data after they're gone. Here's the checklist, step by step. 1. **Disable sign-in.** Block the account in Microsoft 365 or Google Workspace, and in Active Directory if you have one. Don't delete it yet. 2. **Revoke sessions and tokens.** Sign the user out everywhere. More on why below. 3. **Transfer the mailbox and files.** Convert the mailbox to a shared mailbox or give a manager access. Hand off OneDrive or Drive files before anything gets deleted. 4. **Rotate shared passwords.** Wi-Fi, the copier admin page, shared logins in the password manager, anything on a sticky note. 5. **Remove MFA devices.** Delete their registered authenticator apps and phone numbers so an old phone can't approve a sign-in. 6. **Recover devices or wipe them.** Get the laptop back. If you can't, wipe it. 7. **Remove them from vendor portals and banking.** Bank logins, payroll, the line-of-business app, the domain registrar, anything that doesn't sign in through your email. 8. **Forward or monitor their email.** Customers will keep writing to that address for months. 9. **Document it.** Date, time, who did each step. ## Why isn't disabling the account enough? Disabling an account stops new sign-ins, but it doesn't end sessions that are already open. Microsoft's guide to [revoking user access in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/users/users-revoke-access) says "By default, access tokens issued by Microsoft Entra ID last for 1 hour," and that apps using their own session cookies may never check back with Entra while that cookie is valid. So the fix is two clicks, not one. Clear **Account enabled**, then select **Revoke sessions** on the same user. If you sync from on-premises Active Directory, Microsoft says to disable the account there and reset the password twice. Apps that have their own logins (and plenty of them do) need to be turned off in each app. That's step 7. This isn't theoretical. In [CISA advisory AA24-046A](https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-046a), an attacker got into a state government network through "the compromised account of a former employee with administrative privileges." The organization "confirmed that this account was not disabled immediately following the employee's departure." From there the attacker found a domain admin password stored on a server. CISA's fix is a "robust and continuous user management process to ensure accounts of offboarded employees are removed." ## What happens to their email and files? Keep them, then hand them off. Microsoft's [remove a former employee](https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/remove-former-employee) guide walks through it: block sign-in, save the mailbox, wipe their phone, forward the email or convert it to a shared mailbox, give someone access to the OneDrive, then remove the license and delete the account. The order matters because of one number. When you delete the account, Microsoft keeps that user's OneDrive and Outlook content for 30 days, and you can restore the account in that window. After that it's gone. If you're in a regulated business with retention rules, decide what you're keeping before you delete anything. For email, forwarding to a manager or converting to a shared mailbox both work. A shared mailbox keeps the address alive so a client who emails the old address still reaches someone. Watch that inbox for a few weeks. Password reset emails from vendor portals you forgot about will show up there, and each one is a step 7 item you missed. ## Which passwords have to change? Every password the person knew and didn't own. Their own password dies with their account. The risk is the shared ones: the office Wi-Fi, the firewall, the copier, the bank login three people use, the vendor portal with one account for the whole office. You can't rotate what you can't find. That's the case for a password manager with shared vaults, because it gives you a list of what this person could see. In our [password file audit](/reports/password-file-audit), a scan of 67 machines turned up 8 files holding real working credentials, including banking logins and a shared service account sheet that somebody had forgotten about. A departing employee may have a file like that in their Documents folder. Check for one before you wipe or hand off the machine. ## What about the laptop and phone? Get the hardware back before the last day ends if you can. If the device doesn't come back, wipe it. If it's a personal phone, wipe only the company data. A wipe only works if the device can hear you. Microsoft notes that an Intune wipe has to reach a device that's online, and an offline device "still has access to any locally stored data." In our [stolen laptop remote wipe](/reports/mdm-remote-wipe), the device was online to our RMM agent, so we wiped it 16 minutes after the theft was reported instead of waiting for an Intune sync. The same path works for a laptop that doesn't come back after a resignation. Document who authorized the wipe in the ticket. It can't be undone. ## How do you offboard your IT person or an admin? Do it in a different order. With a regular employee you disable access when they leave. With someone who holds the admin passwords, you secure the admin accounts first, and you do it before or during the conversation, not after. We've seen what happens otherwise. In our [locked-out-of-the-domain incident report](/reports/local-admin-provisioning), an 85-person accounting firm's internal IT director resigned on a Friday. On his way out he changed the domain admin password on both domain controllers, and the recovery password too. Nobody else had working admin credentials. We got all 92 machines back in 3 minutes because our RMM agent ran outside their domain, and he couldn't reach it. Rebuilding domain access took another 6 hours. For an admin offboarding, add these steps to the top of the list: - **Inventory every admin account** they held: Microsoft 365 or Google global admin, domain admin, firewall, backup console, RMM, the domain registrar and DNS host. - **Rotate break-glass and recovery passwords**, including AD recovery passwords and anything only they knew. - **Check for accounts they created**: extra admin users, app registrations, forwarding rules, and remote access tools nobody approved. - **Make sure someone outside that person controls at least one admin path.** A co-managed provider, a sealed break-glass account, or both. If one person holds every admin password in your company, that's the risk you fix this month, while they still work there. ## Key takeaways - Disable the account and revoke sessions together. Disabling sign-in stops new logins, but an access token that's already issued can keep working until it expires. - Every shared password the person knew is still a key in their pocket. Rotate them the same day, and start with banking and vendor portals. - Keep the mailbox and OneDrive before you delete anything. Microsoft keeps a deleted user's mail and OneDrive for 30 days, then it's gone. - Offboarding your IT person or an admin is a different job. Secure the admin accounts and the recovery passwords before the conversation, not after. - Write it down. A dated record of what was turned off, and when, is what an auditor or insurer will ask for. ## What this looks like when we do it On our [managed IT and security service](/services/managed-it-security), user offboarding is part of the help desk, not a billed extra. You tell us who's leaving and when. We block sign-in and revoke sessions, hand the mailbox and files to whoever's taking over, pull their access to shared vaults in the password manager, and work through the shared passwords they could see with you. Then we get the laptop back on the shelf or wiped through the same RMM agent that runs on every managed machine. Each step goes in the ticket with a date, so when a cyber insurer or auditor asks how you remove access for people who leave, you have the answer on paper. And if the person leaving is your internal IT person, our [co-managed IT](/industries/co-managed-it) arrangement is the outside admin path that keeps one resignation from locking you out. The [managed IT service page](/services/managed-it-security) covers the rest of the baseline. Have someone leaving soon and want the checklist run for you? [Book a call](/contact) and we'll walk through it. --- # EDR vs Antivirus: What Your Cyber Insurance Form Is Really Asking URL: https://limehawk.io/reports/edr-vs-antivirus Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Antivirus matches files against known malware. EDR watches behavior, isolates an infected PC, and keeps a record. Insurers asking about EDR mean the second. > **TL;DR** Antivirus compares files against signatures of malware someone has already seen. EDR (endpoint detection and response) watches what programs actually do on each computer, can cut an infected machine off the network, and keeps a timeline you can investigate afterward. When a cyber insurance questionnaire asks "do you have EDR?", antivirus alone is not a yes. EDR also isn't magic. It only works if a person reads the alerts and acts on them, and it can stay quiet on signed software that's unwanted but not technically malicious. - OneStart PCs, EDR quiet: 30 ## What is EDR? EDR stands for endpoint detection and response. It's software on every computer and server (the "endpoints") that watches what programs do, flags behavior that looks like an attack, and lets someone respond from a console. That response can be killing a process or cutting the machine off the network. It also keeps a history of activity on each machine, so you can see what happened before and after an alert. The "R" is the part that matters. CISA's guidance on [isolating endpoints from the network](https://www.cisa.gov/eviction-strategies-tool/info-countermeasures/CM0065) assumes "endpoint management software or endpoint detection and response (EDR)" is in place, because that's how responders pull a compromised machine off the network fast. CISA's [#StopRansomware Guide](https://www.cisa.gov/sites/default/files/2025-03/StopRansomware-Guide%20508.pdf) recommends "application allowlisting and/or endpoint detection and response (EDR) solutions on all assets." ## What's the difference between EDR and antivirus? Traditional antivirus asks one question: does this file match something we already know is bad? EDR asks a different one: is this program doing something an attacker would do? Antivirus is a lookup against a list. EDR watches behavior, can isolate the machine, keeps a timeline, and sends alerts to a person. NIST defines a signature as "a set of characteristics of known malware instances." Its [malware guide, SP 800-83 Rev. 1](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-83r1.pdf), puts the limit bluntly. When attackers can send a unique attack to each victim, "largely signature-based security controls, such as antivirus software, cannot keep up with them." Here's what EDR adds on top of that: - **Behavior detection.** A spreadsheet that launches PowerShell, a process encrypting hundreds of files a minute, a new account added to the admin group at 2 a.m. None of those need a known signature to look wrong. - **Isolation.** One click (or an automatic rule) cuts the machine off from the rest of the network. CISA's isolation guidance exists to stop exactly what happens next: the attacker talking to their own servers and moving to other machines. - **An investigation timeline.** What ran, what it spawned, what it touched, what it connected to. The #StopRansomware Guide tells responders to examine "antivirus, EDR, IDS" and logs to find "evidence of additional systems or malware involved in earlier stages of the attack." Without that history, you're guessing which other machines to check. - **A human reading alerts.** The same guide says tools should be "properly configured to escalate warnings and indicators to notify security personnel." A detection nobody reads is just a log entry. EDR doesn't have to replace antivirus. Plenty of setups run both, and ours does. ## Why does my cyber insurance questionnaire ask about EDR? Carriers ask because they want to know whether an attack on one laptop gets stopped there or becomes a claim for the whole company. Ransomware usually doesn't hit everything at once. It lands on one machine, spreads, then encrypts. EDR is the control built to spot the spreading and cut that first machine off. Wording varies by carrier and by policy, but EDR now sits on the usual list next to MFA, encryption, tested backups, and an inventory. Our [cyber insurance readiness page](/services/compliance-backup) walks through that list. The thing to watch for is the checkbox. "Do you have EDR on all endpoints?" is not the same question as "Do you have antivirus?" If what you run is a signature scanner, the honest answer to the EDR question is no. Your answers on the application are statements you're making to the carrier, so get them right before you sign, not after a claim. "All endpoints" matters too. That includes servers, the reception PC, and the laptop that lives in somebody's car. ## EDR vs MDR vs XDR: what's the difference? These are industry labels, not standards, and vendors stretch them. In plain terms: - **EDR** is the tool. Sensors on endpoints, a console, isolation, a timeline. - **MDR (managed detection and response)** is EDR plus people. A team watches the alerts, often around the clock, and takes action. - **XDR (extended detection and response)** pulls in signals beyond the endpoint, like email, sign-ins, cloud apps, and the network, so one alert can show the whole chain. For a firm of 10 or 40 people, the label matters less than two answers. Is the sensor on every machine? And who acts when it fires at 11 p.m. on a Saturday? An "XDR" nobody watches is worse than a plain EDR somebody does. ## Does EDR catch everything? No. It catches a lot of things antivirus misses, but it has blind spots, and software that's unwanted but not technically malicious is one of the big ones. We cleaned [the OneStart browser hijacker off 30 machines](/reports/onestart-malware-removal) for one organization. OneStart had landed on 30 machines, and the client's EDR stayed quiet. The binaries were signed and it shipped with a "legitimate" uninstaller, which is how it slipped past antivirus too. The users knew something was wrong because their homepage changed and popups started. What fixed it was a removal script in the right order, then blocking the domain at the DNS filter so the next install never reached the machine. Cleaning up isn't the end either. We've picked up a laptop that a scan had already declared "clean" and found [Windows itself still broken from the malware](/reports/windows-system-maintenance). Green scans don't mean a machine is healthy. So EDR is one layer. DNS and web filtering, patching, email filtering, and least-privilege accounts cover what it doesn't. ## What should I ask an IT provider about EDR? Ask questions that get you evidence, not reassurance. Here's the list I'd use: 1. **Is it on every endpoint?** Servers, laptops that leave the office, the shared front-desk PC. Ask for the device list showing which machines have the agent and when each last reported in. 2. **Who reads the alerts, and when?** Business hours only, or nights and weekends too? What happens to an alert at 2 a.m.? 3. **Who can isolate a machine, and how fast?** Is isolation automatic for high-confidence detections, or does it wait for a person? 4. **How far back does the history go?** If you find out about something three weeks late, can you still see what happened? 5. **Can users turn it off?** NIST's guidance on antivirus says users "should not be able to disable or delete" it. The same goes for EDR. 6. **What can you give me for the insurance form?** You want something you can attach, not a verbal yes. ## Key takeaways - Antivirus matches files against known malware signatures. EDR watches behavior, isolates machines, and records what happened. - If your insurance application asks about EDR, a plain antivirus product is not a yes. Answer it truthfully. - EDR without a person reading the alerts is just a quieter log. Ask who reads them, and when. - MDR is EDR plus people. XDR pulls in signals beyond the endpoint. Neither label has one official definition, so ask what's actually included. - EDR has blind spots. Signed adware landed on 30 machines in one of our cases without the EDR raising a hand. ## What this looks like when we do it On our [managed IT and security service](/services/managed-it-security), EDR is part of the baseline on every managed endpoint. It isn't a tier or an add-on. That means detection, isolation, and a human reading the alert, with 24/7 monitoring behind it. It runs next to anti-malware, email filtering, password management, and DNS and web filtering, because EDR alone leaves gaps, as the OneStart case showed. When your carrier sends the questionnaire, we map your environment to it and hand you the screenshots and dates it asks for, so the "EDR on all endpoints" box is something you can prove. We tell you plainly if a box isn't true yet, and we close the gap before you sign. Staring at an insurance form that asks about EDR and not sure what you have? Book a call and we'll go through it with you. --- # DFARS 7012 in Plain English: What 252.204-7012 Requires From a Small Defense Shop URL: https://limehawk.io/reports/dfars-252-204-7012 Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min DFARS 7012 requires NIST SP 800-171 on systems with covered defense information, incident reports to DoD within 72 hours, and 90 days of preserved images. > **TL;DR** DFARS 252.204-7012 is the contract clause that puts cybersecurity duties on DoD contractors and subcontractors that handle covered defense information. It does four big things. It requires "adequate security," which means NIST SP 800-171 on the systems that hold that information. It requires you to report a cyber incident to DoD within 72 hours of discovery, through a portal you can only reach with a DoD-approved medium assurance certificate. It requires you to keep images of affected systems for at least 90 days after you report. And it flows down to subcontractors without changes. The CMMC Phase 2 suspension in July 2026 didn't touch any of it. - Cyber incident reporting deadline: 72 hours ## What is DFARS 252.204-7012? DFARS 252.204-7012, "Safeguarding Covered Defense Information and Cyber Incident Reporting," is a clause the DoD puts in contracts where the contractor will handle covered defense information. It requires adequate security on your systems, rapid incident reporting, malware submission, media preservation, and flowdown to subcontractors. The [current text on acquisition.gov](https://www.acquisition.gov/dfars/252.204-7012-safeguarding-covered-defense-information-and-cyber-incident-reporting.) is dated May 2024. Covered defense information means unclassified controlled technical information and other CUI Registry information that's identified in your contract or that you handle to perform it. For a machine shop, that's usually the drawing package. If you're not sure what counts, start with our [guide to CUI](/reports/what-is-cui). It applies to any unclassified system you own or operate that "processes, stores, or transmits covered defense information." That's your file server, email, laptops, and any cloud service the drawings pass through. ## What does "adequate security" mean under 7012? For most contractors, adequate security means implementing NIST SP 800-171 on every covered system. Paragraph (b)(2) says those systems "shall be subject to the security requirements in" NIST SP 800-171. The clause text says the version "in effect at the time the solicitation is issued," but a DoD [class deviation](https://www.acq.osd.mil/dpap/policy/policyvault/USA001074-24-DPC.pdf) (2024-O0013, Revision 1) requires contractors to comply with Revision 2 "instead of the version of NIST SP 800-171 in effect at the time the solicitation is issued." It "remains in effect until rescinded." Rev 2 is also the version the CMMC rule is built on. Our [CMMC Level 2 requirements guide](/reports/cmmc-level-2-requirements) covers what the 110 requirements look like in practice, and our [Rev 3 explainer](/reports/nist-800-171-rev-3) covers why Rev 2 is still the one that counts. The clause adds a few more duties under paragraph (b): - **Variances.** If you can't meet a requirement, you ask the contracting officer in writing. The DoD CIO decides. You don't get to decide on your own that a control doesn't apply. - **Cloud providers.** If an external cloud service will "store, process, or transmit any covered defense information," you have to make sure it "meets security requirements equivalent to those established by the Government for the Federal Risk and Authorization Management Program (FedRAMP) Moderate baseline." The provider also has to follow the clause's incident reporting, malware, media preservation, and forensic access rules. The cloud rule is the one that catches small shops. It covers every cloud service the drawings touch, including email and file sharing, and the burden is on you to check. That's a big reason defense suppliers look at GCC High, which we cover in our [GCC High and CMMC guide](/reports/gcc-high-cmmc). ## What does 7012 require when you have a cyber incident? When you discover a cyber incident that affects a covered system or the covered defense information on it, paragraph (c) requires two things. First, review for evidence of compromise: which computers, servers, data, and user accounts were hit. Second, "rapidly report" it to DoD. The clause defines that as "within 72 hours of discovery of any cyber incident." The clock starts at discovery, not when you finish investigating. The clause defines a cyber incident broadly: anything done through computer networks that results in "a compromise or an actual or potentially adverse effect" on a system or its information. A compromised mailbox that held drawings counts. The rest of the incident duties: - **Report through DIBNet.** The clause sends you to dibnet.dod.mil. As of late September 2026, that address forwards to the [DC3 DCISE page](https://www.dc3.mil/Missions/DIB-Cybersecurity/DIB-Cybersecurity-DCISE/), which links to the reporting portal. DCISE calls itself "the single focal point and data repository for DIB cyber incident reporting required by" DFARS 252.204-7012. - **Have the certificate.** Paragraph (c)(3) says the contractor or subcontractor "shall have or acquire a DoD-approved medium assurance certificate to report cyber incidents." DC3's page says the certificate is required to access the portal and lists IdenTrust and WidePoint as approved External Certification Authority vendors. - **Submit malware to DC3, not your contracting officer.** If you isolate malicious software, paragraph (d) says to send it to the DoD Cyber Crime Center following their instructions. DC3's page adds: "Do not email malicious files." - **Preserve images for 90 days.** Paragraph (e) requires you to "preserve and protect images of all known affected information systems" and "all relevant monitoring/packet capture data for at least 90 days from the submission of the cyber incident report." - **Give DoD access if asked.** Paragraphs (f) and (g) cover forensic analysis and damage assessment requests. ## How does 7012 flow down to subcontractors? Paragraph (m) requires the prime to put 7012 in subcontracts that involve covered defense information or operationally critical support, "without alteration, except to identify the parties." That includes subcontracts for commercial products and services. If you're a sub with CUI drawings, you carry the same clause your prime does. Subs have two extra duties. Tell the next tier up when you ask for a variance from an 800-171 requirement. And when you report an incident, give your prime the incident report number DoD assigns "as soon as practicable." You still report to DoD yourself. ## How does 7012 relate to 7019, 7020, 7021, and CMMC? 7012 sets the duty. The other three are about proving it: - **[252.204-7019](https://www.acquisition.gov/dfars/252.204-7019-notice-nist-sp-800-171-dod-assessment-requirements.)** is a solicitation provision. If you're required to implement 800-171, you need a current assessment, "not more than 3 years old unless a lesser time is specified," posted in SPRS to be considered for award. - **[252.204-7020](https://www.acquisition.gov/dfars/252.204-7020-nist-sp-800-171-dod-assessment-requirements.)** defines the Basic (self), Medium, and High assessments, requires you to give the government access for Medium or High assessments, and says how summary scores reach SPRS. Our [SPRS score guide](/reports/sprs-score) walks through the scoring. - **[252.204-7021](https://www.acquisition.gov/dfars/252.204-7021-contractor-compliance-cybersecurity-maturity-model-certification-level-requirements.)** is the CMMC clause. It requires you to keep a current CMMC status at the level the contracting officer fills in, for every system that handles FCI or CUI on the contract, and to flow the right level down. That's the clause set as codified, and it changed for new solicitations in 2026. [Class Deviation 2026-O0025](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf) took effect February 1, 2026, and Revision 3 was signed September 3, 2026. It tells contracting officers to use a rewritten DFARS Part 240. 7012 and 7021 stay. 7019 isn't in the new clause list. 7020 becomes 252.240-7997, which covers only the government-run Medium and High assessments. Your self-assessed score goes into SPRS under the CMMC clause and [32 CFR 170.16](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.16). Older contracts can still carry 7019 and 7020, so check the clauses in yours. Our [SPRS score guide](/reports/sprs-score) covers the details. CMMC Level 2 assesses the same 800-171 requirements 7012 already required. CMMC didn't create the obligation. It created a way to check it. For the bigger picture, see [what CMMC is](/reports/what-is-cmmc). ## Did the CMMC Phase 2 suspension change 7012? No. The July 13, 2026 [memo suspending the Phase 2 transition](https://dodcio.defense.gov/Portals/0/Documents/Library/ImplementingSuspensionCMMC-PhaseII.pdf) limits new contracts to Level 1 (Self) and Level 2 (Self) during the review, and says DFARS 252.204-7012 stays in effect. The pause moved third-party audits back. It didn't move the 72-hour clock, the certificate, or 800-171. ## What should a small shop have ready before an incident? Have the reporting path, the certificate, and the evidence ready before anything happens. Seventy-two hours is not much time to get a certificate, decide who reports, and find your logs. Here's the short list: 1. **A written incident response plan** that names who decides something is a cyber incident, who files the DoD report, and who calls your prime. Our [incident response plan template](/reports/incident-response-plan-template) has the eight sections, and you can add a 7012 line to the outside-calls section. 2. **The medium assurance certificate**, already issued and installed for the person who will report, with a backup person. 3. **The contacts printed out**: the DCISE line, your prime's security contact, your contracting officer, your insurer, and your IT provider. Your email may be what's down. 4. **Logs that go back far enough** to answer paragraph (c)(1)(i): which computers, accounts, and data were touched. That means sign-in logs, endpoint alerts, and firewall logs kept off the systems an attacker would wipe. 5. **A way to image systems and hold the images and capture data** for at least 90 days after the report, stored where nobody reuses the drive. 6. **A cloud check.** Confirm in writing that every cloud service holding covered defense information meets the FedRAMP Moderate equivalent bar. ## Key takeaways - 7012 applies when your contract or subcontract involves covered defense information. It requires NIST SP 800-171 on every system that stores, processes, or transmits it. - "Rapidly report" means within 72 hours of discovering a cyber incident, to DoD through DIBNet, which now lands on DC3's DCISE reporting page. - You can't file the report without a DoD-approved medium assurance certificate. Get it before an incident, not during one. - After you report, keep images of affected systems and relevant monitoring and packet capture data for at least 90 days. - 7019 and 7020 are about scoring your 800-171 work in SPRS. 7021 is CMMC. 7012 is the security and reporting duty under all of them, and it's still in force. ## What this looks like when we do it We're not a C3PAO or a Registered Practitioner Organization, and we don't do CMMC assessments. We don't file your DoD report either. That's your duty, under your certificate. Our job is making sure the evidence the clause asks for exists and the people who need to act know their part. For a defense supplier, that means centralized sign-in and endpoint logs, firewall monitoring, an inventory that shows which machines hold drawings, and a plan that names who reports and who calls the prime. On the plant floor, we isolate legacy Windows and controllers, as we describe on our [manufacturing IT page](/industries/manufacturing). If your prime just sent a subcontract with 7012 in it and you're not sure you could report within 72 hours, [see how we support manufacturers](/industries/manufacturing), then book a call and we'll go through it with you. --- # CMMC Level 2 Requirements: What a Small Manufacturer Has to Build URL: https://limehawk.io/reports/cmmc-level-2-requirements Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min CMMC Level 2 means meeting all 110 NIST SP 800-171 Rev 2 requirements to protect CUI, with a score in SPRS, an SSP, and a yearly affirmation. > **TL;DR** CMMC Level 2 applies when your DoD contract puts Controlled Unclassified Information (CUI) on your systems. The rule at 32 CFR 170 requires all 110 security requirements in NIST SP 800-171 Revision 2, not Revision 3. You either self-assess and post your score in SPRS, or a C3PAO certifies you, depending on what the contract says. A POA&M can buy you 180 days on some gaps, but only if you score at least 88 out of 110. On July 13, 2026, the Department of War suspended the move to Phase 2, so for now new contracts can only ask for a Level 2 self-assessment. The 110 requirements didn't go anywhere. - Lowest score allowed with a POA&M: 88 of 110 ## What is CMMC Level 2? CMMC Level 2 is the Department of Defense's cybersecurity requirement for contractors that process, store, or transmit Controlled Unclassified Information (CUI) on their own systems. To meet it, you have to implement all 110 security requirements in NIST SP 800-171 Revision 2, and you have to prove it with an assessment. CUI is the step up from Federal Contract Information. If your work for a prime or the DoD only involves FCI, you're looking at Level 1, which we cover in our [CMMC Level 1 guide](/reports/cmmc-level-1). If you get drawings, specs, or technical data marked CUI, you're at Level 2. For a lot of machine shops and fabricators, that's the drawing package itself. The program rule is [32 CFR Part 170](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170). It defines the CMMC security requirements as "the 110 Level 2 requirements from NIST SP 800-171 R2." Those 110 requirements fall into 14 families: access control, awareness and training, audit, configuration management, identification and authentication, incident response, maintenance, media protection, personnel security, physical protection, risk assessment, security assessment, system and communications protection, and system and information integrity. ## Does CMMC Level 2 use NIST SP 800-171 Rev 2 or Rev 3? Rev 2. NIST [withdrew Revision 2](https://csrc.nist.gov/pubs/sp/800/171/r2/upd1/final) on May 14, 2024 and replaced it with Revision 3. The CMMC rule doesn't follow NIST automatically, though. Section 170.2 incorporates the February 2020 Revision 2 by reference, so that's the version you're scored against. The July 2026 suspension memo says the same thing. During the review, DoW will "enforce baseline compliance with NIST SP 800-171 Rev 2." Build to Rev 2. If the rule changes, you'll be starting from a working baseline, not a blank page. ## Do I need a self-assessment or a C3PAO certification? Your contract decides that, not you. The rule has two Level 2 statuses. **Level 2 (Self)** means you assess yourself and post the score in the Supplier Performance Risk System (SPRS). **Level 2 (C3PAO)** means an authorized third-party assessment organization does the assessment and uploads the results. Both have the same 110 requirements, the same scoring, and a three-year cycle. Both need an affirmation of compliance at the time of assessment and every year after. The rule's [phased rollout](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.3(e)) started Phase 1 when the [DFARS acquisition rule](https://www.federalregister.gov/documents/2025/09/10/2025-17359/defense-federal-acquisition-regulation-supplement-assessing-contractor-implementation-of) took effect on November 10, 2025. Phase 2 was supposed to start one year later and bring C3PAO certification in as a condition of award. That changed. On July 13, 2026, the Under Secretary for Acquisition and Sustainment issued a [memo suspending the Phase 2 transition](https://dowcio.war.gov/Portals/0/Documents/Library/ImplementingSuspensionCMMC-PhaseII.pdf). While the DoW CIO reviews the program, contracting offices "may not designate CMMC Level 2 (C3PAO) or Level 3 (DIBCAC) assessments." The only designations allowed are Level 1 (Self) and Level 2 (Self). Contracts that already have a C3PAO requirement get it removed at the next option period or administrative modification. DFARS 252.204-7012 stays in effect. On September 3, 2026, DoD wrote the same limits into contracting rules with [Class Deviation 2026-O0025 Revision 3](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf). The memo promised more guidance after a 60-day review; as of October 2, 2026, no review outcome has been published, so check the solicitation in front of you, not a blog's timeline. None of this makes a self-assessment easier. The rule still lets DoD run a DCMA DIBCAC assessment on a self-assessed contractor. If that assessment finds gaps, its results take precedence over your score. ## What is a POA&M, and what are its limits? A POA&M (plan of action and milestones) lets you get a conditional status while you finish fixing a few gaps. [Section 170.21](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.21(a)(2)) puts hard limits on it: - **Score floor.** Your score divided by 110 has to be at least 0.8. That's 88 of 110. - **One-point items only.** Requirements worth 3 or 5 points can't go on the POA&M. The one exception is encryption that's in place but not FIPS-validated. - **Never on a POA&M:** the system security plan, external connections, control of public information, visitor escorts, physical access logs, and managing physical access devices. - **180 days.** You have to close every item and pass a closeout assessment within 180 days, or the conditional status expires. Scoring starts at 110 and subtracts 1, 3, or 5 points for each requirement that's not met. The score can go negative. MFA, account control, audit logging, incident response, and malware protection are 5-point requirements. If any of those are missing, a POA&M won't cover it. Fix them first. ## What is a System Security Plan? The SSP is the document that describes your CUI environment and how each of the 110 requirements is met there. [Section 170.24](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.24(c)(2)(i)(B)(5)) says you must have one at the time of assessment. Without one, the assessment "could not be completed." A useful SSP names the systems in scope, draws the boundary, and says who does what. If you use a cloud provider or an MSP for in-scope work, the rule says that relationship belongs in your SSP too, along with the provider's customer responsibility matrix. Evidence has to be final, not draft, and you keep it for six years. ## What does IT actually have to build and document? The paperwork follows the build. Here's what a small manufacturer's IT has to be able to show an assessor: 1. **A scope.** An asset inventory and a network diagram that sort every device into CUI assets, security protection assets, contractor risk managed assets, specialized assets, or out of scope. A smaller CUI enclave makes everything else cheaper to run. 2. **Accounts and MFA.** Named accounts only, least privilege, and MFA for every user, not just admins and remote users. 3. **Encryption** for CUI at rest and in transit, using FIPS-validated cryptography. We've written up how we [roll out BitLocker across a fleet with escrowed recovery keys](/reports/bitlocker-encryption-automation). Turning encryption on is the easy half. Proving the module is validated is a separate check. 4. **Logging** that's kept, reviewed, and tied to individual users. 5. **Baseline configurations and patching**, with changes tracked. 6. **An incident response plan** that you've actually tested. 7. **Training records** and physical access controls for the rooms where CUI lives. Plant-floor equipment gets its own treatment. Under the [scoping rules](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170#p-170.19(c)(1)), operational technology and IIoT count as Specialized Assets. They aren't assessed against the other requirements, but they have to show up in your inventory, your SSP, and your network diagram, and you have to manage them under written risk-based policies. An old Windows box running a CNC machine can stay. It just has to be documented and walled off. That's the same segmentation work we describe on our [manufacturing IT page](/industries/manufacturing). An inventory you can trust is the base layer. We pulled one together for an insurer in [our automated hardware inventory report](/reports/automated-hardware-inventory), with serials, specs, and encryption status for every machine. ## Key takeaways - Level 2 is for CUI. The rule ties it to the 110 requirements in NIST SP 800-171 Revision 2, even though NIST has since published Revision 3. - Since July 13, 2026, new solicitations can only ask for Level 2 (Self). Third-party C3PAO requirements are suspended while DoW reviews the program. - A POA&M only works if you score 88 of 110 or better, and the gaps on it get 180 days to close. The SSP itself can never be on a POA&M. - Self-assessment is not a lighter standard. You still have to meet all 110 requirements, post the score in SPRS, and affirm it every year. - Plant-floor OT can be scoped as a Specialized Asset, but only if it's in your inventory, your network diagram, and your SSP. ## What this looks like when we do it Limehawk isn't a C3PAO, a Registered Practitioner Organization, or an assessor. We can't certify you or sign your affirmation. A senior official at your company signs that. What we do is the IT under it. We scope the environment, segment the plant floor from the office, enforce MFA, roll out encryption, set up logging and patching, and pull evidence straight from the tools that run those controls. Then your SSP describes systems that actually exist. If we manage in-scope systems for you, we're an external service provider in your SSP, and our part gets assessed along with yours. We plan for that from day one. If you're a manufacturer with CUI drawings coming in, our [manufacturing IT support](/industries/manufacturing) page covers how we handle the OT side. Have a prime asking for your SPRS score and a list of gaps you haven't counted yet? Book a call and we'll go through it. --- # CMMC Level 1: What a Small Machine Shop Has to Prove to Keep DoD Work URL: https://limehawk.io/reports/cmmc-level-1 Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min CMMC Level 1 is an annual self-assessment against the 15 FAR 52.204-21 safeguards for FCI, affirmed in SPRS by a senior official. No third-party audit. > **TL;DR** CMMC Level 1 applies when your shop handles Federal Contract Information (FCI) on a DoD contract or subcontract but no CUI. It's the 15 basic safeguarding requirements in FAR 52.204-21: access control, user authentication, media disposal, physical access, boundary protection, patching, and malware protection. You assess yourself once a year, enter the result in SPRS, and a senior person at your company affirms it. Every requirement has to be met. There's no plan-of-action grace period at Level 1. CMMC requirements started showing up in contracts on November 10, 2025. In July 2026 the DoD suspended the move to Phase 2, so the program is paused in Phase 1, and Level 1 self-assessments still apply. - Level 1 security requirements: 15 ## What is CMMC Level 1? CMMC Level 1 is the entry tier of the DoD's Cybersecurity Maturity Model Certification program. It applies to contractors and subcontractors that process, store, or transmit Federal Contract Information (FCI) but not Controlled Unclassified Information (CUI). The rule at [32 CFR 170.14](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) says the Level 1 requirements "are those set forth in 48 CFR 52.204-21(b)(1)(i) through (xv)." That FAR clause isn't new. What CMMC adds is the proof. You now have to assess yourself against it, post the result, and have someone with authority sign off every year. The [FAR definition of FCI](https://www.acquisition.gov/far/4.1901) is information "not intended for public release, that is provided by or generated for the Government under a contract." It excludes information the government has already made public and "simple transactional information, such as that necessary to process payments." For a machine shop, think purchase orders, part drawings and specs that come down from a prime, delivery schedules, and the email threads about them. ## Does CMMC Level 1 apply to my shop? If you supply a DoD prime and the subcontract involves FCI but not CUI, yes. Under [32 CFR 170.23](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170), primes have to flow CMMC requirements down "throughout the supply chain at all tiers." A subcontractor that only handles FCI needs a CMMC Status of Level 1 (Self). Your prime decides which level goes in your subcontract, based on the information you'll touch. If the drawings coming to you are marked CUI, you're in Level 2 territory, and that's a bigger project. We cover it in our [CMMC Level 2 requirements guide](/reports/cmmc-level-2-requirements). This guide stays on Level 1. The rule also excludes contracts solely for commercially available off-the-shelf (COTS) items, and those at or under the micro-purchase threshold. ## What are the 15 Level 1 requirements? Level 1 is the 15 basic safeguarding requirements in [FAR 52.204-21](https://www.acquisition.gov/far/52.204-21), paragraph (b)(1). The DoD CIO's [About CMMC page](https://dodcio.defense.gov/cmmc/About/) describes Level 1 as an "annual self-assessment and annual affirmation of compliance with the 15 security requirements in FAR clause 52.204-21." In plain terms, here's the list: 1. Limit system access to authorized users, processes, and devices. 2. Limit users to the transactions and functions they're permitted to run. 3. Verify and control connections to external systems. 4. Control what gets posted on publicly accessible systems. 5. Identify users, processes, and devices. 6. Authenticate them before granting access. 7. Sanitize or destroy media holding FCI before disposal or reuse. 8. Limit physical access to systems and equipment to authorized people. 9. Escort visitors, monitor them, keep physical access logs, and manage keys and badges. 10. Monitor, control, and protect communications at external and key internal boundaries. 11. Put publicly accessible systems on subnetworks separated from internal networks. 12. Identify, report, and correct system flaws in a timely manner. 13. Provide malware protection at appropriate locations. 14. Update malware protection when new releases are available. 15. Run periodic system scans and real-time scans of files from outside sources. Nothing on that list is exotic. It's accounts, passwords, a locked door, a firewall, patching, and antivirus. Level 1 is hard because you have to do every item and be able to show it. ## How does the self-assessment and SPRS affirmation work? You assess yourself once a year, enter the result in the Supplier Performance Risk System (SPRS), and a senior official affirms it. Under [32 CFR 170.15](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170), the shop "must complete and achieve a MET result for all security requirements," and "no POA&Ms are permitted for CMMC Level 1." You can't log a gap and promise to fix it later. The assessment uses the objectives in NIST SP 800-171A for the matching requirements, with FCI substituted for CUI. The SPRS entry includes the CMMC level, status date, assessment scope, the CAGE codes covered, and the result. Then comes the affirmation in [32 CFR 170.22](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170). An "Affirming Official," a senior person with authority over compliance, attests in SPRS that the company "has implemented and will maintain implementation" of the requirements. That happens after each assessment and every year after. Both the self-assessment and the affirmation have to be in SPRS before you're awarded a contract that requires Level 1. You also keep the evidence for six years from the status date. That's the owner's or a senior manager's signature on a federal attestation. It should rest on evidence, not a hunch. ## When does CMMC Level 1 start, and where is the rollout now? It already started. The [DFARS acquisition rule](https://www.federalregister.gov/documents/2025/09/10/2025-17359/defense-federal-acquisition-regulation-supplement-assessing-contractor-implementation-of) took effect November 10, 2025, which began Phase 1. In Phase 1, the [CMMC program rule](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) has the DoD putting Level 1 (Self) or Level 2 (Self) into applicable solicitations as a condition of award. The rule planned three more phases, one year apart. Phase 2 was due November 10, 2026. On July 13, 2026, the department [suspended the Phase 2 transition](https://dodcio.defense.gov/Portals/0/Documents/Library/ImplementingSuspensionCMMC-PhaseII.pdf) pending a 60-day CIO review. During the suspension, programs may only require Level 1 (Self) or Level 2 (Self). On September 3, 2026, DoD made that binding in contracts with [Class Deviation 2026-O0025 Revision 3](https://www.acq.osd.mil/dpap/dars/classdev/DFARS_RFO/Part-240/2026-O0025_Rev3_TAB_A_Deviation_Memo.pdf). The DoD CIO's page, as of late September 2026, says "All Phase I self-assessment requirements remain firmly in place" and that the program "is paused in Phase 1." For a Level 1 shop, the pause changes nothing. Third-party Level 2 audits are what got pushed back. Self-assessments did not. ## What does IT have to prove for Level 1? IT has to show that each of the 15 requirements is in place across every system that touches FCI. Start with scope. [32 CFR 170.19](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170) puts systems that "process, store, or transmit FCI" in scope. You can't scope what you haven't inventoried. Here's the evidence we'd want in hand before anyone signs in SPRS: - **An asset list** of every computer, server, and cloud service that holds FCI. We've built this from live data before. See our [312-asset hardware inventory](/reports/automated-hardware-inventory). - **A user list** with one account per person, no shared logins, and departed employees removed. - **Access rights** showing people only have what their job needs. - **Firewall configuration** showing what's allowed in and out, and nothing exposed that shouldn't be. Remote Desktop open to the internet is exactly what requirement 10 is there to catch. We found exactly that during [onboarding discovery for a new client](/reports/open-rdp-onboarding-discovery). - **Patch reports** showing updates applied on a schedule. - **Antivirus status** on every in-scope machine: installed, updating, and scanning. - **Media disposal records** for old drives and laptops. - **Physical access controls**: locked server closet, visitor log, a list of who holds keys or badges. One thing helps manufacturers. Under 32 CFR 170.19, OT, industrial IoT, and test equipment that "are unable to be fully secured" are Specialized Assets. They "are not part of the Level 1 CMMC Assessment Scope." That old CNC controller doesn't get graded at Level 1. The front-office PC where the drawings land does. It still makes sense to segment the plant floor from the office, which we cover on our [manufacturing IT page](/industries/manufacturing). ## Key takeaways - Level 1 covers FCI only: contract information not meant for the public. If you handle CUI, you need Level 2. - It's the 15 safeguards in FAR 52.204-21, self-assessed every year, with results and an affirmation entered in SPRS before award. - Every requirement must be MET. No POA&Ms are allowed at Level 1, and you keep the evidence for six years. - OT and shop-floor equipment that can't be fully secured are Specialized Assets and sit outside the Level 1 scope. Your office computers do not. - Phase 2 was suspended in July 2026. Phase 1 self-assessment requirements remain in place, so Level 1 is still live. ## What this looks like when we do it We're not a C3PAO or a Registered Practitioner Organization, and we don't perform CMMC assessments. Level 1 doesn't need one. It's your self-assessment and your affirmation. Our job is making sure the IT side holds up when your Affirming Official signs. For a shop supplying DoD primes, that starts with an inventory and a scope: which machines and accounts touch FCI. Then we close the gaps that show up in almost every small office: shared logins, former employees still active, a firewall nobody has looked at in years, patching that stopped. We run patching, endpoint protection, and inventory through our RMM, so the reports you'd hand over come from the same tools doing the work. On the plant floor, we isolate legacy Windows and controllers instead of forcing replacements. More on that on our [manufacturing IT support page](/industries/manufacturing). Have a prime asking for your SPRS entry and not sure your office would pass all 15? [Get in touch](/contact) and we'll walk through it with you. --- # CMMC Enclave: How to Shrink Your Level 2 Scope to the Room Where CUI Lives URL: https://limehawk.io/reports/cmmc-enclave Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min A CMMC enclave is a separated environment where CUI lives, so the rest of the business stays out of Level 2 scope. 32 CFR 170.19 sets how assets are scoped. > **TL;DR** A CMMC enclave is a smaller, separated part of your environment where Controlled Unclassified Information (CUI) is handled. Everything that touches CUI goes inside it. Everything that's physically or logically cut off from it, and doesn't protect it, can be out of scope. The scoping rule at 32 CFR 170.19 sorts every asset into one of five categories: CUI Assets, Security Protection Assets, Contractor Risk Managed Assets, Specialized Assets, and Out-of-Scope Assets. Plant-floor OT usually lands in Specialized Assets. An enclave makes the assessment smaller. It doesn't make the 110 requirements go away, and the tools that protect the enclave come into scope with it. - Level 2 asset categories: 5 ## What is a CMMC enclave? A CMMC enclave is a segmented part of your environment set aside for Controlled Unclassified Information. The CUI goes in, the people who need it work inside it, and everything else in the business stays outside. If the separation holds, the rest of your network can be scoped out of your Level 2 assessment. The rule itself doesn't define the word. The DoD's [Level 2 Scoping Guide](https://dowcio.war.gov/Portals/0/Documents/CMMC/ScopingGuideL2.pdf) (version 2.13, September 2024) has a section called "Use of Enclaves," and it leans on NIST SP 800-171 Rev 2 for the idea. NIST says you "may limit the scope of the security requirements by isolating the designated system components in a separate CUI security domain," using things like "subnetworks with firewalls or other boundary protection devices." If you're not sure what counts as CUI in the first place, start with [what is CUI](/reports/what-is-cui). This guide assumes you already know CUI is coming in the door, usually as a drawing package, and you're deciding where to put it. ## Why does a small shop want an enclave? Because Level 2 applies to every asset in scope, and scope follows the CUI. If CUI drawings land in a shared inbox, sit on the file server, and get opened on the front-office PCs, that's all CUI Assets. Every one of them has to meet all 110 requirements in NIST SP 800-171 Rev 2. We walk through those in our [CMMC Level 2 requirements guide](/reports/cmmc-level-2-requirements). Put the CUI in a small, fenced-off space instead and the assessment covers that space plus what protects it. The accountant's laptop, the shipping PC, and the break-room TV can stay outside, as long as they really can't reach CUI. ## How does CMMC scoping work under 32 CFR 170.19? [32 CFR 170.19](https://www.ecfr.gov/current/title-32/subtitle-A/chapter-I/subchapter-G/part-170/section-170.19) sorts every asset in your environment into 5 categories, and you have to set that scope before any Level 2 assessment. Four categories are in scope. One isn't. Here's what Table 3 of the rule says for each: - **CUI Assets.** Anything that processes, stores, or transmits CUI. Document it in the asset inventory, the SSP, and the network diagram. It's assessed against all Level 2 requirements. - **Security Protection Assets.** Anything that provides security functions to the scope: firewalls, logging, VPNs, the anti-malware console, and the people who run them. The scoping guide lists "managed service provider personnel who implement system maintenance" as an example. These are assessed against the requirements relevant to what they do. - **Contractor Risk Managed Assets.** Assets that can handle CUI but aren't supposed to, because your policies and practices keep CUI off them. They don't have to be separated from CUI Assets. The assessor reviews the SSP and can run a "limited check" if your documentation raises questions. - **Specialized Assets.** Assets that can handle CUI "but are unable to be fully secured": IoT, IIoT, operational technology, government furnished equipment, restricted information systems, and test equipment. They're documented and managed under your risk-based policies. They aren't assessed against the other requirements. - **Out-of-Scope Assets.** Assets that can't process, store, or transmit CUI and don't protect CUI Assets, or that are "physically or logically separated from CUI assets." No documentation is required. But if an asset fits any in-scope category, it can't be out of scope. That last line is where enclaves live or die. The scoping guide says separation "is required only for Out-of-Scope Assets." Logical separation means firewalls, VLANs, routers, or VPNs block data flow. Physical separation means no connection at all, so data only moves by hand. ## What does an enclave look like at a small shop? There's no single blessed design. The scoping guide says, "The OSA determines the architecture that best meets its business needs." For a shop with a handful of people who touch CUI, three patterns come up most: 1. **A dedicated cloud tenant.** CUI email and files live in a separate cloud environment, and only the people who need CUI get accounts in it. If a cloud provider stores CUI for you, the rule says it "shall meet the FedRAMP requirements" in DFARS 252.204-7012. We cover what that means for Microsoft customers in [GCC High and CMMC](/reports/gcc-high-cmmc). 2. **Virtual desktops.** CUI stays on a hosted desktop, and users reach it through a VDI client. The rule says an endpoint running a VDI client that allows nothing "beyond the Keyboard/Video/Mouse sent to the VDI client" is out of scope. The configuration has to actually block copy, print, and drive mapping, and you have to be able to show it. 3. **A few dedicated workstations.** Two or three machines on their own network segment, behind their own firewall rules, used only for CUI work. It's low-tech and easy to explain, and it fits a shop where CUI work happens at one desk. Plenty of shops end up with a mix, such as a dedicated tenant plus dedicated workstations for the engineers who open the drawings. ## Where does shop-floor OT fit? Usually in Specialized Assets. The scoping guide defines OT as "programmable systems or devices that interact with the physical environment" and says it "specifically includes" SCADA. If a CNC controller or the old Windows PC that feeds it gets CUI drawings loaded onto it, it's handling CUI, but you often can't patch it or put modern security tools on it. That's exactly the case the Specialized Asset category was written for. It's not a free pass. You still have to list the machine in the inventory, show it in the network diagram, and describe in the SSP how you manage it under written risk-based policies. In practice, that means the same isolation we describe in [what is OT security](/reports/what-is-ot-security): keep it off the internet, wall it off from the office, and control who can reach it. Our [manufacturing IT page](/industries/manufacturing) covers how we segment the plant floor. ## What are the tradeoffs? An enclave cuts the assessment down. It also adds friction and a few surprises: - **The protection comes with it.** The scoping guide's example is a central anti-malware tool. The tool, its management server, and the IT people who run it can all be in scope even if the enclave is small. Any shared service that protects the enclave becomes a Security Protection Asset. - **Every requirement still has to be MET.** Inside the enclave you can inherit company-wide controls, but the guide says if something in the enclave "would invalidate an implementation at the Enterprise level," you have to meet that requirement some other way. - **Workflow changes.** People switch between two environments. If CUI starts moving through the regular inbox because it's easier, your scope just grew. - **Big changes mean a new assessment.** The guide says "significant architectural or boundary changes" require one. Adding a machine inside the existing boundary that follows the SSP doesn't. - **Out of scope has to be justified.** The rule says you must "prepare to justify" why an out-of-scope asset can't handle CUI. Saying it's separate isn't enough. You need a firewall rule, a diagram, or a config that proves it. On timing: the July 13, 2026 [memo suspending the Phase 2 transition](https://dowcio.war.gov/Portals/0/Documents/Library/ImplementingSuspensionCMMC-PhaseII.pdf) means new contracts can only ask for Level 1 (Self) or Level 2 (Self) for now. Scoping rules don't change with it. A self-assessment still has to say what's in scope, and a DIBCAC review can still check it. As of October 2, 2026, the suspension is still in place, so check the solicitation in front of you. ## Key takeaways - An enclave is a separated environment where CUI lives. The goal is a smaller CMMC Assessment Scope, not fewer requirements. - 32 CFR 170.19 sorts assets into 5 categories. Four are in Level 2 scope. Only Out-of-Scope Assets need no documentation. - Out-of-scope requires physical or logical separation from CUI Assets. A VDI endpoint that only passes keyboard, video, and mouse counts as out of scope. - Tools that protect the enclave, like logging, anti-malware management, and the people who run them, are Security Protection Assets and come into scope. - Shop-floor OT is a Specialized Asset. It isn't assessed against the other requirements, but it must be in the inventory, the SSP, and the network diagram. ## What this looks like when we do it Limehawk isn't a C3PAO, an RPO, or an assessor, and we don't certify anyone. Our part is the IT under the scope. We build the asset inventory and network diagram, sort every device into one of the five categories, and separate the CUI space from the rest of the network with firewall rules and segments we can show on paper. On the plant floor, we keep Windows 98 through 7 and SCADA systems running, isolated, and documented instead of forcing a replacement. That's the work a Specialized Asset entry in your SSP needs behind it. If we manage systems that protect your enclave, we're a Security Protection Asset in your SSP, and we plan for that from the start. Our [manufacturing IT support](/industries/manufacturing) page covers the OT side in more detail. Got CUI drawings landing in the general inbox and no idea how big your scope really is? Book a call and we'll walk through it with you. --- # ClickFix Fake CAPTCHA Malware: What to Do After "Verify You Are Human" Asks for Win+R URL: https://limehawk.io/reports/clickfix-fake-captcha-malware Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min A ClickFix page fakes a CAPTCHA, copies a command, and says to paste it into Win+R. If someone ran it, unplug the PC, call IT, and reset passwords elsewhere. > **TL;DR** ClickFix is a fake "Verify you are human" page. Clicking the checkbox quietly copies a command to your clipboard, then the page tells you to press Win+R, paste, and hit Enter. That runs the attacker's command on your own PC. CISA and the FBI tie it to the LummaC2 infostealer, and Microsoft has seen it drop remote access tools too. If an employee did it: unplug the PC from the network but leave it on, call IT, and change passwords from a different device. To stop the next one, run EDR that someone actually watches, take the Run box away from standard users, and teach one rule: a real CAPTCHA never asks you to type anything into Windows. - LummaC2 thefts FBI identified: 1.7 million ## What should I do right now if someone pasted the command? If an employee pressed Win+R, pasted, and hit Enter, treat that PC as infected. Pull the network cable or turn off Wi-Fi, but leave the machine powered on. Don't run the command again, don't wipe the PC, and don't start deleting things. Call IT now. Then change passwords from a different device. In order: 1. **Disconnect it.** Unplug the network cable or switch off Wi-Fi. That cuts the attacker's connection and stops more data leaving. 2. **Leave it on and leave it alone.** A running machine still holds evidence. If you have EDR, your IT team can investigate and isolate it remotely. Wiping it now destroys the record of what ran. 3. **Take a phone photo** of the fake page and the instructions if they're still on screen. Note the time and the website. 4. **Call IT.** Tell them exactly what was pasted and when. This is what an [incident response plan](/reports/incident-response-plan-template) is for. 5. **Reset passwords from a clean device.** Start with email, then banking and anything saved in that browser. The usual payload steals saved passwords and more (see below), so have IT also sign the account out of every session, not just change the password. 6. **Warn whoever moves money.** Stolen email logins lead straight to [business email compromise](/reports/business-email-compromise). For the next few weeks, verify every new or changed payment instruction by phone, the way our [wire verification guide](/reports/how-to-verify-wire-instructions) lays out. If the employee clicked the checkbox but never pasted anything, the command was only copied to the clipboard. Close the browser, don't paste, and still tell IT so they can block the site. ## How does the ClickFix "Verify you are human" trick work? ClickFix gets the victim to run the attacker's command for them. The page looks like a normal CAPTCHA. When you tick the box, a script on the page copies a hidden command to your clipboard. Then it shows "verification steps": press Windows+R, press Ctrl+V, press Enter. Those three keys run the command. CISA and the FBI described the same steps in their [May 21, 2025 LummaC2 advisory](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-141b): a fake CAPTCHA "contains instructions for users to then open the Windows Run window (Windows Button + R) and paste clipboard contents," and pressing Enter starts an encoded PowerShell process. [Microsoft's August 21, 2025 analysis of ClickFix](https://www.microsoft.com/en-us/security/blog/2025/08/21/think-before-you-clickfix-analyzing-the-clickfix-social-engineering-technique/) adds three things a business owner should know: - **It's common.** Microsoft says it has seen ClickFix campaigns "targeting thousands of enterprise and end-user devices globally every day." - **The command uses tools already on Windows**, like PowerShell and mshta. Nothing looks like a download, which is why plain antivirus can miss it. - **The script varies.** Some versions ask you to paste into PowerShell or Windows Terminal instead of the Run box, and a June 2025 campaign targeted Mac users. People fall for it because it borrows trust. Everyone has clicked a real "I'm not a robot" box, and the fake one shows up on a hacked legitimate website, a malicious ad, or a link in an email. Nothing about it feels like opening an attachment. ## What does ClickFix malware actually install? Most often it installs an infostealer or a remote access tool. Microsoft lists Lumma Stealer as the most prolific ClickFix payload, plus remote access trojans like Xworm, AsyncRAT, and NetSupport, and loaders that pull down more malware later. According to [CISA's LummaC2 advisory](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-141b), LummaC2 takes personal information, financial credentials, cryptocurrency wallets, browser extensions, and multifactor authentication details. That's why step 5 above says to reset passwords from another device and kill sessions. On a compromised PC, anything you type next goes to the attacker too. The Justice Department [seized LummaC2's control domains in May 2025](https://www.justice.gov/opa/pr/justice-department-seizes-domains-behind-major-information-stealing-malware-operation). The FBI had identified at least 1.7 million instances of LummaC2 being used to steal information like this. Microsoft [took action against about 2,300 more domains](https://blogs.microsoft.com/on-the-issues/2025/05/21/microsoft-leads-global-action-against-favored-cybercrime-tool/) at the same time. The trick outlived that takedown. Microsoft's ClickFix analysis came out three months later. It can also be the first step toward ransomware. CISA's [#StopRansomware advisory on Interlock](https://www.cisa.gov/news-events/cybersecurity-advisories/aa25-203a) (July 22, 2025) lists ClickFix fake CAPTCHAs and fake browser updates as ways that group gets in, followed by remote access tools, credential stealers, and keyloggers. Fake updates work the same way; see our guide to [SocGholish fake browser updates](/reports/socgholish-fake-browser-update). For more on what stolen credentials turn into, read our [infostealer malware guide](/reports/infostealer-malware). ## How do I block ClickFix attacks at my business? Block it in layers, because each layer misses something. Run EDR that a person monitors. Remove the Run box for standard users through Group Policy. Filter DNS so known bad sites don't load. Put phishing-resistant MFA on email. Then train staff on the one rule that beats every version of this trick. **EDR, watched by a person.** Endpoint detection and response watches behavior, like a Run box launching PowerShell that reaches out to the internet. CISA's Interlock advisory recommends "robust" EDR. Our [EDR vs antivirus guide](/reports/edr-vs-antivirus) explains the difference, and why an alert nobody reads protects nobody. **Take Win+R away from people who don't need it.** Microsoft's own mitigation is a Group Policy setting: **User Configuration > Administrative Templates > Start Menu and Taskbar > Remove Run menu from Start Menu**. Microsoft says it disables the Win+R key and the Run option. Your front desk and closers don't need it. It won't stop the PowerShell or Terminal versions, so Microsoft also recommends application control to limit which built-in tools standard users can launch, and PowerShell logging. **Filter what loads.** CISA recommends DNS filtering. Many ClickFix pages sit on hacked sites or behind malicious ads, and a filter that blocks known bad domains stops some of them before the page appears. **Protect the accounts, not just the PC.** CISA's LummaC2 advisory recommends [phishing-resistant MFA](/reports/phishing-resistant-mfa). It makes a stolen password less useful. It doesn't replace resetting passwords and revoking sessions after an infection. **Train one sentence.** "No website will ever ask you to press keys on your computer to prove you're human." Say it at the next staff meeting. Tell people that reporting a click is never a firing offense, so they call you in two minutes instead of two days. None of this needs new hardware. It's settings, monitoring, and a short conversation. If you don't know whether your machines have any of it, our [managed IT and security service](/services/managed-it-security) page lists the baseline we put on every machine. ## Key takeaways - No real CAPTCHA asks you to press Win+R, open PowerShell, or paste anything. That instruction is the attack. - If someone pressed Enter, assume it ran. Disconnect the PC, leave it powered on, and call IT before anyone wipes it. - Change passwords from a different, clean device, starting with email and banking, and have IT sign the account out everywhere. - ClickFix usually delivers infostealers like LummaC2 or remote access tools. CISA has also listed it as a way into networks for Interlock ransomware. - Block it in layers: monitored EDR, the Remove Run menu policy for standard users, DNS filtering, phishing-resistant MFA, and a ten-minute training talk. ## What this looks like when we do it On the machines we manage, EDR and 24/7 monitoring are part of the security baseline, so a Run box that suddenly launches PowerShell and calls out to the internet shows up as an alert a person looks at. If a machine looks infected, we can isolate it remotely while it's still powered on, keep the evidence, and walk you through resets and session sign-outs from a clean device. Critical issues get a response in under 15 minutes. We'll also go through which staff actually need the Run box and PowerShell, and help you put the Remove Run menu policy and MFA where they fit. The whole stack is on our [managed IT security page](/services/managed-it-security). Had someone paste a "verification" command today, or want to make sure the next one goes nowhere? Book a call. --- # Browser Hijacker Removal: What to Do When Chrome or Edge Gets Taken Over URL: https://limehawk.io/reports/browser-hijacker-removal Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Reset the browser, remove extensions, uninstall the app that brought it, and check chrome://policy. If it says managed and shouldn't, or it comes back, call IT. > **TL;DR** A browser hijacker changes your homepage, search engine, or new tab page, adds a toolbar or extension, and fills the screen with pop-ups. Most of them ride in with free software. The safe fix you can do yourself: reset Chrome or Edge to defaults, turn off every extension you don't recognize, uninstall the program that brought it from Windows Settings, and look at chrome://policy or edge://policy for rules you didn't set. Stop and call IT if the browser says "managed by your organization" on a PC nobody manages, or if the hijack comes back after you've cleaned it. That means something on the machine is putting it back. - OneStart reinstall task interval: 15 minutes ## What should I do right now if my browser was hijacked? Don't type passwords or card numbers into that browser until it's clean. Don't click "allow" on any pop-up asking for notification permission. Then work through four steps in order: reset the browser, remove extensions, uninstall the program that brought it, and check for policies. If you're on a work PC, tell IT before you start. A hijacker that injects ads can sit on every page, including your bank's login. If you've already logged into something sensitive while it was active, change that password from a different device once you're done here. If your office has IT, call them first. They may want to see the machine before you reset anything, and they can check the rest of the office for the same thing. When a hijacker shows up on one PC, it's often on others that installed the same free tool. ## What does a browser hijacker look like? A browser hijacker takes over the settings you normally control. The usual signs: - Your homepage, new tab page, or default search engine changed and you didn't change it. - Searches go through a site you've never heard of before landing on results. - A new toolbar, sidebar, or extension appeared. - Pop-ups and banner ads show up on sites that never had them. - Chrome or Edge says "Your browser is managed by your organization" on a PC nobody manages. Most hijackers come bundled with free software: PDF converters, video downloaders, "free" utilities. They install during the next-next-next part of the setup. Some are signed, "legitimate" programs that antivirus lets through. That's why [endpoint detection and response matters more than signature antivirus](/reports/edr-vs-antivirus) for this kind of thing. Fake download sites are a common source too, which we cover in [malvertising and fake software downloads](/reports/malvertising-fake-software-downloads). ## How do I remove a browser hijacker from Chrome? Reset Chrome, then remove extensions, then uninstall the program that brought it. Google's [steps for removing unwanted ads, pop-ups, and malware](https://support.google.com/chrome/answer/2765944) follow that order. 1. **Reset settings.** In Chrome, open the three-dot menu, then **Settings**, then **Reset settings**, then **Restore settings to their original defaults**, and confirm. Google says this [resets your default search engine, homepage and startup tabs, new tab page, pinned tabs, site permissions, cookies and site data, and extensions and themes](https://support.google.com/chrome/answer/3296214). Your bookmarks and saved passwords stay. 2. **Remove extensions.** Go to the three-dot menu, then **Extensions**, then **Manage extensions**. Remove anything you don't recognize. Google's advice is short: "Only turn on extensions you trust." 3. **Uninstall the program.** On Windows, open **Start**, then **Settings**, then **Apps**, then **Installed apps**. Sort by install date and look for anything that showed up around the time the browser changed. Uninstall it. 4. **Restart the PC** and open Chrome again. Check the homepage and search engine. ## How do I remove a browser hijacker from Edge? Edge works the same way. Open the three-dot menu, then **Settings**, then **Reset settings**, then **Restore settings to their default values**, and confirm. Then go to edge://extensions and remove anything you don't recognize. Uninstall the program from Windows **Installed apps** the same way you would for Chrome, and restart. If you use both browsers, clean both. A hijacker that came in through an installed program usually hits every browser on the machine. ## What does "managed by your organization" mean on my PC? It means browser policies are set on that machine. On a work PC, that's normal: your IT team sets them. On a personal PC, or a work PC your IT team says they don't manage that way, something else wrote them. Google's guidance is direct: ["If you believe your browser shouldn't be managed, contact your Google Account or device administrator."](https://support.google.com/chrome/answer/9281740) You can look without changing anything. Type chrome://policy in Chrome, or edge://policy in Edge. Microsoft [uses that same page](https://learn.microsoft.com/en-us/deployedge/configure-microsoft-edge) to show every policy applied to the browser. Look for policies that set a homepage, a search provider, or force-install an extension you didn't ask for. This is where the reset stops working. Microsoft's documentation says mandatory policies "override user preferences and prevent the user from changing the policy settings." A hijacker that writes policy wins against the reset button every time. Removing those policies means editing the registry, and that's IT's job. Don't go deleting registry keys from a forum post. ## When should I stop and call IT? Stop and call IT in any of these cases: - **It says "managed by your organization" and it shouldn't.** Rogue policy is past what a reset can fix. - **It came back.** If you reset, removed the extension, uninstalled the app, and it's back an hour or a day later, something on the machine is reinstalling it. - **It's a work PC.** Your IT team needs to know, and the same installer may be on other machines. - **You typed passwords into it** while it was active. Those accounts need resets from a clean device and a look at recent sign-ins. The "it came back" case is the one we've worked. In our [OneStart removal report](/reports/onestart-malware-removal), the Windows uninstaller removed the visible app and left scheduled tasks behind. The task ran every 15 minutes and put the whole thing back. Users had uninstalled it. Their IT contact had tried Control Panel. It kept returning until we killed the processes, deleted the scheduled tasks, and only then removed the files and registry entries. Order mattered more than the commands. That's why "it came back" means call IT. A reset won't find a scheduled task. ## How do we stop browser hijackers from coming back? Block extensions by default and allow only the ones you've approved. Both Chrome and Edge support this through policy, the same mechanism a hijacker abuses, used on purpose. In Edge, the [ExtensionInstallAllowlist policy](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-policies/extensioninstallallowlist) works with the blocklist: "A blocklist value of * means all extensions are blocked and users can only install extensions listed in the allow list." Chrome has matching [ExtensionInstallAllowlist](https://chromeenterprise.google/policies/extension-install-allowlist/) and [ExtensionInstallBlocklist](https://chromeenterprise.google/policies/extension-install-blocklist/) policies. Set the blocklist to *, list the extensions your staff actually needs, and push it through Group Policy or Intune. The rest is ordinary hygiene: - **No local admin for daily work.** A user who can't install software can't install a bundled hijacker. - **EDR and DNS filtering**, so a known installer gets flagged or its domains get blocked. - **Custom install, every time.** Uncheck the bundled extras. Better yet, get software from IT instead of a download site. Hijackers are a nuisance more often than a breach. But the same install habits that let one in also let in worse things, like the [fake browser update pages behind SocGholish](/reports/socgholish-fake-browser-update). Fix the habit and you close both doors. If you want a written plan for the day it's worse than a hijacker, start with our [incident response plan template](/reports/incident-response-plan-template). ## Key takeaways - A browser reset fixes the settings, not the cause. Uninstall the program that brought the hijacker too, or it comes back. - Resetting Chrome keeps your bookmarks and passwords. It resets search, homepage, startup tabs, pinned tabs, site permissions, cookies, and turns off extensions. - "Managed by your organization" on a personal or unmanaged PC means something wrote browser policy. Check chrome://policy or edge://policy, then call IT. - If it comes back after cleanup, something is reinstalling it. That's a job for IT, not another reset. - The fix that sticks is an extension allow list set by policy, so only approved extensions can install. ## What this looks like when we do it On our [managed IT and security service](/services/managed-it-security), every machine gets the same baseline, with EDR and 24/7 monitoring on all of them. When a hijacker gets through, we don't reset browsers one at a time. We find what's reinstalling it, remove it in the right order across every affected machine with a script through our RMM, and block its domains at the DNS filter. That's what we did for OneStart across 30 machines. If you're seeing "managed by your organization" on PCs you don't manage, or a hijacker that keeps coming back, our [security service page](/services/managed-it-security) covers what the baseline includes. Clients get a response in under 15 minutes on critical issues. Want someone to look at it? Book a call and we'll walk through it. --- # Akira Ransomware: What to Do in the First Hour, and How It Gets In URL: https://limehawk.io/reports/akira-ransomware Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Hit by Akira? Disconnect affected machines, don't pay, keep the evidence, call IT and your insurer, and report to the FBI. Most Akira entries start at the VPN. > **TL;DR** Akira is a ransomware group that CISA and the FBI say mostly goes after small and mid-sized businesses. It steals your data, then encrypts your systems, and threatens to publish the data if you don't pay. If you see an Akira note, pull the network cable or Wi-Fi on affected machines, leave them powered on, don't contact the attackers, and call IT from a phone, not company email. Then call your cyber insurer and report to the FBI at IC3. The usual way in is a VPN login with no MFA or an unpatched Cisco or SonicWall firewall. Enforced MFA, patched edge devices, EDR, and immutable backups shut most of that down. - Fastest exfiltration CISA cites: Just over 2 hours ## What should I do in the first hour after an Akira ransomware attack? Isolate, don't erase. Unplug the network cable or turn off Wi-Fi on every machine showing the note, but leave them powered on. Don't wipe, reimage, or restore anything yet. Don't open the attackers' chat site. Call IT on a phone, then your cyber insurer. Coordinate everything by phone or text, not company email. Here's the order we'd want an office manager to follow, built on CISA's [#StopRansomware Guide](https://www.cisa.gov/stopransomware/ransomware-guide) response checklist: 1. **Disconnect, don't shut down.** CISA says to isolate affected systems right away. It only recommends powering devices off if you can't disconnect them, because shutting down destroys evidence held in memory. 2. **Get off company email and chat.** CISA advises "out-of-band communication methods such as phone calls" so you don't tip off the attackers. Assume they can read your mailbox. 3. **Call IT.** Whoever runs your network needs to know now, including after hours. If you don't have someone, this is the call to make before anything else. 4. **Don't wipe anything.** Reimaging a machine erases the trail that shows how they got in. Your insurer and investigators need that trail. 5. **Reset passwords from a clean device.** Use a phone or a personal computer that wasn't on the office network, not an infected workstation. Start with email, banking, and admin accounts. Have IT handle the network and VPN accounts, in the right order. 6. **Don't pay, and don't negotiate on your own.** The [CISA and FBI advisory](https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-109a) says plainly: "FBI and CISA do not encourage paying ransom as payment does not guarantee victim files will be recovered." If you already have an [incident response plan](/reports/incident-response-plan-template), get it out now. If you don't, the steps above are the short version of one. ## Who do I tell: the insurer, the FBI, anyone else? Tell your cyber insurance carrier first, then the FBI. CISA's checklist puts the cyber insurance company on the list of people to engage early. Read your policy's notice section or call the claims line before you hire outside help, because the policy may set terms for who you can use. Then file with the FBI at [ic3.gov](https://www.ic3.gov) or call your local field office. The [Akira advisory](https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-109a) lists what the FBI wants in a report: a point of contact, the status and scope of the infection, estimated loss, operational impact, transaction IDs, the date of infection, the date you found it, the initial attack vector, and any indicators from your systems. You can also report to [CISA](https://www.cisa.gov/report). Medical practices can report to HHS at the address listed in the advisory. If client data was taken, you may also have notification duties under your industry's rules or state law. That's a conversation for your attorney and insurer. Don't guess at it. ## What is Akira ransomware? Akira is a ransomware operation that CISA has tracked since March 2023. CISA and the FBI first published their [#StopRansomware advisory, AA24-109A](https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-109a), on April 18, 2024, and most recently updated it on November 13, 2025. That update added new activity the agencies call "an imminent threat to critical infrastructure." The advisory says Akira "primarily target[s] small- and medium-sized businesses, but [has] also impacted larger organizations." As of late September 2025, it says Akira had claimed about $244.17 million in ransom proceeds. Akira runs double extortion. It copies your data out, then encrypts your systems, then threatens to leak the data. The advisory says that in some incidents, Akira pulled data out in just over 2 hours from initial access. By the time the ransom note shows up, assume the data is already gone. It encrypts Windows machines and VMware ESXi hosts, and in a June 2025 incident it also went after Nutanix AHV virtual machines. Encrypted files usually end in `.akira`, `.powerranges`, `.akiranew`, or `.aki`. On Windows, its encryptor uses PowerShell to delete Volume Shadow Copies, the built-in restore points people often count on. ## How does Akira get into a small business network? Mostly through the front door: the VPN. The first initial-access method CISA lists is "VPN service without multifactor authentication (MFA) configured." A stolen or guessed password plus a VPN with no second factor is all it takes. The advisory also names known vulnerabilities in edge devices and backup software: - **Cisco** VPN flaws (CVE-2020-3259 and CVE-2023-20269). - **SonicWall** CVE-2024-40766, which CISA says Akira "likely abused" for initial access. - **Veeam Backup & Replication** flaws (CVE-2023-27532 and CVE-2024-40711). On SonicWall, the vendor's own [August 2025 notice](https://www.sonicwall.com/support/notices/gen-7-and-newer-sonicwall-firewalls-sslvpn-recent-threat-activity/kA1VN0000000RDG0A2) tied a wave of SSL VPN incidents to that same CVE-2024-40766. It said many involved migrations from Gen 6 to Gen 7 firewalls "where local user passwords were carried over during the migration and not reset." SonicWall's fix: update firmware, reset every local SSL VPN password, remove inactive accounts, and enforce MFA. CISA also lists spearphishing, valid credentials bought or stolen elsewhere, password spraying, and exposed Remote Desktop. We've found [RDP open to the internet](/reports/open-rdp-onboarding-discovery) during onboarding discovery before. Stolen passwords often come from [infostealer malware](/reports/infostealer-malware) on a home or work PC. ## Why do backups need to be offline or immutable? Because Akira looks for your backups. It deletes Windows shadow copies, and two of the vulnerabilities in the advisory are in Veeam, a common backup product. If the attackers can log into your backup server with the same admin account they stole, they can delete your restore points before they encrypt. CISA's mitigation is specific: keep backups offline, and "ensure all backup data is encrypted, immutable (i.e., cannot be altered or deleted), and covers the entire organization's data infrastructure." Immutable means even an admin account can't delete it before the retention period ends. That's the [3-2-1 backup rule](/reports/3-2-1-backup-rule) with a hard requirement on the "1": one copy the network can't touch. And a backup you've never restored from is a guess. Test it. ## What controls stop Akira's initial access? Most Akira entries fail against a short list of controls. These track the advisory's own mitigations: - **Enforced MFA on every remote login.** VPN, remote desktop gateways, email, and admin portals. CISA asks for phishing-resistant MFA. Our [phishing-resistant MFA guide](/reports/phishing-resistant-mfa) explains what that means and where to start. - **Patch the edge first.** Firewalls, VPN appliances, and backup servers get patched before workstations. CISA's first mitigation is to remediate known exploited vulnerabilities. - **Clean up VPN accounts.** Remove old users, reset passwords carried over from old hardware, and turn on account lockout. - **No RDP on the internet.** Put remote access behind the VPN, with MFA. - **EDR on every machine.** CISA lists endpoint detection and response. Plain antivirus isn't the same thing; see [EDR vs. antivirus](/reports/edr-vs-antivirus). - **Separate admin accounts.** CISA recommends long passwords (at least 15 characters) and time-limited admin access. Don't let the account that reads email also manage backups. - **Segment the network.** Keep the plant floor apart from the office. On a [manufacturing network](/industries/manufacturing), that's what keeps a ransomware note in accounting from stopping the line. Akira isn't the only group working this playbook. [Qilin](/reports/qilin-ransomware) goes after the same kinds of businesses, and the same controls apply. ## Key takeaways - If you see an Akira note: isolate the machine, leave it on, don't wipe it, don't contact the attackers, and call IT by phone. - Call your cyber insurer early and report to the FBI at ic3.gov. CISA and the FBI do not encourage paying. - Akira usually comes in through the VPN: no MFA, reused or carried-over passwords, or an unpatched Cisco or SonicWall device. - Akira deletes Windows shadow copies and goes after backup servers. A backup it can reach is a backup it can destroy. Keep one copy offline or immutable. - The fixes are boring and they work: enforced MFA on every remote login, patched firewalls, EDR on every machine, and a restore you've actually tested. ## What this looks like when we do it Our [managed IT and security](/services/managed-it-security) baseline covers most of the controls above: EDR on every managed machine, 24/7 monitoring, patching, and MFA enforced across Microsoft 365. Our managed firewall work keeps RDP off the internet, and our compliance and backup service keeps backups tested, not assumed. For plants, we keep production systems segmented and patch on the schedule the line allows. Our [manufacturing page](/industries/manufacturing) covers how we handle legacy Windows and SCADA without exposing them. When something does go wrong, our clients get a person on the phone in under 15 minutes for critical issues. We isolate first, preserve the evidence, and work with your insurer instead of around them. The details are on our [managed IT and security page](/services/managed-it-security). Worried your VPN is the open door? Book a call and we'll check it with you. --- # The 3-2-1 Backup Rule: What Each Number Means and How to Test It URL: https://limehawk.io/reports/3-2-1-backup-rule Published: 2026-10-02 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min The 3-2-1 backup rule: keep 3 copies of your data, on 2 different media, with 1 copy offsite. CISA adds offline copies and tested restores. > **TL;DR** The 3-2-1 backup rule says keep three copies of any important file (the original plus two backups), on two different types of storage, with one copy offsite. US-CERT published it that way in 2012, citing a photographer's book from 2009. CISA's current ransomware guidance adds two things the rule doesn't say out loud: keep a copy offline or immutable, because ransomware goes after backups it can reach, and test your restores. A sync folder like OneDrive or Dropbox isn't a backup. It copies your mistakes and your ransomware just as faithfully as your work. - CISA rollback test: 7 days ## What is the 3-2-1 backup rule? The 3-2-1 backup rule is a simple way to make sure one bad day can't take out every copy of your data. Keep **3** copies of any important file, store them on **2** different types of media, and keep **1** copy offsite. Here's how the US-CERT [Data Backup Options](https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf) paper words it: - **3**: "Keep 3 copies of any important file: 1 primary and 2 backups." - **2**: "Keep the files on 2 different media types to protect against different types of hazards." - **1**: "Store 1 copy offsite (e.g., outside your home or business facility)." That paper was written by Carnegie Mellon for US-CERT in 2012, and it footnotes the rule to Peter Krogh's *The DAM Book* (2nd edition, 2009), a book on managing digital photo archives. So the rule came out of photography, not enterprise IT. It stuck because it's easy to remember and hard to argue with. CISA's [Back Up Business Data](https://www.cisa.gov/audiences/small-and-medium-businesses/secure-your-business/back-up-business-data) page for small businesses still teaches it the same way today. ## Why does each number exist? Each number covers a different way to lose data. Three copies cover a single failure: a dead drive or a deleted folder still leaves two. Two media types cover a failure that hits one kind of storage at once. One offsite copy covers losing the building, whether that's fire, flood, theft, or a burst pipe over the server closet. The middle number is the one people fudge. Two backups on the same NAS, or two folders on the same server, are really one copy with extra steps. The US-CERT paper makes the point about backups kept on the same internal drive: if that drive is "damaged, stolen, or corrupted, you could lose both your primary and backup files." "Different media" today usually means a local backup appliance or NAS plus cloud storage. CISA's small-business page uses exactly that pairing as its example: "a hard drive and the cloud." ## What do CISA and NIST add to 3-2-1 now? The 2012 rule was written for hardware failure and disasters. Ransomware changed what "offsite" has to mean. CISA's [#StopRansomware Guide](https://www.cisa.gov/stopransomware/ransomware-guide) (September 2023) says to "maintain offline, encrypted backups of critical data, and regularly test the availability and integrity of backups in a disaster recovery scenario." It explains why: "many ransomware variants attempt to find and subsequently delete or encrypt accessible backups." So the copy that's offsite also needs to be out of reach. CISA gives two ways to do that: - **Offline.** The backup is disconnected from the network, so nothing on the network can touch it. - **Immutable.** The same guide notes that "some cloud vendors offer immutable storage solutions that can protect stored data without the need for a separate environment." Immutable means the backup can't be changed or deleted until its retention period runs out, even by an admin account. NIST says the same thing in framework language. The [Cybersecurity Framework 2.0](https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.29.pdf) (February 2024) has one line for backups, PR.DS-11: "Backups of data are created, protected, maintained, and tested." NIST's NCCoE published a [backup guide written for managed service providers](https://www.nccoe.nist.gov/sites/default/files/legacy-files/msp-protecting-data-extended.pdf) that repeats 3-2-1 and adds a "go bag." That's a copy of critical data, passwords, and encryption keys kept "in a separate, secure and accessible location." Your backups are only as good as your ability to log into them after a bad day. ## Is OneDrive, Dropbox, or Google Drive a backup? No. A sync folder keeps every device matching the same files. That's the whole job. When a file gets deleted, the deletion syncs. When ransomware encrypts a file, the encrypted version syncs. The US-CERT paper warns about this with "rolling backups" that update automatically: they "can silently propagate any corruption or malware in the primary files to the backup files." Version history and the recycle bin help, and they're worth turning on. Microsoft's own [Restore your OneDrive](https://support.microsoft.com/en-us/office/restore-your-onedrive-fa231298-759d-41cf-bcd0-25ac53eb8a15) feature lets Microsoft 365 subscribers roll a OneDrive back after files were "deleted, overwritten, corrupted, or infected by malware." But that window is the last 30 days, and it lives inside the same account an attacker may already control. Count your sync folder as the primary copy, then back it up somewhere separate. ## What do regulators and insurers ask about backups? They ask whether you can get the data back, not whether a backup job exists. The clearest written requirement for small regulated firms is HIPAA. The Security Rule at [45 CFR 164.308(a)(7)(ii)(A)](https://www.ecfr.gov/current/title-45/part-164/section-164.308) makes a data backup plan Required: procedures "to create and maintain retrievable exact copies" of ePHI. Testing the contingency plan is an addressable spec under (a)(7)(ii)(D). We walk through how that shows up in a [HIPAA risk assessment](/reports/hipaa-risk-assessment-it). Cyber insurance questionnaires vary by carrier, but they commonly ask about tested backups right next to MFA, laptop encryption, and a device inventory. We keep that list on our [cyber insurance compliance and backup page](/services/compliance-backup). The word that matters is *tested*. A green checkmark on a backup job doesn't answer it. ## How do you test a backup restore? You test a backup by actually restoring from it and checking that what comes back is usable. CISA's small-business guidance says to "test backup procedure to make sure your team can rapidly restore data both fully and partially." It also says to make sure you can roll data back at least 7 days. A simple schedule for a small office: 1. **Single-file restore, monthly.** Pick a real file from last week, restore it to a different folder, and open it. 2. **Point-in-time restore.** Restore a folder as it was at least 7 days ago. That's the test that tells you whether you could get past a ransomware event someone didn't notice for a week. 3. **Full-system restore, at least once a year.** Bring a whole server or a critical PC back on spare hardware or a VM. NIST's NCCoE guide lists what to time: retrieving files from offsite, restoring files, and rebuilding systems. 4. **Restore from the offsite copy.** Testing only the local copy doesn't prove the cloud or offline copy works. 5. **Write it down.** Record the date, what you restored, where it came from, how long it took, and anything that broke. Don't forget the configuration that rebuilds the box. Our [Proxmox host config backup report](/reports/proxmox-host-config-backup-automation) covers the part most backups miss: the network, cluster, and mount settings you need before the VM backups are any use. That script also refuses to run if the backup share isn't mounted, so it can't quietly write "backups" to the same disk it's protecting. ## Key takeaways - 3-2-1 means three copies of the data, on two different types of storage, with one copy offsite. - Ransomware looks for backups it can reach. CISA says to keep backups offline and encrypted, or use immutable storage. - A sync folder copies deletions and encrypted files along with everything else. It isn't one of your three copies. - CISA tells small businesses to test full and partial restores and to make sure they can roll data back at least 7 days. - Write down every test restore: the date, what you restored, and how long it took. That record is the proof an auditor or carrier wants. ## What this looks like when we do it Every managed client we take on runs on 3-2-1. Backups run daily and automatically, the offsite copy is encrypted, and we can restore to a point in time. We test restores on a regular schedule and keep documented recovery procedures, because a backup nobody has restored is a guess. The details are on our [compliance and backup service page](/services/compliance-backup). When a carrier questionnaire or a HIPAA risk analysis asks about backups, the answer is already on file: the last restore test, what we restored, and how long it took. Not sure when your backups were last restored? Book a call and we'll check together. --- # Writing a WISP for a Tax Practice: What Goes In It URL: https://limehawk.io/reports/wisp-for-tax-preparers Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Every paid tax preparer needs a written information security plan under the FTC Safeguards Rule. IRS Pub 5708 is the official sample. Here's what goes in. > **TL;DR** If you prepare returns for pay, federal law treats you as a financial institution and requires a written information security plan. The IRS sample is Publication 5708. Start there, not with a template off a forum. The template is the easy part. The IT sections are the hard part, because they have to describe what your machines actually do, and they have to stay true after tax season changes everything. - Official IRS WISP sample: Pub 5708 ## What is a WISP? A WISP is a written information security plan: the document that says how your practice protects client data, who is in charge of it, and what happens when something goes wrong. The FTC's Safeguards Rule calls it a "comprehensive information security program that is written in one or more readily accessible parts" ([16 CFR 314.3](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.3)). The IRS calls it a WISP. Same thing. It has to fit your "size and complexity, the nature and scope of your activities, and the sensitivity of any customer information at issue." A two-person seasonal office and a ten-partner CPA firm should not have the same document. [IRS Publication 5708](https://www.irs.gov/pub/irs-pdf/p5708.pdf) says it plainly: "There is no one-size-fits-all WISP." ## Do tax preparers have to have one? Yes. The Safeguards Rule names "an accountant or other tax preparation service that is in the business of completing income tax returns" as a financial institution ([16 CFR 314.2](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.2)). [IRS Publication 4557](https://www.irs.gov/pub/irs-pdf/p4557.pdf) says the same thing: "tax return preparers must create and enact security plans to protect client data," and that failing to do so "may result in an FTC investigation." Pub 5708 adds that tax and accounting professionals are financial institutions "regardless of size." That covers [accounting firms](/industries/accounting-firms) doing bookkeeping and payroll next to returns, too. The IRS also puts it in front of you every year. Line 11 of [Form W-12](https://www.irs.gov/pub/irs-pdf/fw12.pdf), the PTIN application and renewal, reads: "I am aware that paid tax return preparers are required by law to create and maintain a written information security plan that provides data and system security protections for all taxpayer information." It's a yes-or-no box, and you must check one. Read that closely. You're acknowledging the law. The form doesn't ask you to send in the plan. So the first person to actually read your WISP may be someone looking at it after a breach. Write it for that reader. ## What goes in it? Start with [IRS Publication 5708](https://www.irs.gov/pub/irs-pdf/p5708.pdf), *Creating a Written Information Security Plan for your Tax & Accounting Practice* (Rev. 8-2024). The Security Summit (the IRS, state tax agencies, and private-sector tax groups) wrote it to help smaller practices, and it includes a fill-in-the-blanks sample. Use that one. I'm not going to hand you a different template. The official one is free, and anyone reviewing your plan will recognize it. Its outline: 1. **Objectives, purpose, and scope.** What data you hold and why the plan exists. 2. **A qualified individual.** Pub 5708's sample calls this person the Data Security Coordinator. List your authorized users and what each can access. 3. **Risk assessment.** What information you handle, where it could be lost, and how you test for new risks. 4. **Hardware inventory.** Every device, where it sits, and what client data it stores or processes. 5. **Safety measures.** Policies for data collection and retention, disclosure, network protection, user access, electronic data exchange, Wi-Fi, remote access, connected devices, and reportable incidents, plus an employee code of conduct. 6. **An implementation clause.** Signed and dated. 7. **Attachments.** Retention policy, rules of behavior, breach procedures, staff acknowledgments, the hardware inventory, and the list of who can access client data. Then check it against the rule, because the rule is what you're held to. [16 CFR 314.4](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.4) requires, among other things: a written risk assessment; access limited to what each person needs; encryption of customer information at rest and in transit over external networks; multi-factor authentication "for any individual accessing any information system"; secure disposal within two years of last use unless you have a legitimate reason to keep it; logging of user activity; security awareness training; service providers bound by contract to keep safeguards; a written incident response plan; and a written report from your qualified individual at least once a year. If a breach involves at least 500 consumers, you notify the FTC no later than 30 days after you discover it. Small practices get one break. If you hold information on fewer than 5,000 consumers, [16 CFR 314.6](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.6) drops four items: the written risk-assessment requirements, the penetration testing and vulnerability scans, the written incident response plan, and the annual report. You still assess risk. You still need the plan, MFA, and encryption. ## Where does IT come in? Most of the WISP is IT. Pub 5708's sample says your firewall "will be secured and maintained by the Firm's IT Service Provider," that event logs get reviewed "at random intervals not to exceed 90 days," and that remote access "will only be allowed using multi-factor Authentication (MFA)." Each of those is a factual claim about your office. Sign the template as written when none of it is true, and you have a document proving you knew what you were supposed to do. The sections IT has to be able to back up: - **Hardware inventory.** Every laptop, desktop, server, and NAS that touches client data, with location and contents. That's Attachment E in Pub 5708. We've pulled a [312-asset inventory in 47 minutes](/reports/automated-hardware-inventory) for an insurance audit, with encryption status included. - **Encryption.** Full-disk encryption on every machine with client data, with recovery keys escrowed. See [BitLocker at scale](/reports/bitlocker-encryption-automation). - **MFA.** Email, remote access, tax software, cloud storage. The rule says any information system. An exception needs your qualified individual's written approval. - **Access and offboarding.** Unique logins, no shared accounts (Pub 5708's sample says so directly), and access cut the day someone leaves. - **Where client data lives.** Pub 5708 tells the coordinator to list every location, including "Database applications, such as Bookkeeping and Tax Software Programs." That list should include the spreadsheet of passwords on someone's desktop. We [find those in audits](/reports/password-file-audit). - **Patching and logging.** OS patches installed, firewall firmware current, event logging on. ## Keeping it true after you write it Pub 5708 calls the WISP "an evergreen document," and the sample says to review it at least annually. Annual is the floor. A plan drifts the week something breaks. Here's a real one. A client's [QuickBooks Desktop hung on the Intuit ID login](/reports/quickbooks-desktop-intuit-id-login-hang) and he couldn't email invoices. The same-day workaround was exporting PDFs and sending them from Gmail. For invoices, fine. Now picture the same kind of hang in tax software in March, and a preparer doing the same thing with a client's return. Pub 5708's sample policy says client data doesn't go out by plain email unless it's encrypted or password-protected, with the password sent some other way. Under deadline pressure, nobody opens the WISP first. So the real job isn't writing the plan. It's catching the moment the office stops matching it: a new hire on a shared login, a new printer on the Wi-Fi with its factory password, a workaround that quietly became the process. The rule expects this. 314.4(g) says you adjust the program after testing, after material changes to how you operate, and after anything else that affects it. ## What this looks like when we do it For [accounting firms and tax practices](/industries/accounting-firms), we start from Pub 5708 and the 314.4 elements, and we fill the IT sections with what's actually on your network instead of what the template assumes. Then: - We inventory every device and tie it to the plan's hardware attachment. - We enforce MFA, encrypt every machine with escrowed keys, and write up any exception for your qualified individual to approve. - We map each control to the 314.4 paragraph it answers, so the annual review is a check, not a rewrite. - We keep it current as part of managed IT, so the document matches the office in April, not just the day it was signed. The Safeguards controls are part of our month-to-month managed IT. The first written program build is scoped separately, on a call. If your WISP is a template nobody has opened since the last PTIN renewal, [get in touch](/contact) and we'll walk through it with you. ## Key takeaways - Every paid preparer needs a WISP. The FTC Safeguards Rule covers tax preparers regardless of size, and Form W-12 makes you acknowledge it at every PTIN renewal. - Use IRS Publication 5708 as the starting template. It's free and written for smaller practices. - MFA and encryption are not optional sections. 16 CFR 314.4(c)(3) and (c)(5) require them, and exceptions have to be approved by your qualified individual. - Under 5,000 consumers, you skip four pieces of the rule. You don't skip the plan. - A WISP that says MFA is on everything when it isn't is not a plan. It's evidence. --- # What Is OT Security? A Plant Manager's Version URL: https://limehawk.io/reports/what-is-ot-security Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min OT security protects the computers and controllers that run physical equipment. The goal is keeping the line running and people safe, not just protecting data. > **TL;DR** OT is the gear that moves things: PLCs, HMIs, SCADA, and the old Windows box bolted to the side of a machine. OT security is keeping that gear off the internet, walled off from the office network, and reachable only by the people who should reach it. You usually can't patch your way there. You isolate, you lock down remote access, and you make sure you can still run the line by hand if it all goes sideways. - CISA core OT mitigations: 5 ## What is OT security? OT security is protecting the computers and controllers that run physical equipment. Presses, mixers, conveyors, CNC machines, the HMI screen your operators tap all day, and the PC in the corner running the SCADA software. If it makes something move, heat, cool, or stop, it's OT. NIST's definition, from its [Guide to Operational Technology Security (SP 800-82 Rev. 3)](https://csrc.nist.gov/pubs/sp/800/82/r3/final), is "programmable systems and devices that interact with the physical environment." Those systems "detect or cause a direct change through the monitoring and/or control of devices, processes, and events." In plain terms: IT holds your data. OT runs your plant. The security job is keeping those systems running, keeping people safe around them, and keeping outsiders from reaching them. That's the work we do for [manufacturers in East Tennessee](/industries/manufacturing). ## How is it different from IT security? The priorities flip. In the office, the worst day is someone stealing your data. On the plant floor, the worst day is a machine doing something it shouldn't, or the line sitting dead for a shift. NIST says it directly. In a typical IT system, "primary concerns include data confidentiality and integrity." For OT, the concerns are safety, equipment loss, and lost or damaged product, and "any security measure that impairs safety is unacceptable." The same guide says [availability is "generally the greatest concern"](https://csrc.nist.gov/pubs/sp/800/82/r3/final) for OT. That changes how you do the basics: - **Rebooting is not a fix.** NIST notes that typical IT moves like rebooting a component "are usually not acceptable for OT." Many processes run continuously, and outages get scheduled days or weeks ahead. - **Security software can break things.** Legacy systems often lack the horsepower, logging, or even password protection modern tools expect. NIST warns that "indiscriminate use of IT security practices in OT may cause availability and timing disruptions." - **Different people own it.** Control networks are usually run by controls engineers and machine vendors, not the IT person. That's how a lot of plants end up with two networks nobody fully owns. If your IT provider treats the HMI like an office laptop, that's the problem. It isn't one. ## Why can't you just patch the old PC? Because the machine's software was certified against one version of Windows, and the vendor never certified it on anything newer. Patching it, or upgrading it, can mean the equipment stops talking to its controller. On a machine that runs your production, that is not a risk anyone signs off on. NIST doesn't pretend otherwise. [SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final) says OT updates "need to be thoroughly tested by both the vendor and the end user" before they go in. It also says "patching older OT components that run on unsupported OSs may not be an option." When that's the case, the guidance is to add protection around the machine. Firewalls and inline security devices in front of the box, instead of changes on it. So the old XP box stays. The question is what you put around it. ## What actually reduces the risk? Five things, and they're not exotic. CISA, the FBI, EPA, and DOE put them in a [fact sheet on OT mitigations](https://www.cisa.gov/resources-tools/resources/primary-mitigations-reduce-cyber-threats-operational-technology) in 2025. Here's the plant manager's version. **1. Get it off the internet.** CISA's words: "OT devices are easy targets when connected to the internet." They get found by people scanning for open ports. Find out what your public IP addresses are actually exposing. You may be surprised. We were not surprised to find [Remote Desktop open to the whole internet](/reports/open-rdp-onboarding-discovery) on day one with a new client. It was a leftover from an old project. Nobody remembered it was there. **2. Change the default passwords.** Every HMI, PLC, router, and remote access box. CISA calls out default and easy-to-guess passwords as a common way in. If the password is on a sticker on the cabinet or in the vendor manual, it's not a password. **3. Lock down remote access.** Vendors need to get in. That's fine. CISA says to use a VPN with strong passwords and phishing-resistant MFA, give each person only the access they need, and turn off accounts nobody uses. No port forwards. No "we left it open so the integrator could dial in." **4. Separate the plant from the office.** Put production on its own network segment with a firewall between it and everything else. A phishing click in accounting should not be able to reach the machine running your press. This is the single biggest thing you can do for an old box you can't patch. **5. Know how to run it by hand.** CISA lists keeping the ability to run OT manually as a core mitigation, with fail-safes and recovery plans tested ahead of time. If the network goes down, can your people still run the line? Find out on a slow Tuesday, not during an incident. None of these require replacing equipment. ## How do we handle legacy machines? We keep Windows 98, XP, and 7 machines and SCADA systems running when replacing them isn't realistic. That's most of the time. Our [legacy systems support](/services/legacy-systems) is built around one idea: if you can't fix the box, contain it. Concretely, that means: - **Its own network zone.** The legacy machine goes on a segment with firewall rules that allow only the traffic it actually needs. Air-gapped entirely where the workflow allows. - **Monitoring from the network, not the box.** We watch traffic around the machine instead of installing agents that could destabilize a PC that hasn't seen an update in a decade. - **Nothing changes during production.** Changes get tested off the line, scheduled around your shifts, and come with a rollback plan. - **Settings that stay put.** Windows likes to undo things. We've seen power settings revert after updates and knock screens dark mid-shift. Our [fix for machines that keep going to sleep](/reports/power-profile-always-on) was a kiosk, not a press, but the problem is the same on a plant floor PC that has to stay awake. - **A real upgrade path, on your schedule.** We track the vendor's roadmap. When a sane upgrade exists, we plan it around your capital budget and production calendar, not ours. ## Key takeaways - OT security is about uptime and safety first. Data comes second. - If a machine can't be patched, isolate it. Don't pretend it's a laptop. - Nothing on the plant floor should be reachable from the open internet. - Vendor remote access goes through a VPN with MFA, not a port forward someone set up years ago. - Change every default password on every HMI, PLC, and remote access box. - Know how you'd run the line by hand. Test it before you need it. ## What this looks like when we do it On a new [manufacturing engagement](/industries/manufacturing), we start where an attacker would. We scan your public IP addresses from the outside and close anything that shouldn't be there. That's the day-one work that caught the open RDP above. Then we walk the floor with you. Which machines run old Windows, which ones talk to the office network, how the vendors get in, and what the passwords are on the gear nobody's touched since it was installed. From there we separate production from the office, move vendor access behind a VPN with MFA, change the defaults, and document all of it so you, your auditor, and your insurance carrier can see what's protecting what. We don't tell you to replace a machine that works. We build the walls around it. If your last IT provider wouldn't touch the plant floor, or told you to rip out equipment that still runs, [get in touch](/contact). Fifteen minutes on a call and we'll tell you honestly what we'd do first. --- # Tennessee Insurance Agencies and the NAIC Data Security Model Law URL: https://limehawk.io/reports/tennessee-insurance-data-security-law Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Tennessee adopted the NAIC Data Security Model Law as the Insurance Data Security Law in 2021. Agencies under 25 people, $5M revenue, or $10M assets are exempt. > **TL;DR** Tennessee adopted the NAIC Insurance Data Security Model Law in 2021 as the Insurance Data Security Law, Tenn. Code Ann. 56-2-1001 through 56-2-1011. It took effect July 1, 2021. The written security program was due July 1, 2022, and vendor requirements July 1, 2023. Tennessee's small-licensee exemption is wider than the model's: the law does not apply to a licensee with fewer than 25 people (employees and contractors), under $5 million in gross annual revenue, or under $10 million in year-end assets. Many small agencies are out. Grow past all three and you have 180 days to comply. - Small-agency exemption: <25 people ## What is the NAIC Insurance Data Security Model Law? It is a template law the National Association of Insurance Commissioners adopted in October 2017, Model #668. States pass their own version. Per the [NAIC's August 2025 brief on the model](https://content.naic.org/sites/default/files/government-affairs-brief-data-security-model-law.pdf), it applies to insurers, agents, and anyone else licensed by a state insurance department. It requires a licensee to keep an information security program, investigate cybersecurity events, and notify the state insurance commissioner. The model itself exempts licensees with fewer than 10 employees from the security program section. Keep that number in mind. Tennessee did not copy it. ## Has Tennessee adopted it? Yes. Tennessee passed it as [Public Chapter 345 of 2021](https://publications.tnsosfiles.com/acts/112/pub/pc0345.pdf) (House Bill 766), signed May 18, 2021, according to the [General Assembly's bill record](https://wapp.capitol.tn.gov/apps/BillInfo/Default.aspx?BillNumber=HB0766&GA=112). It is codified as the "Insurance Data Security Law" at Tenn. Code Ann. 56-2-1001 through 56-2-1011. The [Department of Commerce and Insurance](https://www.tn.gov/commerce/insurance/company-resources/cybersecurity.html) says the law took effect July 1, 2021. The NAIC's brief lists Tennessee among the 28 jurisdictions that have implemented the model. The deadlines came in stages. The law took effect July 1, 2021, for events on or after that date. The written information security program was due by July 1, 2022. Contracts requiring vendors to protect your data were due by July 1, 2023. "Licensee" means anyone licensed, authorized, or registered under Tennessee's insurance title, or required to be. That covers carriers, and it covers agencies and producers. If you run an [insurance agency in East Tennessee](/industries/insurance-agencies), the question isn't whether the law exists. It's whether it reaches you. ## Does it apply to small agencies? Often, no. Section 56-2-1009 says the law "does not apply to" a licensee that meets any one of these: - Employs fewer than 25 individuals, "regardless of whether the individuals are employees or independent contractors." - Has less than $5,000,000 in gross annual revenue. - Has less than $10,000,000 in year-end total assets. That is a much bigger carve-out than the model's 10-employee line, and it exempts the whole law, not just the program section. A 12-person agency is out. So is a 40-person agency doing $4 million a year. Count carefully. Contracted producers count toward the 25. And if you stop qualifying, you have 180 days to comply. There are also separate provisions for licensees already compliant with HIPAA or GLBA, and for producers covered by another licensee's program. Confirm where your agency lands with your compliance counsel or the Department before you rely on any exemption. Exempt from the statute doesn't mean nobody is checking. As we cover on our [insurance agency page](/industries/insurance-agencies), carriers are tightening what they'll accept before they grant or keep portal access, and cyber insurance renewals ask for the same evidence the law describes. ## What does it require? For a covered agency, section 56-2-1004 requires a written information security program built on a risk assessment and scaled to your size and complexity. In plain terms: - **Someone in charge.** Designate one or more employees, an affiliate, or an outside vendor responsible for the program. - **A risk assessment.** Identify foreseeable threats, including at your vendors. Assess safeguards in training, systems, and detection. Test key controls at least annually. - **Safeguards picked from a list.** The law tells you to decide which apply: access controls, an inventory of data, devices, and systems, encryption of data in transit and on laptops and portable media, MFA, monitoring and testing, audit trails, protection against loss, and secure disposal. - **Training.** Cybersecurity awareness training, updated for the risks your assessment finds. - **Vendor oversight.** Due diligence on third-party service providers, and a requirement that they protect your data. - **A written incident response plan.** Roles, decision authority, communications, remediation, and documentation. If a cybersecurity event happens, section 56-2-1006 sets the clock. You notify the commissioner within three business days of determining an event occurred, when Tennessee is your home state and the event is reasonably likely to materially harm a consumer or your operations, or when 250 or more Tennessee consumers are involved and the event is reportable elsewhere or likely harmful. Affected consumers get notice within 45 days when harm is reasonably likely. The Department takes reports through its [cybersecurity event page](https://www.tn.gov/commerce/insurance/company-resources/cybersecurity/report.html). The annual April 15 compliance certification falls on insurers domiciled in Tennessee, not agencies. The Department's [certification form](https://www.tn.gov/commerce/insurance/company-resources/cybersecurity/compliance.html) says so. ## What IT has to keep on file The commissioner can examine and investigate any licensee under section 56-2-1007. Here's what a covered agency should be able to hand over: - **The written program and the risk assessment behind it.** - **The incident response plan.** - **Event records for five years.** Section 56-2-1005 requires records on every cybersecurity event for at least five years from discovery. If you investigate something and decide it wasn't an event, that decision goes in writing and is kept five years too. - **Evidence the safeguards run.** A device inventory, encryption status for every laptop, MFA enforcement, and vendor contracts with security terms. The inventory and the encryption report are where agencies usually come up short. We built an [automated hardware inventory of 312 assets in 47 minutes](/reports/automated-hardware-inventory) when a cyber insurance audit gave one client five business days. We [rolled BitLocker out across 127 endpoints in 72 hours](/reports/bitlocker-encryption-automation), with recovery keys escrowed, when a client's cyber insurance required full-disk encryption. Those are the two artifacts this law and every carrier questionnaire ask for. ## Key takeaways - Tennessee's version is the Insurance Data Security Law, Tenn. Code Ann. 56-2-1001 to 56-2-1011, in effect since July 1, 2021. - Under 25 people, under $5M gross revenue, or under $10M in assets: the law does not apply. Contractors count toward the 25. - Covered agencies need a written program built on a risk assessment, a written incident response plan, and vendor oversight. - Notify the commissioner within three business days of determining a qualifying cybersecurity event. Keep event records for five years. - Exempt or not, carriers and cyber insurers still ask for inventory, encryption, and MFA evidence. ## What this looks like when we do it We start by counting. Headcount with contractors, revenue, and assets tell us whether the Insurance Data Security Law applies to you at all. If it doesn't, we say so and build to what your carriers and cyber insurer are asking for instead. If it does, we map controls to the specific sections of the Tennessee law, not a generic checklist. That means a risk assessment, the written program, an incident response plan you can actually follow, and vendor terms. Then the evidence comes from tools that run every day: inventory and encryption status from the RMM, MFA enforced across email, the agency management system, and carrier portals, and logs that let you make a three-business-day call based on facts. The build is scoped on a call, then folds into month-to-month managed IT so the program doesn't go stale in a binder. More on how we work with agencies is on the [insurance agencies page](/industries/insurance-agencies). If you're not sure which side of the 25-person line you're on, or what you could show the commissioner tomorrow, [book a call](/contact). Fifteen minutes is enough to find out. --- # Switching IT Providers: What the First 30 Days Should Look Like URL: https://limehawk.io/reports/switching-it-providers-first-30-days Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Switching IT providers is mostly a handoff. Get admin access, documentation, and registrar logins from the old shop, then expect discovery and a baseline. > **TL;DR** Switching IT companies is not hard. The handoff is the part that goes wrong. Before you give notice, make a list of every admin login, the domain registrar, DNS, licensing, and whatever documentation exists, and get it in writing. Then the new provider should look at what you actually have before they change anything. When we onboard someone, the external scan of every public IP is day-one work, and every managed machine gets the same security baseline. We are month-to-month, so you are never locked in with us either. - External scan of public IPs: Day one ## How hard is it to switch IT companies? Not very, if you plan the handoff. The work that matters is getting your credentials, documentation, and accounts out of the old provider's hands and into yours. The new provider can do almost everything else. What stops most firms from switching isn't the new shop. It's the fear that something breaks on the way out, and that fear is fair. I have seen what a bad exit looks like. One client's internal IT director resigned on a Friday and [changed every domain admin password on his way out](/reports/local-admin-provisioning). 85 users, payroll due Wednesday. We got them back because our RMM agent was already on every machine. Another new client came to us after their IT person quit with no documentation, two days before an office move, with [23 Wi-Fi networks nobody had the passwords for](/reports/wifi-credential-recovery). Neither of those had to be an emergency. Both were handoff problems. ## What should you get from the old provider? Get every login that lets someone run your business, in writing, before you give notice. This list is general practice. Ask for it whoever you hire next, us or anyone else. - **Admin credentials.** Domain admin, local admin, firewall, Wi-Fi, switches, servers, NAS, backup console, and any line-of-business app admin accounts. - **Microsoft 365 or Google Workspace.** Global admin access in your name, not just theirs. - **Domain registrar and DNS.** Who the registrar is, the login, and where DNS is hosted. If your website and email depend on a domain only the old shop can log into, that is the first thing to fix. - **Licensing.** What you own, what they resell to you, and when each renews. Find out which licenses stay with you and which leave with them. - **Documentation.** Network diagram, IP ranges, firewall rules, backup schedule, vendor contacts, and anything they wrote down about how the place is wired. - **Remote access.** Every remote access tool they installed, so it can be removed. Their agent should not still be on your machines a month from now. If the old provider is slow to hand this over, don't let that hold you hostage. A new provider can recover a lot from the machines themselves. It's slower and it costs you time you didn't need to spend. ## What happens in week one? Discovery. The new provider should look at what you actually have before they touch anything. When we onboard a client, we start from the outside: [a standard external pass](/reports/open-rdp-onboarding-discovery) of public IPs, open ports, certificates, mail authentication, and anything listening that shouldn't be. That scan is day-one work on every onboarding. Then the machines come under management. Every managed endpoint in our [managed IT and security service](/services/managed-it-security) runs the same security baseline: EDR, anti-malware, email filtering, and password management, with 24/7 monitoring and patching across Windows and third-party apps. Nothing in that baseline is optional or upsold separately. From that point the help desk is yours to use. Critical issues get a response in under 15 minutes, standard requests in 1-2 hours. User onboarding, offboarding, and password resets are part of the service. I won't promise you a day-by-day calendar here. Every office is different, and the order depends on what discovery turns up. A wide-open firewall gets fixed before anybody's printer. ## What do we usually find? Leftovers. Remote access someone set up "just for this project" years ago. A firewall rule nobody remembers approving. Passwords living in places they shouldn't. Two real examples from our reports: **Open RDP on day one.** During discovery for a new East Tennessee client, our external scan found Windows Remote Desktop (TCP 3389) open to the whole internet. It was a leftover remote-access path from before we managed the environment. No sign of a breach. We removed the firewall rule the same day and put admin access behind a VPN or jump host. [The full report](/reports/open-rdp-onboarding-discovery) has the timeline. Nobody had done anything malicious. It was just habit that outlived the person who set it up. **Passwords in spreadsheets.** A financial services client in a SOC 2 audit had a password manager deployed and still couldn't prove nobody kept a backup list on their desktop. We scanned 67 endpoints by filename. 23 files matched, and 8 held real working credentials, including banking logins and a forgotten shared service account sheet. That one was an audit, not an onboarding, but [the password file audit](/reports/password-file-audit) is the same lesson. A tool being deployed doesn't mean the habit is gone. That's the kind of thing a new provider should be telling you about. If you switch and nobody hands you a list of what they found, ask for one. ## What does it cost to switch? It depends on how many people you have, what's in the building, and what discovery turns up, so we scope it on a call. No public rate sheet. What I can tell you: our managed IT is month-to-month, there are no multi-year contracts, and the minimum is 3 users. Add-ons and projects are scoped on a call too. No surprise line items. Month-to-month matters more than it sounds like when you're switching. If the handoff out of your current provider is painful because of a long contract, you don't want to sign another one. You should be able to leave us the same way you came in. ## What this looks like when we do it You bring the list above, or as much of it as you can get. We run discovery, starting with the outside of your network, and tell you plainly what we found. Every managed machine gets the same baseline, and you get a help desk with a published response time. If something is left wide open, it gets closed first. We work best with regulated firms of 5-100 users in Knoxville and East Tennessee, the ones who will eventually have to show an auditor or an insurer what's in place. If that's you, the full scope is on the [managed IT and security](/services/managed-it-security) page. Thinking about switching? [Book a 15-minute call](/contact) and bring your current setup. I'll tell you what the handoff should look like for your office. ## Key takeaways - The risk in switching is the handoff, not the new provider. Get every admin credential in writing before you give notice. - Own your domain registrar and DNS logins yourself. If only the old IT shop can log in, fix that first. - Discovery comes before cleanup. Scan what faces the internet on day one. - Ask any new provider what they found in their last onboarding. If they can't tell you, they didn't look. - Month-to-month terms mean the next switch is easy too. --- # Seller Impersonation and Deed Fraud: What a Title Office Should Check URL: https://limehawk.io/reports/seller-impersonation-fraud Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Seller impersonation fraud is someone posing as the owner of vacant or paid-off property to sell it. Title offices stop it by verifying sellers independently. > **TL;DR** Seller impersonation fraud is a stranger pretending to own a property so they can sell it and take the money. It shows up at a title office as a normal-looking order: vacant land or a paid-off house, a seller who is out of town, a remote notary, and a payoff going somewhere odd. ALTA's 2026 study found 59% of title firms saw at least one attempt in 2025, up from 28% in its 2024 study. The fix is mostly process: verify the seller through a channel the fraudster doesn't control. IT's job is to make that process hard to skip and keep your own email from becoming part of the scheme. - Title firms hit by an attempt, 2025: 59% ## What is seller impersonation fraud? Seller impersonation fraud is when a criminal poses as the real owner of a property, hires a real agent and a real title company, and sells land they don't own. The buyer pays, the fraudster takes the proceeds, and the actual owner finds out later. Deed fraud is the wider family: forged or fake deeds used to take or sell property. Seller impersonation is the version that comes through your front door as a normal order. The [FBI's June 2026 alert](https://www.ic3.gov/PSA/2026/PSA260616) lays out the playbook. Criminals "create fake identifications (drivers licenses and/or U.S. passports), Outlook email addresses, and Voice over Internet Protocol (VoIP) generated phone numbers and use them to impersonate landowners." They pull owner details from county websites, data brokers, stolen accounts, and the dark web. Then they "contact a local realtor and title company, pose as the property parcel owner," and ask for the money to go to a co-conspirator attorney in another state. It is getting more common. [ALTA's Seller Impersonation Fraud Study](https://www.alta.org/news-and-publications/news/20260915-Seller-Impersonation-Fraud-Expands-in-Scope-and-Sophistication-ALTA-Finds), released September 2026 from 245 title professionals, found 59% of firms reported at least one attempt in 2025, "more than double the 28% reported in ALTA's 2024 study." Among firms with an attempt, one-quarter had a paid claim. Half of those reporting amounts said their average claim cost was over $100,000. ## How does it reach a title company? It reaches you looking like a clean file. That's the problem. The property is usually one nobody is watching. In ALTA's study, 82% of respondents named vacant land as a frequent target. Vacation homes (51%), rentals (48%), agricultural land (36%), and primary residences (25%) were all up from 2024. Nearly three-quarters flagged absentee owners, 68% flagged properties owned free and clear, and more than half flagged property belonging to someone recently deceased. No mortgage means no lender payoff, so nobody else in the chain is checking who the owner is. An absentee owner won't see the sign in the yard. And a seller who "lives out of state" explains why everything happens by email, text, and a notary in another city. The fraudster also brings real information. 87% of ALTA's respondents named spoofed contact information as a common part of attempts. The study also notes fraudsters using the owner's real birth date, Social Security number, and financial details. Nearly six in ten said deepfaked voice or images are becoming common. Your closer can't catch this by just being careful on the phone. ## What are the red flags? The most reported red flag in ALTA's study was a seller who avoids meetings, phone calls, or video. After that: mail-away signings with the seller's preferred notary, cash deals, no mortgage on the property, a below-market price, and odd behavior in conversation. The [FBI alert](https://www.ic3.gov/PSA/2026/PSA260616) adds a few more worth posting at every desk: - The seller only uses email, text, or VoIP numbers and won't meet, citing illness, travel, or an emergency. - Pressure to close fast, sometimes with a below-market price to get it done. - The seller knows little about the property or is missing basics like a survey or tax information. - Proceeds requested by international wire, to an account in a different name, or to an account somewhere unrelated to the property. - A deed notarized in a foreign country, or other unusual documents. One flag is a reason to look. Three on the same file is a reason to stop. ## How do you verify a seller you'll never meet? You verify them through a channel you found, not one they gave you. Every phone number, email, and ID in the file came from the person you're trying to verify. If they're a fraudster, all of it checks out. ALTA's respondents rated these tools highest: identity verification systems (92%), direct contact with sellers through independently verified channels (90%), multifactor authentication (89%), approved notary networks (88%), and knowledge-based authentication (86%). In practice that looks like: - **Find the owner yourself.** Look up the mailing address on the tax record and reach out there. The FBI's own tip for buyers is to "send a certified letter to the address of record on the land tax record to verify legitimacy of seller." - **Run ID verification on the seller**, not just a photo of a license. A scan of a fake license looks exactly like a scan of a real one. - **Use your notary, not theirs.** If the seller can't sign in person, the signing goes to a notary from your approved network. - **Get on video.** A seller who won't, for any reason, goes on the stop list until someone senior signs off. - **Match the payee to the owner.** Proceeds go to an account in the seller's name, verified by phone to a number you found independently. No exceptions for "my attorney handles my money." - **Ask questions the record can answer and a stranger can't.** How they got the property, what's on it, who pays the tax bill. Some counties also offer free recording alerts to owners. Ours is the [Knox County Register of Deeds Fraud Alert Program](https://rod.knoxcounty.org/fraud-alert-program/). It doesn't help you verify a seller today, but it's worth handing to every buyer at closing. ## Where does IT fit in? Most of this is process, and process belongs to the title agent. Nobody's firewall decides whether a seller is real. But IT decides whether the process holds up on a busy Friday, and whether your own systems become part of the fraud. Here's the split as I see it for [title companies](/industries/title-companies): - **Process (yours):** the verification checklist, who can override it, the notary network, the stop-the-file rule. - **IT (ours):** the email system that every one of those steps runs through. Enforced MFA across the tenant, not just enrolled. DMARC, SPF, and DKIM so nobody can spoof your domain to the buyer's agent. Monitoring for the forwarding rules and inbox filters attackers set up to hide a thread. - **Both:** writing it down. The verification steps live in a documented procedure, the procedure lives in the file, and staff are trained on it. Seller impersonation and business email compromise are different crimes that end the same way: money wired to the wrong person. We stopped one of the second kind for a title company and wrote it up in [our BEC wire fraud incident report](/reports/bec-wire-fraud-incident-response). The fix there was a phone call to a known number before the wire moved. Same rule applies here. ## What this looks like when we do it We haven't published a seller impersonation case. What we do publish is how we run IT for [title and escrow offices](/industries/title-companies): - Enforced MFA, tenant-wide, checked on day one of every account we take over. - Email authentication and filtering, plus monitoring for the mailbox rules and odd sign-ins that show up in a compromise. - Your out-of-band verification steps written down as a procedure, so the new closer follows the same one as the 20-year veteran. - Staff training on closing-day social engineering. - Documentation of the controls for ALTA and underwriter review. That runs on our managed IT baseline: month-to-month, 24/7 monitoring, and under 15 minutes response on anything critical. - Vacant land, free-and-clear property, and absentee owners are the profile. Treat that combination as a reason to verify harder, not a reason to relax. - A seller who won't meet, call, or get on video is the top red flag title professionals report. - Never verify a seller with contact info the seller gave you. Find the owner independently, using the address on the tax record. - Be wary of a mail-away signing with a notary the seller picked. Use your own notary network. - Write the verification steps down, make them part of every file, and protect the mailbox the whole process runs through. If you want a second set of eyes on how your office verifies sellers and protects the inbox around it, [get in touch](/contact) and we'll walk through it on a call. --- # Regulation S-P Amendments: What an RIA Has to Have in Place Now URL: https://limehawk.io/reports/regulation-s-p-amendments-ria Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Amended Reg S-P requires a written incident response program, client notice within 30 days, and vendor oversight. Smaller RIAs had to comply by June 3, 2026. > **TL;DR** The SEC amended Regulation S-P in May 2024. If you are an SEC-registered adviser, you now need a written incident response program, a way to notify affected clients within 30 days, written oversight of every vendor that touches client data, and records proving all of it. Advisers with $1.5 billion or more in AUM had to comply by December 3, 2025. Everyone smaller had until June 3, 2026. Both dates have passed. If the binder isn't on the shelf yet, this is what goes in it. - Client notice deadline: 30 days ## What changed in Regulation S-P? The SEC [adopted amendments to Regulation S-P on May 16, 2024](https://www.sec.gov/files/rules/final/2024/34-100155.pdf), the first real overhaul since the rule was written in 2000. Before, Reg S-P mostly asked you to have safeguards and send privacy notices. Now it asks you to plan for the day something goes wrong, tell clients when it does, and prove you did both. Per the [SEC's small entity compliance guide](https://www.sec.gov/files/rules/final/2024/regulation-s-p-small-entity-compliance-guide.pdf), the amendments: - require a written incident response program - require notice to clients whose sensitive information was, or is reasonably likely to have been, accessed or used without authorization - require written oversight of service providers - expand the disposal rule so it covers customer information, not just consumer information - require written records documenting compliance - codify a statutory exception to the annual privacy notice The rule applies to investment advisers registered with the SEC, along with broker-dealers, funding portals, investment companies, and registered transfer agents. If you are a state-registered adviser, ask your compliance counsel what applies to you. The scope also got wider. The safeguarding and disposal rules now cover all "customer information," meaning any record with nonpublic personal information about a customer that you hold, or that someone holds on your behalf. That last part matters. Your custodian portal, your CRM, and your email provider are all in the picture. I write about this because I run IT for [financial advisors](/industries/financial-advisors), and nearly every requirement in the rule lands on a system somebody in IT manages. ## When did it take effect for smaller firms? June 3, 2026. The amendments became effective August 2, 2024, with a tiered compliance period. For registered investment advisers, the [adopting release](https://www.sec.gov/files/rules/final/2024/34-100155.pdf) draws the line at assets under management: | Adviser size | Compliance date | | --- | --- | | $1.5 billion or more in AUM ("larger entity") | December 3, 2025 | | Under $1.5 billion in AUM ("smaller entity") | June 3, 2026 | Today is October 1, 2026. Both dates have passed. There is no grace period left to plan in. If an examiner asks for your incident response program, they expect to see one. ## What does the incident response program need? It has to be written, and it has to be "reasonably designed to detect, respond to, and recover from unauthorized access to or use of customer information." The SEC guide says it must include procedures to: 1. Assess the nature and scope of an incident. 2. Take steps to contain and control it so it doesn't get worse. 3. Notify affected individuals whose sensitive customer information was, or is reasonably likely to have been, accessed or used without authorization. **Client notice timing.** Notice goes out "as soon as practicable, but not later than 30 days" after you become aware the unauthorized access happened or is reasonably likely to have happened. It has to describe the incident, the data involved, and what clients can do to protect themselves. **The exception.** You don't have to notify if, after a reasonable investigation, you determine the sensitive information has not been, and is not reasonably likely to be, used in a way that would cause substantial harm or inconvenience. The key words are "reasonable investigation." You have to actually do one, and you have to write down how you reached the answer. **The delay.** Notice can be delayed only if the U.S. Attorney General determines it poses a substantial risk to national security or public safety and tells the SEC in writing. For a small RIA, plan as if that will never apply. "Sensitive customer information" is narrower than all customer information. The SEC's examples are a Social Security number, or an account number or username paired with something that could get someone into the account. That is exactly what sits in an advisor's inbox on any given day. Here is the practical version. When we handled [a title company closer whose inbox was taken over](/reports/bec-wire-fraud-incident-response), the containment steps were a password reset, killing every session, removing the attacker's mail rules, and a phone call to the other party. Under Reg S-P, an advisory firm in the same spot would also need to work out what client data was in that mailbox, whether it was reasonably likely to be misused, and whether the 30-day notice clock had started. You can't answer those questions well at 11 a.m. on the day it happens unless the procedure was written before. ## What about your vendors? You need written policies "reasonably designed to require oversight, including through due diligence and monitoring," of your service providers. A service provider is anyone who receives, maintains, processes, or is otherwise permitted access to customer information through services they provide directly to you. That list is longer than most firms think. It includes your CRM, your financial planning software, your document storage, your email host, and your IT provider. Yes, that includes us. Those policies must be reasonably designed to make sure each provider: - takes appropriate measures to protect against unauthorized access to or use of customer information, and - notifies you as soon as possible, and no later than **72 hours** after becoming aware of a breach that resulted in unauthorized access to a customer information system they maintain. When that notice comes in, your own incident response program has to kick off. You can sign a written agreement letting a vendor send client notices on your behalf, but the SEC is clear that the responsibility for the notice stays with you. In practice: pull your vendor list, find the contracts, and check that each one says what happens and how fast if they get breached. Where a contract says nothing, that's your gap. ## What IT has to document The amendments added a new paragraph, 204-2(a)(25), to the Advisers Act books-and-records rule. RIAs now have to keep: - the written incident response policies and procedures - written documentation of any detected unauthorized access to customer information, and the response and recovery - written documentation of any investigation into whether notice was required, including the basis for the decision, any Attorney General delay letter, and a copy of any notice sent - the written service provider oversight policies, and any contracts or agreements made under them - the written disposal policies and procedures The adopting release notes that Rule 204-2 records are kept for five years, the first two in an easily accessible place. Most of what goes in that file comes out of IT. Sign-in logs that show when an account was accessed and from where. Mailbox audit logs that show what an attacker opened. An asset list that tells you which laptop held which client files. If those logs aren't turned on and kept, the "reasonable investigation" behind a no-notice decision has nothing to stand on. The [financial advisors page](/industries/financial-advisors) covers the day-to-day controls we put around that data. This is a summary, not legal advice. Confirm the specifics for your firm with your compliance counsel. - Both compliance dates have passed: December 3, 2025 for advisers with $1.5 billion or more in AUM, June 3, 2026 for everyone else. - Client notice is due as soon as practicable, and no later than 30 days after you learn of the unauthorized access. - Your vendors have to tell you within 72 hours of a breach of a system they run for you. Get that in writing. - A vendor can send client notices for you under a written agreement. The responsibility stays with you. - The rule asks for written policies and written records. If it isn't documented, an examiner will treat it as if it didn't happen. ## What this looks like when we do it We map controls to the specific rule, and for an RIA that rule is Reg S-P. The work is boring on purpose: - **Write the IT half of the incident response program.** Who calls whom, how we contain a compromised account, which logs we pull, and where the evidence lands so your CCO can make the notice call within 30 days. - **Turn on the logs the investigation depends on,** and keep them long enough to matter. - **Build the vendor list from what's actually on your network,** not from memory, so your oversight policy covers the real systems that hold client data. - **Keep the paper trail.** Every incident, every decision, every notice, filed where an examiner can find it. When something does go wrong, critical issues get a response in under 15 minutes. The owner of the firm doesn't have to sort out a compromised inbox alone. If you're an RIA in East Tennessee and you aren't sure your Reg S-P file would hold up, [book a 15-minute call](/contact). Month-to-month, three-user minimum, and I'll tell you straight what's missing. --- # What Counts as Nonpublic Personal Information in a Title File URL: https://limehawk.io/reports/nonpublic-personal-information-title-agencies Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min Nonpublic personal information is personal financial data a consumer gives you or that comes from their transaction. In a title file, that's most of it. > **TL;DR** Nonpublic personal information (NPI) is any personally identifiable financial information you get from a consumer or their deal, unless it's truly public. In a title office that means the IDs, Social Security numbers, bank and payoff details, and even the fact that someone is your customer. It lives in email, scanners, shared drives, and your closing software. GLBA and ALTA Best Practices expect you to know where it is, lock it down, and get rid of it on a schedule. - Dispose of NPI within: 2 years ## What is nonpublic personal information? Nonpublic personal information is personally identifiable financial information that a consumer gives you, that comes out of a transaction with them, or that you otherwise get while serving them. That's the definition Congress wrote into the Gramm-Leach-Bliley Act at [15 U.S.C. 6809(4)](https://www.law.cornell.edu/uscode/text/15/6809). The regulation that carries it out, [12 CFR 1016.3](https://www.law.cornell.edu/cfr/text/12/1016.3), adds one more piece: any list or grouping of consumers built using that information is NPI too. The only thing that's carved out is publicly available information. That means stuff you have a reasonable basis to believe is lawfully public, like government real estate records. Title agencies are in scope. The FTC's [guide to the Privacy Rule](https://www.ftc.gov/business-guidance/resources/how-comply-privacy-consumer-financial-information-rule-gramm-leach-bliley-act) lists real estate settlement services as a financial activity. If your firm is significantly engaged in it, you're a financial institution under GLBA. That's the reason I treat [title companies](/industries/title-companies) as a regulated vertical and not just an office with a lot of PDFs. ## What counts as NPI in a title file? Almost everything in the file that isn't recorded at the courthouse. The federal rule's examples include application information, account balances, payment history, information from a consumer report, and a line most people skip: "the fact that an individual is or has been one of your customers." Your buyer list is NPI. A spreadsheet of this month's closings is NPI. ALTA's own definition, in the [Best Practices Framework 4.2](https://www.alta.org/policies-and-standards/best-practices/), gets specific. NPI includes a name combined with a Social Security number, driver's license number, state ID number, credit or debit card number, or other financial account number. And it says NPI can exist on paper and in electronic form. In a title office, that's: - Copies of driver's licenses and state IDs from signers - Social Security numbers on payoff requests and tax forms - Bank account and routing numbers for incoming and outgoing wires - Payoff letters with loan numbers and balances - Settlement statements and closing disclosures - Lender packages and anything pulled from a consumer report Here's the line that matters. A recorded deed or mortgage is public. The closing file behind it is not. Don't let "it's all public record anyway" become the office's excuse. ## Where does NPI leak in a title office? Usually not from the closing software. It leaks from the copies of the file that pile up around it. **Email.** Payoff letters, ID scans, and wire instructions go back and forth with lenders, agents, and consumers all day. Every attachment sits in a sent folder and an inbox, often for years. One compromised mailbox exposes all of it. That's exactly what happened in [our BEC wire fraud case](/reports/bec-wire-fraud-incident-response): the attacker sat in a closer's inbox and could see the deals. **Scanners.** Scan-to-email and scan-to-folder are convenient, so the copier ends up mailing IDs to personal addresses or dropping them in a share nobody cleans out. **Shared drives and desktops.** Someone saves a payoff to their desktop to fill out a form. Someone exports a closing list to Excel. Those copies don't follow the closing software's permissions or retention. It's the same pattern we found hunting [plaintext passwords on endpoints](/reports/password-file-audit): the system of record is fine, the side copies aren't. **Closing software and other vendors.** Your closing platform, e-signing, remote notary, and document storage vendors all hold NPI. ALTA calls these out by name, down to API integrations, plug-ins, and AI tools. **Paper.** Files on desks, in unlocked cabinets, in the recycling bin. ## What do the GLBA and ALTA Best Practices expect you to do with it? They expect a written plan and controls that actually match it. I won't re-explain the whole rule here. Our [GLBA Safeguards Rule guide for small firms](/reports/glba-safeguards-rule-small-firms) covers that. The parts that land hardest on NPI in a title office are these. The FTC's [Safeguards Rule](https://www.law.cornell.edu/cfr/text/16/314.4) requires you to: - Know where the data is. The FTC's [plain-English summary](https://www.ftc.gov/business-guidance/resources/ftc-safeguards-rule-what-your-business-needs-know) says to inventory where data is "collected, stored, or transmitted." - Encrypt customer information at rest and in transit over external networks. - Use multi-factor authentication for anyone accessing your information systems. - Oversee your service providers, by contract and by periodic review. ALTA's Pillar 3 says to adopt a written information security plan (WISP) and a written privacy plan to protect NPI. Its procedures include multi-factor authentication on systems containing NPI, unique logins, timely software updates, prohibiting or controlling removable media, physically restricting NPI to authorized people, a tested incident response plan, staff training, and vetting vendors that touch NPI. None of this is exotic. Most of it is enforced MFA, encrypted laptops, a cleaned-up inbox, and knowing which vendors have your files. ## How long do you keep it, and how do you get rid of it? Under the Safeguards Rule, you need procedures to securely dispose of customer information, in any format, no later than two years after you last used it to serve that customer ([16 CFR 314.4(c)(6)](https://www.law.cornell.edu/cfr/text/16/314.4)). There are exceptions: you can keep it if you need it for business operations or another legitimate purpose, if a law or regulation requires you to keep it, or if targeted disposal isn't reasonably feasible. You're also required to review your retention policy periodically to cut down on what you keep. Title files often have a legitimate reason to stick around. ALTA's Pillar 3 says the same thing: retain records for the periods the law prescribes, and dispose of paper and electronic records in a way that protects the NPI. So write down how long each kind of record is kept and why. Then actually purge the stray copies: the email attachments, the scan folder, the desktop exports. The closing software's copy is the one you meant to keep. The rest are just exposure. Disposal means shredding paper and wiping drives on retired PCs, copiers, and scanners. It doesn't mean dragging a folder to the recycle bin. ## Key takeaways - If it came from the consumer or the deal and it isn't public record, treat it as NPI. - A recorded deed is public. The closing file behind it is not. - Your scanner, your inbox, and your shared drive hold more NPI than your closing software. - Keeping files forever is a choice the Safeguards Rule makes you justify. ## What this looks like when we do it For a title agency, we start by finding the NPI. We scan endpoints and shares for the side copies, the same filename-sweep approach from [the password file audit](/reports/password-file-audit), and we look at what the scanners and mailboxes are actually doing. Then we close the obvious doors. That means MFA enforced across the tenant, not just enrolled. In my experience, that gap is one of the most common findings in title company email systems. We add [full-disk encryption with escrowed keys](/reports/bitlocker-encryption-automation), email authentication and filtering, and monitoring for the forwarding rules and odd sign-ins that showed up in our BEC case. Then we write it down. You get the WISP, the retention schedule, and a vendor list that an underwriter or lender review can read. That's the same baseline behind our [managed IT for title companies](/industries/title-companies), and the same GLBA work we do for [mortgage brokers](/industries/mortgage-brokers). It's month-to-month, with a 3-user minimum and no multi-year contract. If you're not sure where your NPI lives today, [get in touch](/contact). We'll walk through it on a call. --- # How to Verify Wire Instructions: A Callback Procedure for Title Companies URL: https://limehawk.io/reports/how-to-verify-wire-instructions Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Call the payee at a number you had before the instructions arrived, never one from the email. Treat any emailed change as fraud until that call is done. > **TL;DR** You verify wire instructions with a phone call to a number you already had before the instructions showed up. The original contract, the order, your own file. Never the number in the email. A second person signs off before the wire goes. Any change to instructions that arrives by email is fraud until that call proves otherwise. Tell buyers all of this at contract, in writing, before anyone is in a hurry. IT makes the inbox harder to take over. It does not replace the call. The call is what stopped the loss in our own BEC case. - Lost in our BEC case: $0 ## How do you verify wire instructions? You call the payee at a phone number you got somewhere other than the message that delivered the instructions, and you confirm the account details out loud before any money moves. Not the number in the email signature. Not the number on the PDF. A number from the original contract, the order, or your own records from before this deal got urgent. That's the procedure. The [ALTA Outgoing Wire Preparation Checklist](https://www.alta.org/file/ALTA-Outgoing-Wire-Preparation-Checklist) puts it on two pages: where the instructions came from, how you verified them, and proof the money arrived. The line asking where you got the phone number carries a note in parentheses: "never use the phone number included in an email." Here is the version I write into a title agency's procedure: 1. **Record the source.** In person, mail, fax, email, or through a third party like the agent, attorney, or lender. Write it in the file. 2. **Get the number independently.** Use the contract, the order, or a number already on file. Write down where it came from. 3. **Call and confirm.** Payee name, bank, routing number, account number, amount. Write down who you spoke to and when. 4. **Second set of eyes.** One person prepares the wire, a different person authorizes it. ALTA's checklist has separate signature lines for the wire creator and the wire authorizer. 5. **Confirm receipt.** After it goes, call the payee and confirm it landed. Log the name and date. ## Is it safe to wire money to a title company? Yes, once you have confirmed the instructions by calling the title company at a number you got on your own. The wire isn't the weak spot. Email is. The FTC's [mortgage shopping guidance](https://consumer.ftc.gov/articles/shopping-mortgage-faqs) describes the scam exactly: "Scammers sometimes send emails impersonating your loan officer or another real estate professional, saying there's been a last-minute change." Their advice on wiring to the new account: "Don't do it — it's a scam." Real estate wire fraud isn't rare. The [FBI's 2025 IC3 Annual Report](https://www.ic3.gov/AnnualReport/Reports/2025_IC3Report.pdf) logged 24,768 business email compromise complaints and $3,046,598,558 in reported BEC losses for 2025. One case in that report: buyers closing on a home got an email impersonating their own attorneys and wired over $449,000 to the wrong bank. Title companies see it constantly. In [ALTA's 2025 cybercrime study](https://alta.org/news-and-publications/news/20250227-Title-Companies-Help-Mitigate-Risk-of-Wire-Fraud-ALTA-Cybercrime-Study-Shows) of 360 participants, more than 40% of title companies reported getting at least one email a month in 2023 trying to change wiring or payoff instructions. 13% said their customers had wired funds to a fraudulent account. ## What makes callback verification actually work? Where the phone number came from. That's it. A callback to the number in a fraudulent email reaches the attacker, and the attacker will happily confirm the account. The rules that make it hold: - **The number has to predate the request.** If it arrived in the same thread, it doesn't count. - **You place the call.** "Call me back at this number" is the attacker's line, not yours. - **Urgency is the tell.** "The closing moves today unless this goes out in an hour" is exactly when you slow down. - **Log it.** Who you called, which number, where the number came from, who answered. If it isn't in the file, it didn't happen. The call also covers the case your eyes can't. Checking the sender address only works when the email is spoofed. In [the BEC wire fraud case we responded to](/reports/bec-wire-fraud-incident-response), the attacker was inside the closer's real Google Workspace mailbox. The fraudulent instructions came from her actual account. The attacker also set filters to block mail from the firm's funding desk and a coworker who would have caught it, so any "are these new instructions real?" reply would never reach the people who could stop it. The address was right. Only a phone call to a known number would catch that. ## What should you tell buyers on day one? Tell them at contract, in writing, before anyone talks about money: we will never change wire instructions by email. If you get an email saying we did, it's fraud. Call us at this number before you send anything. The CFPB's [homebuyer one-pager on closing scams](https://files.consumerfinance.gov/f/documents/cfpb_mortgages-scams_one-pager.pdf) backs every part of that. It tells buyers to write down contact information for their real estate and settlement agents, to "Avoid using phone numbers or links in an email," and to "Beware of last-minute changes to the closing process." It even suggests choosing "a code phrase that only they will know." ALTA also publishes an unbranded [buyer rack card](https://www.alta.org/business-operations/marketing/unbranded/protect-your-money-from-closing-scams-when-buying-a-home-usa.pdf) you can hand out as-is. What I put on the buyer sheet: - Our main office number. Save it now. - The name of the person who will handle your closing. - We will never change wire instructions by email, text, or a phone call we start. - Before you wire, call us at the number above and confirm the instructions. - After you wire, call us again to confirm we got it. - If something seems wrong after you wire, call your bank right away and ask for a recall, then file a report at [ic3.gov](https://www.ic3.gov). ## What should happen when instructions "change"? Stop the wire and assume it's an attack until a callback says otherwise. 1. Don't reply to the email. If the mailbox is compromised, whoever sent it is reading the replies. 2. Call the payee at the number already in the file. 3. Bring in the second person who authorizes wires. 4. Tell your IT provider. Somebody's mailbox, yours or the other side's, may be compromised. If money already went out, move fast. The IC3 report says it plainly: "If you discover a fraudulent transfer, time is of the essence." Call your bank, ask for a recall, and file at ic3.gov with full transaction details. In 2025, the IC3 Recovery Asset Team worked 3,900 incidents and froze $679,013,183 of $1,163,919,846 in attempted theft, a 58% success rate. The [ALTA Rapid Response Plan for Wire Fraud Incidents](https://www.alta.org/file/ALTA-Rapid-Response-Plan-for-Wire-Fraud-Incidents.pdf) is worth filling out and printing before you need it. ## Where does IT help? The procedure catches the fraud that gets through. IT keeps less of it from getting through, and makes your own inboxes much harder to take over. The FBI's [BEC public service announcement](https://www.ic3.gov/PSA/2024/PSA240911) recommends using "secondary channels and/or two-factor authentication to verify requests for changes in account information." Here's what that looks like on the IT side: - **MFA enforced across the tenant, not just enrolled.** In our BEC case, the user had 2-Step Verification turned on, but the tenant didn't enforce it, and the attacker got in with the password alone. - **Hardware security keys for funding staff.** - **DMARC, SPF, and DKIM** so spoofing your domain gets harder. - **Email filtering** on every mailbox. - **Monitoring for new inbox rules, forwarding, and odd sign-ins.** These were the exact signs in our case. - **Training** for the closers who actually receive these emails. None of that replaces the call. Your filters can't see into the other side's mailbox. If the realtor or the lender gets compromised, the fake instructions come from a real address on a real thread. ## Key takeaways - Verification is only as good as where the phone number came from. If it came from the same email as the instructions, you called the attacker. - Every emailed change to wire instructions is fraud until a callback to a known number says otherwise. - Two people on every outgoing wire: one prepares it, one authorizes it. - Tell buyers at contract that you will never change wire instructions by email, and give them the number to call. - If money moves to the wrong account, call the bank for a recall and file at ic3.gov the same hour. Time is the whole game. ## What this looks like when we do it For the [title companies](/industries/title-companies) we support, the wire procedure is written down and the IT underneath it is set up to match. That means a documented callback-to-a-known-number rule before funds move, two people on every outgoing wire, and MFA enforced across the tenant. Funding staff get hardware keys. We set up DMARC, SPF, and DKIM and monitor for the mailbox rules and forwarding that show up when an account is compromised. And it's all documented for ALTA and underwriter review. When it went wrong for a client, the phone call made the difference. We called the opposite-side closer out-of-band with the correct details before the wire moved, and the firm lost $0. The full story is in [our BEC wire fraud incident report](/reports/bec-wire-fraud-incident-response). More on how we run [IT for title companies](/industries/title-companies) is on that page. If you want a second set of eyes on your wire procedure or your email security before your next closing, [book fifteen minutes with me](/contact). --- # HIPAA Risk Assessment: What Your IT Has to Be Able to Show URL: https://limehawk.io/reports/hipaa-risk-assessment-it Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min A HIPAA risk assessment is required by the Security Rule. Your IT has to show where patient data lives, what protects it, and what is still exposed. > **TL;DR** The HIPAA Security Rule requires every practice to do a risk analysis and then act on it. The rule does not set a schedule, so you do it at the start and again whenever something changes. The paperwork is the easy part. The hard part is the evidence underneath it: a device inventory, encryption status per machine, who has access to what, and proof the backups restore. That evidence comes from your IT. If your IT can't produce it, your assessment is a guess. - Keep the documentation: 6 years ## Is a HIPAA risk assessment required? Yes. The Security Rule lists risk analysis as a Required implementation specification. The text in [45 CFR 164.308(a)(1)(ii)(A)](https://www.ecfr.gov/current/title-45/part-164/section-164.308) says to "conduct an accurate and thorough assessment of the potential risks and vulnerabilities to the confidentiality, integrity, and availability of electronic protected health information." The next line requires risk management, meaning you then have to fix what you found, down to "a reasonable and appropriate level." It applies to every covered entity and business associate, regardless of size. A three-provider clinic is held to the same rule as a hospital. What changes with size is which controls are reasonable, not whether you have to look. OCR is watching for it. HHS runs a Risk Analysis Initiative, and its [February 2026 settlement with Top of the World Ranch Treatment Center](https://www.hhs.gov/press-room/ocr-settles-hipaa-security-rule-investigation-twrtc.html) was the 11th enforcement action under it. That case started with a phishing attack on one staff email account. OCR's finding was that the provider had not done an accurate and thorough risk analysis. The corrective plan's first item was to go do one. If you run a practice and want the wider picture of what the Security Rule asks of your IT, our [medical practices page](/industries/medical-practices) walks through it. ## How often do you have to do it? The rule doesn't give a number. HHS's own [Guidance on Risk Analysis](https://www.hhs.gov/hipaa/for-professionals/security/guidance/guidance-risk-analysis/index.html) says the Security Rule "does not specify how frequently to perform risk analysis," and that the process "should be ongoing." Two parts of the rule force the issue anyway. [164.308(a)(8)](https://www.ecfr.gov/current/title-45/part-164/section-164.308) requires a periodic evaluation in response to "environmental or operational changes." [164.316(b)(2)](https://www.ecfr.gov/current/title-45/part-164/section-164.316) requires you to review your documentation periodically, update it as needed, and keep it for six years from creation or from the last date it was in effect. In practice, the triggers HHS names in that guidance are the ones that matter: a security incident, a change in ownership, turnover in key staff, or new technology. A new EHR, a move to a new office, a switch to Microsoft 365, or a breach. Any of those means the old assessment no longer describes your office. We tell practices to do it on a calendar and on every one of those changes. Once a year is a reasonable floor. It isn't a legal number, it's just how often things change in a small office. ## What does IT need to produce? The evidence. HHS's guidance lays out what a risk analysis has to cover: every place ePHI is stored or sent, the threats and vulnerabilities to it, the security measures already in place, likelihood, impact, and a risk level for each. Most of those inputs live in your IT systems. Here is what we expect a practice's IT to hand over. **An asset inventory.** HHS's guidance says the scope covers "all e-PHI," on every kind of electronic media, from "a single workstation" to networks across locations. You can't assess what you can't list. That means every workstation, laptop, server, and storage device, with serial numbers, owner, and location. [164.310(d)(2)(iii)](https://www.ecfr.gov/current/title-45/part-164/section-164.310) also asks you to keep a record of the movement of hardware and media. Pull it from live systems. We built one for a cyber insurance audit in [Insurance Audit Hardware Inventory: 312 Assets in 47 Minutes](/reports/automated-hardware-inventory), and the same script output works here. **Encryption status, per device.** Encryption is an addressable specification under [164.312(a)(2)(iv)](https://www.ecfr.gov/current/title-45/part-164/section-164.312). Addressable does not mean optional. Under [164.306(d)(3)](https://www.ecfr.gov/current/title-45/part-164/section-164.306), you either implement it or document why it isn't reasonable and put an equivalent measure in place. For a laptop that leaves the building, that is a hard memo to write. The evidence is a list of every machine, whether its disk is encrypted, and where the recovery key is escrowed. See [BitLocker Encryption at Scale](/reports/bitlocker-encryption-automation) for how we roll that out and keep the keys. **Access reviews.** [164.308(a)(1)(ii)(D)](https://www.ecfr.gov/current/title-45/part-164/section-164.308) requires you to "regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports." [164.312(b)](https://www.ecfr.gov/current/title-45/part-164/section-164.312) requires audit controls that record that activity in the first place. So IT should be able to show who has an account in the EHR, email, and file shares, that former staff are gone, and that someone looked at the logs. **Backups that have been restored.** A data backup plan is Required under [164.308(a)(7)(ii)(A)](https://www.ecfr.gov/current/title-45/part-164/section-164.308): "retrievable exact copies" of ePHI. Testing the contingency plan is addressable under (a)(7)(ii)(D). "Retrievable" is the word that matters. The only proof a backup is retrievable is a restore. Keep the date, what was restored, and how long it took. **The gaps.** HHS's guidance says the output should include risk levels and "a list of corrective actions." An assessment with no findings is not believable. Your IT should be telling you what's still weak and what it would take to fix it. ## Who can do a HIPAA risk assessment? You can. The rule doesn't require a certified assessor or a specific method. HHS says "there is no single method or 'best practice' that guarantees compliance." It does require you to name a security official under [164.308(a)(2)](https://www.ecfr.gov/current/title-45/part-164/section-164.308), the person responsible for the policies. For small practices, HHS and ONC publish a free [Security Risk Assessment Tool](https://www.healthit.gov/topic/privacy-security-and-hipaa/security-risk-assessment-tool), built for medium and small providers. It's a good structure. The same page says plainly that using it "is neither required by nor guarantees compliance." The tool asks the questions. Someone still has to answer them with real data. That is where most small-practice assessments fall apart. The office manager answers "yes, laptops are encrypted" because someone said so once. Then the one unencrypted laptop gets stolen out of a car. If you use an outside firm, HHS's risk management guidance is clear that the practice stays responsible for its own compliance, even when vendors do the work. Hiring help doesn't hand off the liability. So the honest division of labor: the practice owns the assessment and the decisions. IT supplies the evidence and does the fixes. A good IT provider also tells you when the answer to a question is "no." ## Key takeaways - Risk analysis is a Required specification under 45 CFR 164.308(a)(1)(ii)(A). It is not optional for a small practice. - The rule sets no fixed frequency. Redo it after a new system, a move, a breach, or a staff change in a key role. - Pull evidence from live systems, not from a spreadsheet someone last touched two years ago. - Addressable does not mean optional. Do it, or write down why not and what you did instead. - Keep the documentation six years from when it was created or last in effect. ## What this looks like when we do it When a practice comes on with us, we start with discovery, not a questionnaire. We pull the device inventory from our RMM, check encryption and key escrow on every machine, list accounts in Microsoft 365 or Google Workspace and the EHR where we have access, and look at what's exposed at the firewall. We have found [Remote Desktop open to the internet on day one](/reports/open-rdp-onboarding-discovery) of an onboarding. That's the kind of finding a self-assessment checkbox misses. Then we hand the practice the evidence in the shape the risk analysis needs: what holds ePHI, what protects it, what's missing, and what we recommend first. The practice makes the calls. We do the work, and the evidence gets regenerated on a schedule so next year's assessment is a refresh, not a rebuild. Backups get test restores, and we write down the results. We're a managed IT and compliance shop for regulated firms in Knoxville and East Tennessee, and HIPAA is one of the rules we map controls to. More on how we run it for clinics is on the [medical practices page](/industries/medical-practices). If your last risk assessment was a form someone filled out from memory, [book a call](/contact) and we'll walk through what your IT can actually show. --- # HIPAA-Compliant Email: What Actually Qualifies (and What Doesn't) URL: https://limehawk.io/reports/hipaa-compliant-email Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min HIPAA-compliant email takes a signed BAA, enforced access controls, audit logs, and documented encryption. Microsoft 365 and Gmail qualify once set up. > **TL;DR** No email product is HIPAA compliant out of the box, and "encrypted" is not the same thing as compliant. HIPAA doesn't ban email. It requires a signed Business Associate Agreement with whoever stores your mail, access controls, audit logs, and a written decision about how PHI is protected in transit. Microsoft 365 and Google Workspace both offer a BAA. Neither one sets up the rest for you. You can email patients, with reasonable safeguards, and a patient who asks for records by plain email gets them after a short warning. - Stolen laptop wiped in: 16 min ## What makes email HIPAA-compliant? Email is HIPAA-compliant when the way your practice uses it meets the Security Rule, not when the vendor's marketing says so. HHS says plainly that the [Security Rule does not prohibit email for ePHI](https://www.hhs.gov/hipaa/for-professionals/faq/2006/does-the-security-rule-allow-for-sending-electronic-phi-in-an-email/index.html). It does require you to protect it, and you have to document how. The requirements live in the [technical safeguards at 45 CFR 164.312](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.312). For a mailbox that means: - **Access control.** A unique login for every person. No shared front-desk password. - **Audit controls.** A record of who got into which mailbox, and when. - **Integrity.** Mail can't be quietly altered or deleted. - **Person authentication.** Proof the person signing in is who they claim to be. In practice, that's MFA. - **Transmission security.** Protection for PHI while it moves across the internet. Practice managers usually hear "encrypted" and stop there. Encryption is one line on that list. HHS also [doesn't certify or endorse any product](https://www.hhs.gov/hipaa/for-professionals/special-topics/health-information-technology/cloud-computing/index.html), so a "HIPAA certified" badge on an email service means nothing official. One more trap. Encryption is marked "addressable" in the rule, and people read that as optional. It isn't. Under [45 CFR 164.306(d)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.306), you either implement it or document why it isn't reasonable and put an equivalent measure in its place. For email that crosses the internet, I have never seen a good reason to skip it. ## Is Microsoft 365 / Google Workspace HIPAA-compliant? Both can be. Neither is set up that way on day one. Microsoft and Google each sign a BAA covering their email, and each one says in writing that the BAA doesn't make you compliant. The configuration is on you or your IT provider. Microsoft lists Exchange Online among the services covered by its [HIPAA Business Associate Agreement](https://learn.microsoft.com/en-us/compliance/regulatory/offering-hipaa-hitech). Their own FAQ asks whether having the BAA ensures compliance and answers "No," because "using Microsoft services doesn't on its own achieve HIPAA compliance." Google lists Gmail in its [HIPAA Included Functionality](https://workspace.google.com/terms/2015/1/hipaa_functionality.html). Their [HIPAA guidance for Workspace](https://knowledge.workspace.google.com/admin/compliance/hipaa-compliance-with-google-workspace-and-cloud-identity) says third-party add-ons are not covered by the BAA, and that customers without a signed BAA "must not use PHI" in Workspace. Transit is the other gap. Google's own docs say Gmail [tries TLS by default, but if the receiving server doesn't use it, the message still goes out unencrypted](https://knowledge.workspace.google.com/admin/gmail/advanced/send-email-over-a-secure-tls-connection). So "Gmail is encrypted" is true right up until someone emails a referral to a small office running an old mail server. That's why you need a deliberate path for outbound PHI, not a default. The free consumer versions of either product are a different story. If the account has no BAA behind it, PHI doesn't go in it. ## Do you need a BAA with your email provider? Yes. Your email provider stores your messages, which makes it a business associate. HHS's [cloud computing guidance](https://www.hhs.gov/hipaa/for-professionals/special-topics/health-information-technology/cloud-computing/index.html) says a cloud provider that stores ePHI is a business associate "even if the CSP processes or stores only encrypted ePHI and lacks an encryption key for the data." Some vendors lean on the "conduit" exception, the idea that they're like the post office. HHS shuts that down too. The conduit exception covers transmission-only services with temporary storage. A provider that keeps your mailbox is storing PHI, so it isn't a conduit. Getting the BAA works differently on each platform: - **Microsoft 365.** The BAA is included by default through the Microsoft Online Services Data Protection Addendum for customers who are covered entities or business associates. Nothing to sign, but you should know it exists and keep a copy. - **Google Workspace.** A super administrator accepts it electronically in the Admin console under Account settings, then Legal and compliance. If nobody has clicked it, you don't have one. Your IT provider touches your mail too, so they need a BAA with you as well. We sign one with every [medical practice we support](/industries/medical-practices). ## Can you email patients? Yes. HHS says the Privacy Rule [lets providers email patients](https://www.hhs.gov/hipaa/for-professionals/faq/570/does-hipaa-permit-health-care-providers-to-use-email-to-discuss-health-issues-with-patients/index.html) about health issues and treatment, as long as you use reasonable safeguards. Their examples are simple ones: check the address before you hit send, confirm it with the patient first, and limit how much detail goes into an unencrypted message. Patients also have a say in how you reach them. Under the Privacy Rule, a patient can ask you to communicate by alternative means, and you honor that if it's reasonable. Records requests are where practices get nervous. HHS's [right of access guidance](https://www.hhs.gov/hipaa/for-professionals/privacy/guidance/access/index.html) is direct. If a patient asks for a copy of their records by unencrypted email, they have a right to it. You give a brief warning that someone could read it in transit, and confirm they still want it that way. If they say yes, you send it. Once they've been warned and accepted the risk, you aren't responsible for it being intercepted in transit. You still have to type the address right. What you can't do is make them drive in to pick up a printout because email feels risky to you. Write the warning once, keep it on file, and have the front desk use the same script every time. ## What we check on day one When we take over a practice's email, these are the first things we look at, before anything else changes: 1. **Is there a BAA?** For Google, did a super admin actually accept it? For Microsoft, is a copy of the BAA from the Service Trust Portal on file? 2. **Is MFA enforced or just available?** On an inherited tenant, a [title company closer's inbox was taken over](/reports/bec-wire-fraud-incident-response) because 2-Step Verification was enrolled but not enforced. Same risk in a clinic, different data. 3. **Where is mail being forwarded?** Auto-forward rules to personal Gmail or Yahoo accounts move PHI out of the BAA's reach without anyone noticing. 4. **Who has access to what?** Shared inboxes, former employees still holding mailbox access, and one login shared across the front desk all fail the unique-user requirement. 5. **Is audit logging on, and kept long enough to answer a question later?** 6. **What happens when a device walks off?** Every phone and laptop with the mail app synced is a copy of the mailbox. We once [wiped a stolen laptop 16 minutes after the theft](/reports/mdm-remote-wipe). That only works if the device is managed before it's stolen. 7. **What's the outbound path for PHI?** Staff need one clear way to send encrypted mail, and they need to know when to use it. None of this is exotic. It's the same list I'd expect an auditor to ask about. ## What this looks like when we do it For a [medical practice](/industries/medical-practices), email is one piece of the Security Rule work, not a separate product. We confirm the BAAs, enforce MFA at the tenant, kill risky forwarding, turn on and keep audit logs, set up an encrypted path for outbound PHI, and put every device that touches the mailbox under management with encryption and remote wipe. Then we write down what we did and why, because a risk assessment needs that paper trail. It runs on our month-to-month managed IT, three-user minimum, with under-15-minute response on anything critical. Our best fit is a regulated practice of 5-100 users. If you aren't sure your mail would hold up to a question from OCR, [book a call](/contact). Fifteen minutes is enough to find out what's missing. - HIPAA allows email. It requires access control, audit controls, integrity, and transmission security around it (45 CFR 164.312). - Your email provider stores PHI, so it's a business associate. No BAA, no PHI in that mailbox. - Microsoft's BAA comes by default through its Data Protection Addendum. Google's has to be accepted by a super admin in the Admin console. - A BAA is paperwork. MFA enforcement, forwarding rules, logging, and lost-device response are the work. - Encryption is addressable, not optional. Use it, or write down why not and what you did instead. - Patients can ask for their records by unencrypted email. Warn them once, confirm, then send. --- # The GLBA Safeguards Rule in Plain English for Small Firms URL: https://limehawk.io/reports/glba-safeguards-rule-small-firms Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min The GLBA Safeguards Rule requires mortgage brokers, tax preparers, and similar firms to keep a written nine-element security program and report breaches. > **TL;DR** The GLBA Safeguards Rule is the FTC regulation at 16 CFR Part 314. If you broker mortgages, prepare tax returns, or do other work the rule calls financial, you are a financial institution under it. You need a written information security program built on nine elements, from a Qualified Individual to a written incident response plan. Since May 13, 2024, a breach of unencrypted data on 500 or more consumers has to be reported to the FTC within 30 days. Firms with data on fewer than 5,000 consumers skip four requirements, not the rule. The paper is the easy part. The hard part is proving the controls actually run. - FTC breach notice deadline: 30 days ## Who does the Safeguards Rule apply to? It applies to every "financial institution" the FTC has jurisdiction over. That means businesses doing work that counts as financial under the Bank Holding Company Act, as long as no other regulator (a bank regulator, the SEC) already enforces GLBA against them. The [scope section of the rule](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.1) names mortgage lenders, mortgage brokers, payday lenders, finance companies, account servicers, check cashers, wire transferors, collection agencies, credit counselors and other financial advisors, tax preparation firms, non-federally insured credit unions, and investment advisors that don't have to register with the SEC. The [definitions in 314.2](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.2) spell out a few that surprise people. An accountant or tax service that completes income tax returns is a financial institution. So is a car dealership that, as a usual part of its business, leases cars for longer than 90 days, for that leasing business. The rule covers "customer information": any record with nonpublic personal information about a customer, paper or electronic. It also covers customer information other financial institutions hand you. For a [mortgage broker](/industries/mortgage-brokers), that is the whole loan file. For a CPA, it is every return in the drawer and on the server. Most of the small firms I talk to already know they are covered. What they don't know is how specific the rule got after the FTC rewrote it. ## What are the required elements? The [FTC's own guidance](https://www.ftc.gov/business-guidance/resources/ftc-safeguards-rule-what-your-business-needs-know) counts nine elements in [314.4](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.4). Your program has to be written, and it has to be sized to your firm and to how sensitive the data is ([314.3](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.3)). Here are the nine, in plain English: 1. **Name a Qualified Individual.** One person oversees and enforces the program. They can work for you, an affiliate, or a service provider. If you outsource it, you still own compliance, and a senior person on your side has to oversee them. 2. **Do a risk assessment.** In writing, with criteria for rating risks and a plan for how each one gets fixed or accepted. Repeat it periodically. 3. **Build safeguards for those risks.** The rule names them: access controls and least privilege, an inventory of data, devices, and systems, encryption of customer information in transit over external networks and at rest, secure development and vetting of the apps you use, multi-factor authentication for anyone accessing any information system, secure disposal no later than two years after last use (with exceptions), change management, and logging of user activity. 4. **Test and monitor the safeguards.** Either continuous monitoring, or an annual penetration test plus vulnerability assessments at least every six months. 5. **Train your people.** Security awareness training, updated as your risks change, and qualified security staff (yours or a provider's) who keep current. 6. **Oversee service providers.** Pick ones that can protect the data, require it by contract, and reassess them periodically. 7. **Keep the program current.** Adjust it after testing results, business changes, or new risks. 8. **Write an incident response plan.** It has to cover goals, roles, decision authority, communications, fixing what broke, documentation, and a review after each event. 9. **Report to the board.** The Qualified Individual reports in writing at least once a year to the board, or to a senior officer if there's no board. The [2021 amendments](https://www.federalregister.gov/documents/2021/12/09/2021-25736/standards-for-safeguarding-customer-information) added most of that detail. The FTC [pushed the deadline](https://www.federalregister.gov/documents/2022/11/23/2022-25201/standards-for-safeguarding-customer-information) for the heaviest pieces to June 9, 2023. Encryption, MFA, the Qualified Individual, the written risk assessment, and the incident response plan have been mandatory since then. If you run a tax practice, the [accounting firms page](/industries/accounting-firms) covers how this maps onto a CPA office and its written security plan. ## What changed with breach notification? Since May 13, 2024, you have to tell the FTC about a "notification event" that involves the information of at least 500 consumers. That means as soon as possible, and no later than 30 days after you discover it. The FTC added this in a [2023 amendment](https://www.federalregister.gov/documents/2023/11/13/2023-24412/standards-for-safeguarding-customer-information), now [314.4(j)](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.4). A notification event is someone acquiring **unencrypted** customer information without authorization. Two details matter: - If the attacker got the encryption key too, the data counts as unencrypted. - Unauthorized access to unencrypted data is presumed to be acquisition, unless you have reliable evidence it wasn't. The clock starts the first day the event is known to anyone at your firm other than the person who caused it. That includes the employee who noticed something weird and didn't say anything. The notice goes through a form on the FTC's website and includes what data was involved, how many consumers, the dates if you know them, and a general description. Read that definition again from an IT point of view. Encryption decides whether a stolen laptop is a filing or a non-event. Logging decides whether you can show reliable evidence that nothing was taken. ## What's exempt for small firms? If you maintain customer information on fewer than 5,000 consumers, [314.6](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-314/section-314.6) drops four requirements: - the **written** risk assessment (you still have to base the program on a risk assessment) - the continuous monitoring or annual pen test and six-month vulnerability scans - the written incident response plan - the annual written report to the board That's it. You still need a written program, a Qualified Individual, encryption, MFA, access controls, training, vendor oversight, and regular testing of your key controls. The 500-consumer FTC notification still applies to you too. The count is consumers you hold data on, not active clients. A tax practice with a few hundred returns a year and a decade of old files can pass 5,000 faster than the owner thinks. Count before you claim it. ## What IT has to prove The rule talks about programs and policies. In practice, the question an auditor or examiner asks is "show me." These are the controls that need evidence behind them, and where that evidence usually comes from: - **Encryption at rest.** Every laptop and desktop encrypted, with recovery keys escrowed somewhere you control. We rolled [BitLocker out zero-touch across 127 endpoints with escrowed keys](/reports/bitlocker-encryption-automation) when a client's cyber insurance required it. The escrow was the part that would have failed an audit. - **Data inventory and disposal.** You can't protect or dispose of data you don't know about. A [password-file scan we ran for a compliance audit](/reports/password-file-audit) found 23 files with "password" in the name that held real credentials. A written policy didn't stop that. A scan found it. - **MFA on every account.** Not enrolled. Enforced. The [title company inbox takeover](/reports/bec-wire-fraud-incident-response) we contained happened on an inherited tenant where 2-Step Verification was optional. - **Logging and monitoring.** User activity logs and alerting, so a 314.4(j) decision is based on evidence instead of a guess. - **Vendor contracts.** Your IT provider, your loan origination system, your tax software, your cloud storage. Each one needs safeguards in the contract. A [mortgage brokerage](/industries/mortgage-brokers) with all nine elements on paper and no MFA report to show is in worse shape than one with a short program and real evidence behind every line. ## Key takeaways - If your business is financial in nature and no other regulator has you, the FTC's Safeguards Rule probably does. Mortgage brokers and tax preparers are named outright. - The program has to be written, and it has to cover all nine elements in 314.4. - Encrypted data that walks out the door is not a notification event. Unencrypted data on 500+ consumers is, and the FTC clock is 30 days. - The under-5,000-consumers exemption drops four requirements. Encryption, MFA, training, and vendor oversight still apply. - An auditor or examiner wants evidence: scan results, escrowed keys, MFA reports, logs. A binder alone does not prove anything. ## What this looks like when we do it We map controls to the specific section of the rule, 314.4, and build the documentation auditors actually ask for. For a firm starting from nothing, the first pass is a risk assessment, the written program, and the technical controls. That build is scoped as a project on a call, then folds into month-to-month managed IT so the program stays current instead of going stale in a binder. From there the evidence comes from the tools that run every day. Encryption status and escrowed keys from the RMM. MFA enforcement in the tenant. Scans for files that shouldn't exist. Patching and monitoring on every machine. When something does go wrong, I write it up, the same way I wrote every report on this site. If you broker mortgages, prepare returns, or run another firm the rule names, and you aren't sure which of the nine elements you can actually prove, [book a call](/contact). Fifteen minutes is enough to find out where you stand. --- # Business Email Compromise: How It Actually Happens at a Small Firm URL: https://limehawk.io/reports/business-email-compromise Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min Business email compromise is when a criminal uses a real or faked business inbox to redirect a payment. Phone verification and enforced MFA stop most of it. > **TL;DR** Business email compromise (BEC) is a criminal getting into, or faking, a business inbox and using it to steer a payment to their own account. The FBI's 2025 Internet Crime Report logged 24,768 BEC complaints and $3,046,598,558 in losses, second only to investment fraud. At a small firm it usually starts with a password and no enforced second factor. What stops the money from moving is boring: enforced MFA, a phone call to a known number before any wire-instruction change, and someone who checks the inbox rules when something feels off. We stopped one at a title company with that phone call. - BEC losses, 2013-2023 (FBI): $55B ## What is business email compromise? Business email compromise is a scam where a criminal uses a business email account, either one they broke into or one they faked, to get someone to send money to the wrong place. The FBI's [2025 IC3 Internet Crime Report](https://www.ic3.gov/AnnualReport/Reports/2025_IC3Report.pdf) defines it as a scam targeting businesses or individuals who regularly perform wire transfers, carried out "by compromising email accounts and other forms of communication... through social engineering or computer intrusion techniques to conduct unauthorized transfer of funds." The numbers are not small. In that 2025 report, IC3 counted 24,768 BEC complaints and $3,046,598,558 in reported losses. Only investment fraud cost more. In 2024 it was 21,442 complaints and $2,770,151,146. Over a longer window, the FBI's PSA ["Business Email Compromise: The $55 Billion Scam"](https://www.ic3.gov/PSA/2024/PSA240911) put exposed losses at $55,499,915,582 across 305,033 incidents from October 2013 to December 2023. I care about this term because of who it hits. [Title companies](/industries/title-companies) move large wires between parties on a schedule anyone can guess. [Law firms](/industries/law-firms) hold settlement and escrow money and send payment instructions by email all day. Two of the cases the FBI wrote up in the 2025 report are exactly that: a Missouri buyer who got wire instructions for over $1.3 million from a compromised "title company" email, and a couple closing on a home who wired over $449,000 after an email impersonating their attorneys. ## How do attackers get in? At a small firm, almost always through a password. Phishing, a reused password from some other breach, or plain guessing. Then the question is whether a second factor stops them. That is where the gap usually is. Here's the one I've seen up close. In [our BEC wire fraud incident at a title company](/reports/bec-wire-fraud-incident-response), the attacker logged into a closer's Google Workspace inbox with her password and nothing else. The firm had come to us from a prior IT provider. Users could enroll in 2-Step Verification, and some had. But the tenant never had enforcement turned on, and Google only challenges for 2SV on suspicious sign-ins unless enforcement is on. The login didn't look suspicious. So it went through on the password alone. 2SV enrolled is not 2SV enforced. That one setting is the difference between a single-factor account and a protected one. Not every BEC needs a break-in, either. The FBI PSA's tips include verifying the sender address and making sure links belong to the business they claim to come from, because a look-alike address can do the job without touching your mailbox. The 2025 report also names chat-written emails and voice cloning as BEC tools, and says businesses reported losses over $30 million to BEC scams involving AI. The playbook stays the same. The writing just gets better. ## What does the fake email look like? It looks like a normal email from someone you already work with, about a deal you already know is happening. That's the whole trick. In our title company case the attacker: 1. Sent a harmless note about a real pending closing first, likely to confirm the inbox was live. 2. Seventeen minutes later, sent "updated" wire instructions to the closing agent on the other side of that deal, in the closer's own tone. 3. Two hours after that, created mail filters that blocked email from the firm's funding desk and from another closer who would have spotted it. That third step is what most people miss. BEC attackers don't send one email and leave. They stay logged in and watch. If the other side replies "are these new instructions real?", the attacker wants to see it and answer it first, while keeping coworkers out of the thread. Things worth treating as a red flag, every time: - Any change to wire or payment instructions, especially close to a deadline. - Urgency or secrecy: "funding is today," "please don't call, I'm in a meeting." - A sender address or link that is one character off from the real one. - A thread that suddenly goes quiet on your side when you expected replies. ## What stops it? A phone call. Specifically, a call to a number you already had on file, before any money moves on changed instructions. The FBI's first prevention tip in its [BEC PSA](https://www.ic3.gov/PSA/2024/PSA240911) is to use secondary channels or two-factor authentication to verify requests for changes in account information. Email can't verify email. If the inbox is owned, the attacker answers. The rest is the layer that keeps them out of the inbox in the first place: - **Enforce MFA at the tenant level**, not just "available." Check the setting on every Microsoft 365 or Google Workspace tenant you inherit. - **Use phishing-resistant MFA for the people who move money.** CISA's [phishing-resistant MFA fact sheet](https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf) points to FIDO/WebAuthn, which in practice means hardware security keys. - **Unique passwords** from a password manager, so one breached site doesn't hand over your mailbox. - **Watch for inbox rules and forwarding** on accounts that touch payments. A new rule that hides mail from your own funding desk is not normal. - **Write the wire rule down.** Any change to payment instructions gets a phone call to a known number. No exceptions for the partner who's in a hurry. If money already left, speed matters more than anything. The 2025 IC3 report says to contact your financial institution immediately and request a recall, then file at [ic3.gov](https://www.ic3.gov/) with full transaction details, because IC3's Recovery Asset Team can ask banks to freeze funds. In the $449,000 case above, the full amount was still on hold at the receiving bank after IC3 stepped in. ## What we did when it happened to a client The title company's owner called us and said, "People are sending emails from her actual account changing wiring instructions, we need to shut down whoever it is." The full write-up is in [the BEC wire fraud incident report](/reports/bec-wire-fraud-incident-response). The short version: - Forced a password change and killed every active session, so any stolen cookie died with it. - Removed the two block rules so internal email flowed again. - Went through sent mail, found every message the attacker sent, and **phoned the other side's closer** to tell them to disregard the fake instructions. - Checked for the worse possibilities: no OAuth apps, no app passwords, no forwarding rules, no malware on the laptop. Credential-only compromise. The phone call is what saved the money. The wire was never sent. Zero funds lost on a high-six-figure transfer, contained in about two and a half hours. The owner calling us right away instead of trying to sort it out internally mattered just as much. The same day, we turned on org-wide 2SV enforcement and started our managed security baseline on that tenant: hardware keys for funding staff, context-aware access blocking high-risk geographies, Advanced Protection for executives, and the written phone-verification rule for any wire change. ## Key takeaways - BEC is a payment-redirect scam. The email is the tool; the wire is the target. - Most small-firm BEC starts with a password and a second factor that was enrolled but never enforced. - Attackers stay in the inbox and hide replies with rules. Check rules and filters on every suspected compromise. - Verify every change to payment instructions by phone, to a number you already had on file. - If money already moved, call your bank first, then file at ic3.gov. Hours matter. ## What this looks like when we do it Every tenant we fully take over gets that baseline on day one, not after an incident. Enforced MFA for everyone. Hardware keys for whoever touches funding. A written wire-verification rule your staff can point to when someone pushes back. And on any account that looks wrong, we check rules, filters, forwarding, OAuth apps, and sessions, because that's where BEC hides. We work with title companies, law firms, and other regulated firms of 5 to 100 users in Knoxville and East Tennessee. Critical issues get a response in under 15 minutes. If you inherited a Microsoft 365 or Google Workspace tenant and nobody has checked whether MFA is actually enforced, [contact us](/contact) and we'll look at it with you. --- # ALTA Best Practices: What the IT and Security Pillars Actually Ask For URL: https://limehawk.io/reports/alta-best-practices-it Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min ALTA Best Practices is a voluntary seven-pillar framework for title companies. Pillars 2 and 3 cover wire procedures and the WISP, and IT owns the proof. > **TL;DR** ALTA Best Practices is the American Land Title Association's seven-pillar framework for title and settlement companies. The current version is 4.2, effective August 19, 2025. It's voluntary, and ALTA doesn't certify anyone. Your lenders and underwriters are the ones who ask for the certification letter. Two pillars land on IT: Pillar 2 (escrow trust accounts, including written wire procedures that use multi-factor authentication) and Pillar 3 (a written information security plan to protect NPI). An assessor won't ask whether you have MFA. They'll ask you to prove it, with a user list, an access review, a tested backup, and an incident response plan. - ALTA v4.2 effective: Aug 19, 2025 ## What are the ALTA Best Practices? The ALTA Best Practices are a set of seven standards the American Land Title Association publishes for title agencies and settlement companies. They cover licensing, escrow money, data security, closings, title policies, insurance, and consumer complaints. ALTA calls them "voluntary." The [current framework is version 4.2](https://www.alta.org/best-practices/download.cfm?bestPracID=113&type=pdf), approved by ALTA's Board of Governors in June 2025 and effective August 19, 2025. Here are the seven pillars, numbered the way ALTA numbers them: 1. Maintain current licenses. 2. Written procedures and controls for escrow trust accounts. 3. A written information security plan (WISP) and a written privacy plan to protect NPI. 4. Settlement policies and procedures. 5. Title policy production, delivery, reporting, and premium remittance. 6. Insurance and fidelity coverage. 7. Written procedures for resolving consumer complaints. The framework has three parts: the Best Practices document, the [Assessment Procedures](https://www.alta.org/best-practices/download.cfm?bestPracID=98&type=word) an assessor works from, and a certification package. Everything below is taken from those documents and [ALTA's Best Practices page](https://www.alta.org/best-practices). ## Which pillars are about IT and security? Pillars 2 and 3 are where IT does the work. Pillar 3 is all information security. Pillar 2 is about money, but it's also where ALTA puts wire fraud, and wire fraud starts in somebody's inbox. The other pillars touch IT around the edges. Pillar 4 asks for an identity fraud prevention program for signers and vetted remote notarization platforms. Pillar 6 calls for cyber liability insurance. Pillar 2 says a written wire transfer procedure has to be "in place and tested at least annually." For outgoing wires, that procedure has to verify wire instructions "independent of the initial communication." The framework also says it "should include the use of multi-factor authentication." You also need a written wire fraud response procedure built on ALTA's Rapid Response Plan for Wire Fraud Incidents, reviewed every year. And former employees have to come off the escrow account signer list right away. That's the scenario in [our BEC wire fraud incident report](/reports/bec-wire-fraud-incident-response). An attacker got into a closer's inbox and sent new wire instructions on a real closing. What saved the wire was a phone call to the other side, made outside of email. That's the independent verification Pillar 2 asks for. The attacker got in because MFA was enrolled but not enforced. ## What does Pillar 3 (NPI) require? Pillar 3 requires a written information security plan sized to your company that protects NPI: names combined with things like Social Security, driver's license, or account numbers. ALTA's [Pillar 3 text](https://www.alta.org/best-practices/download.cfm?bestPracID=113&type=pdf) points to the Gramm-Leach-Bliley Act as the legal reason title companies need one. The WISP has to cover: - **Multi-factor authentication**, if available, on systems that hold NPI. - **Passwords**: unique logins, re-entering the password after the system idles, and complex passwords at least eight characters long. - **Timely software updates.** - **Physical security**: background-checked access only, removable media prohibited or controlled, and NPI on paper locked up. - **Network and cloud security**, whether the data sits in your office, a data center, or a vendor's cloud. - **Acceptable use guidelines** for company IT. - **A business continuity and disaster recovery plan** and **an incident response plan**, both written and both tested periodically. The incident response plan has to include everything in ALTA's Cybersecurity Incident Response Plan template. - **Training** on the WISP and on current threats. - **Records retention and disposal** that follows the law. - **Vendor oversight**: service providers and software with access to NPI, including "IT consultant employees" and AI tools, need security policies consistent with your WISP. - **A published privacy policy.** Version 4.2 also names systems that weren't built to hold NPI but can let someone in: guest Wi-Fi, smart devices, climate control, and personal devices plugged into the office network. If you're a title company, your IT provider is on that vendor list. Ask them how they'll show up in your WISP. ## Is ALTA Best Practices certification required? Not by ALTA. ALTA calls the practices voluntary, and it doesn't certify anyone. ALTA's [Best Practices FAQ](https://www.alta.org/policies-and-standards/best-practices/frequently-asked-questions) says it "does not perform any Best Practices assessments, nor does ALTA certify any company's compliance," and it has no badge for it either. The certification is a letter you produce. You can do an internal assessment and sign a representation letter, or hire a third party to run the Assessment Procedures. You send the result to your lender partners or underwriter if they ask for it. You don't send it to ALTA. In practice, the requirement comes from your lenders and underwriters. ALTA suggests renewing every 24 months. Its FAQ also says each lender and underwriter "is free to choose a shorter or longer timeline." So check what your lenders actually ask for. ## What evidence will an assessor or underwriter ask for? Assessors want records, not promises. The [Assessment Procedures](https://www.alta.org/best-practices/download.cfm?bestPracID=98&type=word) tell them to "obtain evidence" and to test samples. For Pillars 2 and 3, here's what IT has to be able to hand over: - **The WISP**, reviewed and updated at least annually, covering every section of Pillar 3. - **A risk assessment** that lists where NPI is stored, processed, sent, and disposed of, ranks the threats, and records how the WISP gets tested and how gaps get fixed. - **Training records**: WISP training at hire and refresher training every year. - **A user ID list** for every system that holds NPI, showing that each ID belongs to one person. - **Access records**: approval before access was granted, access removed for a sample of people who left, admin rights kept away from people who do business transactions in the same system, and a management access review at least once a year. - **MFA on every access point** to remotely hosted or remotely accessible systems that hold NPI. - **Device settings** that match the removable media policy. - **Proof of encryption** or another secure way to deliver NPI. - **Monitoring**: how attacks are detected, a sample of security alerts with management follow-up, and outsourced monitoring reports with proof that management reviewed them. - **Change management**: a sample of hardware and software changes that were documented, tested, and approved, plus a timely patching procedure. - **Backup and recovery** procedures, with documented test results. - **An incident response plan** that names a single point of contact, lists who to notify, and includes your cyber insurance carrier contacts. - **Vendor due diligence** that considered security, plus checks that vendors carry cyber and breach insurance. - **Wire procedures**: the written wire transfer procedure with MFA and independent verification, plus the wire fraud response procedure. Plaintext passwords sitting in files fail Pillar 3 quickly. We wrote up how we [scan endpoints for password files](/reports/password-file-audit) before an audit finds them. ## Key takeaways - ALTA Best Practices is voluntary and ALTA doesn't certify anyone. The certification letter goes to your lenders and underwriters when they ask for it. - Version 4.2 took effect August 19, 2025. Pillar 2 covers escrow and wire procedures. Pillar 3 is the WISP that protects NPI. - ALTA suggests renewing every 24 months, but a lender or underwriter can set a shorter or longer timeline. - Assessors sample evidence: unique user IDs, removed access for people who left, an annual access review, MFA on remote access, backup test results, and a WISP you've actually reviewed this year. - A policy nobody follows fails the test. Build the controls first, then write down what you built. ## What this looks like when we do it When we take on a title company, we build the controls first and write them down second. Then the WISP describes systems that actually exist. On our [managed IT for title companies](/industries/title-companies) baseline, that means MFA enforced across the whole tenant, not optional for each user. It also means email authentication, monitoring for the mailbox rules and odd sign-ins attackers leave behind, patch management, and endpoint detection and response. The evidence comes out of the same tools that run the controls. We can export the user and MFA list, show who lost access and when, report on patching and encryption, and keep backup test results on file. Your wire procedure is yours to write and run. We make sure the email and identity side of it holds up, and we know what to look for when it doesn't, because we've already worked a BEC case on a real closing. We don't sign your certification letter. You do, or the third-party assessor you hire does. Our job is to make sure the IT pages of that package are true and every item has evidence behind it. Our [title company page](/industries/title-companies) covers the wire fraud side in more detail. Have a lender asking for your ALTA letter and an IT section you can't prove yet? [Get in touch](/contact) and we'll go through it with you. --- # ABA Formal Opinion 477R for Small Firms, in Plain English URL: https://limehawk.io/reports/aba-formal-opinion-477r Published: 2026-10-01 Author: Corey Watson, Managing Member Topic: Guides Read time: 6 min ABA Formal Opinion 477R says you may email clients if you've made reasonable efforts to secure it. Here is what that means in actual IT controls. > **TL;DR** ABA Formal Opinion 477R says a lawyer can send client information over the internet as long as the lawyer made reasonable efforts to keep it from the wrong people. It does not name a product or a checklist. It names factors and a process. In practice that means MFA, patched and encrypted devices, a way to wipe a lost laptop, encryption when the matter calls for it, and vendors you have actually checked. Opinion 483 adds the other half: watch your systems, and tell clients when their information is hit. - 477R security considerations: 7 I'm not a lawyer, and this isn't legal advice. Talk to your ethics counsel about your own obligations. What I can do is tell you what the opinion actually says and what it looks like when we set up a firm's IT to meet it. ## What does 477R require? [ABA Formal Opinion 477R](https://www.americanbar.org/content/dam/aba/administrative/professional_responsibility/ethics-opinions/aba-formal-opinion-477.pdf) says a lawyer may send client information over the internet without breaking the Model Rules, as long as the lawyer has made reasonable efforts to prevent inadvertent or unauthorized access. It was issued May 11, 2017, and revised May 22, 2017. The catch is the second half: some information, some client agreements, and some laws require more than the baseline. It rests on two rules the ABA changed in 2012. [Model Rule 1.1, Comment [8]](https://www.americanbar.org/groups/professional_responsibility/publications/model_rules_of_professional_conduct/rule_1_1_competence/comment_on_rule_1_1/) says competence includes keeping up with "the benefits and risks associated with relevant technology." [Model Rule 1.6(c)](https://www.americanbar.org/groups/professional_responsibility/publications/model_rules_of_professional_conduct/rule_1_6_confidentiality_of_information/) says a lawyer "shall make reasonable efforts to prevent the inadvertent or unauthorized disclosure of, or unauthorized access to, information relating to the representation of a client." Tennessee adopted the same language, with one numbering difference that trips people up. In [Tennessee Supreme Court Rule 8](https://www.tncourts.gov/rules/supreme-court/8), the reasonable-efforts duty is **RPC 1.6(d)**, not 1.6(c). Tennessee's RPC 1.1 Comment [8] carries the same technology language as the ABA's. The opinion itself notes that each jurisdiction's own rules control, so for a Tennessee firm, Rule 8 is the text that counts. If you want the short version of how this applies to a firm like yours, our [IT for law firms](/industries/law-firms) page covers it. ## What counts as reasonable efforts? There's no checklist. The opinion says reasonable efforts are "not susceptible to a hard and fast rule." It borrows a process from the ABA Cybersecurity Handbook instead: assess the risks, put security measures in place that answer those risks, verify they work, and keep them updated as things change. Comment [18] to Rule 1.6 (Tennessee has the same comment) lists the factors: - how sensitive the information is - how likely disclosure is without more safeguards - what more safeguards would cost - how hard they are to put in place - how much they get in the way of representing clients 477R then gives seven considerations. Here is each one next to what it turns into on a real network: 1. **Understand the threat.** The opinion names M&A, trade secrets, and industries like healthcare and banking as higher-risk matters. Higher risk means more effort. 2. **Know where client data lives and how it moves.** "Every access point is a potential entry point." That's an asset inventory: every laptop, phone, mailbox, and cloud share. 3. **Use reasonable security measures.** The opinion lists secure Wi-Fi or VPN, strong unique passwords, firewalls, anti-malware, and patching on every device that touches client data. It names encryption of stored data and multi-factor authentication as other tools, and says remote disable and wipe of lost devices may be reasonable, "especially if encryption is not also being used." 4. **Decide how each kind of communication should be protected.** More on that below. 5. **Label confidential communications.** A "privileged and confidential" marking or an accurate email disclaimer. 6. **Train lawyers and staff.** Policies, periodic training, and follow-up, under Rules 5.1 and 5.3. 7. **Do due diligence on technology vendors.** Reference checks, the vendor's security policies, confidentiality agreements, and so on. None of that is exotic. Most of it is what a properly run small office should have anyway. The opinion even says the basic measures in item 3 are "routinely accessible and reasonably affordable or free." ## When do you need to encrypt client email? When the information is sensitive enough to need it. 477R says unencrypted routine email "generally remains an acceptable method of lawyer-client communication," but only if you've put basic security measures in place first. If the client information is of sufficient sensitivity, the opinion says the lawyer "should encrypt the transmission," and consider password-protecting attachments or using a vetted, secure file-sharing service instead. It goes further for the most sensitive matters: reasonable efforts "might require avoiding the use of electronic methods or any technology" at all. And it flags a common one that small firms miss: a client using a work computer or an email account someone else can get into. The opinion says you should warn that client, because privilege can be waived. The opinion also says lawyer and client should talk about the level of security each communication needs at the start of the relationship. That's a conversation, not a setting. Our job is to make sure the secure option actually exists and is easy, so nobody falls back to a personal Gmail account to get a document out the door. ## What about 483 (breach duties)? [ABA Formal Opinion 483](https://www.americanbar.org/content/dam/aba/administrative/professional_responsibility/ethics-opinions/aba-formal-op-483.pdf), issued October 17, 2018, picks up where 477R left off. It says that when a breach involves, or has a substantial likelihood of involving, material client information, lawyers have a duty to notify clients. The parts that land on IT: - **Monitoring.** Lawyers "must employ reasonable efforts to monitor" their internet-connected technology, external data sources, and vendors. Otherwise, the opinion says, noticing a breach is left to happenstance. - **Stop it and fix it.** When a breach is suspected or detected, the lawyer has to act reasonably and promptly to stop it and limit the damage. The opinion recommends an incident response plan written before you need it. - **Find out what happened.** Enough investigation to confirm the intruder is out and, as far as reasonably possible, what was accessed or lost. - **Tell the client.** At minimum, that there was unauthorized access or it's reasonably suspected, and the known extent of it. 483 also counts ransomware as a breach when it locks you out of client information for a material amount of time, even if nothing was stolen. And it's clear that complying with state breach-notification laws doesn't automatically satisfy the ethics duty, or the reverse. The most common way we see a firm's information get exposed is a mailbox takeover. We wrote up [a business email compromise at a title company](/reports/bec-wire-fraud-incident-response), where an attacker sent fake wire instructions from a real closer's inbox and hid the replies with mail rules. Law firms that touch settlement funds or real estate closings face the same attack. ## Key takeaways - 477R does not require a specific product. It requires a process: assess the risk, put controls in place, verify they work, keep them current. - Routine unencrypted email is generally still acceptable under 477R, but only on top of basic security measures. Highly sensitive matters can require encryption or no email at all. - In Tennessee the duty lives in RPC 1.6(d), not 1.6(c). The reasonableness factors in Comment [18] match the ABA's. - Opinion 483 expects you to monitor your systems and vendors, and to tell affected clients when material client information is accessed or reasonably suspected to be. - If you can't evaluate the safeguards yourself, 477R says you can get there by working with an expert. That is the job we do. ## How we set this up for a firm This is the part a generic guide can't write. Here's what our baseline looks like when we take on a firm. It lines up with 477R's considerations, and it's the same work described on our [law firm IT](/industries/law-firms) page: - **MFA enforced at the tenant**, not just offered. The BEC report above is what "enrolled but not enforced" costs you. - **Microsoft 365 or Google Workspace locked down**, not left on defaults, with an encrypted send option staff can actually find. See [Microsoft 365 administration](/services/cloud-solutions). - **Full-disk encryption on every machine**, with recovery keys escrowed. We've [rolled that out across a fleet in 72 hours](/reports/bitlocker-encryption-automation). - **Remote wipe that works on short notice.** We [wiped a stolen laptop 16 minutes after the theft](/reports/mdm-remote-wipe) because the tool was ready before it was needed. - **Patching, endpoint protection, and a managed firewall**, with no Remote Desktop open to the internet. - **An asset inventory** so you know every device client data sits on. - **Monitoring and tested backups**, which is what 483 expects and what gets you through ransomware. - **Written documentation** of all of it, so when a client, a carrier, or the bar asks what you did, the answer is on paper. What we don't do is pretend to be your ethics counsel. We build and document the controls. You and your counsel decide what the rules require for your matters. 477R itself says a lawyer who can't evaluate safeguards alone can get there "through association with another lawyer or expert." That's us on the technical side. If you want to walk through where your firm stands against 477R, [get in touch](/contact). Critical issues get a response in under 15 minutes, and managed IT is month-to-month. --- # WordPress CVE-2026-87902: Unauthenticated Path Traversal Under Active Attack URL: https://limehawk.io/reports/wordpress-cve-2026-87902-path-traversal Published: 2026-09-28 Author: Corey Watson, Managing Member Topic: Security Read time: 5 min CVE-2026-87902 lets an unauthenticated attacker make WordPress 4.7.0 through 7.1.1 include a PHP file outside the theme folder, and on some servers that becomes remote code execution. Update to 7.1.2 or your branch's backport now; it's being exploited. > **TL;DR** A bug in WordPress core's get_page_template() lets anyone on the internet point the template loader at a readable PHP file outside the theme directory. With the right server setup (a theme with a top-level page- folder and PHP's register_argc_argv turned on), attackers chain it through pearcmd.php to write their own PHP to disk. WordPress shipped 7.1.2 plus backports on September 22, probing started the same day, and CISA added it to the KEV catalog on September 25. Update, then check your logs. - CVSS v4: 9.2 - Auth required: None - Affected: 4.7.0–7.1.1 - Fixed in: 7.1.2 ## What is CVE-2026-87902? CVE-2026-87902 is an unauthenticated path traversal bug in WordPress core. The function `get_page_template()` builds a template filename partly from the request, and it didn't properly handle encoded `../` sequences. An attacker can steer it to any readable `.php` file on the server outside the active theme directories, and WordPress runs it. The CVE record classes it as CWE-98 (PHP file inclusion). The WordPress advisory scores it 9.2 on CVSS v4; the CVE record's CVSS v3.1 score is 8.1, with attack complexity marked high because code execution depends on server setup. ## Which versions are affected? Every WordPress release from 4.7.0 through 7.1.1. WordPress fixed it in 7.1.2 on September 22, 2026, and backported the fix to 24 older branches, including 7.0.6, 6.9.9, 6.8.10, and 4.7.37. If you're on an older branch, the latest point release on that branch is patched. Sites with automatic background updates enabled should already have it. Check the version under Dashboard → Updates anyway. ## When does it become remote code execution? Including a random PHP file is bad; running attacker code is worse. The chain Patchstack observed needs two things on the server: 1. The active theme has a top-level `page-` directory. 2. PHP has `register_argc_argv` enabled, which hands the query string to included scripts as command-line arguments. With both, attackers include PEAR's `pearcmd.php` and pass it `config-create` arguments, which writes a PHP file of their choosing to disk. From there it's a web shell. ## How are attackers exploiting it? Patchstack saw the first probe at 11:49 UTC on September 22, the same day the patch shipped. The attacks run in three stages: - **Check the bug is there.** Requests with `pagename` holding encoded traversal (`%2e%2e` or double-encoded `%252e%252e`) pointing at harmless core files like `wp-links-opml.php` or `wp-includes/feed-rss2.php`. - **Find PEAR.** The same trick aimed at `pearcmd` with `+config-show`, trying `/usr/local/lib/php/`, `/usr/share/php/`, and `/usr/share/pear/`. - **Write a file.** `pearcmd` with `+config-create`, a PHP payload, and a path in `/tmp`. A public Nuclei template landed on September 23, and traffic went up more than tenfold from the first evening. CISA added the CVE to its Known Exploited Vulnerabilities catalog on September 25. ## How do I check whether my site was hit? Search your web server access logs for: - A `pagename` parameter containing `%2e%2e` or `%252e%252e`. - `pagename` and `page_id` together in requests to the site root. - `pearcmd`, `+config-show`, or `+config-create` anywhere in the URL. - User agents `cve-2026-87902-poc/1.0` or `nuclei-cve-2026-87902/1.0`. ``` grep -Ei 'pagename=[^& ]*(%2e%2e|%252e%252e)|pearcmd|config-(show|create)|cve-2026-87902' /var/log/nginx/access.log* ``` Then look for PHP files that shouldn't exist, especially in `/tmp` and `/var/tmp`. Names seen so far include `wp-pear-rce-flag.php` and `poc87902.php`. ``` find /tmp /var/tmp -name '*.php' -newermt 2026-09-22 -ls ``` Probes against core files only prove someone scanned you. A `config-create` request that returned 200, or a new PHP file on disk, means treat the server as compromised: take it offline, rotate credentials and salts, and rebuild from a known-good backup. ## What if I can't update right now? Update. If something truly blocks it for a few hours: - Set `register_argc_argv = Off` in php.ini and reload PHP-FPM. That breaks the pearcmd chain. - Block requests where `pagename` contains `..`, `%2e%2e`, or `%252e%252e` at your WAF or reverse proxy. Both reduce exposure. Neither fixes the bug. ## Sources - [CVE record, CVE-2026-87902](https://www.cve.org/CVERecord?id=CVE-2026-87902) - [Patchstack: attackers started probing WordPress sites hours after the patch](https://patchstack.com/articles/cve-2026-87902-attackers-started-probing-wordpress-sites-hours-after-the-patch/) - [Help Net Security: WordPress 7.1.2 fixes critical path traversal](https://www.helpnetsecurity.com/2026/09/23/cve-2026-87902-wordpress-7-1-2-security-release/) - Sep 9 — CVE ID reserved. - Sep 22 — WordPress 7.1.2 released, with fixes backported to every branch back to 4.7. - Sep 22, 11:49 UTC — First exploitation attempt seen by Patchstack, probing harmless core files. - Sep 22, 15:34 UTC — First file-write attempts through pearcmd.php. - Sep 23 — Public Nuclei template released; attack traffic spikes. - Sep 25 — CISA adds CVE-2026-87902 to the Known Exploited Vulnerabilities catalog. - Update to 7.1.2, or the patched release on your branch (7.0.6, 6.9.9, 6.8.10 … down to 4.7.37). - Background auto-updates should have applied it. Confirm the version; don't assume. - Turn off register_argc_argv in php.ini. It breaks the known RCE chain and web servers don't need it. - Grep access logs for traversal in pagename and for pearcmd. Probing started hours after the patch. - Look for stray .php files in /tmp and /var/tmp. --- # The Best 5 Knoxville IT Companies URL: https://limehawk.io/reports/knoxville-it-companies Published: 2026-08-16 Author: Corey Watson, Managing Member Topic: Guides Read time: 7 min The best 5 Knoxville IT companies for managed IT. Limehawk for regulated firms of 5-100. TenHats, The IT Company, Hyperion, and TeamLogic are strong shops. > **TL;DR** Search Knoxville IT companies and you will see Limehawk, TenHats, The IT Company, Hyperion Networks, and TeamLogic IT. I run Limehawk. We are the pick if you are 5-100 users and you have to survive an audit. The other four are real local shops. TeamLogic already sends us work. Stay with them if they have you. | Company | Best for | Notable | | --- | --- | --- | | Limehawk | Regulated firms of 5-100 users that have to survive an audit | Founded 2022. Controls mapped to FTC Safeguards, HIPAA, SEC/FINRA, NAIC, GLBA | | TenHats | Accounts bigger than ours, in healthcare, banking, and manufacturing | Own data center on Fort Summit Way. 2026 MSP 501 at No. 201, the only Knoxville company on that list | | The IT Company | Small and mid-sized businesses wanting fully managed or co-managed IT | Second Harvest Food Bank of East Tennessee has been a client since 2011 | | Hyperion Networks | Plants and clinics on the Louisville and Blount side of town | Started April 5, 2013. Their about page says they crossed 100 networks in 2021 | | TeamLogic IT | West Knoxville outsourced, co-managed, and project work | Can pull extra hands from the national TeamLogic bench | ## Which Knoxville IT companies do managed IT? [Limehawk](/services/managed-it-security), [TenHats](https://tenhats.com/), [The IT Company](https://theitcompany.com/), [Hyperion Networks](https://www.hyperionnetworks.com/), and [TeamLogic IT](https://www.teamlogicit.com/WKnoxvilleTN/). Those are the five I would give you if you asked me at lunch. All of them do managed IT for Knoxville and East Tennessee firms. I run Limehawk. The rest of this page is who each shop is actually good for. ## Limehawk I founded Limehawk in 2022. We take [title companies](/industries/title-companies), [law firms](/industries/law-firms), CPAs, clinics, and plants in the 5-100 seat range. Three users minimum. Month-to-month. Critical in under 15 minutes, standard in 1-2 hours. I write every [incident report](/reports) on this site, including [the title closer whose inbox got taken over](/reports/bec-wire-fraud-incident-response) and [the open RDP we found on day one of an onboarding](/reports/open-rdp-onboarding-discovery). We map controls to FTC Safeguards, HIPAA, SEC/FINRA, NAIC, and GLBA. We will keep a Windows 98 box or a SCADA panel online when ripping it out is not an option. We are a bad fit for a 250-seat NOC, and a worse fit if you only want someone when a PC dies. ## TenHats TenHats is the Knoxville shop that built a data center on Fort Summit Way and made the 2026 MSP 501 as the only Knoxville company on that list, at No. 201. They started in 2016 as SH Data Technologies and took the TenHats name in 2022. Microsoft Solutions Partner. 24/7 help desk. Healthcare, banking, manufacturing, and accounts larger than the ones we take. They are a strong call once you need a local MSP that already runs at that scale. ## The IT Company The IT Company is a Knoxville firm that has been doing fully managed and co-managed IT for small and mid-sized businesses long enough that people search the name on purpose. Second Harvest Food Bank of East Tennessee has been with them since 2011. Their published clients also include shops like ERISA Services and Magnum Venus Products. On top of the help desk they sell strategy, automation, and AI. If you typed "the it company knoxville," this is the firm. They earned that search. ## Hyperion Networks Joe Ray started Hyperion on April 5, 2013, at 3648 Louisville Road, just west of Knoxville. He came out of Knox County Schools and other Knoxville IT shops. Manufacturing and healthcare are the verticals they put on the door. By 2018 they had added cybersecurity, cloud, and VoIP. Their about page says they crossed 100 networks in 2021. They have been in this market longer than we have. A plant or a clinic on the Louisville / Blount side of town will like that team. ## TeamLogic IT TeamLogic IT of West Knoxville is at 9111 Cross Park Drive. Randall Wilson runs the office. They do outsourced, co-managed, and project work, and they can pull extra hands from the national TeamLogic bench when a job needs it. They also send us clients when the account looks like ours. If TLIT already has you, stay. ## What is the best IT company in Knoxville? For a 5-100 person firm that has to survive an audit, Limehawk. That is the company I built. TenHats is the better call once you want a Knoxville MSP with its own data center and an MSP 501 slot. The IT Company is the Knoxville SMB shop people mean when they search that name. Hyperion is Joe's Louisville team, good on manufacturing and healthcare. TeamLogic already covers a lot of West Knoxville desks, and they do it well. Read the [incident reports](/reports), then [book the call](/contact) if the 5-100 regulated book is yours. --- # Open RDP on the Public Internet: Found During Onboarding Discovery URL: https://limehawk.io/reports/open-rdp-onboarding-discovery Published: 2026-07-18 Author: Corey Watson, Managing Member Topic: Security Read time: 5 min Day-one discovery for a new client found Remote Desktop exposed to the public internet. Nothing was breached yet; we closed it before someone found it. - Time to close: Same day - Port open: TCP 3389 - Confirmed breach: None - Root cause: Legacy RDP rule > **TL;DR** Day one of onboarding, external scan of the new client's public edge turned up TCP 3389 open to the world with weak authentication. No evidence of compromise yet. The exposure was the finding. We cut the public path the same day, put RDP behind a VPN/jump host, and locked the account surface. Discovery exists for this: find the gun on the table before someone else picks it up. ## Incident During the discovery phase of onboarding a new East Tennessee business onto managed IT, we ran our standard external edge pass: public IPs, open ports, certificates, mail auth, and anything listening that shouldn't be. One of the public addresses answered on **TCP 3389** — Windows Remote Desktop — from the open internet. No ticket. No "we can't remote in." No ransomware note. Just a door that had been left open long enough that we found it before a stranger did. That is the point of discovery. You do not inherit a network and assume the last person locked it. ## Timeline - 09:10 — External discovery scan against the client's public IPs. - 09:25 — TCP 3389 open to 0.0.0.0/0; RDP reachable from the open internet. - 11:40 — Firewall rule removed; RDP no longer internet-facing. - 16:15 — VPN / jump path for admin access; local RDP hardening and monitoring started. The open port was the whole incident. No ticket, no ransomware note, no "we can't remote in" call. Discovery found a door that had been left open long enough that we got there first. Everything after 09:25 is containment and a permanent path so the next person who needs remote access does not recreate the same hole. ## Why open RDP is a problem Remote Desktop is a full interactive session into a Windows machine. Exposed to the public internet it becomes: - A constant target for **credential stuffing and brute force** (RDP is on every scanner list) - A path for **ransomware** operators who buy access or spray weak passwords - A bypass of every internal control you think you have, because authentication happens at the edge with whatever password still works "We only use it sometimes" does not shrink the attack surface. Bots do not wait for business hours. If 3389 is reachable from a coffee shop in another country, it is reachable from a botnet. For firms in regulated spaces — [manufacturing with OT adjacency](/industries/manufacturing), [title and law](/industries/title-companies) handling money, medical and finance with data obligations — internet-facing RDP is also an audit and insurance problem. Cyber carriers and security questionnaires ask about remote access. "RDP open to the world" is the wrong answer. Closing it is part of [managed network monitoring and edge hygiene](/services/network-infrastructure), not a one-off firewall favor. ## Root cause Almost never malice. Almost always habit: - A vendor needed in "just for this project" years ago - A prior IT shop preferred RDP over a VPN and never cleaned up the firewall rule - A router port-forward that survived a firewall upgrade - An owner who works from home and wanted "the easy way" In this case it was a leftover remote-access path from before we managed the environment. The business had been living with it. Nobody was watching the logons the way a managed baseline does. ## Response Same-day containment, then a proper path: 1. **Confirmed scope** — which host, which public IP, which firewall object, whether anything else forwarded 3389. 2. **Closed the public hole** — removed the allow rule so 3389 was no longer reachable from the internet. 3. **Preserved legitimate need** — admins still need remote access; we put them on a **VPN or jump host**, then RDP only on the private side. 4. **Hardened what remains** — network-level restriction, account hygiene, and monitoring so failed RDP attempts on the internal path still get seen. 5. **Documented the finding** — discovery notes for the client: what was open, what we closed, what the permanent pattern is. No confirmed compromise. We treated the exposure as the incident. ## Outcome Public RDP closed the same day. Admin access moved behind VPN/jump. No evidence of prior compromise in this case — the win is that discovery caught the gun on the table before someone else picked it up. If you manage your own edge (or you just hired an MSP and they have not shown you an external scan), ask: - Is **any** host listening on 3389, 22, or other admin ports from `0.0.0.0/0`? - Who approved that rule, and is the project that needed it still active? - If someone needs remote desktop, is there a **VPN** (or Zero Trust / jump box) in front of it? - Are failed logon attempts on RDP monitored, or does the noise go nowhere? A free external port scan of your own public IPs is not advanced security. It is hygiene. We run it on every onboarding for a reason. ## Key takeaways - Open RDP is not a 'risk score.' It is an unlocked door with a well-known lock-picking community. - Onboarding discovery is not paperwork. External scan of every public IP is day-one work, before you touch internal polish. - If someone needs desktop remote access, put a VPN or jump host in front. Never 3389 to the world. - Absence of a breach is not evidence the exposure was fine. Bots scan for 3389 continuously. - Prior-MSP or owner-built remote access habits often survive long after everyone forgot they were temporary. --- # WebView2 Runtime Won't Install? The Admin MSI Fix That Actually Works URL: https://limehawk.io/reports/webview2-runtime-admin-install Published: 2026-04-02 Author: Corey Watson, Managing Member Topic: Software Read time: 4 min A user's app wouldn't launch because WebView2 Runtime silently failed to install. The fix was to grab the MSI directly from Microsoft and install it elevated. - Time to fix: ~2 hours (ticket open to resolution, most of it waiting) ## Incident Ticket came in: a client's application was blocked from launching because the WebView2 Runtime wouldn't install. He'd hit the error, tried the normal install path, and gotten nowhere. The app had a hard dependency on WebView2 being present machine-wide and wouldn't even start its initial window without it. Classic "nothing happens" symptom from the user's side. The installer would run, appear to do something, and then the app would still say WebView2 was missing. This is a small ticket with a mundane fix, but the pattern is worth writing up because WebView2 install failures are a steady trickle. Enough apps depend on WebView2 now that when it misbehaves, it breaks multiple things on the same machine, in this case our own remote-support client plus Lenovo Vantage, both of which quietly rely on WebView2 under the hood. ## What webview2 actually is WebView2 is Microsoft's embedded Chromium runtime. When a desktop app wants to render web content inside itself (a settings pane, an onboarding screen, a log-in window), it uses WebView2 instead of shipping its own browser engine. The runtime installs once per machine and gets shared across every app that depends on it. When WebView2 is missing or broken, every app that depends on it breaks simultaneously. Users don't connect the dots but from the engineer's side it's a strong tell. ## What goes wrong with the default installer The standard WebView2 install path is the bootstrapper, a small Evergreen installer that phones home, downloads the real payload, and installs it. That bootstrapper can fail quietly in a few ways we see in the field: - the user doesn't have admin rights and the installer elevates inconsistently - the bootstrapper can't reach Microsoft's download endpoint (proxy, filtering, flaky wifi mid-download) - a prior partial install left registry or file-system artifacts that the bootstrapper refuses to step on - AV or endpoint controls flag the bootstrapper and kill it without a clear error - the bootstrapper returns a success code but the actual runtime didn't register In any of these cases, the user sees "it finished" or "it errored" without useful signal, and the dependent app still won't launch. Running the bootstrapper a second time usually doesn't change anything. ## The fix Skip the bootstrapper. Grab the standalone WebView2 MSI directly from Microsoft's evergreen download page and install it elevated. The standalone installer is self-contained, doesn't need network access at install time, and goes in cleanly as long as it runs with admin rights. Steps: 1. On Microsoft's WebView2 download page, grab the Evergreen Standalone Installer, not the Bootstrapper 2. Pick x64 for modern machines (x86 only if you're on a legacy 32-bit install, which is rare) 3. Right-click → Run as administrator (or push it via RMM with elevated context) 4. Let it complete. It's silent and fast 5. Restart the dependent app That's it. In this ticket we downloaded the standalone installer, ran it elevated on the user's machine, and the app came up. Because WebView2 registers machine-wide, everything else on the system that depended on it (Lenovo Vantage in this case) also started working without any additional work. ## Why elevated install matters The WebView2 runtime installs into Program Files and writes to HKLM. A non-elevated process can't do either. If the bootstrapper fails to elevate cleanly (which happens under some UAC and Group Policy configurations), it silently falls back to behavior that looks like an install but doesn't actually put the runtime in the right place. Forcing the install elevated eliminates that whole class of failure. ## Outcome Ticket opened at about 3pm, resolved by about 5pm. Most of that was coordination time and the remote session itself. The install was a couple minutes once we were on the machine. User replied: "Thank you." Then got the close notification and replied with "What am I supposed to do with this Email?" Which, honestly, is peak end-user. App launched. Vantage launched. Nothing else had to be touched. The entire fix was replacing the bootstrapper with the standalone MSI and running it elevated. Tickets like this are the steady drumbeat of [managed support](/services/managed-it-security): mundane, fast, and worth exactly none of the client's own afternoon. time to fix: ~2 hours end-to-end, install itself was under 5 minutes apps restored: 2 (the requesting app + Lenovo Vantage, as a bonus) complexity: low ## Key takeaways - WebView2 is a machine-wide dependency, so when it's broken multiple unrelated apps break together. - the default WebView2 bootstrapper can fail silently, especially without admin rights or with flaky network conditions. - the fix is the Evergreen Standalone Installer MSI from Microsoft, run elevated, not the bootstrapper, not a retry. - if a user reports "install finishes but the app still says it's missing," that's almost always a bootstrapper-without-elevation outcome. - installing machine-wide repairs everything that depended on WebView2 in one shot, no per-app fiddling needed. --- # Getting Verification Codes You Didn't Request? How to Tell Spam From Account Compromise URL: https://limehawk.io/reports/email-address-enumeration-otp-spam Published: 2026-03-30 Author: Corey Watson, Managing Member Topic: Security Read time: 5 min A title company user received back-to-back OTP codes she never asked for. The triage playbook: attacker on a signup form versus actual account compromise. - Time to triage: under an hour ## Incident A user at a [title company](/industries/title-companies) forwarded us an email with a verification code she hadn't requested. An hour later: "I've now gotten two codes that I did not request on that email I forwarded this morning." By the time we circled back she was seeing several more, back to back. The first one was a Proofpoint encryption registration code from a regional bank's secure-email portal. The follow-ups were landing in the same inbox in quick succession. This is the exact scenario that sends people into a panic, and for good reason. Unexpected OTPs are one of the classic signs of an account takeover in progress. An attacker who has your password but not your 2FA code will hammer the login page, and each attempt kicks a code to your inbox. So when a user forwards you one of these, the first question has to be: is this an attacker trying to get INTO an account, or is someone using your email address to sign up for something OUT in the world? The responses are completely different. One is a potential incident. The other is annoying spam. ## The triage playbook We walked the user through the standard set of questions. These are the four that do most of the work: 1. What service sent the code, and what was the code FOR? 2. Is it the same sender every time, or different senders? 3. Do you already have an account with that service? 4. When you go to that service and click "forgot password" with your email, does it say an account exists? Step 1 is the biggest tell. A code labeled "complete your registration" or "verify your email" is almost always a signup flow. Someone is creating a new account and typed your address in the email field. A code labeled "sign in" or "login verification" means someone is trying to authenticate AS you, which is the scary version. In this case the subject line said "Proofpoint Encryption Registration" and the body read "Please use this validation code to complete your registration." Registration flow language. Someone was trying to sign up for the bank's secure-email portal using her address, not logging into an account of hers. Step 2 narrows it further. Codes from DIFFERENT services usually mean an email enumeration attack: a scripted bot cycling through signup forms that don't rate-limit. Codes from the SAME service are either one persistent idiot mistyping their own email, or a targeted attempt on a real account. ## What we told the user Short version: don't click anything inside the emails, don't enter the codes anywhere, and let them expire on their own. If she wanted to confirm nothing existed under her address at that bank's portal, go directly to the service's website (not via any link in the email) and run a password reset. The site will tell you whether an account exists. No account = signup attempt = ignore. Account exists = take it seriously. ## Why this isn't a compromise signal Her mailbox wasn't at risk from these codes. Receiving a code proves nothing about account access. Anyone who knows your email can trigger one from any service with an unprotected signup form. The code is useless to the attacker unless they can read your inbox. They can't; the code was sent to HER. No action she took caused this: no link clicked, no attachment opened, no credentials entered. Her actual work email account (the one the codes were landing in) was not involved in any authentication event. Nobody tried to log into it. ## What would change the answer For contrast, here's what would have flipped this from "ignore it" to "incident response": - codes labeled "sign in verification" or "login code" instead of registration - password reset emails she didn't request - login notifications from unknown locations/devices - 2FA prompts for services she didn't just try to access None of that was present. She had a signup-flow code followed by a burst of spam from the same pattern. Textbook low-severity. ## Outcome We explained the triage logic, confirmed it was a signup enumeration, and told her to expect the codes to stop on their own once whoever typed her address got frustrated and moved on. No credentials exposed, no account access attempted, no follow-up incident. The ticket went quiet and auto-closed a week and a half later. ## Key takeaways - unexpected OTPs are scary by default, but the email's own language tells you whether it's registration (low risk) or login (high risk). - "same sender, repeating" usually means one person is mistyping or a bot is hammering a signup form, not account compromise. - never enter an unrequested code anywhere; never click a link inside one of these emails. - real compromise attempts come with other signals: password reset requests, login alerts, 2FA prompts for services you didn't just access. - the best confirmation is a password reset from the service's actual website, not from the email. --- # Finding Your BitLocker Recovery Key After a Windows Update Breaks Boot URL: https://limehawk.io/reports/bitlocker-recovery-key-lookup Published: 2026-02-13 Author: Corey Watson, Managing Member Topic: Security Read time: 5 min How to find your BitLocker recovery key after a Windows update breaks boot: Microsoft account, Entra, or the copy your IT team escrowed. There is no bypass. - Time to fix: 22 minutes from ticket open to recovery key delivered ## Incident A nonprofit opened a ticket on behalf of a new hire with a four-month-old HP laptop: "She was trying to connect her scanner and an error message came up and she has had zero success with any of the options. We forced a shut down and the same blue screen popped up. We tried to reset the PC and when the key came up, the only number she was able to enter was 4 and then it wouldn't let her do anything else." Translation from user-speak: the laptop hit the BitLocker recovery screen. That screen asks for a 48-digit numeric recovery key and until you type it in, the machine will not boot into Windows. No reset, no safe mode, no getting to the desktop. The "only number she was able to enter was 4" comment is because she was trying to pick menu options on a screen that actually wants a 48-digit key. ## How do I find my BitLocker recovery key after a Windows update? You need the 48-digit key. Check the Microsoft account used when BitLocker was turned on (aka.ms/myrecoverykey), then Entra ID / Azure AD if the PC is work-joined, then whoever manages the fleet if they escrowed keys. Nothing else unlocks the drive. If the key was never saved, the data stays locked. ## What triggered it This was a known issue at the time. Microsoft had pushed a January Windows update that was causing Windows 11 machines to boot into BitLocker recovery on the next restart. BleepingComputer had an article up that week tracking the investigation. The update changed something low enough in the boot chain that BitLocker's TPM measurements no longer matched, and BitLocker did exactly what it's designed to do: assume tampering and demand the recovery key. This is not a failure of BitLocker, it's BitLocker working correctly. The question is never "how do I bypass it." The question is "where is my recovery key." ## Why the recovery key is everything Without the recovery key, a BitLockered drive is encrypted ciphertext. You can pull it, image it, boot from USB. None of that matters. The key is the only way in. If the TPM-sealed key is invalidated (which is what happens when boot measurements change) you fall back to the numeric recovery key, and if you don't have THAT, the data is gone. Microsoft gives you several escrow options: Active Directory, Entra ID, a Microsoft account, a printout, a file saved somewhere other than the encrypted drive. The one thing you cannot do is save it only on the drive you just encrypted. ## How we found it We keep BitLocker recovery keys escrowed to our RMM as part of every encryption rollout under our [managed IT service](/services/managed-it-security). It's the first thing we set up before enabling BitLocker on any fleet. The key for this laptop was sitting in our system tagged to the machine's hostname. Lookup took about 20 seconds: 1. Open the asset record in our RMM for the affected laptop 2. Pull the BitLocker recovery key from the secure field 3. Paste the full 48-digit key into the ticket reply 4. User types it at the BitLocker recovery prompt 5. Windows unlocks and boots normally Total time from ticket open to key delivered was about 22 minutes, most of which was identifying the laptop. She entered the key, Windows came up, back at her desktop the same day. ## Why we had it and most shops don't This is the part worth dwelling on. The only reason that recovery took 22 minutes instead of 22 hours (or "we're sorry, her data is gone") is that the key was escrowed before it was needed. Too many environments we inherit have BitLocker enabled but no key escrow. The user turned it on during Windows setup, the key got saved to a Microsoft account nobody remembers the credentials for, and when the recovery screen shows up nobody knows what to do. What has to be true for this to work: encryption enablement centralized rather than left to the user or OOBE flow, keys escrowed automatically at the time of encryption, escrow storage tied to the device identity in your asset system so lookup takes seconds, and whoever's on the support desk able to retrieve the key without escalation. If any of those are missing, "where's the BitLocker key" becomes an archaeology project. We wrote a [separate piece on the automation side](/reports/bitlocker-encryption-automation). The punchline is you want the key BEFORE you need it, not after. ## Outcome Laptop was unlocked and back in normal use the same afternoon. The user replied two days later (her printer had gone offline, unrelated issue, resolved with a reinstall), and then: "Hi, all is resolved. Thanks!" No data loss. No reimage. No frantic call to HP about a four-month-old laptop. time to key delivery: ~22 minutes data preserved: 100% reimage avoided: yes cost: $0 (already escrowed) ## Key takeaways - the BitLocker recovery screen is not a failure mode, it's the encryption working correctly when the boot chain changes. - Windows updates have triggered this multiple times in the wild (including a widely reported Jan 2026 wave). - you need the 48-digit recovery key. Nothing else will unlock the drive. - if the key wasn't escrowed before the incident, the data is effectively gone. - centralize encryption rollout, automate key escrow, tag keys to asset records so retrieval is a 20-second lookup. - the difference between "back online in 20 minutes" and "we lost her laptop" is entirely a preparation problem. --- # QuickBooks Desktop Hangs on Intuit ID Login: The Send Forms Workaround URL: https://limehawk.io/reports/quickbooks-desktop-intuit-id-login-hang Published: 2025-12-30 Author: Corey Watson, Managing Member Topic: Software Read time: 5 min QuickBooks Desktop couldn't email invoices. The Intuit ID login window hung forever. The fix wasn't a password. - Status: workaround deployed same day, SMTP cutover planned - Cost today: $0 - Time to fix: one hour of diagnostics, five minutes to explain the PDF flow ## Incident The ticket came in from the office manager at a small distribution business, forwarded on behalf of the owner who handles his own billing out of a separate property-management LLC. His message was straightforward: he could open QuickBooks, pull up the company file, create an invoice, print it, scan it. Everything worked. But the second he clicked "Email Invoice," QuickBooks threw up a dialog: ``` QuickBooks is unable to send your email as you are signed out of Intuit ID. Sign in to continue. ``` He clicked Sign In. The Intuit login window opened. And then it just sat there. No spinner progressing, no error, no timeout: hourglasses inside the window and nothing else. He tried his credentials. He tried the office manager's credentials. He restarted the machine. He went back and forth with her over email for ten days trying to guess at passwords. At one point he wrote, "You know you're dealing with the smartest, dumbest person you know." He wasn't dumb. The dialog was lying to him. No password was going to work because the auth handshake never reached Intuit's servers. He was leaving for vacation in a few days and still had invoices to send. ## Diagnostics We signed in as him on his workstation and reproduced the hang immediately. Click Email Invoice, Intuit ID window opens, hourglasses, nothing. No error in the event log, no firewall block, no proxy in the way. This matches a documented Intuit support thread where the embedded login window in QuickBooks Desktop hangs indefinitely. It's a known product issue, not a misconfiguration, and it hits [accounting firms](/industries/accounting-firms) and anyone else who bills out of QuickBooks Desktop the hardest — invoice day doesn't wait for Intuit. Intuit's own community forum has threads going back months from current-version users hitting the same hourglass. Connected-services auth flows for QuickBooks Desktop have been flaky for a while, and the client-side behavior when the handshake stalls is the indefinite hang: no error surface, no timeout, just the spinner forever. As a side note: QuickBooks Desktop 2020 and earlier are past end-of-life (May 31, 2023), and for those versions Intuit has fully deprecated connected-services auth, meaning the same hang is permanent and the only path is an upgrade. This client was on a current version, so their problem is the product bug, not EOL. ## Why the password-hunting didn't work The email thread was full of hopeful guesses: try a different Intuit ID, try the office manager's password, did the dialog give you a place to put it. None of that could have worked. The hang wasn't an auth failure. It was a handshake that never completed. No credentials were ever transmitted because the client never got that far. The generic "repair your QB installation" / "reinstall" / "run QuickBooks Tool Hub" advice scattered across forums is also a long shot. People report all three fixing it, not fixing it, making it worse, or making it different. It's not a deterministic bug. ## Resolution We laid out three options with the office manager and the owner: 1. Route invoice email through our business mail server via SMTP. QuickBooks Desktop's Send Forms feature supports SMTP as an alternative to the Intuit-mediated "Email Invoice" flow. Configure the SMTP credentials in Edit → Preferences → Send Forms, and invoices send as normal email over your own mail server, no Intuit ID required. The mailbox and relay are an add-on we scope on a call. 2. Export each invoice as PDF and email it from Gmail directly. Zero cost, works immediately, fine for a business sending a handful of invoices a week. 3. Keep QuickBooks on paid in-version updates through the product cycle and wait for Intuit to ship a fix. Not a real plan for an owner with invoices due tomorrow. For the immediate "I'm leaving for vacation" pressure, option 2 cleared all pending billing the same day. The office manager is evaluating option 1 for the longer-term fix. ## Why routing through your own smtp works QuickBooks' Send Forms has two transport modes: Webmail (Intuit-mediated, requires Intuit ID auth through the embedded browser) and Outlook / SMTP (direct handoff to an SMTP server you configure). The SMTP path doesn't touch Intuit's auth infrastructure at all. QuickBooks composes a MIME message and delivers it through whatever mail server you point it at. The broken component is sidestepped entirely. That's also why this workaround holds up for EOL versions of QuickBooks Desktop where Intuit has pulled the plug on connected services. If you can get SMTP configured, email keeps working regardless of what Intuit decides to turn off next. ## Outcome The owner got all his December invoices out through Gmail before leaving town. No more hang, no more dialog. On the way out the door he told us the PDF-and-Gmail flow took him maybe two minutes per invoice, which he was fine with for now. The office manager is queueing up SMTP configuration and a broader Send Forms review for Q1. ## Key takeaways - an Intuit ID login window that hangs with hourglasses and no error is almost always the documented client-side handshake bug, not a password problem. - SMTP in QuickBooks Send Forms bypasses Intuit's auth entirely. Works on current versions and on EOL versions where connected services are permanently gone. - when a user is hunting for a password because the login window won't respond, the problem is never the password. - "repair QB install" and Tool Hub are not reliable fixes for this specific hang. Go straight to SMTP or PDF-and-email. --- # BEC Wire Fraud at a Title Company: How We Stopped a High-Six-Figure Loss URL: https://limehawk.io/reports/bec-wire-fraud-incident-response Published: 2025-12-23 Author: Corey Watson, Managing Member Topic: Security Read time: 6 min An attacker got into a closer's inbox and sent fraudulent wire instructions to the other side of a real estate deal. We contained it in under three hours. - Time to contain: ~2.5 hrs - Funds at risk: high six fig. - Funds lost: $0 - Root cause: inherited optional 2SV > **TL;DR** Attacker took over a title closer's Google Workspace inbox on a tenant we inherited from a prior admin: users had 2SV enrolled, but tenant enforcement was still optional, so a clean-looking login got through on password alone. Sent fraudulent wire instructions on a real pending closing, then created mail-block rules to hide the thread from coworkers. We forced a password change, killed sessions, and (most importantly) phoned the counterparty out-of-band before the wire moved. Zero loss. Same day we locked the account, we flipped org-wide 2SV enforcement and started the rest of our managed security baseline — the controls that should have been on before anyone handed us the keys. > People are sending emails from her actual account changing wiring instructions, we need to shut down whoever it is. > — — Title company owner, on the call ## Incident The call came in from the managing owner of a title and escrow company. One of his closers had lost control of her inbox. Not a forgotten password, an active takeover. [Title companies](/industries/title-companies) are a prime BEC target. Every closing involves a large wire between parties coordinated by email. Own the closer's inbox on funding day and you own the wire. This attacker had done their homework: picked a real pending transaction, copied the closer's tone, and sent updated wire instructions to the opposite-side firm. A high-six-figure wire. The window to stop it was measured in hours. ## Timeline - 08:47 — Attacker sends benign-looking email referencing the pending closing, likely a probe to confirm the inbox is live. - 09:04 — Fraudulent wire instructions sent to the closing agent on the other side of the deal. - 11:02 — Attacker creates mail filters blocking incoming email from the firm's funding desk and another closer who would have spotted the fraud. - 11:30 — Legitimate user regains access; password forced change. - 12:03 — Secondary password change forced via backup code to kill any lingering session. The inbox-blocking rules are the part that matters operationally. BEC attackers don't send the fraud email and leave. They stay logged in, watching. When the counterparty replies with "confirming, are these new wire instructions real?" the attacker wants to intercept that reply and answer it themselves, while keeping coworkers from ever seeing the thread. Blocking the funding desk was how they tried to keep the scheme alive long enough for the wire to hit. ## Investigation Point of entry: weak password on a Google Workspace tenant we inherited with 2-Step Verification left optional by the prior admin. This was not Limehawk's security posture. The firm had stood up Workspace under a previous IT shop: users could enroll in 2SV, and some had, but the admin console never flipped org-wide enforcement. Under our managed baseline that toggle is on for every tenant we own — funding staff get hardware keys on top. We took the account over mid-stream; security-baseline cutover on this Workspace was not complete when the attacker walked in on password alone. How the gap works technically: Google only challenges for 2SV when the sign-in looks suspicious (new device, impossible travel, known-bad IP) unless tenant enforcement is on. The attacker's session did not trip those heuristics, so they authenticated with the password alone and never saw a challenge. 2SV "on" for the user is not the same as 2SV enforced at the tenant. Day-one job on any inherited Workspace: verify Security → 2SV → Enforcement is ON, not optional. Login activity traced to an IP in West Africa. Direct residential connection, no VPN, no Tor. Typical for this class of attacker. Not hiding, just fast. **What we ruled out (technical investigation)** We ruled out the worse possibilities one by one: - no malicious OAuth apps connected to the account - no app-specific passwords created - no email forwarding rules (they're usually the first thing an attacker sets) - no mail filters other than the two "block sender" rules - no malware or persistence on the endpoint (the laptop was clean) Credential-only compromise. No backdoor, no device implant, no session-token theft. Change the password, kill the sessions, attacker is locked out. ## Response Containment in order: 1. Force password change on the compromised account. 2. Terminate every active session via the admin console. This invalidates the attacker's cookie even if they had one. 3. Remove the two attacker-created block rules so internal email flowed again. 4. Review sent mail, identify every outbound message from the attacker, phone the recipient firm directly and tell them to disregard the fraudulent wire instructions. 5. Enroll the user in a password manager and seed a new high-entropy password. The call to the opposite-side closer was the highest-value action of the morning. Out-of-band, by phone, with the correct wire details. That's what stopped the loss. ## Why this worked (and why it almost didn't) The fix was mechanically simple because the compromise was mechanically simple. Password plus a prior-admin tenant left on optional 2SV is a single-factor account in practice, and single-factor accounts fall to credential stuffing and phishing constantly. We caught this fast because the owner called us immediately instead of trying to sort it out internally. Thirty more minutes and the wire would have been sent. Containment closed the incident. The same afternoon we finished what takeover should have finished first — applying Limehawk's managed security baseline to this Workspace, not leaving the inherited optional-2SV config in place: - 2-Step Verification enforced org-wide (Security > 2SV > Enforcement: ON) - hardware security keys mandated for funding staff - context-aware access blocking sign-ins from high-risk geographies - Advanced Protection enabled for executives - written rule: any wire-instruction change verified by phone to a number on file before funds move That is day-one baseline on every tenant we fully own. This one still had prior-admin settings when the attacker got lucky. It does not anymore. ## Outcome The attacker lost the account at 11:30. The wire was never sent. The counterparty had been warned. No funds lost. By the end of the afternoon the account was clean and the firm was back to normal business. Org-wide 2SV enforcement went on the same day; hardware-key rollout for funding staff finished over the following week. Inherited optional 2SV is closed — this tenant runs on our baseline now. ## Key takeaways - BEC attackers don't send one email and leave. They stay logged in and manipulate the inbox in real time. Look for block rules and filters during every compromise investigation. - 2SV enrolled is not 2SV enforced. Google only challenges on suspicious signals unless tenant enforcement is on — check that toggle on every Workspace you inherit, day one. - The phone call to the counterparty is the single most valuable thing you can do in the first hour of a BEC. Email them and the attacker can still intercept. - Every wire-instruction change should be verified out-of-band, by phone, to a number on file, before a penny moves. - Credential-only compromises can be fully remediated by password change plus session invalidation, but only if you're sure nothing else was touched. - Inherited tenants are not 'our' baseline until we apply it. Prior-admin optional 2SV is a takeover finding, not a managed-service default. --- # Automating CrushFTP Upgrades and In-Place Updates URL: https://limehawk.io/reports/crushftp-upgrade-automation Published: 2025-11-24 Author: Corey Watson, Managing Member Topic: Automation Read time: 7 min Shell scripts for major version upgrades and minor updates. Transform a complex manual process into a single, reliable command. ## Summary To streamline server maintenance, we built two bash scripts for CrushFTP management. The first automates in-place updates through the built-in API, ensuring servers stay patched with minimal downtime. The second handles major version upgrades (v10 to v11), migrating all critical settings, user data, SSH keys, jobs, and custom branding, turning a complex manual process into a single reliable command. ## The challenge Manually updating and upgrading CrushFTP servers is repetitive and error-prone. In-place updates mean logging into the admin console, clicking through prompts, waiting, verifying. Major version upgrades are worse: you're copying a dozen folders and config files by hand, uninstalling the old daemon, installing the new one, and hoping nothing got missed. Done across multiple environments, this is exactly the kind of task where one tired copy-paste turns into a 2 AM support ticket. The ask was simple: make both operations automated, consistent, and safe enough to run on a cron schedule. ## Solution: automation scripts Two distinct bash scripts: one for minor in-place updates, one for the v10 to v11 major version migration. Different goals, different designs. ## Script 1: automated in-place updates This one leverages the CrushFTP HTTPS API to check for and apply pending updates. Designed to run as a cron job so servers stay current with security patches and bug fixes automatically. Every action writes to /var/log/crushftp_update.log for later auditing. It hits /?command=checkForUpdate, parses the XML response, and only fires /?command=updateNow if an update is actually available. No unnecessary restarts, no noise when there's nothing to do. ```crushftp_update.sh #!/bin/bash LOG_FILE="/var/log/crushftp_update.log" CHECK_UPDATE_URL="https://127.0.0.1:443/?command=checkForUpdate" UPDATE_URL="https://127.0.0.1:443/?command=updateNow" USERNAME="crushadmin" PASSWORD="YOUR_SECRET_PASSWORD" # Use vault in production { echo "Starting CrushFTP update check..." echo "Date/Time: $(date)" RESPONSE=$(curl -k -s -u $USERNAME:$PASSWORD "$CHECK_UPDATE_URL") RESULT=$(echo "$RESPONSE" | grep -oPm1 "(?<=)[^<]+") if [ "$RESULT" == "true" ]; then echo "Update available! Updating..." UPDATE_RESPONSE=$(curl -k -s -u $USERNAME:$PASSWORD "$UPDATE_URL") UPDATE_RESULT=$(echo "$UPDATE_RESPONSE" | grep -oPm1 "(?<=)[^<]+") if [ "$UPDATE_RESULT" == "Success" ]; then echo "Update successfully completed!" else echo "Update process failed!" fi else echo "No updates available." fi echo "CrushFTP update check completed." } 2>&1 | tee -a "$LOG_FILE" ``` ## Script 2: major version upgrade (V10 to v11) The major upgrade is where manual work really bites. CrushFTP's data is scattered across a lot of folders: users, prefs.XML, SSH host keys, TempAccounts, Previews, logs, jobs (enterprise only), statsDB. Miss any of them and the new install comes up wrong in ways that aren't always obvious until a user tries to log in. This script handles the whole thing: downloads the new version, extracts it next to the old install, copies everything that matters, preserves the custom web interface logo, uninstalls the old daemon, installs the new one. ```crushftp_v10_to_v11_upgrade.sh #!/bin/bash # Define the old and new CrushFTP installation paths OLD_CRUSH_PATH="/var/opt/CrushFTP10" NEW_CRUSH_PATH="/var/opt/CrushFTP11" # Download the CrushFTP 11 package echo "Downloading CrushFTP 11..." wget https://www.crushftp.com/early11/CrushFTP11.zip -O "$NEW_CRUSH_PATH.zip" # Unzip the package echo "Unzipping CrushFTP 11 package..." unzip "$NEW_CRUSH_PATH.zip" -d /var/opt # Make the initialization script executable echo "Making initialization script executable..." chmod +x "$NEW_CRUSH_PATH/crushftp_init.sh" # Copy over the necessary files and folders echo "Copying users, prefs, keys, and other critical files..." cp -R "$OLD_CRUSH_PATH/users" "$NEW_CRUSH_PATH/" cp "$OLD_CRUSH_PATH/prefs.XML" "$NEW_CRUSH_PATH/" cp "$OLD_CRUSH_PATH/ssh_host_dsa_key" "$NEW_CRUSH_PATH/" cp "$OLD_CRUSH_PATH/ssh_host_rsa_key" "$NEW_CRUSH_PATH/" cp "$OLD_CRUSH_PATH/ssh_host_dsa_key.pub" "$NEW_CRUSH_PATH/" cp "$OLD_CRUSH_PATH/ssh_host_rsa_key.pub" "$NEW_CRUSH_PATH/" cp -R "$OLD_CRUSH_PATH/TempAccounts" "$NEW_CRUSH_PATH/" cp -R "$OLD_CRUSH_PATH/Previews" "$NEW_CRUSH_PATH/" cp -R "$OLD_CRUSH_PATH/logs" "$NEW_CRUSH_PATH/" cp -R "$OLD_CRUSH_PATH/jobs" "$NEW_CRUSH_PATH/" cp -R "$OLD_CRUSH_PATH/statsDB" "$NEW_CRUSH_PATH/" # Copy custom logo cp "$OLD_CRUSH_PATH/WebInterface/images/logo.png" "$NEW_CRUSH_PATH/WebInterface/images/" # Remove old version daemon launch references echo "Removing old version daemon launch references..." cd "$NEW_CRUSH_PATH" ./crushftp_init.sh uninstall # Install new version daemon launch references echo "Installing new version daemon launch references..." ./crushftp_init.sh install echo "CrushFTP 11 upgrade complete. Verify services are running." ``` ## Security considerations The in-place update script needs an admin username and password to talk to the API. We've sanitized the version shown here for public view. In production, never hardcode credentials directly. - use a secret store (HashiCorp Vault, AWS Secrets Manager, or environment variables injected at runtime) to supply the password - lock down script permissions with chmod 700 so only the owner can read or execute - license registration for the major upgrade is still a manual step after the script completes. That's intentional ## Outcome These scripts cut the time and effort to maintain CrushFTP infrastructure way down. What used to be a multi-step manual process is now a single reliable command that can be scheduled without worry. - drastically reduced manual effort for updates and upgrades - consistent application of updates across all environments - minimizes risk of human error during migrations (the copy step is where people always miss something) - cron-job compatibility means "set-and-forget" security patching If you have a server chore like this that eats an evening every quarter, it's exactly the kind of thing our [automation and scripting](/services/ai-automation) projects turn into a cron job. --- # Front App Keeps Sending Emails to Spam: Why Whitelisting in Front Doesn't Work URL: https://limehawk.io/reports/front-app-gmail-spam-sender-whitelist Published: 2025-10-31 Author: Corey Watson, Managing Member Topic: Software Read time: 6 min Wire confirmations kept landing in Front's Spam folder. Not Spam clears one message; the durable allow list lives in Gmail, where classification happens. - Time to fix: 5 minutes once we found the right layer ## Incident A [title company's](/industries/title-companies) funding department opened a ticket: "In Front we are having an issue with it marking all of the wire confirmations that come from [a regional bank] as Spam. It does not matter if it is an incoming or outgoing wire or the amount. On each wire we are setting them to 'Unblock Sender' and 'Not Spam' but it will not help the issue." Every confirmation landed back in Spam. Wire confirmations landing in Spam is not a small annoyance for a funding department. These are the emails that tell them a several-hundred-thousand-dollar wire actually went through. If one gets missed, a closing gets delayed. If one gets missed the wrong way, it becomes a fraud-risk conversation. The funding manager flagged a useful breadcrumb: "I know there was an adjustment made when we had an issue with the wires with [a different bank] so not sure if that is somehow related." Same pattern, different bank. ## Diagnostics Front is a shared-inbox client on top of a real mailbox (here, Gmail). Spam decisions for that stack happen at the gateway and at Gmail — Front mostly displays the result. So the path was layer-by-layer, not "poke Front until it sticks." First check: third-party mail hygiene in front of the mailbox. Clean. Messages were passing spam filtering at the MX layer. Not a gateway problem. The funding team had already been hitting "Unblock Sender" and "Not Spam" in Front on every confirmation; those only reclassify that one message. We confirmed Front contacts, account records, and Front-side allowlisting for the bank's sender as well — those own Front metadata and routing, not Gmail's spam score. A test confirmation looked fine for about 48 hours, then the next one dropped back into Spam the same way. That pattern is the tell: Front was reflecting a classification made underneath it. The durable rule had to live on the mailbox Gmail was scoring against. ## How Front actually works with gmail Here's the piece that catches people. Front is not a mail server. It's a shared-inbox client that syncs against an underlying mailbox: Gmail, Microsoft 365, whatever. Mail hits Gmail first, Gmail applies its filters and folders, and Front displays whatever Gmail did. Front's "Spam" folder in the UI is literally Gmail's Spam label. So when you click "Not Spam" in Front, Front tells Gmail to remove the Spam label from that one message. Good. But Front has no mechanism to tell Gmail "never send mail from this sender to Spam in the first place." That rule lives in Gmail itself, in user filters or Google Workspace policies. Front's "Unblock Sender" is a Front-level concept that doesn't translate down to the Gmail filter layer. Single-message "Not Spam" (in Front or Gmail) only clears that copy; Gmail still scores the next arrival on its own and can put it right back in Spam. We opened a support ticket with Front to confirm there wasn't a platform-side allowlist outside that model. Front confirmed the diagnosis: Gmail classification, not something their product overrides. ## Resolution We added a Gmail filter on the specific mailbox Front was syncing to. The filter matched the bank's sender address and applied the "Never send to Spam" action: 1. Log into the Gmail account that Front is syncing (the actual underlying mailbox, not Front) 2. Settings → Filters and Blocked Addresses → Create a new filter 3. From: the exact sender address from the wire confirmations 4. Next → Check "Never send it to Spam" 5. Save That's it. One filter, one mailbox, five minutes of work. The rule runs at Gmail's classification layer, which means by the time Front syncs the message, Gmail has already decided it's not spam, and Front displays it in the normal inbox. ## Why in-front whitelisting doesn't stick Worth spelling out because this is the exact trap. Front syncs against an external mailbox. It doesn't own the filtering rules. Clicking "Not Spam" reclassifies one message, not the sender. Clicking "Unblock Sender" is a Front-level policy invisible to Gmail. Front contacts and "accounts" affect Front's own metadata, not Gmail classification. Gmail keeps scoring each new inbound independently and keeps flagging the sender. The fix has to happen where the classification happens: for Gmail-backed Front, a Gmail filter; for M365-backed Front, a mail flow rule or tenant allow-list. Knowing which layer owns the decision is most of what [Google Workspace and Microsoft 365 administration](/services/cloud-solutions) actually is. ## Outcome After the Gmail filter went in, the funding manager sent two more wires that week. Both confirmations landed in the normal Front inbox. A few days later: "We have been getting the confirmations in Front again for that bank again and not in the Spam folder. I think the issue has been resolved." While we were in that mailbox, we also ran real unsubscribe links on legitimate marketing that had piled up in Spam — not the scammy ones. time to fix: 5 minutes mailboxes touched: 1 wires recovered from Spam during troubleshooting: several bonus marketing unsubscribes while cleaning up: a bunch ## Key takeaways - Front is a client on top of Gmail (or M365), not its own mail server. - Front's "Not Spam" and "Unblock Sender" don't propagate to the underlying mailbox's filters. - if mail is landing in Spam persistently, the fix has to happen at the mailbox layer, not Front. - for Gmail: create a filter with "Never send to Spam" against the specific sender. - for M365: use a mail flow rule or tenant allow-list. - when a user reports "Front is doing X," check whether Front is actually doing X or just displaying what Gmail/M365 did. --- # AT&T Fiber Keeps Dropping? How We Proved the ISP Was the Problem URL: https://limehawk.io/reports/intermittent-att-modem-fiber-flap Published: 2025-10-21 Author: Corey Watson, Managing Member Topic: Infrastructure Read time: 6 min An insurance agency's remote desktop kept reconnecting all day. AT&T blamed our equipment. External ping monitors proved them wrong. - Time to fix: ~5 weeks, most of it spent proving the ISP wrong ## Incident The ticket opened with one of the staff at an East Tennessee [insurance agency](/industries/insurance-agencies) saying the remote desktop kept "reconnecting and disconnecting" all morning. Within an hour a second employee chimed in. Within a day a third. Their agency management system and the twenty or so carrier portals they work out of were either spinning forever, throwing server errors, or timing out mid-page. Not slow all the time. Intermittent. Fine for ten minutes, then nothing for two, then fine again. Intermittent is the worst word in networking. It's the reason this ticket took five weeks to close instead of an afternoon. ## First pass: eliminating the obvious AT&T pushed a modem swap on day one. Old modem out, supposedly more reliable model in, two hours of calm, then the flapping came back. We worked through the usual suspects: - rebooted the firewall while the user rebooted their workstation - replaced the Ethernet cable between the new modem and the firewall - ran a script on affected workstations to disable their wifi adapters. Two had wifi enabled alongside the cable and we didn't want them falling onto a weaker path when the wired connection hiccuped - confirmed no firewall config changes in the last thirty days None of it stopped the disconnects. The firewall was still reporting packet loss to the gateway. The flaps were real and on the WAN side of our equipment. ## How we proved it was the ISP AT&T's first move when you tell them their fiber is unstable is to blame your equipment. The on-site tech (who eventually lost his composure on the phone and had to be escalated past) kept insisting the firewall was the problem. Fine. The way you win that argument is to take your equipment out of the picture entirely. We set up two independent external ping monitors from cloud hosts outside the client's network, each targeting the modem's public IP directly. Not the firewall behind it. The modem itself. If AT&T's circuit was clean the pings would be flat. If it was flapping, both monitors would show identical timeouts at identical moments, regardless of anything inside the office. ``` # one monitor pinging the public modem IP from a cloud host # the other pinging from a different provider's network, for independence # sample timeout windows from the 24-hour capture: 09:14:22 request timed out 09:14:23 request timed out 09:14:24 request timed out 09:14:25 58 ms ... 11:47:02 request timed out 11:47:03 request timed out 11:47:04 163 ms (jitter spike) ``` Both monitors showed the same pattern: random multi-second gaps and latency spikes throughout the day, correlated across both external sources. That's not possible if the problem is inside the client's firewall. The firewall isn't in the path from a cloud host to the modem's WAN interface. If both external monitors see loss, the circuit is losing packets before it reaches the firewall. We sent the graphs to AT&T. That changed the conversation. ## What we did while AT&T fixed their circuit ISP repair timelines are what they are. The agency still had to sell insurance. The site had a secondary cable circuit sitting mostly idle as backup. We reconfigured the firewall to run the two circuits in load-balancing mode instead of primary/failover: both WAN links up simultaneously, outbound sessions distributed across both, TCP reconnects landing on whichever link was healthy at the moment of retry. Load-balancing doesn't eliminate the pain of a flapping circuit (an existing TCP session doesn't get teleported to the other link when its path dies), but it smooths the experience. New connections route around the bad link immediately. Over a workday of clicking between carrier portals, that's the difference between "the internet is broken" and "that page loaded a little slow." ## Monitoring what the users actually use Staff also couldn't tell the difference between "the agency system is down" and "our internet is down." A SaaS throwing a 500 error looks identical to a network dropout from the user's seat. Alongside the circuit evidence, we listed the sites they live in every day (agency management system, a dozen carrier portals, premium finance, payments) and stood up external uptime monitors for each. Next time the office yells "the internet is broken," we can look at a dashboard and tell them in thirty seconds whether it's the internet, a carrier, or something in between. ## Outcome Once the ping-monitor evidence went up the AT&T chain, the case got reassigned away from the original on-site tech and handled at a higher tier. Over the next two weeks the circuit stabilized. We left the load-balanced WAN config in place (no downside to running both circuits hot), and the uptime monitors stayed up as part of the [network infrastructure we manage](/services/network-infrastructure) for the agency. Follow-up from the client a few weeks later: "Everything has been running smoothly on my end, and there are no issues encountered so far. Looks like the network is much more stable now." time to fix: ~5 weeks end-to-end evidence that moved the ISP: two independent external ping monitors uptime improvements kept in place: dual-WAN load balancing, carrier-portal status monitors ## Key takeaways - "intermittent" network issues are the hardest to escalate because the ISP can always claim it was working when they checked. Continuous external ping monitors capture the evidence they can't dismiss. - ping the modem's public IP from outside the network, not the firewall. That eliminates your own equipment from the argument before they can raise it. - when one circuit is unstable, load-balancing a second circuit beats passive failover. Failover only helps during a full outage; load-balancing smooths over flaps. - users can't tell the difference between "our internet" and "their SaaS." External status monitors for the apps they actually live in let you answer "is it us or them?" in thirty seconds instead of chasing the wrong path. - when an on-site tech starts blaming your equipment and getting hostile, escalate past them. The case moves faster once it's out of their hands. --- # Automated Proxmox Host Configuration Backups to SMB Share URL: https://limehawk.io/reports/proxmox-host-config-backup-automation Published: 2025-01-31 Author: Corey Watson, Managing Member Topic: Infrastructure Read time: 5 min Deployed automated monthly backup solution for Proxmox VE host configurations using bash script and cron.monthly. ## Summary Rolled out automated monthly backups of Proxmox VE host configuration to a centralized SMB share across a multi-node cluster. Bash script in cron.monthly, tar.gz archives of the directories that actually matter when a host dies, 6 archives of retention per directory, automatic cleanup. Because if you can't rebuild the host, you can't restore the VMs running on it. ## The challenge Proxmox has excellent built-in backup for VMs and containers. Host-level config is the part everyone forgets until a node dies and they're staring at a fresh install trying to remember whether the bond was LACP or active-backup, or what the SMB credentials for the backup NAS were. The stuff you actually need to rebuild a Proxmox host includes: - network config: bridges, bonds, VLANs - custom scripts and tooling in /root and /opt - cluster certificates and membership data - storage mount points and their credentials - any /etc customizations: sysctl tweaks, systemd units, cron entries Without host-level backups, rebuilding a failed node means reconstructing all of that from memory or from documentation that's probably out of date. Neither is fast, neither is reliable, and both happen at 2 AM. ## Solution architecture Bash script in /etc/cron.monthly/ runs on the first of every month, tars up the critical directories into timestamped archives, and drops them on an SMB share. Old archives get pruned automatically so storage stays bounded. - monthly execution via cron.monthly - central SMB share reachable by every Proxmox host - timestamped archives for point-in-time recovery - keep the last 6 per directory, delete the rest - logs everything to /var/log/proxmox_config_backup.log - pre-flight check bails out if the SMB share isn't mounted instead of silently dumping backups to local disk ## Directories backed up - /etc: network config, storage mounts, services, sysctl, systemd, everything else - /root, admin home: custom scripts, SSH keys, tooling - /opt, third-party: monitoring agents, custom apps - /var/lib/pve-cluster, cluster database: VM configs, resource pools, user permissions, datacenter settings, cluster membership ## The script Placed in /etc/cron.monthly/ so cron runs it on the first of each month without anything extra in crontab. ```proxmox_config_to_nas #!/bin/bash # Define backup location (Proxmox SMB mount point) SMB_FOLDER="/mnt/pve/proxmox_hosts" LOG_FILE="/var/log/proxmox_config_backup.log" TIMESTAMP=$(date +'%Y-%m-%d_%I-%M-%S%p') HOSTNAME=$(hostname) # Directories to back up (Absolute Paths) DIRS_TO_BACKUP=( "/etc" "/root" "/opt" "/var/lib/pve-cluster" ) # Ensure SMB mount is available before proceeding if ! mount | grep -q "$SMB_FOLDER"; then echo "$(date +'%Y-%m-%d %H:%M:%S') - ERROR: SMB Share ${SMB_FOLDER} not mounted!" | tee -a "${LOG_FILE}" exit 1 fi echo "$(date +'%Y-%m-%d %H:%M:%S') - Starting Proxmox config backup for ${HOSTNAME}..." | tee -a "${LOG_FILE}" # Backup each directory for dir in "${DIRS_TO_BACKUP[@]}"; do if [ -d "$dir" ]; then BACKUP_FILE="${SMB_FOLDER}/${HOSTNAME}_$(basename $dir)_${TIMESTAMP}.tgz" echo "$(date +'%Y-%m-%d %H:%M:%S') - Backing up ${dir} to ${BACKUP_FILE}" | tee -a "${LOG_FILE}" tar --ignore-failed-read -czf "${BACKUP_FILE}" "${dir}" \ && echo "$(date +'%Y-%m-%d %H:%M:%S') - Backup successful: ${BACKUP_FILE}" | tee -a "${LOG_FILE}" \ || echo "$(date +'%Y-%m-%d %H:%M:%S') - ERROR: Failed to backup ${dir}" | tee -a "${LOG_FILE}" else echo "$(date +'%Y-%m-%d %H:%M:%S') - INFO: Skipping ${dir} (not found)" | tee -a "${LOG_FILE}" fi done # Cleanup: Keep only the last 6 backups per directory echo "$(date +'%Y-%m-%d %H:%M:%S') - Cleaning up old backups..." | tee -a "${LOG_FILE}" for dir in "${DIRS_TO_BACKUP[@]}"; do BACKUP_PREFIX="${SMB_FOLDER}/${HOSTNAME}_$(basename $dir)_" ls -t ${BACKUP_PREFIX}*.tgz | tail -n +7 | xargs rm -f 2>/dev/null echo "$(date +'%Y-%m-%d %H:%M:%S') - Cleanup done for ${dir}" | tee -a "${LOG_FILE}" done echo "$(date +'%Y-%m-%d %H:%M:%S') - Backup completed successfully for ${HOSTNAME}." | tee -a "${LOG_FILE}" ``` Key bits: - pre-flight check: if the SMB share isn't mounted, it logs an error and exits instead of silently dumping backups to local disk - `--ignore-failed-read`: keeps tar going if it hits something it can't read (open files, sockets) instead of aborting the whole archive - cleanup loop uses `ls -t | tail -n +7` to keep the 6 most recent per directory and delete the rest ## File naming Format is {hostname}_{directory}_{timestamp}.tgz. From a single host you end up with: - proxmox01_etc_2025-01-28_11-06-13PM.tgz - proxmox01_root_2025-01-28_11-06-13PM.tgz - proxmox01_opt_2025-01-28_11-06-13PM.tgz - proxmox01_pve-cluster_2025-01-28_11-06-13PM.tgz ## Implementation steps 1. Mount the SMB share persistently. Add to /etc/fstab with a credentials file (not a plaintext password in the fstab line). Create /root/.smbcredentials with permissions 600 containing username/password, then reference it with credentials=/root/.smbcredentials. Verify with ls /mnt/pve/proxmox_hosts. 2. Drop the script in /etc/cron.monthly/proxmox_config_to_nas and chmod +x it. 3. Run it manually once. Confirm archives appear on the share and /var/log/proxmox_config_backup.log shows the expected output. 4. If scripts in cron.monthly aren't firing on your install (it happens), add an explicit crontab entry: `0 2 1 * * root /etc/cron.monthly/proxmox_config_to_nas >/dev/null 2>&1`, then `systemctl restart cron`. ## Multi-host deployment The script uses $(hostname) everywhere the hostname appears, so it's host-agnostic. Deploy the exact same file to every Proxmox node. Backups land on the shared SMB target already namespaced by hostname, retention is managed per host, and nothing needs per-node customization. ## Outcome Every host in the cluster gets its config snapshotted monthly, with six months of retention, to one SMB share. Zero ongoing maintenance. If we lose a node, the rebuild path is: install Proxmox, restore /etc, /root, /opt, and /var/lib/pve-cluster from the latest archive, rejoin the cluster. The VMs (already backed up by Proxmox's built-in tooling) land back on the rebuilt host and come up. - zero ongoing maintenance, once per month log check in monitoring - rapid rebuild capability covering everything the Proxmox installer doesn't - same script across the cluster, no per-host customization - timestamped archives give you an audit trail and the ability to roll back a config change Host-level coverage is part of the backup standard in our [compliance and backup service](/services/compliance-backup). If you can't rebuild the host, your VM backups are theater. ## Key takeaways - VM backups are useless if you can't rebuild the host they run on. - network config and cluster data are what actually determine your RTO on a dead node. - simple bash + cron beats the complex backup tool you won't configure correctly. - timestamped archives double as change history when you're trying to figure out what someone "fixed" three weeks ago. --- # Automating Wildcard SSL Certificates on Synology NAS with acme.sh URL: https://limehawk.io/reports/synology-wildcard-ssl-automation Published: 2025-01-20 Author: Corey Watson, Managing Member Topic: Infrastructure Read time: 6 min How to run acme.sh on a Synology NAS: wildcard Let's Encrypt via Cloudflare DNS-01, then the synology_dsm hook so certs survive DSM updates. > **TL;DR** How to run acme.sh on a Synology NAS when DSM's Let's Encrypt tool cannot issue a wildcard and needs port 80 open. Install acme.sh in a home directory, issue a wildcard Let's Encrypt cert via Cloudflare DNS-01, then the synology_dsm deploy hook so certs survive DSM updates and renew into DSM. Nobody has clicked through Certificate Manager since. ## How do I install acme.sh on Synology? Install acme.sh in a home directory on the Synology NAS, not `/root/.acme.sh`. DSM's built-in Let's Encrypt tool cannot issue a wildcard and needs port 80 open. Use Cloudflare DNS-01 for the Let's Encrypt DNS challenge, then the `synology_dsm` deploy hook so the acme.sh certificate lands in DSM and survives DSM updates. ## Why can't Synology issue a wildcard Let's Encrypt certificate? DSM's built-in Let's Encrypt tool issues one public hostname over HTTP-01. It will not issue `*.int.example.com`, it will not validate a name that is not reachable from the internet, and it wants port 80 open to do the challenge. A Synology wildcard certificate needs an acme.sh client and a Let's Encrypt DNS challenge, not the built-in tool. The NAS hosts internal names: DSM itself, dashboards, a reverse proxy for a few services behind the firewall. Those names needed a trusted cert with no extra hole in the edge. Cutting a cert per name in DSM meant either exposing those names on 80/443 or living with browser warnings. So we stood up acme.sh as a [network infrastructure](/services/network-infrastructure) job. acme.sh is a shell ACME client. Cloudflare's DNS API writes the TXT record for the challenge. The `synology_dsm` deploy hook pushes the finished cert into DSM and restarts the services that use it. No Python runtime, no port 80, no clicking through Certificate Manager on renew day. ## Where should the acme.sh client live on a Synology NAS? Not in `/root/.acme.sh`. That is the default, and it is a bad place on a Synology: DSM updates have wiped it, and it does not back up with the rest of the admin home. Install acme.sh into a sysadmin home directory and split certs from config. ```install-acme.sh mkdir -p /var/services/homes/sysadmin/acme.sh/acme_certs mkdir -p /var/services/homes/sysadmin/acme.sh/acme_config curl https://get.acme.sh | sh -s email=admin@example.com \ --home /var/services/homes/sysadmin/acme.sh/acme_config \ --config-home /var/services/homes/sysadmin/acme.sh/acme_config \ --cert-home /var/services/homes/sysadmin/acme.sh/acme_certs ``` `--home` is the scripts. `--config-home` is account config and logs. `--cert-home` is the issued certs. The email registers the ACME account so Let's Encrypt has somewhere to send expiry mail. ## How do you test acme.sh --staging without hitting the rate limit? Run acme.sh `--staging` first. Production allows five certificates per registered domain per week. A fresh zone and a typo in the DNS plugin will burn that before the pipeline works. ```issue-staging.sh CF_Token="YOUR_CLOUDFLARE_API_TOKEN" \ CF_Email="admin@example.com" \ SYNO_USE_TEMP_ADMIN=1 \ /var/services/homes/sysadmin/acme.sh/acme_config/acme.sh \ --home /var/services/homes/sysadmin/acme.sh/acme_config \ --cert-home /var/services/homes/sysadmin/acme.sh/acme_certs \ --config-home /var/services/homes/sysadmin/acme.sh/acme_config \ --staging \ --issue \ -d '*.int.example.com' \ --dns dns_cf ``` `--staging` hits the untrusted Let's Encrypt staging directory. `--dns dns_cf` is the Cloudflare plugin. `CF_Token` and `CF_Email` are what the plugin uses to write the TXT record at `_acme-challenge.int.example.com`. `-d '*.int.example.com'` is the wildcard. Nothing on this NAS has to listen on port 80. ## How do you issue the production wildcard? Same acme.sh command. Drop `--staging` and point `--server` at the production directory. ```issue-prod.sh CF_Token="YOUR_CLOUDFLARE_API_TOKEN" \ CF_Email="admin@example.com" \ SYNO_USE_TEMP_ADMIN=1 \ /var/services/homes/sysadmin/acme.sh/acme_config/acme.sh \ --home /var/services/homes/sysadmin/acme.sh/acme_config \ --cert-home /var/services/homes/sysadmin/acme.sh/acme_certs \ --config-home /var/services/homes/sysadmin/acme.sh/acme_config \ --server https://acme-v02.api.letsencrypt.org/directory \ --issue \ -d '*.int.example.com' \ --dns dns_cf ``` Staging is `https://acme-staging-v02.api.letsencrypt.org/directory`. Production is `https://acme-v02.api.letsencrypt.org/directory`. Do not mix them. A staging cert will not be trusted in a browser. That is the point of the test. ## How do you automate SSL renewal with the synology_dsm deploy hook? One acme.sh command, root, in Task Scheduler. acme.sh only renews when the cert has fewer than 30 days left, so a daily run is a no-op most mornings. On the morning it matters, `--renew` plus `--deploy-hook synology_dsm` installs the new cert and restarts the services that use it. ```renew-deploy.sh CF_Token="YOUR_CLOUDFLARE_API_TOKEN" \ CF_Email="admin@example.com" \ SYNO_USE_TEMP_ADMIN=1 \ /var/services/homes/sysadmin/acme.sh/acme_config/acme.sh \ --home /var/services/homes/sysadmin/acme.sh/acme_config \ --cert-home /var/services/homes/sysadmin/acme.sh/acme_certs \ --config-home /var/services/homes/sysadmin/acme.sh/acme_config \ --renew \ --server https://acme-v02.api.letsencrypt.org/directory \ --deploy \ --deploy-hook synology_dsm \ -d '*.int.example.com' ``` In DSM: Control Panel > Task Scheduler > Create > Scheduled Task > User-defined script. The user has to be root or the DSM deploy fails. Daily at 3:00 AM is fine. ## How does SYNO_USE_TEMP_ADMIN work with the acme.sh Synology hook? `SYNO_USE_TEMP_ADMIN=1`. The `synology_dsm` deploy hook cannot log in as a 2FA admin, so it creates a temporary admin, pushes the cert, and deletes that account. It exists for those few seconds of the deploy, not as a standing user. Create a scoped Cloudflare API token, not a Global API Key. Permission is Zone > DNS > Edit, locked to the one zone. acme.sh writes the token into `acme_config/account.conf` and sets the file to 600. Back that directory up with the rest of the home share. ## Outcome One wildcard Let's Encrypt cert covers the internal names on the Synology NAS. Port 80 stays closed. acme.sh renewals run at 3 AM, the `synology_dsm` hook lands them in DSM, and services pick up the new cert. The last time anyone clicked through DSM Certificate Manager on this NAS was the day we set this up. Running IT in-house and want a second set of hands on jobs like this? That's [co-managed IT](/industries/co-managed-it): your person stays, and we cover after hours, overflow, and the tooling. ## Key takeaways - DSM's Let's Encrypt is HTTP-01 only. A Synology wildcard certificate and names that are not on the public internet need a DNS challenge. - Do not install acme.sh in /root on a Synology NAS. Put the acme.sh client in a home directory with certs and config split so it survives DSM updates and backups. - Always issue acme.sh --staging first. Production is five certificates per registered domain per week. - The synology_dsm deploy hook plus SYNO_USE_TEMP_ADMIN is how acme.sh deploys when the real admin account has 2FA. It creates a temp admin, pushes the cert, and deletes the account. - Use a scoped Cloudflare token (Zone, DNS, Edit, one zone), not a Global API Key. - One Task Scheduler job as root does acme.sh --renew and --deploy-hook synology_dsm to automate SSL renewal. Most mornings it is a no-op. --- # Lenovo ThinkBook Camera Stuck in DFU Mode After Update URL: https://limehawk.io/reports/lenovo-camera-dfu-fix Published: 2025-01-15 Author: Corey Watson, Managing Member Topic: Infrastructure Read time: 8 min A Camera DFU Device in Device Manager means the webcam is stuck in firmware update mode, usually after a Windows update. Here is the remote fix. - Resolution time: under 10 minutes ## What is a Camera DFU Device? A Camera DFU Device is not a missing driver. DFU means Device Firmware Update: the webcam microcontroller is sitting in bootloader mode, usually because a Windows update interrupted a firmware flash. Device Manager lists it as a generic WinUsb device instead of a camera. Until something pushes firmware or tells it to leave DFU, Teams and the Camera app will keep saying they cannot find the camera. ## Incident A client's Lenovo ThinkBook 16 G6 ABP (Ryzen 7 7730U, Windows 11 24H2) had a dead integrated camera after a Windows update. Teams couldn't find it, the Camera app showed "We can't find your camera," and Device Manager listed it under "Universal Serial Bus devices" as a generic "WinUsb Device" instead of under "Cameras." The user was overseas (about 20,000 miles away), so this had to be fixed in place over a remote session — no desk-side visit, no vendor firmware file, no manufacturer RMA. ## Symptoms Device Manager told most of the story: - listed under "USB devices", not "Cameras" - shows up as "WinUsb Device" - Hardware ID: USB\VID_1BCF&PID_0B1D (SunplusIT camera module) - driver: generic Windows USB driver, not the camera driver The camera's firmware hadn't been uninstalled and the hardware hadn't died. Something interrupted a firmware update during the Windows update process and the camera's controller was sitting in DFU (Device Firmware Update) bootloader mode, waiting for a firmware blob to be pushed to it. Until something either pushes new firmware or tells it to stop waiting, it will never enumerate as a camera again. ## Diagnostics Confirmed the state with PowerShell: ``` Get-PnpDevice | Where {$_.FriendlyName -like "*cam*"} | Select Status, Class, FriendlyName, InstanceId ``` Class came back as USB instead of Camera. That's the tell. Windows has correctly enumerated the device but it's identifying itself as a DFU bootloader, not a camera. The USB descriptor fields match what you'd expect: - USB Class: 0xFE (Application Specific) - SubClass: 0x01 (Device Firmware Update) - Protocol: 0x02 ## Tools required - dfu-util 0.11 Windows binaries from SourceForge: https://dfu-util.sourceforge.net/ - 7-Zip to extract the .tar.xz release archive - Zadig (zadig.akeo.ie) as a fallback for driver reassignment if dfu-util can't see the device dfu-util is an open-source DFU implementation. Nothing proprietary, no manufacturer firmware file. The whole binary is around 500KB. ## Recovery procedure After extracting dfu-util and dropping into its win64 directory, the sequence is: ``` # Navigate to dfu-util cd "Downloads\dfu-util-0.11-binaries\win64" # Verify working .\dfu-util.exe --version # List devices in DFU mode .\dfu-util.exe -l # Send exit command (primary method) .\dfu-util.exe -d 1bcf:0b1d -e # Alternate: exit with leave command .\dfu-util.exe -d 1bcf:0b1d -s :leave ``` Expected behavior: the WinUsb Device disappears from Device Manager for a few seconds as the controller reboots out of bootloader and back into its normal firmware, then reappears, this time as "Integrated Camera" under the Cameras section. The Windows Camera app sees it immediately. ## If dfu-util can't detect the device Sometimes Windows has the WinUSB driver bound in a way that dfu-util can't talk to. Zadig fixes this: 1. Download and run Zadig (zadig.akeo.ie) 2. Options > List All Devices 3. Find the SunplusIT camera in the dropdown 4. Select libusbK or libusb-win32 as the target driver 5. Click "Replace Driver" 6. Retry `dfu-util.exe -l` and confirm it now sees the device 7. Run the exit command ## Post-recovery Reset the Windows Camera app and force a device rescan so any UWP app that cached "no camera" clears out: ``` # Reset Windows Camera app Get-AppxPackage Microsoft.WindowsCamera | Reset-AppxPackage # Force hardware rescan pnputil /scan-devices ``` If something really stubborn is still broken, the nuclear option is `pnputil /remove-device "USB\VID_1BCF&PID_0B1D\01.00.00" /subtree /force` followed by `/scan-devices`, which forces Windows to rediscover the camera from scratch. ## Why this works DFU is a standardized USB protocol defined in the USB spec. It's not a SunplusIT thing or a Lenovo thing. When a USB device enters DFU mode, its bootloader is running in place of the normal firmware, and the bootloader exposes a standard command set: - upload new firmware - download current firmware - exit bootloader and boot the existing firmware Half the internet assumes "stuck in DFU" means "firmware is corrupted, you need a firmware file." That's not what was happening here. The firmware was intact; the bootloader just never got told to hand control over to it, because whatever update flow Windows kicked off got interrupted before it finished. The `-e` flag in dfu-util sends "exit/detach": "stop waiting, boot what you already have." No firmware files needed. No proprietary tools. No Lenovo support case. Just a standard USB DFU command that works on any DFU-compliant device. ## Outcome Ran over a remote session, total time from touching the keyboard to the Camera app showing a live feed was well under 10 minutes. No physical access, no firmware file, no manufacturer support, no replacement hardware. - hardware: Lenovo ThinkBook 16 G6 ABP (Ryzen 7 7730U) - camera: SunplusIT (VID 1BCF, PID 0B1D) - OS: Windows 11 24H2 - resolution time: under 10 minutes - physical access: not required - manufacturer support: not required A dead camera 20,000 miles from the office is just another ticket when the machine is already on the [help desk we run](/services/managed-it-security). Distance stops mattering once the tooling is in place. ## Key takeaways - understanding standard protocols (USB DFU) gets you vendor-independent fixes. - "firmware stuck in bootloader" is not the same as "firmware is corrupt". - remote hardware troubleshooting is absolutely doable with the right tools and USB-level access via a remote session. - open-source utilities routinely out-perform the vendor's own tooling, when the vendor even has tooling. --- # PC Replacement: Zero Data Loss User Profile Migration URL: https://limehawk.io/reports/usmt-profile-migration Published: 2024-12-18 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min CFO's laptop dying, needed everything moved to new machine. 47GB of documents, app settings, and browser data migrated in 90 minutes with USMT. ## Incident CFO's four-year-old laptop was throwing SMART warnings and random freezes, classic signs of an SSD about to give up. Replacement machine was already approved, ordered, and sitting on our bench. The actual job wasn't the hardware swap, it was getting four years of the CFO's working environment onto the new machine without losing a single thing. We're talking documents, browser bookmarks, Outlook signatures (with the embedded logo), app settings, Sticky Notes, everything she'd tuned to her workflow over four years. Executive-level user, zero tolerance for "I lost my files." ## The challenge A manual copy gets you the obvious stuff: Documents, Desktop, Pictures. What it misses is the stuff that actually makes a computer feel like yours: AppData\Local and AppData\Roaming, HKCU registry settings, browser profiles with saved logins and extensions, Outlook signatures (which live in AppData\Roaming\Microsoft\Signatures), custom dictionaries, pinned items. OneDrive sync only covers Documents and Desktop, and even that's inconsistent if selective sync was ever touched. - Documents: 28GB of files - Desktop: shortcuts and live files people forget aren't synced - AppData: app settings, Chrome profile, Teams cache - Outlook: custom signatures with embedded images - Sticky Notes: yes, executives live in these - registry: HKCU preferences across dozens of apps ## Solution Microsoft's User State Migration Tool. USMT has been the enterprise standard for this for years for good reason. It's built to capture exactly this kind of state. Two binaries: ScanState (capture) and LoadState (restore). XML config files define the scope. Migration store can be encrypted in transit with /encrypt and a password. Works across Windows versions, so a Win10 source to a Win11 target is a supported path. ```usmt_profile_migrate.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : USMT Profile Migration Tool v1.1.4 AUTHOR : Limehawk.io DATE : June 2026 USAGE : .\usmt_profile_migrate.ps1 ================================================================================ FILE : usmt_profile_migrate.ps1 DESCRIPTION : Interactive USMT backup/restore for remote terminal sessions -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Menu-driven USMT (User State Migration Tool) utility for backing up a Windows user profile on one machine and restoring it to another over a remote terminal session. Carries documents, settings, and application data (profile state) -- not installed applications. Restore can merge into an existing local account or create a brand-new one. USMT is auto-downloaded on first run. This is the single-machine / store-on-disk sibling of usmt_lan_migrate.ps1 (which moves the store across the LAN). DATA SOURCES & PRIORITY - User profiles: HKLM ProfileList registry (enumerated for backup/restore). - Migration stores: backup_info.json + USMT.MIG under the store base path. - All operator choices come from the interactive menu (no config inputs). REQUIRED INPUTS This tool is interactive -- there are no hardcoded operator inputs. The only hardcoded values are fixed endpoints/paths in the CONFIGURATION block: - $USMTx64URL / $USMTx86URL : USMT download mirrors (SuperGrate) - $USMTBasePath : where USMT is installed (C:\USMT) - $DefaultStorePath : default migration store base (C:\MigrationStore) SETTINGS - Backup options (interactive): Documents, Desktop, Downloads, Pictures, Music, Videos, Favorites, AppData (Roaming on by default, Local off), Printers, Wallpaper; compression, Volume Shadow Copy, continue-on-error, EFS handling, and optional password encryption. - Restore options (interactive): merge to existing account, or create a new local account (optionally Administrator); /mu source:target mapping. - USMT is auto-downloaded if absent. The encryption key is never persisted (backup_info.json records it only as the literal "[ENCRYPTED]" marker). BEHAVIOR 1. Detect admin rights; pre-flight kill any stale scanstate/loadstate. 2. Show a menu: backup, restore-to-existing, restore-to-new, view backups. 3. Backup: install USMT, pick a profile, choose options, run scanstate to produce USMT.MIG + backup_info.json under the store path. 4. Restore: install USMT, pick a store, pick/create the target account, run loadstate (with /mu mapping and /decrypt as needed). 5. On exit/cancel, force-kill any tracked scanstate/loadstate child. PREREQUISITES - Windows 10/11 or Windows Server, PowerShell 5.1 or later - Administrator privileges (scanstate, loadstate, account creation) - Internet connectivity on first run (USMT download) - Sufficient disk space for the migration store SECURITY NOTES - No secrets in logs: the encryption key and any new-account password are never printed and never written to backup_info.json (key stored only as the "[ENCRYPTED]" marker). - Restore into a profile is destructive; the tool prompts before running. ENDPOINTS - https://github.com/belowaverage-org/SuperGrate/raw/master/USMT/x64.zip - https://github.com/belowaverage-org/SuperGrate/raw/master/USMT/x86.zip EXIT CODES 0 = Success (or normal menu exit) 1 = Failure (error occurred) EXAMPLE RUN [INFO] LIMEHAWK USMT PROFILE MIGRATION TOOL ============================================================== Computer: OLDLT-01 User: admin Admin: Yes [INFO] MAIN MENU ============================================================== 1. Backup a user profile 2. Restore profile to EXISTING account (merge) 3. Restore profile to NEW account (create user) 4. View available backups 5. Exit [OK] BACKUP COMPLETE ============================================================== Location: C:\MigrationStore\OLDLT-01_jdoe_2026-06-23_101500 Size: 6.20 GB Source: OLDLT-01\jdoe -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-06-23 v1.1.4 Fix StrictMode regression class (lockstep with usmt_lan_migrate v1.2.1): .Count/indexing on Get-UserProfiles and Get-MigrationStores threw "property 'Count' cannot be found" on WinPS 5.1 with a single profile/store or none; wrap returns and call sites in @() 2026-06-23 v1.1.3 Parity-harden (lockstep with usmt_lan_migrate): track + force-kill scanstate/loadstate children on exit/cancel via Start-Tracked + per-wizard try-finally + Ctrl+C/exit handler (fixes orphan hang and scanstate code 29); pre-flight stale-process kill at startup 2026-06-23 v1.1.2 Fix Install-USMT: SuperGrate zip extracts flat (no amd64 subfolder); extract into arch subfolder, add recursive scanstate.exe fallback + contents listing on failure 2026-01-19 v1.1.1 Updated to two-line ASCII console output style 2026-01-08 v1.1.0 Added full USMT options, new account creation on restore 2026-01-08 v1.0.0 Initial release ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================== # CONFIGURATION # ============================================================================== $USMTx64URL = 'https://github.com/belowaverage-org/SuperGrate/raw/master/USMT/x64.zip' $USMTx86URL = 'https://github.com/belowaverage-org/SuperGrate/raw/master/USMT/x86.zip' $USMTBasePath = 'C:\USMT' $DefaultStorePath = 'C:\MigrationStore' # Child processes this run has launched (scanstate/loadstate), so a cancel or # exit can force-kill any survivor instead of orphaning it. $script:TrackedChildren = @() # Process names we own (no .exe) for stale detection and pre-flight cleanup. $script:MigrationProcNames = @('scanstate', 'loadstate') # ============================================================================== # HELPER FUNCTIONS # ============================================================================== function Write-Header { param([string]$Title) Write-Host "" Write-Host "[INFO] $Title" -ForegroundColor Cyan Write-Host "==============================================================" -ForegroundColor Cyan } function Write-Step { param([string]$Message) Write-Host "[RUN] $Message" -ForegroundColor Yellow } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Failure { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } function Write-Info { param([string]$Message) Write-Host " $Message" -ForegroundColor Gray } # ============================================================================== # CHILD-PROCESS LIFECYCLE # # scanstate / loadstate are long-running children. If they orphan on a cancel # they hold the remote terminal's console pipe open and keep USMT's single- # instance lock (next scanstate then throws code 29). Every child we launch is # tracked here; Stop-TrackedChildren force-kills any survivor and is called # from each wizard's finally block and from the Ctrl+C / exit handler. # ============================================================================== function Start-Tracked { param( [string]$FilePath, [object]$ArgumentList, # string or string[] [switch]$Wait ) $spParams = @{ FilePath = $FilePath PassThru = $true NoNewWindow = $true } if ($null -ne $ArgumentList) { $spParams.ArgumentList = $ArgumentList } if ($Wait) { $spParams.Wait = $true } $proc = Start-Process @spParams if ($proc) { $script:TrackedChildren += $proc } return $proc } function Stop-TrackedChildren { if (-not $script:TrackedChildren) { return } foreach ($proc in $script:TrackedChildren) { try { if ($proc -and -not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } } catch { } } $script:TrackedChildren = @() } function Stop-StaleMigrationProcesses { param([switch]$Force) $stale = Get-Process -Name $script:MigrationProcNames -ErrorAction SilentlyContinue if (-not $stale) { return } $names = ($stale | ForEach-Object { "$($_.ProcessName) (PID $($_.Id))" }) -join ', ' if ($Force) { Write-Host "[WARN] Killing stale migration process(es) before starting: $names" -ForegroundColor Yellow } else { Write-Host "[WARN] Found stale migration process(es) from a prior/cancelled run: $names" -ForegroundColor Yellow Write-Host "[WARN] Killing them so this run does not hit USMT lock (scanstate code 29)." -ForegroundColor Yellow } foreach ($p in $stale) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } Start-Sleep -Seconds 1 } function Format-FileSize { param([long]$Bytes) if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } return "$Bytes bytes" } function Get-FolderSize { param([string]$Path) $size = 0 if (Test-Path $Path) { $items = Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue if ($items) { $size = ($items | Measure-Object -Property Length -Sum).Sum } } if ($null -eq $size) { $size = 0 } return $size } function Get-UserProfiles { $RegKey = 'Registry::HKey_Local_Machine\Software\Microsoft\Windows NT\CurrentVersion\ProfileList\*' $profiles = @() Get-ItemProperty -Path $RegKey -ErrorAction SilentlyContinue | ForEach-Object { try { $SID = New-Object System.Security.Principal.SecurityIdentifier($_.PSChildName) $User = $SID.Translate([System.Security.Principal.NTAccount]).Value if ($User -notlike 'NT AUTHORITY\*' -and $User -notlike 'NT SERVICE\*') { $profilePath = $_.ProfileImagePath $profileSize = Get-FolderSize -Path $profilePath $profiles += [PSCustomObject]@{ Account = $User Path = $profilePath Size = $profileSize SizeFormatted = Format-FileSize $profileSize SID = $_.PSChildName } } } catch { } } # Force array semantics so a single-profile (one item) or zero result still # supports .Count / indexing under Set-StrictMode (WinPS 5.1 throws # "property 'Count' cannot be found" on a bare scalar). return @($profiles) } function Get-MigrationStores { param([string]$BasePath) $stores = @() if (-not (Test-Path $BasePath)) { return @($stores) } Get-ChildItem -Path $BasePath -Directory -ErrorAction SilentlyContinue | ForEach-Object { $storePath = $_.FullName $infoFile = Join-Path $storePath 'backup_info.json' $usmtFile = Join-Path $storePath 'USMT\USMT.MIG' # Check for USMT files (@() so a single .MIG match still has a .Count) $hasMigFiles = @(Get-ChildItem -Path $storePath -Filter '*.MIG' -ErrorAction SilentlyContinue).Count -gt 0 if ((Test-Path $infoFile) -or (Test-Path $usmtFile) -or $hasMigFiles) { $info = $null if (Test-Path $infoFile) { $info = Get-Content $infoFile -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json } $storeSize = Get-FolderSize -Path $storePath $stores += [PSCustomObject]@{ Name = $_.Name Path = $storePath Size = $storeSize SizeFormatted = Format-FileSize $storeSize SourceAccount = if ($info) { $info.SourceAccount } else { 'Unknown' } SourceComputer = if ($info) { $info.SourceComputer } else { 'Unknown' } BackupDate = if ($info) { $info.BackupDate } else { $_.CreationTime } Encrypted = if ($info) { $info.Encrypted } else { $false } Options = if ($info -and $info.Options) { $info.Options } else { $null } } } } # Force array semantics for the single-store / no-store cases (see above). return @($stores) } function Install-USMT { $OSArch = (Get-WmiObject Win32_OperatingSystem).OSArchitecture if ($OSArch -match '64') { $Arch = 'amd64' $URL = $USMTx64URL } else { $Arch = 'x86' $URL = $USMTx86URL } $script:USMTPath = Join-Path $USMTBasePath $Arch $ScanStateExe = Join-Path $script:USMTPath 'scanstate.exe' if (Test-Path $ScanStateExe) { Write-Success "USMT already installed at $script:USMTPath" return $script:USMTPath } Write-Step "Downloading USMT ($Arch)..." try { if (-not (Test-Path $USMTBasePath)) { New-Item -Path $USMTBasePath -ItemType Directory -Force | Out-Null } $zipPath = Join-Path $USMTBasePath "usmt_$Arch.zip" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $URL -OutFile $zipPath -UseBasicParsing Write-Step "Extracting USMT..." # SuperGrate's USMT zips extract FLAT (scanstate.exe, *.xml at the zip root, # no amd64/ subfolder). Extract INTO the arch subfolder so binaries and the # MigUser/MigDocs/MigApp XMLs all land under $script:USMTPath. Expand-Archive -Path $zipPath -DestinationPath $script:USMTPath -Force Remove-Item $zipPath -Force -ErrorAction SilentlyContinue # Defensive fallback: if scanstate.exe isn't where we expect (e.g. SuperGrate # changes the zip layout), locate it anywhere under the USMT base and point # $script:USMTPath at its parent so the XML paths resolve too. if (-not (Test-Path $ScanStateExe)) { $found = Get-ChildItem -Path $USMTBasePath -Filter 'scanstate.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($found) { $script:USMTPath = $found.Directory.FullName $ScanStateExe = $found.FullName } } if (-not (Test-Path $ScanStateExe)) { $extracted = (Get-ChildItem -Path $USMTBasePath -Recurse -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name) -join ', ' throw "scanstate.exe not found after extraction. Extracted contents under ${USMTBasePath}: $extracted" } Write-Success "USMT installed at $script:USMTPath" return $script:USMTPath } catch { Write-Failure "Failed to install USMT: $($_.Exception.Message)" return $null } } function New-LocalUser { param( [string]$Username, [string]$Password, [string]$FullName = '', [switch]$Admin ) try { $securePassword = ConvertTo-SecureString $Password -AsPlainText -Force # Check if user exists $existingUser = Get-LocalUser -Name $Username -ErrorAction SilentlyContinue if ($existingUser) { Write-Info "User $Username already exists" return $true } # Create user $params = @{ Name = $Username Password = $securePassword PasswordNeverExpires = $true UserMayNotChangePassword = $false } if (-not [string]::IsNullOrWhiteSpace($FullName)) { $params.FullName = $FullName } New-LocalUser @params | Out-Null Write-Success "Created local user: $Username" # Add to Administrators if requested if ($Admin) { Add-LocalGroupMember -Group 'Administrators' -Member $Username -ErrorAction SilentlyContinue Write-Info "Added $Username to Administrators group" } # User needs to log in once to create profile Write-Info "User must log in once to create profile before restore" return $true } catch { Write-Failure "Failed to create user: $($_.Exception.Message)" return $false } } function Show-BackupOptions { Write-Host "" Write-Host "[INFO] BACKUP OPTIONS" -ForegroundColor Cyan Write-Host "==============================================================" -ForegroundColor Cyan Write-Host "" $options = @{ IncludeDocuments = $true IncludeDesktop = $true IncludeDownloads = $true IncludePictures = $true IncludeMusic = $true IncludeVideos = $true IncludeFavorites = $true IncludeAppData = $true IncludeLocalAppData = $false IncludePrinters = $true IncludeWallpaper = $true UseCompression = $true UseVSC = $true ContinueOnError = $true EFSHandling = 'skip' EncryptionKey = '' } # Quick or Advanced Write-Host " 1. Quick backup (recommended settings)" -ForegroundColor White Write-Host " 2. Advanced options" -ForegroundColor White Write-Host "" $modeChoice = Read-Host " Select mode (1-2) [1]" if ($modeChoice -eq '2') { Write-Host "" Write-Host " What to include (y/n for each):" -ForegroundColor Yellow $response = Read-Host " Documents [$( if ($options.IncludeDocuments) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeDocuments = $false } $response = Read-Host " Desktop [$( if ($options.IncludeDesktop) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeDesktop = $false } $response = Read-Host " Downloads [$( if ($options.IncludeDownloads) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeDownloads = $false } $response = Read-Host " Pictures [$( if ($options.IncludePictures) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludePictures = $false } $response = Read-Host " Music [$( if ($options.IncludeMusic) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeMusic = $false } $response = Read-Host " Videos [$( if ($options.IncludeVideos) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeVideos = $false } $response = Read-Host " Favorites/Bookmarks [$( if ($options.IncludeFavorites) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeFavorites = $false } $response = Read-Host " AppData (Roaming) [$( if ($options.IncludeAppData) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeAppData = $false } $response = Read-Host " AppData (Local) - can be large [$( if ($options.IncludeLocalAppData) {'Y'} else {'n'} )]" if ($response -eq 'y' -or $response -eq 'Y') { $options.IncludeLocalAppData = $true } $response = Read-Host " Printers [$( if ($options.IncludePrinters) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludePrinters = $false } $response = Read-Host " Wallpaper [$( if ($options.IncludeWallpaper) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.IncludeWallpaper = $false } Write-Host "" Write-Host " Technical options:" -ForegroundColor Yellow $response = Read-Host " Use compression (smaller but slower) [$( if ($options.UseCompression) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.UseCompression = $false } $response = Read-Host " Use Volume Shadow Copy (backup locked files) [$( if ($options.UseVSC) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.UseVSC = $false } $response = Read-Host " Continue on errors [$( if ($options.ContinueOnError) {'Y'} else {'n'} )]" if ($response -eq 'n') { $options.ContinueOnError = $false } Write-Host "" Write-Host " EFS (Encrypted File System) handling:" -ForegroundColor Yellow Write-Host " 1. skip - Skip encrypted files (default)" Write-Host " 2. abort - Stop if encrypted files found" Write-Host " 3. decryptcopy - Decrypt and copy (requires access)" Write-Host " 4. copyraw - Copy encrypted (same user only)" $efsChoice = Read-Host " Select (1-4) [1]" switch ($efsChoice) { '2' { $options.EFSHandling = 'abort' } '3' { $options.EFSHandling = 'decryptcopy' } '4' { $options.EFSHandling = 'copyraw' } default { $options.EFSHandling = 'skip' } } } # Encryption (always ask) Write-Host "" $encryptChoice = Read-Host " Encrypt backup with password? (y/N)" if ($encryptChoice -eq 'y' -or $encryptChoice -eq 'Y') { $options.EncryptionKey = Read-Host " Enter encryption key" $confirmKey = Read-Host " Confirm encryption key" if ($options.EncryptionKey -ne $confirmKey) { Write-Failure "Keys don't match!" $options.EncryptionKey = '' } } return $options } function Start-ProfileBackup { param( [string]$USMTPath, [string]$SourceAccount, [string]$StorePath, [hashtable]$Options ) $ScanStateExe = Join-Path $USMTPath 'scanstate.exe' $scanArgs = @( "`"$StorePath`"" '/o' '/ue:*' "/ui:`"$SourceAccount`"" "/l:`"$StorePath\scan.log`"" "/progress:`"$StorePath\scan_progress.log`"" '/v:5' ) # VSC option if ($Options.UseVSC) { $scanArgs += '/vsc' } # Compression if (-not $Options.UseCompression) { $scanArgs += '/nocompress' } # Continue on error if ($Options.ContinueOnError) { $scanArgs += '/c' } # EFS handling if ($Options.EFSHandling -ne 'abort') { $scanArgs += "/efs:$($Options.EFSHandling)" } # Encryption if (-not [string]::IsNullOrWhiteSpace($Options.EncryptionKey)) { $scanArgs += '/encrypt' $scanArgs += "/key:`"$($Options.EncryptionKey)`"" } # Migration XMLs $scanArgs += "/i:`"$USMTPath\MigUser.xml`"" $scanArgs += "/i:`"$USMTPath\MigDocs.xml`"" if ($Options.IncludeAppData) { $scanArgs += "/i:`"$USMTPath\MigApp.xml`"" } $argString = $scanArgs -join ' ' Write-Step "Running scanstate.exe..." Write-Info "This may take several minutes depending on profile size." Write-Host "" $process = Start-Tracked -FilePath $ScanStateExe -ArgumentList $argString -Wait return $process.ExitCode } function Show-RestoreOptions { Write-Host "" Write-Host "[INFO] RESTORE OPTIONS" -ForegroundColor Cyan Write-Host "==============================================================" -ForegroundColor Cyan Write-Host "" $options = @{ ContinueOnError = $true CreateNewAccount = $false NewUsername = '' NewPassword = '' NewFullName = '' MakeAdmin = $false } Write-Host " 1. Quick restore (recommended settings)" -ForegroundColor White Write-Host " 2. Advanced options" -ForegroundColor White Write-Host "" $modeChoice = Read-Host " Select mode (1-2) [1]" if ($modeChoice -eq '2') { Write-Host "" $response = Read-Host " Continue on errors? (Y/n)" if ($response -eq 'n') { $options.ContinueOnError = $false } } return $options } function Start-ProfileRestore { param( [string]$USMTPath, [string]$StorePath, [string]$SourceAccount, [string]$TargetAccount, [string]$EncryptionKey, [hashtable]$Options ) $LoadStateExe = Join-Path $USMTPath 'loadstate.exe' $loadArgs = @( "`"$StorePath`"" "/l:`"$StorePath\load.log`"" "/progress:`"$StorePath\load_progress.log`"" '/v:5' ) if ($Options.ContinueOnError) { $loadArgs += '/c' } # Migration XMLs $loadArgs += "/i:`"$USMTPath\MigUser.xml`"" $loadArgs += "/i:`"$USMTPath\MigDocs.xml`"" $loadArgs += "/i:`"$USMTPath\MigApp.xml`"" # User mapping for merge if ($SourceAccount -ne $TargetAccount -and -not [string]::IsNullOrWhiteSpace($TargetAccount)) { $loadArgs += "/mu:`"$SourceAccount`":`"$TargetAccount`"" Write-Info "Merging: $SourceAccount -> $TargetAccount" } # Decryption if (-not [string]::IsNullOrWhiteSpace($EncryptionKey)) { $loadArgs += '/decrypt' $loadArgs += "/key:`"$EncryptionKey`"" } # Local account creation flags if ($Options.CreateNewAccount) { $loadArgs += '/lac' # Create local account if (-not [string]::IsNullOrWhiteSpace($Options.NewPassword)) { $loadArgs += "/lac:`"$($Options.NewPassword)`"" } $loadArgs += '/lae' # Enable local account } $argString = $loadArgs -join ' ' Write-Step "Running loadstate.exe..." Write-Info "This may take several minutes." Write-Host "" $process = Start-Tracked -FilePath $LoadStateExe -ArgumentList $argString -Wait return $process.ExitCode } # ============================================================================== # MAIN MENU # ============================================================================== function Show-MainMenu { Clear-Host Write-Host "" Write-Host "[INFO] LIMEHAWK USMT PROFILE MIGRATION TOOL" -ForegroundColor Cyan Write-Host "==============================================================" -ForegroundColor Cyan Write-Host " Computer: $($env:COMPUTERNAME)" -ForegroundColor Gray Write-Host " User: $($env:USERNAME)" -ForegroundColor Gray Write-Host " Admin: $( if ($script:IsAdmin) { 'Yes' } else { 'No (limited features)' } )" -ForegroundColor $(if ($script:IsAdmin) { 'Green' } else { 'Yellow' }) Write-Host "" Write-Host "[INFO] MAIN MENU" -ForegroundColor Cyan Write-Host "==============================================================" -ForegroundColor Cyan Write-Host " 1. Backup a user profile" Write-Host " 2. Restore profile to EXISTING account (merge)" Write-Host " 3. Restore profile to NEW account (create user)" Write-Host " 4. View available backups" Write-Host " 5. Exit" Write-Host "" } function Start-BackupWizard { try { Write-Header "BACKUP USER PROFILE" $USMTPath = Install-USMT if (-not $USMTPath) { Write-Failure "Cannot proceed without USMT" Read-Host "Press Enter to continue" return } Write-Host "" Write-Step "Scanning for user profiles..." $profiles = @(Get-UserProfiles) if ($profiles.Count -eq 0) { Write-Failure "No user profiles found" Read-Host "Press Enter to continue" return } Write-Host "" Write-Host " Available Profiles:" -ForegroundColor Yellow Write-Host " -------------------" for ($i = 0; $i -lt $profiles.Count; $i++) { $p = $profiles[$i] Write-Host " $($i + 1). $($p.Account)" -ForegroundColor White Write-Host " Path: $($p.Path)" -ForegroundColor Gray Write-Host " Size: ~$($p.SizeFormatted)" -ForegroundColor Gray } Write-Host "" $selection = Read-Host "Select profile to backup (1-$($profiles.Count))" $index = [int]$selection - 1 if ($index -lt 0 -or $index -ge $profiles.Count) { Write-Failure "Invalid selection" Read-Host "Press Enter to continue" return } $selectedProfile = $profiles[$index] Write-Success "Selected: $($selectedProfile.Account)" # Get backup options $options = Show-BackupOptions # Get destination Write-Host "" $storePath = Read-Host " Migration store path [$DefaultStorePath]" if ([string]::IsNullOrWhiteSpace($storePath)) { $storePath = $DefaultStorePath } # Create folder $dateStamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' $usernameClean = $selectedProfile.Account -replace '[\\/:*?"<>|]', '_' $backupFolderName = "${env:COMPUTERNAME}_${usernameClean}_${dateStamp}" $backupPath = Join-Path $storePath $backupFolderName Write-Host "" Write-Step "Creating migration store: $backupPath" try { if (-not (Test-Path $storePath)) { New-Item -Path $storePath -ItemType Directory -Force | Out-Null } New-Item -Path $backupPath -ItemType Directory -Force | Out-Null } catch { Write-Failure "Failed to create directory: $($_.Exception.Message)" Read-Host "Press Enter to continue" return } # Run backup Write-Host "" $exitCode = Start-ProfileBackup -USMTPath $USMTPath ` -SourceAccount $selectedProfile.Account ` -StorePath $backupPath ` -Options $options # Save metadata $metadata = @{ SourceAccount = $selectedProfile.Account SourceComputer = $env:COMPUTERNAME BackupDate = (Get-Date).ToString('o') Encrypted = (-not [string]::IsNullOrWhiteSpace($options.EncryptionKey)) Options = $options } # Don't save the actual encryption key! if ($metadata.Options.EncryptionKey) { $metadata.Options.EncryptionKey = '[ENCRYPTED]' } $metadata | ConvertTo-Json -Depth 3 | Out-File -FilePath "$backupPath\backup_info.json" -Encoding UTF8 Write-Host "" if ($exitCode -le 1) { $storeSize = Get-FolderSize -Path $backupPath Write-Host "" Write-Host "[OK] BACKUP COMPLETE" -ForegroundColor Green Write-Host "==============================================================" -ForegroundColor Green Write-Host " Location: $backupPath" -ForegroundColor White Write-Host " Size: $(Format-FileSize $storeSize)" -ForegroundColor White Write-Host " Source: $($selectedProfile.Account)" -ForegroundColor White } else { Write-Failure "Backup completed with errors (exit code: $exitCode)" Write-Host " Check logs: $backupPath\scan.log" -ForegroundColor Yellow } Write-Host "" Read-Host "Press Enter to continue" } finally { # Never leave a scanstate child alive when leaving this wizard or on cancel. Stop-TrackedChildren } } function Start-RestoreWizard { param([switch]$CreateNewAccount) try { if ($CreateNewAccount) { Write-Header "RESTORE TO NEW ACCOUNT" } else { Write-Header "RESTORE / MERGE TO EXISTING ACCOUNT" } if ($CreateNewAccount -and -not $script:IsAdmin) { Write-Failure "Creating new accounts requires Administrator privileges!" Write-Host "" Write-Host " Please run this script as Administrator." -ForegroundColor Yellow Write-Host "" Read-Host "Press Enter to continue" return } $USMTPath = Install-USMT if (-not $USMTPath) { Write-Failure "Cannot proceed without USMT" Read-Host "Press Enter to continue" return } Write-Host "" $storePath = Read-Host "Migration store base path [$DefaultStorePath]" if ([string]::IsNullOrWhiteSpace($storePath)) { $storePath = $DefaultStorePath } Write-Step "Scanning for backups..." $stores = @(Get-MigrationStores -BasePath $storePath) if ($stores.Count -eq 0) { Write-Failure "No backups found at: $storePath" Write-Host "" $specificPath = Read-Host "Enter full path to backup folder (or Enter to cancel)" if ([string]::IsNullOrWhiteSpace($specificPath) -or -not (Test-Path $specificPath)) { return } $infoFile = Join-Path $specificPath 'backup_info.json' $info = $null if (Test-Path $infoFile) { $info = Get-Content $infoFile -Raw | ConvertFrom-Json } $stores = @([PSCustomObject]@{ Name = Split-Path $specificPath -Leaf Path = $specificPath SourceAccount = if ($info) { $info.SourceAccount } else { 'Unknown' } SourceComputer = if ($info) { $info.SourceComputer } else { 'Unknown' } Encrypted = if ($info) { $info.Encrypted } else { $false } }) } Write-Host "" Write-Host " Available Backups:" -ForegroundColor Yellow Write-Host " ------------------" for ($i = 0; $i -lt $stores.Count; $i++) { $s = $stores[$i] Write-Host " $($i + 1). $($s.Name)" -ForegroundColor White Write-Host " Source: $($s.SourceAccount) @ $($s.SourceComputer)" -ForegroundColor Gray Write-Host " Size: $($s.SizeFormatted)" -ForegroundColor Gray if ($s.Encrypted) { Write-Host " [ENCRYPTED]" -ForegroundColor Yellow } } Write-Host "" $selection = Read-Host "Select backup (1-$($stores.Count))" $index = [int]$selection - 1 if ($index -lt 0 -or $index -ge $stores.Count) { Write-Failure "Invalid selection" Read-Host "Press Enter to continue" return } $selectedStore = $stores[$index] Write-Success "Selected: $($selectedStore.Name)" $targetAccount = '' $restoreOptions = Show-RestoreOptions if ($CreateNewAccount) { # Create new account flow Write-Host "" Write-Host "[INFO] CREATE NEW LOCAL ACCOUNT" -ForegroundColor Cyan Write-Host "==============================================================" -ForegroundColor Cyan Write-Host "" $newUsername = Read-Host " Enter username for new account" if ([string]::IsNullOrWhiteSpace($newUsername)) { Write-Failure "Username required" Read-Host "Press Enter to continue" return } $newPassword = Read-Host " Enter password for new account" $confirmPassword = Read-Host " Confirm password" if ($newPassword -ne $confirmPassword) { Write-Failure "Passwords don't match" Read-Host "Press Enter to continue" return } $newFullName = Read-Host " Full name (optional)" $makeAdmin = Read-Host " Make this user an Administrator? (y/N)" $isAdmin = ($makeAdmin -eq 'y' -or $makeAdmin -eq 'Y') Write-Host "" Write-Step "Creating local user: $newUsername" $created = New-LocalUser -Username $newUsername -Password $newPassword -FullName $newFullName -Admin:$isAdmin if (-not $created) { Write-Failure "Failed to create user account" Read-Host "Press Enter to continue" return } $targetAccount = "$env:COMPUTERNAME\$newUsername" $restoreOptions.CreateNewAccount = $true $restoreOptions.NewUsername = $newUsername $restoreOptions.NewPassword = $newPassword Write-Host "" Write-Host " IMPORTANT: The new user must log in ONCE before restore" -ForegroundColor Yellow Write-Host " to initialize their profile. You can:" -ForegroundColor Yellow Write-Host " 1. Log in as $newUsername now, then log back in as admin" -ForegroundColor Gray Write-Host " 2. Or continue - USMT will attempt to create the profile" -ForegroundColor Gray Write-Host "" $continueChoice = Read-Host " Continue with restore now? (y/N)" if ($continueChoice -ne 'y' -and $continueChoice -ne 'Y') { Write-Host "" Write-Host " Restore cancelled. Run restore again after user logs in." -ForegroundColor Yellow Read-Host "Press Enter to continue" return } } else { # Existing account flow Write-Host "" Write-Host " Select target account:" -ForegroundColor Yellow $profiles = @(Get-UserProfiles) for ($i = 0; $i -lt $profiles.Count; $i++) { Write-Host " $($i + 1). $($profiles[$i].Account)" -ForegroundColor White } Write-Host " $($profiles.Count + 1). Same as source ($($selectedStore.SourceAccount))" -ForegroundColor White Write-Host "" $targetSelection = Read-Host "Select target (1-$($profiles.Count + 1))" $targetIndex = [int]$targetSelection - 1 if ($targetIndex -eq $profiles.Count) { $targetAccount = $selectedStore.SourceAccount } elseif ($targetIndex -ge 0 -and $targetIndex -lt $profiles.Count) { $targetAccount = $profiles[$targetIndex].Account } else { Write-Failure "Invalid selection" Read-Host "Press Enter to continue" return } } Write-Success "Target: $targetAccount" # Encryption key $encryptionKey = '' if ($selectedStore.Encrypted) { Write-Host "" $encryptionKey = Read-Host "This backup is encrypted. Enter decryption key" } # Confirmation Write-Host "" Write-Host "[WARN] RESTORE SUMMARY" -ForegroundColor Yellow Write-Host "==============================================================" -ForegroundColor Yellow Write-Host " From: $($selectedStore.SourceAccount)" -ForegroundColor White Write-Host " To: $targetAccount" -ForegroundColor White if ($selectedStore.SourceAccount -ne $targetAccount) { Write-Host "" Write-Host " Profile data will be MERGED into: $targetAccount" -ForegroundColor Cyan } Write-Host "" $confirm = Read-Host "Proceed? (y/N)" if ($confirm -ne 'y' -and $confirm -ne 'Y') { Write-Host "Cancelled." Read-Host "Press Enter to continue" return } # Run restore Write-Host "" $exitCode = Start-ProfileRestore -USMTPath $USMTPath ` -StorePath $selectedStore.Path ` -SourceAccount $selectedStore.SourceAccount ` -TargetAccount $targetAccount ` -EncryptionKey $encryptionKey ` -Options $restoreOptions Write-Host "" if ($exitCode -le 1) { Write-Success "Restore completed successfully!" Write-Host "" Write-Host " The user should log out and back in for all settings to apply." -ForegroundColor Yellow } else { Write-Failure "Restore failed (exit code: $exitCode)" Write-Host " Check logs: $($selectedStore.Path)\load.log" -ForegroundColor Yellow } Write-Host "" Read-Host "Press Enter to continue" } finally { # Never leave a loadstate child alive when leaving this wizard or on cancel. Stop-TrackedChildren } } function Show-Backups { Write-Header "VIEW AVAILABLE BACKUPS" $storePath = Read-Host "Migration store path [$DefaultStorePath]" if ([string]::IsNullOrWhiteSpace($storePath)) { $storePath = $DefaultStorePath } Write-Step "Scanning..." $stores = @(Get-MigrationStores -BasePath $storePath) if ($stores.Count -eq 0) { Write-Failure "No backups found at: $storePath" } else { Write-Host "" Write-Success "Found $($stores.Count) backup(s):" Write-Host "" foreach ($s in $stores) { Write-Host " ─────────────────────────────────────────────────────────" -ForegroundColor Gray Write-Host " Name: $($s.Name)" -ForegroundColor White Write-Host " Path: $($s.Path)" -ForegroundColor Gray Write-Host " Source: $($s.SourceAccount) @ $($s.SourceComputer)" -ForegroundColor Gray Write-Host " Size: $($s.SizeFormatted)" -ForegroundColor Gray Write-Host " Date: $($s.BackupDate)" -ForegroundColor Gray if ($s.Encrypted) { Write-Host " Status: ENCRYPTED" -ForegroundColor Yellow } } } Write-Host "" Read-Host "Press Enter to continue" } # ============================================================================== # MAIN # ============================================================================== $script:IsAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator') # Route Ctrl+C / engine exit through the same child-process cleanup so a cancel # (including from inside a wizard's Read-Host) never orphans scanstate/loadstate # -- which would hold USMT's lock and the console pipe. Fires on the menu's # exit paths and on Ctrl+C alike. $null = Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PsEngineEvent]::Exiting) -Action { if ($script:TrackedChildren) { foreach ($proc in $script:TrackedChildren) { try { if ($proc -and -not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } } catch { } } } } # Pre-flight: a prior cancelled run can leave scanstate/loadstate alive, which # poisons a fresh run with USMT's single-instance lock (scanstate code 29). Stop-StaleMigrationProcesses while ($true) { Show-MainMenu $choice = Read-Host " Select option (1-5)" switch ($choice) { '1' { Start-BackupWizard } '2' { Start-RestoreWizard } '3' { Start-RestoreWizard -CreateNewAccount } '4' { Show-Backups } '5' { Write-Host "" Write-Host " Goodbye!" -ForegroundColor Cyan exit 0 } default { Write-Host " Invalid option" -ForegroundColor Red Start-Sleep -Seconds 1 } } } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/usmt_profile_migrate.ps1 ## What USMT captures ScanState reads the MigUser.xml, MigApp.xml, and MigDocs.xml config files and captures the full matching set of user state. It pulls registry keys under HKCU, AppData (Local and Roaming), the full user profile tree, and application-specific settings for the apps Microsoft has shipped templates for. LoadState reverses the process on the new machine, restoring everything to the right paths even when the target OS version differs. - user folders: Documents, Desktop, Downloads, Pictures - AppData: Local and Roaming app data - registry: HKCU settings and preferences - browser data: Chrome, Edge, Firefox profiles - Office: signatures, templates, custom dictionaries - Sticky Notes and other UWP app state ## Outcome Ran ScanState on the old laptop. It took 35 minutes for a 47GB capture with /encrypt turned on. Migration store went to a network share. New laptop was already imaged and domain-joined, so LoadState pulled from the share and ran for about 45 minutes. CFO logged in, everything was where she left it: same desktop wallpaper, same Chrome bookmarks bar, same Outlook signature with the logo, same Sticky Notes on the right side of the screen. She noticed approximately none of the things we moved, which is the gold standard for a migration. Old laptop got wiped and decommissioned. That whole lifecycle — SMART warnings surfaced by monitoring, a replacement staged before the drive died, a migration nobody noticed — is what [managed IT](/services/managed-it-security) looks like when it's working. - data migrated: 47GB - ScanState time: 35 minutes - LoadState time: 45 minutes - total elapsed: 90 minutes - data loss: zero Running IT in-house and want a second set of hands on jobs like this? That's [co-managed IT](/industries/co-managed-it): your person stays, and we cover after hours, overflow, and the tooling. ## Key takeaways - USMT beats manual copy for completeness on executive migrations. - /encrypt protects the migration store on the wire and at rest. - works cleanly across Windows versions (Win10 to Win11). - the win is the user not noticing anything changed. --- # Mac Running Slow: Disk Errors Causing Performance Issues URL: https://limehawk.io/reports/macos-disk-health-check Published: 2024-12-10 Author: Corey Watson, Managing Member Topic: Infrastructure Read time: 5 min Designer's MacBook Pro crawling during large file operations. Disk Utility showed nothing. Deep scan found filesystem inconsistencies across 3 volumes. ## Incident Graphic designer called in saying her MacBook was "unusably slow" any time she was working with large Photoshop files. Saves took minutes. Finder beach-balled on basic operations. She'd already done the obvious thing: ran Disk Utility First Aid on her boot volume, got the green "The volume appears to be OK" message, and called us because she was out of ideas. Activity Monitor was showing disk I/O pegged even when nothing should have been hitting the disk. ## Investigation M1 Pro MacBook Pro from 2021, 1TB internal SSD, APFS with three volumes on the container (Macintosh HD, Macintosh HD - Data, and a small preboot). Free space was 340GB so that wasn't the problem. SMART reported Verified, so the drive hardware wasn't failing either. On paper everything looked fine. The catch with Disk Utility's First Aid from within the running OS is that it can only do so much. It's a quick verify pass and it has to work around live system files. Plenty of filesystem inconsistencies (especially at the APFS container level) don't surface in that check. If you want real coverage you have to verify the container, each volume's B-tree structure, AND the SMART/ATA data separately. - model: MacBook Pro 2021 (M1 Pro) - storage: 1TB APFS, 3 volumes - free space: 340GB (not the issue) - SMART status: Verified (not a hardware fault) ## Solution We wrote a shell script that does a proper read-only sweep: partition map check, APFS container verify, per-volume verify, and a SMART pull via smartctl. All of it is non-destructive: diskutil's verify subcommands don't write anything, so it's safe to run on a production machine without taking it offline. ```disk_scan_fix.sh #!/bin/bash # # ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ # ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ # ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ # ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ # ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ # ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ # ================================================================================ # SCRIPT : Disk Scan and Fix (macOS) v2.0.1 # AUTHOR : Limehawk.io # DATE : January 2026 # USAGE : sudo ./disk_scan_fix.sh # ================================================================================ # FILE : disk_scan_fix.sh # DESCRIPTION : Comprehensive disk and volume health check for macOS # -------------------------------------------------------------------------------- # README # -------------------------------------------------------------------------------- # PURPOSE # # Performs a comprehensive health check on all mounted disks and volumes # on macOS. Verifies partition maps, file systems, and SMART status where # supported. # # DATA SOURCES & PRIORITY # # 1. diskutil - macOS disk utility # 2. System disk information # # REQUIRED INPUTS # # None - script auto-detects all mounted disks. # # SETTINGS # # - Scans all mounted disks # - Checks partition map health # - Verifies HFS and APFS volumes # - Reports SMART status when available # # BEHAVIOR # # 1. Lists all mounted disks # 2. For each disk: # - Verifies partition map # - Gets list of volumes (HFS/APFS) # - Verifies each volume's file system # - Checks SMART status if supported # 3. Reports results for each disk/volume # # PREREQUISITES # # - macOS operating system # - Root/sudo privileges # # SECURITY NOTES # # - No secrets exposed in output # - Read-only operations (verification only) # - Requires elevated privileges for disk access # # ENDPOINTS # # Not applicable - this script does not connect to any network endpoints # # EXIT CODES # # 0 = Success - all checks completed # 1 = Failure - error during disk verification # # EXAMPLE RUN # # [RUN] DISK SCAN # ============================================================== # Starting comprehensive disk and volume health check... # # [INFO] DISK: disk0 # ============================================================== # Checking partition map health for disk0... # [OK] Partition map of disk0 appears to be OK # # [INFO] VOLUMES: disk0 # ============================================================== # Checking file system health for disk0s1... # [OK] File system of disk0s1 appears to be OK # # [INFO] SMART STATUS: disk0 # ============================================================== # [OK] SMART status of disk0 indicates it is OK # # [OK] FINAL STATUS # ============================================================== # Result : SUCCESS # Comprehensive health check completed # # [INFO] SCRIPT COMPLETE # ============================================================== # # -------------------------------------------------------------------------------- # CHANGELOG # -------------------------------------------------------------------------------- # 2026-01-19 v2.0.1 Updated to two-line ASCII console output style # 2026-01-14 v2.0.0 Complete rewrite with Limehawk Script Framework # 2024-01-01 v1.0.0 Initial release # ================================================================================ # ============================================================================ # STATE VARIABLES # ============================================================================ ERROR_OCCURRED=false DISKS_CHECKED=0 VOLUMES_CHECKED=0 SMART_CHECKED=0 # ============================================================================ # MAIN EXECUTION # ============================================================================ echo "" echo "[RUN] DISK SCAN" echo "==============================================================" echo "Starting comprehensive disk and volume health check..." # Get the list of all mounted disks DISKS=$(diskutil list | grep 'disk[0-9]' | awk '{print $1}' | sort -u) if [[ -z "$DISKS" ]]; then echo "" echo "[ERROR] ERROR OCCURRED" echo "==============================================================" echo "No disks found" echo "" exit 1 fi # Loop through each disk for disk in $DISKS; do echo "" echo "[INFO] DISK: $disk" echo "==============================================================" echo "Checking partition map health for $disk..." # Using diskutil to verify the partition map if diskutil verifyDisk "$disk" 2>&1; then echo "[OK] Partition map of $disk appears to be OK" else echo "[WARN] An error occurred with the partition map of $disk" echo "Check the disk using Disk Utility" ERROR_OCCURRED=true fi DISKS_CHECKED=$((DISKS_CHECKED + 1)) # Get the list of volumes on the disk VOLUMES=$(diskutil list "$disk" | grep -E 'Apple_HFS|Apple_APFS' | awk '{print $NF}') if [[ -n "$VOLUMES" ]]; then echo "" echo "[INFO] VOLUMES: $disk" echo "==============================================================" # Loop through each volume on the disk for volume in $VOLUMES; do echo "Checking file system health for $volume..." # Using diskutil to verify the volume if diskutil verifyVolume "$volume" 2>&1; then echo "[OK] File system of $volume appears to be OK" else echo "[WARN] An error occurred with the file system of $volume" echo "Check the volume using Disk Utility" ERROR_OCCURRED=true fi VOLUMES_CHECKED=$((VOLUMES_CHECKED + 1)) done fi # Check if the disk supports SMART status SMART_STATUS=$(diskutil info "$disk" 2>/dev/null | grep "SMART Status" | awk '{print $3}') if [[ "$SMART_STATUS" == "Verified" || "$SMART_STATUS" == "Supported" ]]; then echo "" echo "[INFO] SMART STATUS: $disk" echo "==============================================================" echo "Checking SMART status for $disk..." SMART_OUTPUT=$(diskutil info "$disk" | grep "SMART Status") echo "$SMART_OUTPUT" if echo "$SMART_OUTPUT" | grep -q "Verified"; then echo "[OK] SMART status of $disk indicates it is OK" else echo "[WARN] SMART status check indicates a potential issue with $disk" echo "Consider further diagnostics" fi SMART_CHECKED=$((SMART_CHECKED + 1)) elif [[ -n "$SMART_STATUS" ]]; then echo "" echo "[INFO] SMART STATUS: $disk" echo "==============================================================" echo "SMART Status : $SMART_STATUS" fi done echo "" echo "[INFO] SUMMARY" echo "==============================================================" echo "Disks Checked : $DISKS_CHECKED" echo "Volumes Checked : $VOLUMES_CHECKED" echo "SMART Checked : $SMART_CHECKED" echo "" if [[ "$ERROR_OCCURRED" = true ]]; then echo "[WARN] FINAL STATUS" echo "==============================================================" echo "Result : COMPLETED WITH WARNINGS" echo "Some checks reported issues - review output above" else echo "[OK] FINAL STATUS" echo "==============================================================" echo "Result : SUCCESS" echo "Comprehensive health check completed" fi echo "" echo "[INFO] SCRIPT COMPLETE" echo "==============================================================" if [[ "$ERROR_OCCURRED" = true ]]; then exit 1 else exit 0 fi ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/disk_scan_fix.sh ## What it checks Script walks every mounted disk and does the full stack of checks. `diskutil verifyDisk` handles the partition map. `diskutil apfs verify` hits the container superblock and space manager. Per-volume verify runs the catalog/B-tree check on each APFS or HFS+ volume. smartctl pulls the SMART data that Disk Utility only summarizes. Read-only throughout. It's a diagnostic, not a repair. - partition maps: GPT/MBR structure integrity - APFS containers: superblock and space manager - APFS volumes: per-volume B-tree and catalog - HFS+ volumes: catalog file and extents overflow - SMART data: full drive health indicators via smartctl ## Outcome Scan flagged what First Aid missed: errors on both Macintosh HD and Macintosh HD - Data. Partition map was clean, SMART was clean, so this was pure filesystem corruption, probably from an ungraceful shutdown at some point. The important thing about macOS disk repair is that you cannot repair the boot volume while you're booted from it. You have to go to Recovery. We rebooted into Recovery Mode (hold power on Apple Silicon until options appear), launched Disk Utility from there, and ran First Aid against the container. From Recovery it has the write access it needs to actually fix things. Both volumes came back clean. Back in the OS, Photoshop saves were instant again and Finder stopped beach-balling. - disk0 partition map: OK - Macintosh HD: errors found, repaired from Recovery - Macintosh HD - Data: errors found, repaired from Recovery - SMART status: Verified throughout - performance: returned to normal Macs get the same treatment as everything else under our [managed IT service](/services/managed-it-security): same monitoring, same help desk, no "we're a Windows shop" shrug. ## Key takeaways - Disk Utility from the running OS has limited repair ability. - a deep scan catches what quick First Aid misses. - APFS needs container-level verification, not just per-volume. - you have to boot to Recovery to actually repair the system volume. --- # Winget for RMM: Silent Software Deployment at Scale URL: https://limehawk.io/reports/winget-rmm-automation Published: 2024-12-03 Author: Corey Watson, Managing Member Topic: Automation Read time: 8 min 85 machines needed 15 applications each. Manual install: 64 hours. With winget automation: 3 hours. Plus weekly auto-updates. - Machines deployed: 85 - Applications per machine: 15 - Time with winget: 3 hours - Manual estimate: 64 hours ## How do I use winget with an RMM? Winget works in SYSTEM context if you call the full path under WindowsApps, not `winget` on PATH. SuperOps (or any RMM) can then install a package by name and run a weekly upgrade-all. That is how we put 15 apps on 85 machines in 3 hours instead of 64. ## The challenge New client onboarding. 85 workstations needed a standardized software stack: Chrome, Adobe Reader, 7-Zip, Zoom, Teams, and 12 other applications. The previous MSP had been installing software by RDP'ing into each machine and running installers by hand, about 45 minutes per workstation on a good day. We needed silent deployment with no user interaction and a repeatable process we could re-run for every new hire's machine. ## Why winget for RMM? Windows Package Manager (winget) is Microsoft's answer to apt-get and homebrew. It's baked into Windows 11 and available as an install on Windows 10. For MSP use the advantages line up nicely: - 60,000+ packages: the Microsoft-curated repository covers most business software already - silent install flags: `--silent` and `--accept-package-agreements` work for roughly 99% of packages - upgrade all: a single command updates every installed application, perfect for patch compliance - no licensing: unlike PDQ Deploy or SCCM, winget is free and built into Windows The catch: winget does not work out of the box when scripts run as SYSTEM, which is how most RMM tools execute them. The winget.exe binary exists, but it's not on SYSTEM's PATH and its app execution alias behaves differently under the SYSTEM context. We built a three-script toolkit to solve this cleanly. ## Script 1: winget setup First challenge: winget isn't always installed, especially on older Windows 10 builds or fresh deployments. This script downloads the latest release from GitHub (Microsoft.DesktopAppInstaller) and installs it in a way that works for SYSTEM-context execution. ```winget_setup.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Winget Setup for RMM v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\winget_setup.ps1 ================================================================================ FILE : winget_setup.ps1 DESCRIPTION : Installs winget (Windows Package Manager) for RMM environments -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Installs winget (Windows Package Manager) on Windows systems using the most reliable installation method. Optimized for RMM environments running under SYSTEM context. Uses manual AppX installation method which is more reliable than the Repair-WinGetPackageManager approach. Handles all OS versions including Windows 10, 11, and Server 2019+. DATA SOURCES & PRIORITY 1) Hardcoded script configuration (timeout, force reinstall) 2) GitHub API (microsoft/winget-cli releases) 3) System environment (OS version, architecture) REQUIRED INPUTS - $forceReinstall - Reinstall even if winget already exists (boolean) - $downloadTimeout - Timeout for file downloads in seconds (integer) - $skipServerCore - Skip installation on Server Core (boolean) SETTINGS - Uses alternate/manual AppX installation method (most reliable) - Automatically detects SYSTEM context and adjusts behavior - Installs for all users when possible - Configures PATH environment variable automatically - 120 second download timeout per file BEHAVIOR 1. Validates configuration inputs 2. Checks OS compatibility and admin privileges 3. Detects if running as SYSTEM account 4. Checks if winget is already installed (skips if present unless forced) 5. Downloads winget dependencies from GitHub 6. Downloads winget AppX package from GitHub 7. Installs dependencies (VCLibs, UI.Xaml) 8. Installs winget AppX package 9. Configures PATH environment variable 10. Registers winget application 11. Verifies installation success 12. Cleans up temporary files PREREQUISITES - Administrator or SYSTEM privileges - Windows 10 1809+ or Windows Server 2019+ - Internet connectivity to GitHub - PowerShell 5.1 or higher SECURITY NOTES - No secrets or credentials required - Downloads only from official Microsoft GitHub repository - All temporary files are cleaned up after installation - No sensitive information logged to console ENDPOINTS - https://api.github.com/repos/microsoft/winget-cli/releases/latest - https://github.com/microsoft/winget-cli (release assets) EXIT CODES - 0 = Success - winget installed and verified - 1 = Failure - installation failed or system incompatible EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Configuration validated successfully Force Reinstall : No Download Timeout: 120 seconds [INFO] SYSTEM CHECK ============================================================== OS Version : Windows 10 22H2 Architecture : x64 Admin Rights : Yes Running Context : SYSTEM Winget Status : Not installed [RUN] DOWNLOAD DEPENDENCIES ============================================================== Fetching latest release information... Latest Version : v1.7.10861 Downloading dependencies package... Downloaded : 15.2 MB Extracting dependencies... Extracted : VCLibs.140.00.UWPDesktop Extracted : UI.Xaml.2.8 [RUN] DOWNLOAD WINGET ============================================================== Downloading winget package... Downloaded : 45.8 MB Package verified [RUN] INSTALL DEPENDENCIES ============================================================== Installing VCLibs.140.00.UWPDesktop... Installed successfully Installing UI.Xaml.2.8... Installed successfully [RUN] INSTALL WINGET ============================================================== Installing winget AppX package... Installation successful Configuring PATH environment variable... PATH configured Registering winget... Registration complete [INFO] VERIFICATION ============================================================== Testing winget command... Winget Version : v1.7.10861 Installation : Verified [INFO] CLEANUP ============================================================== Removing temporary files... Cleanup complete [OK] FINAL STATUS ============================================================== Status : Success Winget : Installed and working [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Updated to two-line ASCII console output style 2026-01-14 v1.0.2 Fixed header formatting for framework compliance 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2025-01-31 v1.0.0 Initial release - reliable winget installation for RMM ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # HARDCODED INPUTS # ============================================================================ $forceReinstall = $false # Reinstall even if winget already exists $downloadTimeout = 120 # Timeout for file downloads in seconds $skipServerCore = $false # Skip installation on Server Core # ============================================================================ # INPUT VALIDATION # ============================================================================ $errorOccurred = $false $errorText = "" Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" if ($downloadTimeout -lt 30) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Download timeout must be at least 30 seconds" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host "Input validation failed:" Write-Host $errorText Write-Host "" exit 1 } Write-Host "Configuration validated successfully" Write-Host "Force Reinstall : $(if ($forceReinstall) { 'Yes' } else { 'No' })" Write-Host "Download Timeout: $downloadTimeout seconds" # ============================================================================ # SYSTEM CHECK # ============================================================================ Write-Host "" Write-Host "[INFO] SYSTEM CHECK" Write-Host "==============================================================" # Check if running as SYSTEM $runAsSystem = $false $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent() if ($currentUser.User.Value -eq "S-1-5-18") { $runAsSystem = $true } # Check admin privileges (for non-SYSTEM accounts) $isAdmin = ([Security.Principal.WindowsPrincipal]$currentUser).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin -and -not $runAsSystem) { Write-Host "" Write-Host "[ERROR] PRIVILEGES REQUIRED" Write-Host "==============================================================" Write-Host "Administrator or SYSTEM privileges required" Write-Host "Please run this script as Administrator" Write-Host "" exit 1 } # Get OS information $osInfo = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" $osName = $osInfo.ProductName $osBuild = $osInfo.CurrentBuild $installationType = $osInfo.InstallationType # Get architecture $osArch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } # Get OS type $osDetails = Get-CimInstance -ClassName Win32_OperatingSystem $osType = if ($osDetails.ProductType -eq 1) { "Workstation" } else { "Server" } Write-Host "OS Name : $osName" Write-Host "OS Build : $osBuild" Write-Host "Architecture : $osArch" Write-Host "Installation : $installationType" Write-Host "Admin Rights : $(if ($isAdmin) { 'Yes' } else { 'No' })" Write-Host "Running Context : $(if ($runAsSystem) { 'SYSTEM' } else { 'User' })" # Check OS compatibility $osNumeric = ($osName -replace "[^\d]").Trim() if ($osType -eq "Workstation" -and [int]$osBuild -lt 17763) { Write-Host "" Write-Host "[ERROR] INCOMPATIBLE OS VERSION" Write-Host "==============================================================" Write-Host "Windows 10 version 1809 or later required" Write-Host "Your build: $osBuild" Write-Host "" exit 1 } if ($osType -eq "Server" -and [int]$osBuild -lt 17763) { Write-Host "" Write-Host "[ERROR] INCOMPATIBLE OS VERSION" Write-Host "==============================================================" Write-Host "Windows Server 2019 or later required" Write-Host "Your build: $osBuild" Write-Host "" exit 1 } # Check for Server Core if ($installationType -eq "Server Core" -and $skipServerCore) { Write-Host "" Write-Host "[WARN] SERVER CORE SKIPPED" Write-Host "==============================================================" Write-Host "Server Core installation detected and skipServerCore is enabled" Write-Host "Installation skipped" Write-Host "" exit 1 } # Check if winget already installed Write-Host "" Write-Host "Checking for existing winget installation..." $wingetInstalled = $false $wingetVersion = "Not installed" try { if ($runAsSystem) { # For SYSTEM context, look for winget executable directly $wingetPath = Resolve-Path "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe" -ErrorAction SilentlyContinue | Sort-Object | Select-Object -Last 1 if ($wingetPath) { $wingetExe = Join-Path $wingetPath.Path "winget.exe" if (Test-Path $wingetExe) { $wingetInstalled = $true $versionOutput = & $wingetExe --version 2>&1 if ($versionOutput -match 'v([\d.]+)') { $wingetVersion = $matches[1] } } } } else { $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue if ($wingetCmd) { $wingetInstalled = $true $versionOutput = & winget --version 2>&1 if ($versionOutput -match 'v([\d.]+)') { $wingetVersion = $matches[1] } } } } catch { # Winget not found } Write-Host "Winget Status : $(if ($wingetInstalled) { 'Installed' } else { 'Not installed' })" if ($wingetInstalled) { Write-Host "Winget Version : $wingetVersion" } if ($wingetInstalled -and -not $forceReinstall -and -not $runAsSystem) { Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Status : Winget already installed" Write-Host "Version : $wingetVersion" Write-Host "Action : Skipped installation" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 } if ($wingetInstalled -and -not $forceReinstall -and $runAsSystem) { Write-Host "" Write-Host "Winget installed for SYSTEM, ensuring user registration..." # Skip to registration section without reinstalling $skipInstallation = $true } else { $skipInstallation = $false } if ($wingetInstalled -and $forceReinstall) { Write-Host "Force reinstall enabled, proceeding with installation" } # ============================================================================ # DOWNLOAD AND INSTALL (skip if already installed for SYSTEM) # ============================================================================ if (-not $skipInstallation) { # ============================================================================ # DOWNLOAD DEPENDENCIES # ============================================================================ Write-Host "" Write-Host "[RUN] DOWNLOAD DEPENDENCIES" Write-Host "==============================================================" # Suppress progress bar for faster downloads $ProgressPreference = 'SilentlyContinue' Write-Host "Fetching latest release information from GitHub..." try { $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/microsoft/winget-cli/releases" -TimeoutSec 30 -ErrorAction Stop # Get latest non-prerelease version $latestRelease = $releases | Where-Object { -not $_.prerelease } | Select-Object -First 1 $releaseVersion = $latestRelease.tag_name Write-Host "Latest Version : $releaseVersion" Write-Host "Published : $($latestRelease.published_at)" } catch { Write-Host "" Write-Host "[ERROR] GITHUB API FAILED" Write-Host "==============================================================" Write-Host "Failed to fetch release information from GitHub" Write-Host "Error: $($_.Exception.Message)" Write-Host "Check internet connectivity and GitHub availability" Write-Host "" exit 1 } # Create temp directory $tempDir = Join-Path $env:TEMP "winget_install_$(Get-Random)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null Write-Host "" Write-Host "Temp directory : $tempDir" # Download dependencies package Write-Host "" Write-Host "Downloading dependencies package..." $depsAsset = $latestRelease.assets | Where-Object { $_.name -match 'Dependencies\.zip' } | Select-Object -First 1 if (-not $depsAsset) { Write-Host "" Write-Host "[ERROR] MISSING ASSET" Write-Host "==============================================================" Write-Host "Dependencies package not found in release assets" Write-Host "" exit 1 } $depsPath = Join-Path $tempDir "dependencies.zip" try { Invoke-WebRequest -Uri $depsAsset.browser_download_url -OutFile $depsPath -TimeoutSec $downloadTimeout -ErrorAction Stop $fileSize = (Get-Item $depsPath).Length Write-Host "Downloaded : $([math]::Round($fileSize/1MB, 1)) MB" } catch { Write-Host "" Write-Host "[ERROR] DOWNLOAD FAILED" Write-Host "==============================================================" Write-Host "Failed to download dependencies package" Write-Host "Error: $($_.Exception.Message)" Write-Host "" exit 1 } # Extract dependencies for current architecture Write-Host "" Write-Host "Extracting dependencies for $osArch architecture..." Add-Type -AssemblyName System.IO.Compression.FileSystem $depsExtractPath = Join-Path $tempDir "deps" New-Item -ItemType Directory -Path $depsExtractPath -Force | Out-Null try { $zip = [System.IO.Compression.ZipFile]::OpenRead($depsPath) $archPattern = if ($osArch -eq "x64") { "x64" } else { "x86" } $entries = $zip.Entries | Where-Object { $_.FullName -match "$archPattern.*\.appx$" } foreach ($entry in $entries) { $destPath = Join-Path $depsExtractPath $entry.Name [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true) Write-Host "Extracted : $($entry.Name)" } $zip.Dispose() } catch { Write-Host "" Write-Host "[ERROR] EXTRACTION FAILED" Write-Host "==============================================================" Write-Host "Failed to extract dependencies" Write-Host "Error: $($_.Exception.Message)" Write-Host "" exit 1 } # ============================================================================ # DOWNLOAD WINGET # ============================================================================ Write-Host "" Write-Host "[RUN] DOWNLOAD WINGET" Write-Host "==============================================================" Write-Host "Downloading winget package..." $wingetAsset = $latestRelease.assets | Where-Object { $_.name -match '\.msixbundle$' } | Select-Object -First 1 if (-not $wingetAsset) { Write-Host "" Write-Host "[ERROR] MISSING ASSET" Write-Host "==============================================================" Write-Host "Winget package not found in release assets" Write-Host "" exit 1 } $wingetPath = Join-Path $tempDir "winget.msixbundle" try { Invoke-WebRequest -Uri $wingetAsset.browser_download_url -OutFile $wingetPath -TimeoutSec $downloadTimeout -ErrorAction Stop $fileSize = (Get-Item $wingetPath).Length Write-Host "Downloaded : $([math]::Round($fileSize/1MB, 1)) MB" Write-Host "Package verified" } catch { Write-Host "" Write-Host "[ERROR] DOWNLOAD FAILED" Write-Host "==============================================================" Write-Host "Failed to download winget package" Write-Host "Error: $($_.Exception.Message)" Write-Host "" exit 1 } # Download license file Write-Host "" Write-Host "Downloading license file..." $licenseAsset = $latestRelease.assets | Where-Object { $_.name -match 'License.*\.xml$' } | Select-Object -First 1 $licensePath = Join-Path $tempDir "license.xml" if ($licenseAsset) { try { Invoke-WebRequest -Uri $licenseAsset.browser_download_url -OutFile $licensePath -TimeoutSec 30 -ErrorAction Stop Write-Host "License downloaded" } catch { Write-Host "License download failed (non-critical): $($_.Exception.Message)" } } # ============================================================================ # INSTALL DEPENDENCIES # ============================================================================ Write-Host "" Write-Host "[RUN] INSTALL DEPENDENCIES" Write-Host "==============================================================" $depFiles = Get-ChildItem -Path $depsExtractPath -Filter "*.appx" foreach ($depFile in $depFiles) { Write-Host "Installing $($depFile.Name)..." try { if ($runAsSystem) { Add-AppxProvisionedPackage -Online -PackagePath $depFile.FullName -SkipLicense -ErrorAction Stop | Out-Null } else { Add-AppxPackage -Path $depFile.FullName -ErrorAction Stop | Out-Null } Write-Host "Installed successfully" } catch { # Some dependencies might already be installed or fail gracefully $errorMsg = $_.Exception.Message if ($errorMsg -match '0x80073D06') { Write-Host "Already installed (higher version)" } elseif ($errorMsg -match '0x80073CF0') { Write-Host "Already installed (same version)" } else { Write-Host "Warning: $errorMsg" } } } # ============================================================================ # INSTALL WINGET # ============================================================================ Write-Host "" Write-Host "[RUN] INSTALL WINGET" Write-Host "==============================================================" Write-Host "Installing winget AppX package..." try { if ($runAsSystem) { if (Test-Path $licensePath) { Add-AppxProvisionedPackage -Online -PackagePath $wingetPath -LicensePath $licensePath -ErrorAction Stop | Out-Null } else { Add-AppxProvisionedPackage -Online -PackagePath $wingetPath -SkipLicense -ErrorAction Stop | Out-Null } } else { Add-AppxPackage -Path $wingetPath -ErrorAction Stop | Out-Null } Write-Host "Installation successful" } catch { $errorMsg = $_.Exception.Message if ($errorMsg -match '0x80073D06') { Write-Host "Higher version already installed" } elseif ($errorMsg -match '0x80073CF0') { Write-Host "Same version already installed" } else { Write-Host "" Write-Host "[ERROR] INSTALLATION FAILED" Write-Host "==============================================================" Write-Host "Failed to install winget package" Write-Host "Error: $errorMsg" Write-Host "" exit 1 } } } # End of installation section # ============================================================================ # PATH AND REGISTRATION (always run for SYSTEM to ensure user access) # ============================================================================ # Configure PATH Write-Host "" Write-Host "[RUN] CONFIGURATION" Write-Host "==============================================================" Write-Host "Configuring PATH environment variable..." $localAppData = if ($runAsSystem) { "C:\Windows\System32\config\systemprofile\AppData\Local" } else { $env:LOCALAPPDATA } $wingetPaths = @( "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe", "$localAppData\Microsoft\WindowsApps" ) foreach ($pathPattern in $wingetPaths) { $resolvedPath = Resolve-Path $pathPattern -ErrorAction SilentlyContinue | Sort-Object | Select-Object -Last 1 if ($resolvedPath) { $pathToAdd = $resolvedPath.Path $currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine") if ($currentPath -notlike "*$pathToAdd*") { [Environment]::SetEnvironmentVariable("Path", "$currentPath;$pathToAdd", "Machine") $env:Path = "$env:Path;$pathToAdd" Write-Host "Added to PATH : $pathToAdd" } } } Write-Host "PATH configured" # Register winget Write-Host "" Write-Host "Registering winget..." try { Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -ErrorAction SilentlyContinue | Out-Null Write-Host "Registration complete" } catch { Write-Host "Registration completed with warnings (may not be needed)" } # For SYSTEM context, register for all users if ($runAsSystem) { Write-Host "" Write-Host "Registering for all user accounts..." # Get all user profiles $userProfiles = Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" | ForEach-Object { $profilePath = (Get-ItemProperty $_.PSPath).ProfileImagePath if ($profilePath -and (Test-Path $profilePath) -and $profilePath -notmatch '(systemprofile|LocalService|NetworkService|Default)') { $profilePath } } $registeredCount = 0 foreach ($profilePath in $userProfiles) { $username = Split-Path $profilePath -Leaf # Load user registry hive if not already loaded $userSid = (Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" | Where-Object { (Get-ItemProperty $_.PSPath).ProfileImagePath -eq $profilePath }).PSChildName # Create PowerShell script to register winget for this user $registerScript = @" Add-AppxPackage -DisableDevelopmentMode -Register "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe\AppXManifest.xml" -ErrorAction SilentlyContinue "@ # Try to register using scheduled task try { $taskName = "WingetUserRegistration_$username" $action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -Command `"$registerScript`"" $principal = New-ScheduledTaskPrincipal -UserId $userSid -LogonType S4U $task = Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force -ErrorAction Stop Start-ScheduledTask -TaskName $taskName -ErrorAction Stop Start-Sleep -Milliseconds 500 Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue $registeredCount++ } catch { # Silently continue if registration fails for a user } } if ($registeredCount -gt 0) { Write-Host "Registered for : $registeredCount user account(s)" } else { Write-Host "User Registration: Users may need to log out/in or run registration manually" } } # ============================================================================ # VERIFICATION # ============================================================================ Write-Host "" Write-Host "[INFO] VERIFICATION" Write-Host "==============================================================" Write-Host "Waiting for winget to initialize..." Start-Sleep -Seconds 3 Write-Host "Testing winget command..." $verificationSuccess = $false try { if ($runAsSystem) { $wingetPath = Resolve-Path "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe\winget.exe" -ErrorAction Stop | Sort-Object | Select-Object -Last 1 $versionOutput = & $wingetPath.Path --version 2>&1 } else { $versionOutput = & winget --version 2>&1 } if ($versionOutput -match 'v([\d.]+)') { $installedVersion = $matches[1] Write-Host "Winget Version : v$installedVersion" Write-Host "Installation : Verified" $verificationSuccess = $true } else { Write-Host "Version output : $versionOutput" Write-Host "Installation : Command available but version unclear" $verificationSuccess = $true } } catch { Write-Host "Verification : Command not immediately available" Write-Host "Note : May require restart or new session" if ($runAsSystem) { Write-Host "SYSTEM Context : Winget may work for user accounts after restart" } } # ============================================================================ # CLEANUP # ============================================================================ Write-Host "" Write-Host "[INFO] CLEANUP" Write-Host "==============================================================" if (-not $skipInstallation -and $tempDir -and (Test-Path $tempDir)) { Write-Host "Removing temporary files..." try { Remove-Item -Path $tempDir -Recurse -Force -ErrorAction Stop Write-Host "Cleanup complete" } catch { Write-Host "Cleanup warning : $($_.Exception.Message)" } } else { Write-Host "No temporary files to clean up" } # ============================================================================ # FINAL STATUS # ============================================================================ Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" if ($verificationSuccess) { Write-Host "Status : Success" Write-Host "Winget : Installed and working" if ($runAsSystem) { Write-Host "Note : Running as SYSTEM - restart may be needed for user access" } Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 } else { Write-Host "Status : Installed with warnings" Write-Host "Winget : Installed but verification incomplete" Write-Host "Action : Restart PowerShell or system to use winget" Write-Host "" Write-Host "[WARN] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/winget_setup.ps1 Key insight: when running as SYSTEM, winget.exe exists but isn't in PATH. We use Resolve-Path to find it in C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_* and call the full path directly. ## Script 2: install any package This is the workhorse. It uses SuperOps runtime text replacement so technicians can deploy any winget package without editing the script. Just enter the package ID (Google.Chrome, Mozilla.Firefox, Adobe.Acrobat.Reader.64-bit) when running the job. ```winget_package_install.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Winget Package Installer v1.0.2 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\winget_package_install.ps1 FILE : winget_package_install.ps1 DESCRIPTION : Installs software package using winget with SuperOps integration ================================================================================ README -------------------------------------------------------------------------------- PURPOSE Installs a software package using winget (Windows Package Manager). Uses SuperOps runtime text replacement for the package name. Handles silent installation with automatic acceptance of agreements. DATA SOURCES & PRIORITY 1) Hardcoded script configuration (package name via SuperOps replacement) 2) Winget package repository REQUIRED INPUTS - $PackageName - SuperOps runtime replacement variable for winget package ID (e.g., "Google.Chrome", "Mozilla.Firefox") SETTINGS - Silent installation mode - Accepts package and source agreements automatically - Uses machine scope when available BEHAVIOR 1. Validates software name input 2. Checks winget availability 3. Searches for package to verify it exists 4. Installs package silently 5. Reports installation result PREREQUISITES - Winget must be installed - Administrator privileges recommended - Internet connectivity SECURITY NOTES - No secrets in logs - Downloads only from official winget sources EXIT CODES - 0 = Success - package installed - 1 = Failure - installation failed or winget unavailable EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Software Name : Google.Chrome [INFO] WINGET CHECK ============================================================== Winget : Available Version : v1.7.10861 [RUN] INSTALLATION ============================================================== Installing Google.Chrome... Installation complete [OK] FINAL STATUS ============================================================== Status : Success Package : Google.Chrome installed [OK] SCRIPT COMPLETED ============================================================== CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2025-12-03 v1.0.0 Initial release - winget package installer for SuperOps ================================================================================ #> # ============================================================================ # HARDCODED INPUTS (SuperOps runtime replacement) # ============================================================================ $PackageId = "$PackageName" # Winget package ID - SuperOps replaces $PackageName # ============================================================================ # INPUT VALIDATION # ============================================================================ $errorOccurred = $false $errorText = "" Set-StrictMode -Version Latest Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" if ([string]::IsNullOrWhiteSpace($PackageId)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Package ID is required (set via SuperOps runtime replacement)" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] INPUT VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText Write-Host "" exit 1 } Write-Host "Package ID : $PackageId" # ============================================================================ # WINGET CHECK # ============================================================================ Write-Host "" Write-Host "[INFO] WINGET CHECK" Write-Host "==============================================================" $wingetPath = $null $runAsSystem = ([System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq "S-1-5-18") try { if ($runAsSystem) { $resolvedPath = Resolve-Path "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe" -ErrorAction SilentlyContinue | Sort-Object | Select-Object -Last 1 if ($resolvedPath) { $wingetPath = Join-Path $resolvedPath.Path "winget.exe" if (-not (Test-Path $wingetPath)) { $wingetPath = $null } } } else { $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue if ($wingetCmd) { $wingetPath = $wingetCmd.Source } } } catch { $wingetPath = $null } if (-not $wingetPath) { Write-Host "" Write-Host "[ERROR] WINGET NOT AVAILABLE" Write-Host "==============================================================" Write-Host "Winget is not installed or not available" Write-Host "Run winget_installer.ps1 first to install winget" Write-Host "" exit 1 } # Get version try { $versionOutput = & $wingetPath --version 2>&1 $wingetVersion = if ($versionOutput -match 'v[\d.]+') { $matches[0] } else { "Unknown" } } catch { $wingetVersion = "Unknown" } Write-Host "Winget : Available" Write-Host "Version : $wingetVersion" # ============================================================================ # INSTALLATION # ============================================================================ Write-Host "" Write-Host "[RUN] INSTALLATION" Write-Host "==============================================================" Write-Host "Installing $PackageId..." try { $installArgs = @( "install" "--id", $PackageId "--silent" "--accept-package-agreements" "--accept-source-agreements" ) $process = Start-Process -FilePath $wingetPath -ArgumentList $installArgs -Wait -PassThru -NoNewWindow if ($process.ExitCode -eq 0) { Write-Host "Installation complete" $installSuccess = $true } elseif ($process.ExitCode -eq -1978335189) { # Package already installed Write-Host "Package already installed" $installSuccess = $true } else { Write-Host "Winget exit code : $($process.ExitCode)" $installSuccess = $false } } catch { Write-Host "" Write-Host "[ERROR] INSTALLATION FAILED" Write-Host "==============================================================" Write-Host "Error: $($_.Exception.Message)" Write-Host "" exit 1 } # ============================================================================ # FINAL STATUS # ============================================================================ if ($installSuccess) { Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Status : Success" Write-Host "Package : $PackageId installed" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 } else { Write-Host "" Write-Host "[ERROR] FINAL STATUS" Write-Host "==============================================================" Write-Host "Status : Failed" Write-Host "Package : $PackageId" Write-Host "Action : Check winget logs or try manual installation" Write-Host "" Write-Host "[ERROR] SCRIPT COMPLETED" Write-Host "==============================================================" exit 1 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/winget_package_install.ps1 Common package IDs we deploy most often: - Google.Chrome - Mozilla.Firefox - Adobe.Acrobat.Reader.64-bit - 7zip.7zip - Zoom.Zoom - Microsoft.Teams - VideoLAN.VLC - Notepad++.Notepad++ Find a package ID with: `winget search "application name"` ## Script 3: upgrade all The real power of winget is keeping everything updated. We run this weekly via a scheduled job. It upgrades every winget-managed application to the latest version silently, and handles the log churn automatically. We keep 14 days of upgrade logs for troubleshooting and purge the rest. ```winget_upgrade_all.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Winget Upgrade All v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\winget_upgrade_all.ps1 FILE : winget_upgrade_all.ps1 DESCRIPTION : Upgrades all winget-managed packages to latest versions ================================================================================ README -------------------------------------------------------------------------------- PURPOSE: Upgrades all winget-managed packages to their latest versions. Includes logging and automatic cleanup of old log files. REQUIRED INPUTS: None BEHAVIOR: 1. Checks Windows version requirements 2. Locates winget executable in WindowsApps 3. Runs winget upgrade --all with silent options 4. Logs output to Windows temp directory 5. Cleans up logs older than 14 days PREREQUISITES: - Windows 10 1809+ or Windows 11 or Server 2022 - Administrator privileges - winget (App Installer) installed SECURITY NOTES: - No secrets in logs - Accepts all package agreements automatically EXIT CODES: 0 = Success 1 = Failure (system requirements not met) EXAMPLE RUN: [INFO] SYSTEM CHECK ============================================================== Windows Version : 10.0.22631 Product Type : Workstation Requirements met [RUN] UPGRADE ============================================================== Locating winget executable... Running winget upgrade --all... [Upgrade output...] Upgrade completed [RUN] LOG CLEANUP ============================================================== Cleaning up logs older than 14 days... Cleanup completed [OK] FINAL STATUS ============================================================== Result : SUCCESS Log file : C:\Windows\temp\winget-upgrade-log_2024-12-01.txt [OK] SCRIPT COMPLETE ============================================================== CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Fixed EXAMPLE RUN section formatting 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2024-12-01 v1.0.0 Initial release - migrated from SuperOps ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # SYSTEM CHECK # ============================================================================ Write-Host "" Write-Host "[INFO] SYSTEM CHECK" Write-Host "==============================================================" $osInfo = Get-WmiObject -Class Win32_OperatingSystem $version = [Version]$osInfo.Version $productType = $osInfo.ProductType Write-Host "Windows Version : $version" Write-Host "Product Type : $(if ($productType -eq 1) { 'Workstation' } elseif ($productType -eq 3) { 'Server' } else { 'Unknown' })" $meetsRequirements = ($productType -eq 1 -and $version -ge [Version]"10.0.17763") -or ($productType -eq 3 -and $version -ge [Version]"10.0.20348") if (-not $meetsRequirements) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host "System does not meet minimum requirements" Write-Host "Requires: Windows 10 1809+, Windows 11, or Server 2022" exit 1 } Write-Host "[OK] Requirements met" # ============================================================================ # UPGRADE # ============================================================================ Write-Host "" Write-Host "[INFO] UPGRADE" Write-Host "==============================================================" try { Write-Host "[RUN] Locating winget executable..." Set-Location "C:\Program Files\WindowsApps\" $installer = "Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe" $appxDirs = Get-ChildItem $installer -ErrorAction SilentlyContinue if (-not $appxDirs) { throw "Could not find winget installation directory" } $appx = if ($appxDirs.Count -gt 1) { $appxDirs[1] } else { $appxDirs } Set-Location $appx $logPath = "$env:windir\temp\" $logFile = "winget-upgrade-log_" + (Get-Date -Format "yyyy-MM-dd_HH-mm-ss") + ".txt" $fullLogPath = Join-Path -Path $logPath -ChildPath $logFile Write-Host "[RUN] Running winget upgrade --all..." .\winget.exe upgrade --all --silent --include-unknown --include-pinned --accept-package-agreements --accept-source-agreements --disable-interactivity | Out-File -FilePath $fullLogPath -Append Get-Content -Path $fullLogPath | Where-Object { $_ -notmatch "^\s*([/\-\|\\])" -and $_ -notmatch "\sMB" -and $_ -notmatch "%" -and $_.Trim() -ne "" } | ForEach-Object { Write-Host $_ } Write-Host "[OK] Upgrade completed" } catch { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host "Failed to run winget upgrade" Write-Host "Error : $($_.Exception.Message)" exit 1 } # ============================================================================ # LOG CLEANUP # ============================================================================ Write-Host "" Write-Host "[INFO] LOG CLEANUP" Write-Host "==============================================================" Write-Host "[RUN] Cleaning up logs older than 14 days..." Get-ChildItem -Path $logPath -Filter "winget-upgrade-log_*.txt" -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | Remove-Item -Force -ErrorAction SilentlyContinue Write-Host "[OK] Cleanup completed" # ============================================================================ # FINAL STATUS # ============================================================================ Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : SUCCESS" Write-Host "Log file : $fullLogPath" Write-Host "" Write-Host "[INFO] SCRIPT COMPLETE" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/winget_upgrade_all.ps1 ## Outcome The 85-machine deployment that would have taken 64 hours of manual RDP work finished in 3 hours. We ran winget_setup first to normalize the fleet, then batch-deployed all 15 applications in parallel from the RMM. Weekly upgrade jobs now keep everything patched automatically. No more chasing Chrome ZDIs the day after a release. Fleet-scale buildouts like this are the bread and butter of our [RMM automation and fleet scripting](/services/ai-automation) work. machines deployed: 85 (full software stack) time to deploy: 3 hours vs 64 hours manual applications: 15 per machine weekly updates: automated, silent ## Key takeaways - winget works in SYSTEM context if you locate the exe in WindowsApps directly and call the full path. - SuperOps runtime replacement makes one script work for any package, no fork per app. - weekly "upgrade all" jobs eliminate manual patch management for third-party apps. - Microsoft's own repository covers the vast majority of what a small business stack needs. --- # Print to PDF Not Working in Citrix: Remote Application Printing Fix URL: https://limehawk.io/reports/citrix-print-to-pdf Published: 2024-12-03 Author: Corey Watson, Managing Member Topic: Software Read time: 4 min Citrix printer redirection not working, or Print to PDF does nothing? Microsoft Print to PDF is not a redirectable driver. Install PDF24 on the local PC. - Time to fix: 5 minutes ## Incident The ticket came in from the accounting team at a [title company](/industries/title-companies): "When we go to do the reports and print them to PDF, nothing happens." They were running escrow reconciliations in Closer's Choice through a Citrix virtual application, and the finished reports needed to be saved as PDFs for auditing and client delivery. One user clicked Print to PDF and got nothing. No error, no file, no dialog. Just silence. Her coworker had found a worse workaround: she could print to a physical printer, so she was printing each page, photographing it with her phone, and emailing the pictures back to herself. Her own words: "They don't look as professional and we feel like it's just so many extra steps." Creative, yes. Sustainable for a professional reconciliation service, no. ## Why is Citrix printer redirection not working for PDF? Citrix printer redirection not working on Print to PDF is expected. Microsoft Print to PDF is not a real printer driver, so Citrix Workspace never maps it back to the workstation. Physical printers still work. PDF does not. The fix is a third-party PDF printer (we use PDF24) installed on the local machine, not inside the Citrix session. ## Why Microsoft print to PDF doesn't work in Citrix This is a classic Citrix printing gotcha and it trips people up constantly. Microsoft Print to PDF is a Windows feature that saves to the "local" machine, but inside a Citrix session, "local" means the Citrix server. The PDF printer doesn't register as a redirectable client printer, so Citrix Workspace never maps it back to the user's workstation. When you hit print, the file either lands in a temp folder on the server (where the user can't reach it) or disappears entirely. Physical printers keep working because they're already mapped through the session. PDF printers need to support Citrix's printer redirection protocol, and Microsoft's doesn't. ## The key insight You need a PDF printer that Citrix recognizes as a client printer and can redirect through the session. That's the whole fix. ## Resolution We installed PDF24 on the user's local machine (not in the Citrix session, on the actual Windows workstation). PDF24 is free, installs as a proper Windows printer driver, and plays nicely with Citrix client printer mapping. Install steps: 1. Install PDF24 on the LOCAL machine (not in the Citrix session) 2. Download: apps.microsoft.com/detail/XPFD51H3VQZFM0 (or pdf24.org/en/pdf-printer) 3. Restart Citrix Workspace, or log out and back into the virtual app 4. Open the Citrix application. PDF24 now shows in the printer list 5. Print to PDF24. The PDF saves directly to the local Downloads folder ## Why PDF24 works when microsoft's doesn't PDF24 installs as a real printer driver in the Windows spooler, which is what Citrix client printer mapping looks for. A few technical points: - real printer driver: registers in the Windows spooler, visible to Citrix printer redirection - port redirection: uses a virtual port that Citrix can route back to the client - local save: PDF is generated on the client side after receiving the print job - no server dependency: nothing needs to be installed on the Citrix server itself Microsoft Print to PDF is technically a print-to-file handler, not a redirectable driver, which is why it vanishes in a Citrix session. ## Outcome PDF24 was installed in under two minutes. User logged back into the Citrix app, the PDF24 printer appeared in the print dialog, she selected it, clicked print, and the PDF landed in her Downloads folder. No more phone photos. Reports look professional again and the workflow dropped from about 15 steps to 2. time to fix: 5 minutes (including download) cost: $0 (PDF24 is free) steps eliminated: 13 (print → photo → email → save) ## Key takeaways - Microsoft Print to PDF doesn't work with Citrix client printer redirection. It's a known limitation, not a misconfiguration. - third-party PDF printers like PDF24 install as proper drivers that Citrix can redirect. - install on the LOCAL machine, not inside the Citrix session. They map through automatically. - when users build elaborate workarounds (photographing printed pages), there's almost always a 5-minute fix they don't know about. --- # OneStart Browser Hijacker: Complete Removal from 30 Infected Machines URL: https://limehawk.io/reports/onestart-malware-removal Published: 2024-12-01 Author: Corey Watson, Managing Member Topic: Security Read time: 7 min How to uninstall OneStart: the Windows uninstaller leaves scheduled tasks that put it back. Kill the processes, delete the tasks, then wipe the files. - Machines: 30 ## Incident Help desk tickets started flooding in over a few days: "My browser homepage changed." "There's a weird toolbar I didn't install." "Popups keep appearing." By the time we correlated them, 30 machines across the organization were all showing the same fingerprint. OneStart.ai had taken over browsers, flipped search settings, and planted itself deep enough that standard antivirus wasn't flagging it. The client's EDR was happy. The users were not. ## What is OneStart? OneStart.ai markets itself as a "productivity browser" but behaves like classic adware. It gets bundled with free software downloads (PDF converters, video players, YouTube-to-MP3 sites, the usual suspects) and installs during the "next, next, next" phase of a free tool install. Once on a machine, it does the whole adware playbook: - browser hijacking: changes homepage, default search, and new tab page to OneStart properties - ad injection: injects banner ads and popups into web pages, including banking sites - persistence: creates multiple scheduled tasks that reinstall it if removed - data collection: tracks browsing history and sends to remote servers - AV evasion: signed binaries and a "legitimate" uninstaller let it bypass most antivirus The uninstaller in Add/Remove Programs is the bait. It removes the visible application and nothing else: the scheduled tasks, registry entries, and hidden folders all stay put, and within 15 minutes the whole thing is back. ## How do I uninstall OneStart? You cannot uninstall OneStart from Settings or Add/Remove Programs and be done. That path is how to remove onestart from the Start menu, not from the machine. Working order on one PC: end OneStart.exe and DBar.exe, delete the OneStart scheduled tasks, then remove the leftover files and registry keys. Skip a step and it comes back in about 15 minutes. The script below is that sequence, used to uninstall OneStart on 30 infected machines at once. ## Why standard removal fails Users had tried uninstalling it themselves. Their IT contact had tried Control Panel. We got the tickets because it kept coming back within hours. Four things keep it alive: 1. scheduled tasks: OneStartUpdaterTaskUser runs every 15 minutes and reinstalls if files are missing 2. hidden AppData folders: files in %LOCALAPPDATA%\OneStart.ai are untouched by the uninstaller 3. registry persistence: startup entries in HKCU\Software\Microsoft\Windows\CurrentVersion\Run 4. process respawn: if you kill OneStart.exe, DBar.exe relaunches it immediately Order matters. If you delete files first, the scheduled task restores them. If you kill the process first but leave the task, the task respawns it. We needed a script that kills every process first, then removes scheduled tasks, then cleans files, then purges registry, in that exact sequence. ## Solution We built a PowerShell removal script that calls NirSoft UninstallView for the initial uninstall attempt, then does the manual cleanup the uninstaller skips. Every registry key is backed up before deletion so we have a recovery path if anything goes sideways on an unusual machine build. ```onestart_remove_complete.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : OneStart Complete Removal v1.2.2 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\onestart_remove_complete.ps1 ================================================================================ FILE : onestart_remove_complete.ps1 DESCRIPTION : Completely removes OneStart.ai PUP from Windows systems -------------------------------------------------------------------------------- README -------------------------------------------------------------- PURPOSE Completely removes OneStart.ai browser/PUP from Windows systems. First attempts a clean uninstall via NirSoft UninstallView, then performs manual cleanup of any remaining processes, files, registry keys, and scheduled tasks. -------------------------------------------------------------- DATA SOURCES & PRIORITY 1. NirSoft UninstallView for silent uninstall attempt 2. Windows Task Scheduler for scheduled task removal 3. File system for leftover folders 4. Windows Registry for leftover keys -------------------------------------------------------------- REQUIRED INPUTS - $appName : Application name for uninstall (default: OneStart) -------------------------------------------------------------- SETTINGS - Downloads NirSoft UninstallView if not present - Attempts silent uninstall first - Performs comprehensive manual cleanup after uninstall -------------------------------------------------------------- BEHAVIOR 1. Downloads NirSoft UninstallView (architecture-appropriate) 2. Attempts silent uninstall of OneStart via UninstallView 3. Terminates any running OneStart/DBar processes 4. Removes scheduled tasks matching OneStart patterns 5. Removes leftover files and folders from AppData, ProgramData, Program Files 6. Backs up registry keys to $env:SystemDrive\limehawk\registry_backup before removal 7. Cleans up registry keys in HKCU and HKLM 8. Reports final cleanup status -------------------------------------------------------------- PREREQUISITES - Windows PowerShell 5.1+ - Administrative privileges required - Internet access for UninstallView download -------------------------------------------------------------- SECURITY NOTES - No secrets in logs - Only removes OneStart-related items using known paths/patterns - Registry keys are backed up before removal for recovery if needed -------------------------------------------------------------- ENDPOINTS - https://www.nirsoft.net/utils/uninstallview-x64.zip - https://www.nirsoft.net/utils/uninstallview.zip -------------------------------------------------------------- EXIT CODES - 0 : Success - 1 : Failure -------------------------------------------------------------- EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Application Name : OneStart [RUN] SETTING UP NIRSOFT UNINSTALLVIEW ============================================================== Architecture : 64-bit Download URL : https://www.nirsoft.net/utils/uninstallview-x64.zip Downloading UninstallView... Download complete Extracting to C:\limehawk\nirsoft... Extraction complete [RUN] ATTEMPTING UNINSTALL ============================================================== Searching for : OneStart Attempting silent uninstall... Uninstall command issued Waiting 10 seconds for uninstall to complete... [RUN] TERMINATING PROCESSES ============================================================== Searching for OneStart/DBar processes... Found process : OneStart (PID: 12345) Terminated : OneStart Process cleanup complete [RUN] REMOVING SCHEDULED TASKS ============================================================== Searching for OneStart scheduled tasks... Found : OneStartUpdaterTaskUser134.0.6 Removed : OneStartUpdaterTaskUser134.0.6 Removing task folders... Task cleanup complete [RUN] CLEANING FILES ============================================================== Checking known OneStart locations... Removed : C:\Users\User\AppData\Local\OneStart.ai Removed : C:\Users\User\AppData\Roaming\OneStart File cleanup complete [RUN] BACKING UP REGISTRY ============================================================== Creating registry backups before removal... Backed up : HKCU:\Software\OneStart.ai Backup location : \limehawk\registry_backup\onestart_20251201_120000 Registry backup complete [RUN] CLEANING REGISTRY ============================================================== Cleaning registry keys... Removed : HKCU:\Software\OneStart.ai Checking startup entries... Registry cleanup complete [INFO] FINAL STATUS ============================================================== Result : Success Uninstall Attempted : Yes Processes Terminated : 1 Tasks Removed : 1 Folders Removed : 2 Registry Keys Backed Up : 1 Registry Keys Removed : 1 [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.2.2 Fixed EXAMPLE RUN section formatting 2026-01-19 v1.2.1 Updated to two-line ASCII console output style 2025-12-23 v1.2.0 Updated to Limehawk Script Framework 2025-12-01 v1.1.0 Add registry backup before removal to $env:SystemDrive\limehawk\registry_backup 2025-12-01 v1.0.0 Initial release - combines NirSoft uninstall with manual cleanup ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================== # INPUTS # ============================================================================== $appName = 'OneStart' # ============================================================================== # CONFIGURATION (internal - not user inputs) # ============================================================================== $destinationFolder = "$env:SystemDrive\limehawk\nirsoft" $registryBackupFolder = "$env:SystemDrive\limehawk\registry_backup" $processPatterns = @( 'OneStart' 'onestart' 'onestartbar' 'onestartupdate' 'DBar' ) $taskPathPatterns = @( '\OneStartUser\*' '\OneStart*' ) $taskNamePatterns = @( 'OneStart*' '*OneStart*' ) $folderPaths = @( "$env:APPDATA\OneStart" "$env:APPDATA\OneStart.ai" "$env:LOCALAPPDATA\OneStart" "$env:LOCALAPPDATA\OneStart.ai" "$env:LOCALAPPDATA\OneStartBar" "$env:ProgramData\OneStart" "$env:ProgramData\OneStart.ai" "$env:ProgramFiles\OneStart" "$env:ProgramFiles\OneStart.ai" "${env:ProgramFiles(x86)}\OneStart" "${env:ProgramFiles(x86)}\OneStart.ai" ) $registryPaths = @( 'HKCU:\Software\OneStart.ai' 'HKCU:\Software\OneStart' 'HKCU:\Software\OneStartBar' 'HKCU:\Software\DBar' 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OneStart.ai OneStart' 'HKLM:\Software\OneStart.ai' 'HKLM:\Software\OneStart' 'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{31F4B209-D4E1-41E0-A34F-35EFF7117AE8}' ) $runKeyPaths = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' ) # ============================================================================== # STATE TRACKING # ============================================================================== $uninstallAttempted = $false $processesTerminated = 0 $tasksRemoved = 0 $foldersRemoved = 0 $registryKeysBackedUp = 0 $registryKeysRemoved = 0 # ============================================================================== # INPUT VALIDATION # ============================================================================== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" $errorOccurred = $false $errorText = "" if ([string]::IsNullOrWhiteSpace($appName)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Application name is required" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host "Input validation failed:" Write-Host $errorText exit 1 } Write-Host "Application Name : $appName" # ============================================================================== # NIRSOFT UNINSTALLVIEW SETUP # ============================================================================== Write-Host "" Write-Host "[INFO] NIRSOFT UNINSTALLVIEW SETUP" Write-Host "==============================================================" if ([Environment]::Is64BitOperatingSystem) { $downloadUrl = "https://www.nirsoft.net/utils/uninstallview-x64.zip" Write-Host "Architecture : 64-bit" } else { $downloadUrl = "https://www.nirsoft.net/utils/uninstallview.zip" Write-Host "Architecture : 32-bit" } Write-Host "Download URL : $downloadUrl" try { if (-not (Test-Path $destinationFolder)) { New-Item -ItemType Directory -Path $destinationFolder -Force | Out-Null Write-Host "Created folder : $destinationFolder" } $zipFilePath = Join-Path $destinationFolder "UninstallView.zip" $uninstallViewPath = Join-Path $destinationFolder "UninstallView.exe" if (-not (Test-Path $uninstallViewPath)) { Write-Host "[RUN] Downloading UninstallView..." Invoke-WebRequest -Uri $downloadUrl -OutFile $zipFilePath -UseBasicParsing Write-Host "[OK] Download complete" Write-Host "[RUN] Extracting to $destinationFolder..." Expand-Archive -Path $zipFilePath -DestinationPath $destinationFolder -Force Write-Host "[OK] Extraction complete" if (Test-Path $zipFilePath) { Remove-Item $zipFilePath -Force } } else { Write-Host "UninstallView already present" } } catch { Write-Host "Warning: Failed to setup UninstallView - $($_.Exception.Message)" Write-Host "Continuing with manual removal only..." $uninstallViewPath = $null } # ============================================================================== # UNINSTALL ATTEMPT # ============================================================================== Write-Host "" Write-Host "[INFO] UNINSTALL ATTEMPT" Write-Host "==============================================================" if ($uninstallViewPath -and (Test-Path $uninstallViewPath)) { Write-Host "[RUN] Searching for : $appName" Write-Host "[RUN] Attempting silent uninstall..." try { $process = Start-Process -FilePath $uninstallViewPath ` -ArgumentList "/quninstallwildcard `"$appName`" 5" ` -PassThru -Wait -WindowStyle Hidden $uninstallAttempted = $true Write-Host "[OK] Uninstall command issued (exit code: $($process.ExitCode))" Write-Host "Waiting 10 seconds for uninstall to complete..." Start-Sleep -Seconds 10 } catch { Write-Host "Warning: Uninstall attempt failed - $($_.Exception.Message)" } } else { Write-Host "UninstallView not available, skipping automated uninstall" } # ============================================================================== # PROCESS TERMINATION # ============================================================================== Write-Host "" Write-Host "[INFO] PROCESS TERMINATION" Write-Host "==============================================================" Write-Host "[RUN] Searching for OneStart/DBar processes..." foreach ($pattern in $processPatterns) { try { $processes = Get-Process -Name "*$pattern*" -ErrorAction SilentlyContinue foreach ($proc in $processes) { Write-Host "Found process : $($proc.ProcessName) (PID: $($proc.Id))" try { Stop-Process -Id $proc.Id -Force -ErrorAction Stop Write-Host "[OK] Terminated : $($proc.ProcessName)" $processesTerminated++ } catch { Write-Host "[WARN] Failed to terminate : $($proc.ProcessName)" } } } catch { # No matching processes found } } if ($processesTerminated -eq 0) { Write-Host "No matching processes found" } Write-Host "[OK] Process cleanup complete" # ============================================================================== # SCHEDULED TASK REMOVAL # ============================================================================== Write-Host "" Write-Host "[INFO] SCHEDULED TASK REMOVAL" Write-Host "==============================================================" Write-Host "[RUN] Searching for OneStart scheduled tasks..." foreach ($taskPath in $taskPathPatterns) { foreach ($taskName in $taskNamePatterns) { try { $tasks = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue foreach ($task in $tasks) { Write-Host "Found : $($task.TaskName)" try { Unregister-ScheduledTask -TaskPath $task.TaskPath -TaskName $task.TaskName -Confirm:$false -ErrorAction Stop Write-Host "[OK] Removed : $($task.TaskName)" $tasksRemoved++ } catch { Write-Host "[WARN] Failed to remove : $($task.TaskName)" } } } catch { # No matching tasks } } } # Also search root level try { $rootTasks = Get-ScheduledTask -TaskName "*OneStart*" -ErrorAction SilentlyContinue foreach ($task in $rootTasks) { Write-Host "Found : $($task.TaskName)" try { Unregister-ScheduledTask -TaskPath $task.TaskPath -TaskName $task.TaskName -Confirm:$false -ErrorAction Stop Write-Host "[OK] Removed : $($task.TaskName)" $tasksRemoved++ } catch { Write-Host "[WARN] Failed to remove : $($task.TaskName)" } } } catch { # No matching tasks } # Remove task folders Write-Host "[RUN] Removing task folders..." $taskFoldersToRemove = @('\OneStartUser\OneStartUpdater\', '\OneStartUser\') try { $scheduleService = New-Object -ComObject Schedule.Service $scheduleService.Connect() foreach ($folder in $taskFoldersToRemove) { try { $parentPath = Split-Path $folder.TrimEnd('\') -Parent $folderName = Split-Path $folder.TrimEnd('\') -Leaf if ([string]::IsNullOrWhiteSpace($parentPath)) { $parentPath = '\' } $parentFolder = $scheduleService.GetFolder($parentPath) $parentFolder.DeleteFolder($folderName, 0) Write-Host "Removed folder : $folder" } catch { # Folder may not exist or not empty } } } catch { Write-Host "Could not access Task Scheduler COM object" } if ($tasksRemoved -eq 0) { Write-Host "No matching tasks found" } Write-Host "[OK] Task cleanup complete" # ============================================================================== # FILE CLEANUP # ============================================================================== Write-Host "" Write-Host "[INFO] FILE CLEANUP" Write-Host "==============================================================" Write-Host "[RUN] Checking known OneStart locations..." foreach ($folderPath in $folderPaths) { if (Test-Path $folderPath) { try { Remove-Item -Path $folderPath -Recurse -Force -ErrorAction Stop Write-Host "[OK] Removed : $folderPath" $foldersRemoved++ } catch { Write-Host "[WARN] Failed to remove : $folderPath - $($_.Exception.Message)" } } } # Check all user profiles $userProfiles = Get-ChildItem "$env:SystemDrive\Users" -Directory -ErrorAction SilentlyContinue foreach ($profile in $userProfiles) { $userPaths = @( "$($profile.FullName)\AppData\Roaming\OneStart" "$($profile.FullName)\AppData\Roaming\OneStart.ai" "$($profile.FullName)\AppData\Local\OneStart" "$($profile.FullName)\AppData\Local\OneStart.ai" "$($profile.FullName)\AppData\Local\OneStartBar" ) foreach ($userPath in $userPaths) { if (Test-Path $userPath) { try { Remove-Item -Path $userPath -Recurse -Force -ErrorAction Stop Write-Host "[OK] Removed : $userPath" $foldersRemoved++ } catch { Write-Host "[WARN] Failed to remove : $userPath" } } } } if ($foldersRemoved -eq 0) { Write-Host "No leftover folders found" } Write-Host "[OK] File cleanup complete" # ============================================================================== # REGISTRY BACKUP # ============================================================================== Write-Host "" Write-Host "[INFO] REGISTRY BACKUP" Write-Host "==============================================================" Write-Host "[RUN] Creating registry backups before removal..." try { if (-not (Test-Path $registryBackupFolder)) { New-Item -ItemType Directory -Path $registryBackupFolder -Force | Out-Null Write-Host "Created backup folder : $registryBackupFolder" } $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $backupSubFolder = Join-Path $registryBackupFolder "onestart_$timestamp" New-Item -ItemType Directory -Path $backupSubFolder -Force | Out-Null foreach ($regPath in $registryPaths) { if (Test-Path $regPath) { try { # Convert PowerShell path to reg.exe format $regExePath = $regPath -replace '^HKCU:', 'HKEY_CURRENT_USER' -replace '^HKLM:', 'HKEY_LOCAL_MACHINE' $safeName = ($regPath -replace ':', '' -replace '\\', '_' -replace '\{', '' -replace '\}', '') $backupFile = Join-Path $backupSubFolder "$safeName.reg" $regExport = Start-Process -FilePath "reg.exe" -ArgumentList "export `"$regExePath`" `"$backupFile`" /y" -Wait -PassThru -WindowStyle Hidden if ($regExport.ExitCode -eq 0) { Write-Host "[OK] Backed up : $regPath" $registryKeysBackedUp++ } else { Write-Host "[WARN] Failed to backup : $regPath" } } catch { Write-Host "[WARN] Failed to backup : $regPath - $($_.Exception.Message)" } } } # Backup Run key entries foreach ($runKey in $runKeyPaths) { if (Test-Path $runKey) { try { $props = Get-ItemProperty -Path $runKey -ErrorAction SilentlyContinue if ($props) { $matchingProps = $props.PSObject.Properties | Where-Object { $_.Name -like "*OneStart*" -or $_.Name -like "*DBar*" } if ($matchingProps) { $regExePath = $runKey -replace '^HKCU:', 'HKEY_CURRENT_USER' -replace '^HKLM:', 'HKEY_LOCAL_MACHINE' $safeName = ($runKey -replace ':', '' -replace '\\', '_') + "_Run" $backupFile = Join-Path $backupSubFolder "$safeName.reg" $regExport = Start-Process -FilePath "reg.exe" -ArgumentList "export `"$regExePath`" `"$backupFile`" /y" -Wait -PassThru -WindowStyle Hidden if ($regExport.ExitCode -eq 0) { Write-Host "[OK] Backed up Run key : $runKey" $registryKeysBackedUp++ } } } } catch { # Could not access run key } } } if ($registryKeysBackedUp -gt 0) { Write-Host "Backup location : $backupSubFolder" } } catch { Write-Host "Warning: Could not create backup folder - $($_.Exception.Message)" } if ($registryKeysBackedUp -eq 0) { Write-Host "No registry keys found to backup" } Write-Host "[OK] Registry backup complete" # ============================================================================== # REGISTRY CLEANUP # ============================================================================== Write-Host "" Write-Host "[INFO] REGISTRY CLEANUP" Write-Host "==============================================================" Write-Host "[RUN] Cleaning registry keys..." foreach ($regPath in $registryPaths) { if (Test-Path $regPath) { try { Remove-Item -Path $regPath -Recurse -Force -ErrorAction Stop Write-Host "[OK] Removed : $regPath" $registryKeysRemoved++ } catch { Write-Host "[WARN] Failed to remove : $regPath" } } } # Clean Run keys Write-Host "[RUN] Checking startup entries..." foreach ($runKey in $runKeyPaths) { if (Test-Path $runKey) { try { $props = Get-ItemProperty -Path $runKey -ErrorAction SilentlyContinue if ($props) { $props.PSObject.Properties | Where-Object { $_.Name -like "*OneStart*" -or $_.Name -like "*DBar*" } | ForEach-Object { try { Remove-ItemProperty -Path $runKey -Name $_.Name -Force -ErrorAction Stop Write-Host "[OK] Removed startup entry : $($_.Name)" $registryKeysRemoved++ } catch { Write-Host "[WARN] Failed to remove startup entry : $($_.Name)" } } } } catch { # Could not access run key } } } if ($registryKeysRemoved -eq 0) { Write-Host "No leftover registry keys found" } Write-Host "[OK] Registry cleanup complete" # ============================================================================== # FINAL STATUS # ============================================================================== Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : Success" Write-Host "Uninstall Attempted : $(if ($uninstallAttempted) { 'Yes' } else { 'No' })" Write-Host "Processes Terminated : $processesTerminated" Write-Host "Tasks Removed : $tasksRemoved" Write-Host "Folders Removed : $foldersRemoved" Write-Host "Registry Keys Backed Up : $registryKeysBackedUp" Write-Host "Registry Keys Removed : $registryKeysRemoved" Write-Host "" Write-Host "[INFO] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/onestart_remove_complete.ps1 Safety feature: all registry keys are backed up to C:\limehawk\registry_backup before deletion. If something goes wrong we can restore. ## Post-removal After the script runs, browsers still have the jacked settings cached in their own profiles. Users need to reset each browser manually: - Chrome: Settings > Reset settings > Restore settings to their original defaults - Edge: Settings > Reset settings > Restore settings to their default values - Firefox: Help > More troubleshooting information > Refresh Firefox - All browsers: check the Extensions page and remove anything suspicious ## Outcome Script deployed to all 30 affected machines via RMM. Average runtime was 47 seconds per machine. Zero reinfections reported in the 30 days since cleanup. We also blocked the OneStart.ai domain at the DNS filter and added detection rules to flag future installations before they spread. machines cleaned: 30 script runtime: 47 seconds per machine items removed: 12+ per machine average reinfections: zero (30 days) Prevention measures implemented: - blocked OneStart.ai and related domains at the DNS filter - added OneStart to the application blacklist in RMM - user training: always choose Custom Install and uncheck bundled software - weekly scheduled scan for OneStart processes as an early warning The DNS blocking and RMM blacklisting are now part of the baseline on every fleet under our [managed IT and security service](/services/managed-it-security), so the next PUP like this gets stopped at install time instead of removed after the fact. ## Key takeaways - the "uninstall" option in Add/Remove Programs is deliberately incomplete for PUPs like this. - scheduled tasks are the real persistence mechanism. Kill them before touching files. - signed-but-unwanted is the hardest category for AV to catch; DNS blocking is the better control. - when a removal keeps failing, it's almost always process-order, not the cleanup commands themselves. --- # Laptop Stolen at Airport: Emergency Remote Wipe via RMM URL: https://limehawk.io/reports/mdm-remote-wipe Published: 2024-12-01 Author: Corey Watson, Managing Member Topic: Security Read time: 5 min Friday 4:47 PM. Sales rep's laptop stolen with customer data. Device online on a different network. We had minutes to act. - Time to wipe: 16 minutes from theft ## Incident Friday, 4:47 PM. Sales rep calls from an airport. Laptop bag was sitting next to her seat at the gate, she walked 30 feet to a phone charging station, came back five minutes later, laptop was gone. Ticket came in as "laptop stolen at airport - need help." Within two minutes of pulling the device up in the RMM console we had the worst-case confirmation: the agent was online, and the public IP was not the airport's WiFi. It had already changed while she was on the phone with us. Whoever took it had it on their own network. We had a connection to it, and we had minutes, not hours. ## Assessment - theft confirmed: device on a different network than the airport WiFi, IP changed mid-call - data at risk: customer contracts, pricing sheets, CRM credentials cached in Chrome - BitLocker status: enabled, but the device was in sleep mode, not requiring a boot-time decryption prompt - RMM agent: online and responsive. We had a channel ## Why standard options weren't good enough Each "standard" option for a stolen laptop has a critical weakness when the attacker is already running: - wait for Intune wipe: only triggers when the device checks in. Default Intune sync schedule is 8 hours. If the thief disables WiFi, it's never. - rotate credentials: disabling the user's AD account and revoking CRM tokens is fine, but cached credentials in Chrome still work offline, and local files on disk don't care whether her account is enabled - trust BitLocker: the device was in sleep mode, not fully powered off. Opening the lid hits a Windows lock screen, not the BitLocker pre-boot prompt. A competent attacker can boot to recovery and work from there. - RMM direct wipe: the agent is online right now. We can push a command to it in the next 60 seconds. No waiting for an MDM sync cycle that might never come. Decision was made. We got verbal authorization from the sales director and documented it in the ticket. Time from theft report to authorization: 11 minutes. ## Resolution Windows 10/11 ships with an MDM_RemoteWipe CIM class in the root\cimv2\mdm\dmmap namespace regardless of whether the device is MDM-enrolled. It exposes the same factory-reset mechanism Intune uses, which means you can invoke it locally via PowerShell, and if you can invoke it via PowerShell, you can push it through RMM. ## Warning: destructive operation Irreversible factory reset. All data permanently erased. No undo, no confirmation prompt, no recovery. Last-resort for confirmed theft scenarios only. ```workstation_remote_wipe.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Remote Wipe v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\workstation_remote_wipe.ps1 ================================================================================ FILE : workstation_remote_wipe.ps1 DESCRIPTION : Initiates MDM remote wipe to factory reset Windows device -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Initiates a remote wipe of the Windows device using the MDM RemoteWipe CSP. This completely erases all data on the device and resets it to factory state. *** WARNING: THIS ACTION IS IRREVERSIBLE *** *** ALL DATA ON THE DEVICE WILL BE PERMANENTLY DELETED *** DATA SOURCES & PRIORITY - Local MDM namespace (root\cimv2\mdm\dmmap) - MDM_RemoteWipe class instance REQUIRED INPUTS None - all configuration is internal to the MDM subsystem SETTINGS - No configurable settings; wipe executes immediately upon script run BEHAVIOR The script performs the following actions in order: 1. Creates CIM session to local MDM namespace 2. Retrieves MDM_RemoteWipe instance 3. Invokes the doWipeMethod 4. Device begins factory reset process PREREQUISITES - Windows 10/11 (MDM enrolled or Azure AD joined) - Administrator privileges - Device must have MDM RemoteWipe capability SECURITY NOTES - THIS IS A DESTRUCTIVE OPERATION - Use only on lost/stolen devices or for secure decommissioning - Cannot be undone once initiated - Ensure proper authorization before running - No secrets in logs ENDPOINTS - Not applicable (local CIM/WMI operations only) EXIT CODES 0 = Wipe initiated successfully 1 = Failure (CIM session, instance not found, or wipe failed) EXAMPLE RUN [WARN] INITIALIZING REMOTE WIPE ============================================================== CIM Session : Created MDM Instance : Found [RUN] EXECUTING WIPE ============================================================== Status : Invoking doWipeMethod... Result : Wipe initiated successfully [OK] FINAL STATUS ============================================================== REMOTE WIPE INITIATED - DEVICE WILL RESET [OK] SCRIPT COMPLETE ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Updated to two-line ASCII console output style 2026-01-17 v1.0.2 Fixed framework compliance: header format, section names, removed param() blocks, added missing README sections 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2024-12-01 v1.0.0 Initial release - migrated from SuperOps ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Write-Section { param([string]$title, [string]$status = "INFO") Write-Host "" Write-Host ("[$status] $title") Write-Host ("=" * 62) } function PrintKV($label, $value) { $lbl = $label.PadRight(24) Write-Host (" {0} : {1}" -f $lbl, $value) } # ============================================================================ # MAIN SCRIPT # ============================================================================ try { # MDM Configuration $namespaceName = "root\cimv2\mdm\dmmap" $className = "MDM_RemoteWipe" $methodName = "doWipeMethod" Write-Section "INITIALIZING REMOTE WIPE" "WARN" Write-Host "" Write-Host " *** WARNING: THIS WILL ERASE ALL DATA ON THIS DEVICE ***" Write-Host " *** THIS ACTION CANNOT BE UNDONE ***" Write-Host "" # Create CIM session $session = New-CimSession -ErrorAction Stop if (-not $session) { PrintKV "CIM Session" "FAILED" throw "Failed to create CIM session" } PrintKV "CIM Session" "Created" # Get MDM_RemoteWipe instance $instance = Get-CimInstance -Namespace $namespaceName -ClassName $className ` -Filter "ParentID='./Vendor/MSFT' and InstanceID='RemoteWipe'" -ErrorAction Stop if (-not $instance) { PrintKV "MDM Instance" "NOT FOUND" throw "MDM_RemoteWipe instance not found. Device may not be MDM enrolled." } PrintKV "MDM Instance" "Found" # Create method parameters $params = New-Object Microsoft.Management.Infrastructure.CimMethodParametersCollection $param = [Microsoft.Management.Infrastructure.CimMethodParameter]::Create("param", "", "String", "In") $params.Add($param) # Execute wipe Write-Section "EXECUTING WIPE" "RUN" PrintKV "Status" "Invoking doWipeMethod..." $result = $session.InvokeMethod($namespaceName, $instance, $methodName, $params) # Check result switch ($result.ReturnValue) { 0 { PrintKV "Result" "Wipe initiated successfully" Write-Section "FINAL STATUS" "OK" Write-Host " REMOTE WIPE INITIATED - DEVICE WILL RESET" Write-Host "" Write-Host " The device will restart and begin the factory reset process." Write-Host " All data will be permanently erased." Write-Section "SCRIPT COMPLETE" "OK" exit 0 } default { PrintKV "Result" "FAILED (Return code: $($result.ReturnValue))" Write-Section "FINAL STATUS" "ERROR" Write-Host " REMOTE WIPE FAILED" Write-Section "SCRIPT COMPLETE" "ERROR" exit 1 } } } catch { Write-Section "ERROR OCCURRED" "ERROR" PrintKV "Error Message" $_.Exception.Message PrintKV "Error Type" $_.Exception.GetType().FullName Write-Host "" Write-Host " Common causes:" Write-Host " - Device is not MDM enrolled" Write-Host " - Device is not Azure AD joined" Write-Host " - Insufficient permissions" Write-Host " - MDM policies not configured" Write-Section "SCRIPT COMPLETE" "ERROR" exit 1 } finally { # Clean up CIM session if ($session) { Remove-CimSession -CimSession $session -ErrorAction SilentlyContinue } } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/workstation_remote_wipe.ps1 ## How it works 1. opens a CIM session to the local WMI repository 2. queries root\cimv2\mdm\dmmap for the MDM_RemoteWipe provider 3. invokes doWipeMethod, the same method Intune calls remotely 4. Windows triggers a SYSTEM-level factory reset, bypassing all user permissions 5. device immediately reboots into Windows RE and begins the secure erase Critical advantage: this doesn't wait for cloud sync. The RMM agent executes it locally with SYSTEM privileges. If the device is online to your RMM, you can wipe in seconds instead of hours. ## Outcome Script executed at 5:03 PM. RMM confirmed the command was received. 47 seconds later the device dropped offline permanently, exactly what we expected as Windows rebooted into recovery mode to begin the wipe. Whoever was in possession of that laptop got a freshly factory-reset device. We confirmed no data was exfiltrated before the wipe started. - time to wipe: 16 minutes from theft report to execution - data exposed: 0 records (confirmed, no evidence of exfiltration) - compliance status: maintained, no breach notification required The reason this took 16 minutes instead of a weekend: the device was already on our RMM with a tested wipe procedure on the shelf. That's the unglamorous value of [managed IT](/services/managed-it-security) — the emergency plan exists before the emergency does. ## Key takeaways - RMM agents give you immediate access when cloud MDM sync cycles are too slow. If you only rely on Intune, a default sync schedule can mean 8+ hours of exposure. - sleep mode does not equal BitLocker protection. The device has to be fully powered off for BitLocker to actually gate access at boot. - have your wipe script tested and documented before you need it. 5 PM on a Friday is not the time to write and QA PowerShell. - document authorization in the ticket before you wipe. Remote wipe is destructive and irreversible. You want a paper trail with an authorized approver on it, not just a verbal "go.". --- # Mass BIOS & Driver Updates: Dell Fleet Vulnerability Patching URL: https://limehawk.io/reports/dell-command-update Published: 2024-12-01 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Critical BIOS vulnerability affecting 127 Dell machines. Manual updates would take weeks. Dell Command Update via RMM: 2 weeks, zero site visits. - Machines: 127 ## Incident Dell security bulletin dropped on a Friday: critical BIOS vulnerability affecting OptiPlex and Latitude models, CVSS 8.2, local privilege escalation. Our client had 127 affected Dell machines split across 4 locations, and the cyber insurance policy obligated them to patch within 30 days of a critical bulletin. Manual updates would mean physically visiting every machine. Weeks of work and a lot of windshield time. ## The challenge BIOS updates used to be a hands-on nightmare. You download the .exe from Dell's support site, stage it on a USB stick or a share, visit each machine, run it, reboot, pray nothing flashes wrong. For 127 machines across 4 sites, that's weeks and a nonzero brick risk per machine. Initial assessment: - affected machines: 127 Dell OptiPlex 7080, 7090, Latitude 5520, 5530 - vulnerability: BIOS privilege escalation, CVSS 8.2 - compliance deadline: 30 days from bulletin release - constraint: zero disruption to business operations, updates during off-hours only Dell Command Update is Dell's official tool for managing drivers and BIOS. It has a CLI mode that can scan for updates, download them, and apply them silently. The trick is getting it installed and configured across the fleet without touching each machine. ## The solution: Dell command update via RMM We built a PowerShell wrapper that installs Dell Command Update via winget (so it's always the current version), configures it for silent unattended operation, and runs the scan/apply cycle. Three configuration options did the heavy lifting: - autoSuspendBitLocker=enable: automatically suspends BitLocker before a BIOS update and resumes after. Without this, post-update reboots hit the BitLocker recovery key prompt and users get locked out. - userConsent=disable: no popups asking users to approve updates - reboot=disable: updates stage but don't force a reboot. We control reboot timing separately through the RMM ```dell_command_update.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Dell Command Update v2.1.4 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\dell_command_update.ps1 ================================================================================ FILE : dell_command_update.ps1 DESCRIPTION : Installs Dell Command Update and applies driver/firmware updates -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Installs Dell Command Update via winget and runs it to scan and apply driver/firmware updates silently. Designed for automated Dell system maintenance in RMM environments. DATA SOURCES & PRIORITY 1) Hardcoded values (defined within the script body) 2) winget package manager 3) Dell Command Update CLI REQUIRED INPUTS - DcuCliPath : Path to Dell Command Update CLI executable - DcuLogPath : Directory for DCU log files - WingetId : winget package ID for Dell Command Update SETTINGS - Uses winget for package management - Configures DCU to auto-suspend BitLocker and disable user consent prompts - Applies updates without rebooting (reboot=disable) - Logs scan and apply operations to C:\dell\logs BEHAVIOR 1. Verifies winget is available 2. Installs Dell Command Update if not present 3. Verifies DCU CLI exists 4. Configures DCU for silent operation 5. Scans for available updates and outputs log to console 6. Applies updates without rebooting and outputs log to console PREREQUISITES - Windows 10 1809+ or Windows 11 with winget - Dell system (script will fail on non-Dell hardware) - Admin privileges for installation and updates SECURITY NOTES - No secrets in logs - Only affects Dell driver/firmware components EXIT CODES - 0: Success - 1: Failure (winget not found, DCU not installed, etc.) EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== DCU CLI Path : C:\Program Files\Dell\CommandUpdate\dcu-cli.exe DCU Log Path : C:\dell\logs Winget ID : Dell.CommandUpdate [RUN] OPERATION ============================================================== Checking for winget... winget found Checking for Dell Command Update... Dell Command Update already installed Configuring Dell Command Update... Configuration complete Scanning for updates... Scan complete [INFO] SCAN LOG ============================================================== [RUN] APPLY UPDATES ============================================================== Applying updates... Updates applied [INFO] APPLY UPDATES LOG ============================================================== [OK] RESULT ============================================================== Status : Success [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v2.1.4 Fixed EXAMPLE RUN section formatting 2026-01-19 v2.1.3 Updated to two-line ASCII console output style 2026-01-14 v2.1.2 Fixed header formatting for framework compliance 2025-12-23 v2.1.1 Updated to Limehawk Script Framework 2025-12-01 v2.1.0 Output scan and apply log contents to console for RMM visibility 2025-12-01 v2.0.0 Switched from Chocolatey to winget for package management 2025-11-29 v1.0.0 Initial Style A implementation ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE ==== $errorOccurred = $false $errorText = "" # ==== HARDCODED INPUTS ==== $DcuCliPath = "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe" $DcuLogPath = "C:\dell\logs" $WingetId = "Dell.CommandUpdate" # ==== VALIDATION ==== if ([string]::IsNullOrWhiteSpace($DcuCliPath)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- DcuCliPath is required." } if ([string]::IsNullOrWhiteSpace($DcuLogPath)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- DcuLogPath is required." } if ([string]::IsNullOrWhiteSpace($WingetId)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- WingetId is required." } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "DCU CLI Path : $DcuCliPath" Write-Host "DCU Log Path : $DcuLogPath" Write-Host "Winget ID : $WingetId" Write-Host "" Write-Host "[RUN] OPERATION" Write-Host "==============================================================" try { # Check for winget Write-Host "Checking for winget..." $wingetPath = Get-Command "winget" -ErrorAction SilentlyContinue if (-not $wingetPath) { throw "winget is not available. Windows 10 1809+ or Windows 11 required." } Write-Host "winget found" # Check if Dell Command Update is already installed Write-Host "Checking for Dell Command Update..." $installed = winget list -e --id $WingetId --accept-source-agreements 2>$null | Select-String $WingetId if (-not $installed) { Write-Host "Installing $WingetId..." $installResult = winget install -e --id $WingetId --silent --accept-package-agreements --accept-source-agreements 2>&1 if ($LASTEXITCODE -ne 0) { throw "Failed to install $WingetId. Exit code: $LASTEXITCODE" } Write-Host "Installation complete" } else { Write-Host "Dell Command Update already installed" # Check for updates Write-Host "Checking for DCU updates..." $upgradeResult = winget upgrade -e --id $WingetId --silent --accept-package-agreements --accept-source-agreements 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host "DCU upgraded to latest version" } } # Verify DCU CLI exists if (-not (Test-Path $DcuCliPath)) { throw "DCU CLI not found at $DcuCliPath. Installation may have failed." } # Ensure log directory exists if (-not (Test-Path $DcuLogPath)) { New-Item -Path $DcuLogPath -ItemType Directory -Force | Out-Null Write-Host "Created log directory: $DcuLogPath" } # Configure DCU for silent operation Write-Host "Configuring Dell Command Update..." & $DcuCliPath /configure -silent -autoSuspendBitLocker=enable -userConsent=disable Write-Host "Configuration complete" # Scan for updates Write-Host "Scanning for updates..." & $DcuCliPath /scan -outputLog="$DcuLogPath\scan.log" Write-Host "Scan complete" # Output scan log Write-Host "" Write-Host "[INFO] SCAN LOG" Write-Host "==============================================================" if (Test-Path "$DcuLogPath\scan.log") { Get-Content "$DcuLogPath\scan.log" | ForEach-Object { Write-Host $_ } } else { Write-Host "Scan log not found." } # Apply updates without rebooting Write-Host "" Write-Host "[RUN] APPLY UPDATES" Write-Host "==============================================================" Write-Host "Applying updates..." & $DcuCliPath /applyUpdates -reboot=disable -outputLog="$DcuLogPath\applyUpdates.log" Write-Host "Updates applied" # Output apply log Write-Host "" Write-Host "[INFO] APPLY UPDATES LOG" Write-Host "==============================================================" if (Test-Path "$DcuLogPath\applyUpdates.log") { Get-Content "$DcuLogPath\applyUpdates.log" | ForEach-Object { Write-Host $_ } } else { Write-Host "Apply updates log not found." } } catch { $errorOccurred = $true $errorText = $_.Exception.Message } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] OPERATION FAILED" Write-Host "==============================================================" Write-Host $errorText Write-Host "" Write-Host "[ERROR] RESULT" Write-Host "==============================================================" Write-Host "Status : Failure" } else { Write-Host "" Write-Host "[OK] RESULT" Write-Host "==============================================================" Write-Host "Status : Success" } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] FINAL STATUS" } else { Write-Host "[OK] FINAL STATUS" } Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Dell Command Update process failed. See error above." } else { Write-Host "Dell Command Update completed successfully." Write-Host "Reboot may be required for some updates to take effect." } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] SCRIPT COMPLETED" } else { Write-Host "[OK] SCRIPT COMPLETED" } Write-Host "==============================================================" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/dell_command_update.ps1 ## Deployment strategy We didn't blast the script to all 127 machines at once. Firmware is the one area where "deploy to all" is a genuinely bad idea. One bad update or a power loss mid-flash can brick a machine, and bricked machines across four locations is not a Monday anyone wants. 1. pilot group (5 machines): one of each model type, users notified, ran during lunch 2. wave 1 (30 machines): IT department and volunteers, overnight deployment 3. wave 2 (50 machines): non-critical workstations, weekend deployment 4. wave 3 (42 machines): remaining systems including exec laptops, scheduled maintenance window After each wave we verified BIOS versions via RMM inventory scan. Any machine that didn't update got flagged for manual review before we moved on. ## Outcome All 127 machines patched within 2 weeks, well ahead of the 30-day deadline. Zero bricked machines. Zero user complaints. Total hands-on time: about 4 hours for script development, pilot testing, and monitoring. Without automation this would have been 200+ hours of on-site work split across four offices. machines patched: 127 across 4 locations time to complete: 2 weeks (phased rollout) hours saved: 196+ vs manual updates bricked machines: zero Staying ahead of vendor bulletins like this is [routine fleet automation](/services/ai-automation) for us. The 30-day insurance clock only feels short when the process is manual. ## Key takeaways - Dell Command Update CLI makes BIOS updates remotely deployable. It's a genuine shift for fleet management. - autoSuspendBitLocker is critical. Without it, users hit the recovery key prompt after the first reboot. - phased rollout is non-negotiable for firmware; one bad update could brick dozens of machines. - With the wrapper in place, monthly DCU is proactive maintenance — drivers and BIOS stay current without waiting on the next critical bulletin. --- # Fixing Windows Time Sync: Clock Wrong, Can't Log In URL: https://limehawk.io/reports/windows-time-sync-fix Published: 2024-11-29 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Law firm paralegal locked out Monday morning. Clock showing March 2019. Kerberos won't authenticate with 5+ minute time drift. - Time to resolution: under 15 minutes ## Incident Monday morning, [law firm](/industries/law-firms). Paralegal back from a two-week vacation tries to log in and gets "The trust relationship between this workstation and the primary domain has failed." Their internal IT had already tried rejoining the domain (failed), resetting her AD computer account (no change), and resetting her password (also no change). By the time it landed on our side of the [co-managed arrangement](/industries/co-managed-it), she'd been locked out most of the morning. The clock in the bottom-right corner of Windows read March 14, 2019, 08:47 AM. Actual date was September 2, 2025. That's the whole story. Everything after this is just untangling what six years of time drift does to a domain-joined machine. ## What we found - system clock: March 14, 2019 08:47 AM - actual date: September 2, 2025 - time drift: 6+ years (2,363 days) - Kerberos tolerance: 5 minutes, per default domain policy - root cause: dead CMOS battery on a 7-year-old laptop The laptop had been powered off and unplugged the entire vacation. With a dead CMOS battery, every power loss resets the RTC to its BIOS default, which, on this particular Lenovo, is March 2019. Plug in, boot up, and Windows sees itself as six years in the past. Kerberos will not authenticate outside a 5-minute window. Six years is outside that window. Nothing live to alert on while it sat cold in a bag — the bad clock only shows up the moment domain auth is needed. ## Initial diagnostics Our RMM agent still worked. It doesn't rely on domain auth, so the broken time didn't break our channel into the box. First thing we checked was the time service: w32tm /query /status - Leap Indicator: 3 (not synchronized) - Stratum: 0 (unspecified) - Last Successful Sync Time: unspecified - Source: Free-running System Clock - Poll Interval: 10 (1024s) "Free-running System Clock" and "Last Successful Sync Time: unspecified" are the flags. The Windows Time service had never successfully synced since boot. It was trying to pull time from the DC, but it couldn't Kerberos-authenticate to make the request, because... the time was wrong. Classic chicken-and-egg. ## Why their attempts failed Every standard fix assumes the domain trust is the problem. None of them work when the real problem is time: - rejoin domain: "network path not found," the machine can't even authenticate to find the DC - reset AD computer account: no effect, the machine still can't present a valid ticket - w32tm /resync: "the computer did not resync because no time data was available" - restart W32Time: service comes back up but can't sync from a source it can't authenticate to Domain-joined machines sync time from the DC. To talk to the DC you need a Kerberos ticket. To get a ticket, time has to be within 5 minutes. If your clock is off by six years, you are completely locked out of the domain and out of every domain-mediated fix for the domain lockout. ## Resolution Break the loop by taking the DC out of the time-sync picture entirely. Point the machine at external NTP, force the sync, and let everything else catch up once the clock is right. We deployed this script via RMM: ```time_sync_fix.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Time Sync Fix v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\time_sync_fix.ps1 ================================================================================ FILE : time_sync_fix.ps1 DESCRIPTION : Fixes Windows time synchronization by resetting NTP configuration -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Fixes Windows time synchronization by setting the timezone, resetting the Windows Time service, configuring NTP servers, and forcing a time sync. Resolves common time drift and synchronization issues. DATA SOURCES & PRIORITY 1) Hardcoded values (defined within the script body) 2) Windows Time service (w32time) REQUIRED INPUTS - TimeZone : Windows timezone ID (e.g., "Eastern Standard Time") - NtpServers : Comma-separated list of NTP servers SETTINGS - Uses pool.ntp.org servers by default - Sets SpecialPollInterval to 86400 seconds (24 hours) - Configures w32time triggers for network on/off BEHAVIOR 1. Sets the system timezone 2. Stops the Windows Time service 3. Unregisters and re-registers w32time 4. Configures NTP poll interval in registry 5. Sets service triggers for network connectivity 6. Starts the Windows Time service 7. Configures NTP server list 8. Forces immediate time resync PREREQUISITES - Windows 10/11 - Admin privileges required - Network connectivity to NTP servers SECURITY NOTES - No secrets in logs - Modifies system time settings - Uses public NTP servers EXIT CODES - 0: Success - 1: Failure EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Time Zone : Eastern Standard Time NTP Servers : 0.pool.ntp.org,1.pool.ntp.org,2.pool.ntp.org,3.pool.ntp.org [RUN] OPERATION ============================================================== Setting timezone... Stopping Windows Time service... Re-registering Windows Time service... Configuring NTP poll interval... Setting service triggers... Starting Windows Time service... Configuring NTP servers... Forcing time resync... [OK] RESULT ============================================================== Status : Success Time Zone : Eastern Standard Time Time : 2025-11-29 14:32:15 [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Fixed EXAMPLE RUN section formatting 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2025-11-29 v1.0.0 Initial Style A implementation ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE ==== $errorOccurred = $false $errorText = "" # ==== HARDCODED INPUTS ==== $TimeZone = "Eastern Standard Time" $NtpServers = "0.pool.ntp.org,1.pool.ntp.org,2.pool.ntp.org,3.pool.ntp.org" # ==== VALIDATION ==== if ([string]::IsNullOrWhiteSpace($TimeZone)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- TimeZone is required." } if ([string]::IsNullOrWhiteSpace($NtpServers)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- NtpServers is required." } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "Time Zone : $TimeZone" Write-Host "NTP Servers : $NtpServers" Write-Host "" Write-Host "[RUN] OPERATION" Write-Host "==============================================================" try { # Set timezone Write-Host "[RUN] Setting timezone..." tzutil /s "$TimeZone" if ($LASTEXITCODE -ne 0) { throw "Failed to set timezone. Verify timezone ID is valid." } # Stop Windows Time service Write-Host "[RUN] Stopping Windows Time service..." Stop-Service -Name w32time -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 # Unregister and re-register the service Write-Host "[RUN] Re-registering Windows Time service..." w32tm /unregister 2>$null Start-Sleep -Seconds 1 w32tm /register 2>$null Start-Sleep -Seconds 1 # Configure SpecialPollInterval (24 hours = 86400 seconds) Write-Host "[RUN] Configuring NTP poll interval..." $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\TimeProviders\NtpClient" Set-ItemProperty -Path $regPath -Name "SpecialPollInterval" -Value 86400 -Type DWord -Force # Set service triggers Write-Host "[RUN] Setting service triggers..." sc.exe triggerinfo w32time start/networkon stop/networkoff 2>$null # Start Windows Time service Write-Host "[RUN] Starting Windows Time service..." Start-Service -Name w32time -ErrorAction Stop Start-Sleep -Seconds 2 # Configure NTP servers Write-Host "[RUN] Configuring NTP servers..." $peerList = ($NtpServers -split ',') | ForEach-Object { "$_,0x1" } $peerListString = $peerList -join ' ' w32tm /config /manualpeerlist:"$peerListString" /syncfromflags:manual /update if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] NTP configuration returned non-zero exit code" } # Force resync Write-Host "[RUN] Forcing time resync..." Start-Sleep -Seconds 2 w32tm /resync /force if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] Time resync returned non-zero exit code" } } catch { $errorOccurred = $true $errorText = $_.Exception.Message } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host $errorText } # Get current time info $currentTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $currentTz = (Get-TimeZone).Id Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" if ($errorOccurred) { Write-Host "[ERROR] Status : Failure" } else { Write-Host "[OK] Status : Success" Write-Host "Time Zone : $currentTz" Write-Host "Time : $currentTime" } Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" if ($errorOccurred) { Write-Host "[ERROR] Time synchronization fix failed. See error above." } else { Write-Host "[OK] Time synchronization has been reset and configured." } Write-Host "" Write-Host "[INFO] SCRIPT COMPLETED" Write-Host "==============================================================" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/time_sync_fix.ps1 ## Why this works - NTP servers don't require authentication: pool.ntp.org will tell anyone what time it is - that bypasses Kerberos, which bypasses the chicken-and-egg entirely - unregister/register w32time clears any corrupted service state from years of failed sync attempts - service triggers (start/networkon) make sure the laptop re-syncs whenever it gets a network, which is the pattern you want on a mobile device - /force ignores the rate limiter. When you're six years off, that's not your concern ## Outcome Script ran, time synced from pool.ntp.org in about 3 seconds. Clock jumped from March 2019 to September 2025. She logged in on the next try with her normal credentials. Cached creds worked because the trust relationship was never actually broken, the machine just couldn't authenticate to prove it. - time to resolution: under 15 minutes from escalation - time correction: 6 years, 171 days - domain rejoin: not required - user productivity lost: about 2 hours Follow-up actions: - ordered CMOS battery replacement (CR2032, ~$3) so the next unplug doesn't reset the clock again - put the laptop on the hardware refresh list — at seven years, dead RTC is a symptom of age, not a one-off software mystery - kept the external-NTP recovery path as the break-glass for this class of lockout (storage return, long power-off, domain time sync can't help until the clock is right) ## Key takeaways - time is security infrastructure: Kerberos, certificates, and MFA all depend on accurate time. - external NTP is how you escape domain auth when domain auth is what's broken. - "trust relationship failed" is often not actually a trust problem. Check the clock first before you rejoin anything. - dead CMOS batteries are routine on 5+ year old laptops; if a machine loses time whenever it's unplugged, it's a $3 part, not a software problem. --- # Fixing Windows Update Stuck in Reboot Loop: Nuclear Reset URL: https://limehawk.io/reports/reset-windows-update Published: 2024-11-29 Author: Corey Watson, Managing Member Topic: Automation Read time: 8 min HR laptop cycling 'Undoing changes' for 6 hours. 24H2 update failing at 47%. Benefits deadline in 3 hours. Standard fixes failed. ## Incident The ticket came in as "laptop keeps restarting. I can't work." HR director had left her laptop on overnight to "let it update" and came back to find it mid-rollback. We connected remotely and watched it cycle through the same pattern: boot, attempt update, fail at 47%, "We couldn't complete the updates. Undoing changes," reboot. Rinse and repeat. Six hours of this by the time we got on. The bad part: she was three hours away from the annual benefits enrollment cutoff, and this was the laptop she needed to finalize enrollments on. Machine was unusable during the only window that mattered. ## Problem identification - update failure: Windows 11 24H2 feature update failing consistently at 47% - error code: event log showing 0x80070002, "the system cannot find the file specified" - disk space: 89 GB free, not a space issue - SoftwareDistribution: 12 GB folder full of corrupted download fragments The user had tried to "help" by force-restarting during a previous update attempt two weeks ago. That interrupted update left behind corrupted component store entries, and they'd been poisoning every subsequent update attempt since. ## What we tried first We had a 3-hour window before the benefits system locked out. Worked through the standard escalation path: - Windows Update Troubleshooter: said "Windows Update components must be repaired," then did nothing - DISM /RestoreHealth: failed with 0x800f081f, couldn't find source files because the component store itself was corrupted - SFC /scannow: found corrupt files but couldn't fix them, needed DISM to work first - delete SoftwareDistribution: service wouldn't stop, "access denied" even as admin - safe-mode cleanup: deleted the folder, but the corruption had already spread to catroot2 and the BITS queue The corruption had cascaded past what targeted repairs could reach. Each standard step was correct on its own, but none of them could clear the full chain. We needed a coordinated reset of everything at once: services, caches, DLL registrations, security descriptors, network stack. Full component reset. ## Resolution Booted to Safe Mode with Networking, connected via RMM, and ran this script. Same procedure Microsoft support would walk through over two hours on the phone, automated and executed in 90 seconds. ```windows_update_reset.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Windows Update Reset v1.0.2 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\windows_update_reset.ps1 ================================================================================ FILE : windows_update_reset.ps1 DESCRIPTION : Resets Windows Update components and clears cache -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Completely resets Windows Update components by stopping services, clearing caches, resetting security descriptors, re-registering DLLs, and restarting services. Fixes most Windows Update problems. DATA SOURCES & PRIORITY 1) Hardcoded values (defined within the script body) 2) Windows Update services and components REQUIRED INPUTS - RebootAfterReset : Whether to reboot after reset (default: $false) SETTINGS - Stops: BITS, wuauserv, appidsvc, cryptsvc - Clears: SoftwareDistribution, catroot2, QMGR data - Resets: BITS and wuauserv security descriptors - Registers: 30+ Windows Update related DLLs - Resets: Winsock BEHAVIOR 1. Stops all Windows Update related services 2. Flushes DNS cache 3. Clears BITS download queue data 4. Renames SoftwareDistribution and catroot2 folders 5. Resets Windows Update policies in registry 6. Resets BITS and wuauserv security descriptors 7. Re-registers all Windows Update DLLs 8. Resets Winsock 9. Restarts all services 10. Optionally reboots system PREREQUISITES - Windows 10/11 - Admin privileges required - Internet connectivity for updates after reset SECURITY NOTES - No secrets in logs - Modifies system registry and service configurations - Creates .bak folders for recovery EXIT CODES - 0: Success - 1: Failure EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Reboot After Reset : False [RUN] STOPPING SERVICES ============================================================== Stopping BITS... Stopping wuauserv... Stopping appidsvc... Stopping cryptsvc... [RUN] CLEARING CACHES ============================================================== Flushing DNS... Clearing QMGR data... Renaming SoftwareDistribution... Renaming catroot2... [RUN] RESETTING COMPONENTS ============================================================== Resetting security descriptors... Re-registering DLLs... Resetting Winsock... [RUN] STARTING SERVICES ============================================================== Starting BITS... Starting wuauserv... Starting appidsvc... Starting cryptsvc... [OK] RESULT ============================================================== Status : Success [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2025-11-29 v1.0.0 Initial Style A implementation ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE ==== $errorOccurred = $false $errorText = "" # ==== HARDCODED INPUTS ==== $RebootAfterReset = $false # ==== ADMIN CHECK ==== $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") if (-not $isAdmin) { Write-Host "" Write-Host "[ERROR] ADMIN PRIVILEGES REQUIRED" Write-Host "==============================================================" Write-Host "Script requires admin privileges." Write-Host "Please relaunch as Administrator." exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "Reboot After Reset : $RebootAfterReset" # ==== STOP SERVICES ==== Write-Host "" Write-Host "[RUN] STOPPING SERVICES" Write-Host "==============================================================" $services = @('bits', 'wuauserv', 'appidsvc', 'cryptsvc') foreach ($svc in $services) { Write-Host "Stopping $svc..." Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue $retries = 0 while ((Get-Service -Name $svc -ErrorAction SilentlyContinue).Status -ne 'Stopped' -and $retries -lt 3) { Start-Sleep -Seconds 2 Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue $retries++ } } # ==== CLEAR CACHES ==== Write-Host "" Write-Host "[RUN] CLEARING CACHES" Write-Host "==============================================================" try { Write-Host "Flushing DNS..." ipconfig /flushdns | Out-Null Write-Host "Clearing QMGR data..." Remove-Item -Path "$env:ALLUSERSPROFILE\Application Data\Microsoft\Network\Downloader\qmgr*.dat" -Force -ErrorAction SilentlyContinue Remove-Item -Path "$env:ALLUSERSPROFILE\Microsoft\Network\Downloader\qmgr*.dat" -Force -ErrorAction SilentlyContinue Write-Host "Clearing Windows Update logs..." Remove-Item -Path "$env:SYSTEMROOT\Logs\WindowsUpdate\*" -Recurse -Force -ErrorAction SilentlyContinue # Rename SoftwareDistribution Write-Host "Renaming SoftwareDistribution..." $sdPath = "$env:SYSTEMROOT\SoftwareDistribution" $sdBackup = "$env:SYSTEMROOT\SoftwareDistribution.bak" if (Test-Path $sdBackup) { Remove-Item -Path $sdBackup -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path $sdPath) { Rename-Item -Path $sdPath -NewName "SoftwareDistribution.bak" -Force -ErrorAction Stop } # Rename catroot2 Write-Host "Renaming catroot2..." $crPath = "$env:SYSTEMROOT\System32\catroot2" $crBackup = "$env:SYSTEMROOT\System32\catroot2.bak" if (Test-Path $crBackup) { Remove-Item -Path $crBackup -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path $crPath) { Rename-Item -Path $crPath -NewName "catroot2.bak" -Force -ErrorAction Stop } } catch { Write-Host "Warning: Some cache operations failed: $($_.Exception.Message)" } # ==== RESET COMPONENTS ==== Write-Host "" Write-Host "[RUN] RESETTING COMPONENTS" Write-Host "==============================================================" try { # Reset Windows Update policies Write-Host "Resetting Windows Update policies..." $regPaths = @( "HKCU:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\WindowsUpdate", "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\WindowsUpdate" ) foreach ($path in $regPaths) { if (Test-Path $path) { Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue } } # Reset security descriptors Write-Host "Resetting service security descriptors..." sc.exe sdset bits "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;AU)(A;;CCLCSWRPWPDTLOCRRC;;;PU)" | Out-Null sc.exe sdset wuauserv "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;AU)(A;;CCLCSWRPWPDTLOCRRC;;;PU)" | Out-Null # Re-register DLLs Write-Host "Re-registering Windows Update DLLs..." $dlls = @( "atl.dll", "urlmon.dll", "mshtml.dll", "shdocvw.dll", "browseui.dll", "jscript.dll", "vbscript.dll", "scrrun.dll", "msxml.dll", "msxml3.dll", "msxml6.dll", "actxprxy.dll", "softpub.dll", "wintrust.dll", "dssenh.dll", "rsaenh.dll", "gpkcsp.dll", "sccbase.dll", "slbcsp.dll", "cryptdlg.dll", "oleaut32.dll", "ole32.dll", "shell32.dll", "initpki.dll", "wuapi.dll", "wuaueng.dll", "wuaueng1.dll", "wucltui.dll", "wups.dll", "wups2.dll", "wuweb.dll", "qmgr.dll", "qmgrprxy.dll", "wucltux.dll", "muweb.dll", "wuwebv.dll", "wudriver.dll" ) $system32 = "$env:SYSTEMROOT\System32" foreach ($dll in $dlls) { $dllPath = Join-Path $system32 $dll if (Test-Path $dllPath) { regsvr32.exe /s $dllPath 2>$null } } # Reset Winsock Write-Host "Resetting Winsock..." netsh winsock reset | Out-Null netsh winsock reset proxy | Out-Null # Set services to auto start Write-Host "Configuring service startup types..." sc.exe config wuauserv start= auto | Out-Null sc.exe config bits start= auto | Out-Null sc.exe config DcomLaunch start= auto | Out-Null } catch { $errorOccurred = $true $errorText = $_.Exception.Message } # ==== START SERVICES ==== Write-Host "" Write-Host "[RUN] STARTING SERVICES" Write-Host "==============================================================" foreach ($svc in $services) { Write-Host "Starting $svc..." Start-Service -Name $svc -ErrorAction SilentlyContinue } if ($errorOccurred) { Write-Host "" Write-Host "[WARN] ERRORS OCCURRED" Write-Host "==============================================================" Write-Host $errorText } if ($errorOccurred) { Write-Host "" Write-Host "[WARN] RESULT" Write-Host "==============================================================" Write-Host "Status : Partial Success (some operations failed)" } else { Write-Host "" Write-Host "[OK] RESULT" Write-Host "==============================================================" Write-Host "Status : Success" } Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" Write-Host "Windows Update components have been reset." Write-Host "A reboot is recommended to complete the process." if ($RebootAfterReset) { Write-Host "Initiating reboot in 60 seconds..." shutdown /g /f /t 60 /c "Windows Update reset complete. Rebooting..." } if ($errorOccurred) { Write-Host "" Write-Host "[WARN] SCRIPT COMPLETED" Write-Host "==============================================================" } else { Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" } if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/windows_update_reset.ps1 ## Why this works Windows Update is a fragile stack of interdependent components. When corruption spreads between them, targeted fixes fail because they can't account for the cascading damage. This script resets everything at once. What each phase does: 1. stop services: BITS, wuauserv, appidsvc, cryptsvc (releases locks on corrupted files) 2. flush DNS: clears stale entries pointing at dead Microsoft update servers 3. clear QMGR data: purges corrupted BITS download queue entries 4. rename SoftwareDistribution: forces Windows to rebuild the entire download cache fresh 5. rename catroot2: resets the signature verification database, fixes trust chain issues 6. reset security descriptors: restores default permissions corrupted by malware or bad software 7. re-register DLLs: fixes 15 critical COM registrations that cause cryptic update errors 8. reset Winsock: clears network catalog corruption blocking downloads at the TCP/IP level Why Safe Mode: ensures no third-party software is holding locks on Windows Update components. Also prevents the update service from automatically restarting mid-reset, which would undo half the work. ## Outcome Script completed in 87 seconds. Rebooted to normal mode, waited 10 minutes for Windows to rebuild its update database, then manually triggered Windows Update. The 24H2 feature update downloaded fresh and installed successfully on the first attempt. HR director was back online with 47 minutes to spare before the enrollment deadline. - time to fix: 2 hours 13 min (including the failed earlier attempts) - script runtime: 87 seconds - deadline met: yes, 47 minutes to spare The whole save depended on things that existed before the ticket did: RMM access to the machine, a tested reset script, and a [help desk](/services/managed-it-security) that answers while the deadline still matters. ## Key takeaways - force-restarting during updates creates corruption that spreads over time. - when DISM can't find source files, the component store itself is corrupted. Targeted fixes won't work. - the nuclear reset resolves around 90% of Windows Update failures that resist standard troubleshooting. - run it in Safe Mode to avoid service lock conflicts. Completes faster and more reliably. --- # Locked Out of Client PC: Emergency Local Admin via RMM URL: https://limehawk.io/reports/local-admin-provisioning Published: 2024-11-29 Author: Corey Watson, Managing Member Topic: Security Read time: 7 min IT director quit and changed every admin password on the way out. Domain locked, DSRM locked, 85 users dead in the water. RMM saved the day. - Endpoints: 92 ## Security incident Monday, 7:43 AM. Call from a client we'd been managing for three years: an 85-person [accounting firm](/industries/accounting-firms). Their internal IT director had resigned Friday afternoon. Somewhere between cleaning out his desk and walking out the door, he'd changed the domain admin password on both DCs. Then, because he was the one who knew how, he also changed the DSRM recovery password on both. Nobody else had working admin credentials. Payroll was due Wednesday. This is the textbook malicious insider scenario. Domain admin account locked. Can't log into the DCs. Can't reset passwords through AD because we can't authenticate to AD. But our RMM agent was installed on every workstation and every server (including the DCs), and it was still checking in, still running as SYSTEM. ## Assessment - domain admin: password changed by the departing employee, locked out - DSRM password: also changed, can't boot DC into recovery mode - built-in Administrator: disabled on workstations per the client's security policy - LAPS: enabled, passwords stored in AD, which we cannot currently read - RMM agent: installed on all 92 endpoints, running as SYSTEM Here's the thing about RMM agents: SYSTEM is more privileged than any domain admin on the local machine. The departing IT director could change every password in Active Directory. What he couldn't do is reach into our RMM tenant and disable the agent. It was still checking in, still running, still had full local system privileges on 92 machines. We had a path. ## Why we couldn't just reset the domain admin The standard recovery path for a compromised domain admin is DSRM: boot the DC into Directory Services Restore Mode and reset the admin account from there. That was closed: - DSRM password: already changed by the same departing employee - offline password reset: possible, but requires booting from external media and working on the NTDS and SAM hives. Risky on a production DC, time-consuming, and we had 85 people who needed to work in the morning - DC RMM agent: installed, but useless for this. You can't reset a domain admin account via SYSTEM on a DC, that's an AD operation - business impact: 85 people couldn't do anything, payroll deadline in 48 hours We'd deal with DC recovery separately. The immediate need was local admin on the workstations so people could get logged in and working with their cached domain credentials while we untangled AD in the background. ## Resolution Pushed a script to all 92 endpoints via RMM, simultaneously. SYSTEM privileges let us create a new local admin account on every machine, bypassing the compromised domain entirely. Each machine got a unique, cryptographically random password stored back into an encrypted custom field on the endpoint in RMM. ```local_user_admin_create.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Local Admin Create v1.3.5 AUTHOR : Limehawk.io DATE : May 2026 USAGE : .\local_user_admin_create.ps1 ================================================================================ FILE : local_user_admin_create.ps1 DESCRIPTION : Creates local administrator account with secure random password -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Creates or updates a local administrator account with a cryptographically secure random password. If the account exists, resets the password. If not, creates the account and adds it to the Administrators group. DATA SOURCES & PRIORITY 1) Hardcoded values (username) 2) RNG for password generation REQUIRED INPUTS - Username : Username for the local admin account (via SuperOps $NewAdminUsername) SETTINGS - Password length: 16 characters - Password includes: uppercase, lowercase, numbers, special characters - Account added to local Administrators group BEHAVIOR 1. Generates cryptographically secure random password 2. Checks if user account exists 3. If exists: resets password 4. If not exists: creates account and adds to Administrators group 5. Outputs password (for RMM custom field capture) PREREQUISITES - Windows 10/11 - Admin privileges required - PowerShell 5.1+ SECURITY NOTES - Password generated using RNGCryptoServiceProvider - Password output to console for RMM capture only - Consider storing password securely in RMM custom fields EXIT CODES - 0: Success - 1: Failure EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Username : sudohawk Password Length : 16 [RUN] OPERATION ============================================================== Checking for existing account... Account does not exist, creating... Account created successfully Adding to Administrators group... [OK] RESULT ============================================================== Status : Success Action : Created Password : **************** [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-05-22 v1.3.5 Set display name (FullName) to the username, not hardcoded text 2026-05-22 v1.3.4 Enrich catch output with exception type and failing line 2026-05-22 v1.3.3 Reject username matching the computer name (Windows error 2253) 2026-01-19 v1.3.2 Updated to two-line ASCII console output style 2025-12-23 v1.3.1 Updated to Limehawk Script Framework 2025-12-03 v1.3.0 Use descriptive runtime variable name ($NewAdminUsername) 2025-12-03 v1.2.0 Standardize variable names ($Username instead of $AdminUsername); 2025-12-03 v1.1.0 Use SuperOps runtime variable for username instead of hardcoded; 2025-11-29 v1.0.0 Initial Style A implementation ================================================================================ #> # ==== STATE ==== $errorOccurred = $false $errorText = "" $actionTaken = "" $generatedPassword = "" # ==== HARDCODED INPUTS ==== $Username = "$NewAdminUsername" $PasswordLength = 16 # ==== HELPER FUNCTIONS ==== Set-StrictMode -Version Latest function Get-SecureRandomPassword { param([int]$Length = 16) $charSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/*-+,!?=()@;:._" $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider $bytes = New-Object byte[]($Length) $rng.GetBytes($bytes) $result = New-Object char[]($Length) for ($i = 0; $i -lt $Length; $i++) { $result[$i] = $charSet[$bytes[$i] % $charSet.Length] } $rng.Dispose() return -join $result } # ==== VALIDATION ==== if ([string]::IsNullOrWhiteSpace($Username) -or $Username -eq '$' + 'NewAdminUsername') { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Username is required (set via SuperOps runtime variable)." } if ($Username -eq $env:COMPUTERNAME) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Username cannot match the computer name '$env:COMPUTERNAME' (Windows rejects this with error 2253)." } if ($PasswordLength -lt 8) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- PasswordLength must be at least 8 characters." } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] INPUT VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "Username : $Username" Write-Host "Password Length : $PasswordLength" Write-Host "" Write-Host "[RUN] OPERATION" Write-Host "==============================================================" try { # Generate secure password $generatedPassword = Get-SecureRandomPassword -Length $PasswordLength $securePass = ConvertTo-SecureString $generatedPassword -AsPlainText -Force Write-Host "Checking for existing account..." $existingAccount = Get-LocalUser -Name $Username -ErrorAction SilentlyContinue if ($existingAccount) { Write-Host "Account exists, resetting password..." $existingAccount | Set-LocalUser -Password $securePass -ErrorAction Stop $actionTaken = "Password Reset" Write-Host "Password reset successfully" } else { Write-Host "Account does not exist, creating..." New-LocalUser -Name $Username -Password $securePass -FullName $Username -Description "Local Administrator Account" -ErrorAction Stop | Out-Null $actionTaken = "Created" Write-Host "Account created successfully" Write-Host "Adding to Administrators group..." Add-LocalGroupMember -Group "Administrators" -Member $Username -ErrorAction Stop Write-Host "Added to Administrators group" } } catch { $errorOccurred = $true $errorText = $_.Exception.Message $errorText += "`n Type : $($_.Exception.GetType().Name)" $errorText += "`n Where : line $($_.InvocationInfo.ScriptLineNumber): $($_.InvocationInfo.Line.Trim())" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] OPERATION FAILED" Write-Host "==============================================================" Write-Host $errorText } Write-Host "" Write-Host "[OK] RESULT" Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Status : Failure" } else { Write-Host "Status : Success" Write-Host "Action : $actionTaken" Write-Host "Username : $Username" Write-Host "Password : $generatedPassword" } Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Local admin account operation failed. See error above." } else { Write-Host "Local admin account '$Username' is ready." Write-Host "Store the password securely in your RMM system." } Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/local_user_admin_create.ps1 ## Technical details - RNGCryptoServiceProvider: cryptographically secure randomness, not the pseudo-random System.Random - 24-character password with 70 possible characters: 70^24 combinations, brute force is not a concern - idempotent design: if the account exists, reset its password; if not, create it. Safe to rerun as many times as you want. - JSON output to stdout: structured, so RMM parses it into the encrypted custom field on that endpoint. No plaintext passwords in log files. Every machine gets a different password. If one endpoint is compromised, the attacker has that machine's local admin, not the keys to the other 91. Same principle behind Microsoft LAPS, just implemented via RMM instead of AD, which matters here, because AD is exactly what we can't trust right now. ## Outcome Within 3 minutes of the call, all 92 machines had working local admin accounts. Staff logged in with cached domain credentials, and we had a way to elevate locally for any follow-up work. Business never stopped. DC recovery took the rest of the day, about 6 hours, done via offline NTDS extraction on a copy of the database, resetting the KRBTGT and admin credentials, then bringing the DCs back online. Payroll ran on time Wednesday. - local admin restored: 3 minutes across all 92 endpoints - domain recovered: 6 hours (full AD access restored) - business impact: zero downtime, payroll on schedule This firm ran internal IT alongside us, and that [co-managed arrangement](/industries/co-managed-it) is the whole reason an outside management plane existed when the inside one went dark. ## Key takeaways - RMM running as SYSTEM is break-glass access for when the domain itself is compromised. It's the one thing an insider can't easily reach if they're not in your RMM tenant. - Deploy the RMM break-glass local admin at onboarding — unique password per machine, stored outside AD — so the path exists before the domain is dark, not only after. - unique passwords per machine stop lateral movement cold. One compromise is one machine, not the whole fleet. - insider threats can't disable what they don't control; keep your management plane outside the client's identity provider. --- # Server at 99% Disk: Recovering 47GB Windows Couldn't Clean URL: https://limehawk.io/reports/disk-cleanup-automation Published: 2024-11-29 Author: Corey Watson, Managing Member Topic: Automation Read time: 7 min Application server at 0.7% free space. Standard Disk Cleanup found 400MB. We found 47GB hiding in WinSxS and SoftwareDistribution. - Space recovered: 47.3 GB ## Incident RMM alert came in at 7 AM: "C: drive below 2% free space." Application server, 99.2% capacity. Security patches failing to install. Event log flooded with "insufficient disk space" errors. Users were already reporting that the line-of-business application was crashing mid-save. This was a 5-year-old application server we inherited mid-life: prior admins had upgraded it in-place from Windows Server 2016 to 2019 to 2022, dragging years of WinSxS and update-cache cruft forward with every upgrade and never running a real component-store cleanup. Under our [managed baseline](/services/managed-it-security), monthly DISM + SoftwareDistribution cleanup is standard on servers we own from day one — this box still had the inherited upgrade trail. The 256 GB C: drive had 1.8 GB free, and Windows was reporting it needed 4 GB minimum for the pending cumulative update. Ran standard Disk Cleanup as a first pass: recovered 400 MB. Not even close. We needed to find the other 40+ GB Windows was hiding. ## Problem identification - C: drive: 252 GB used of 256 GB, 1.8 GB free (0.7%) - Windows Update: failing, needs 4 GB free for the pending cumulative update - event log: 847 "disk full" errors in the past 24 hours - application data: only 67 GB, not the problem The math didn't add up. Windows itself was around 67 GB, application data was 67 GB, that's 134 GB accounted for. Where was the other 118 GB? Running TreeSize gave us the answer: C:\Windows\WinSxS was 48 GB, SoftwareDistribution was 23 GB, and a 19 GB Windows.old folder was still present from the 2019 to 2022 upgrade — the kind Disk Cleanup often can't remove while services hold locks. ## Why standard disk cleanup wasn't enough The obvious solution was Disk Cleanup with "Clean up system files." Recovered 400 MB from temp files and thumbnails. Here's why it missed the real bloat: - Windows.old: Disk Cleanup showed "Previous Windows Installation" but the checkbox was grayed out. File locks from running services kept it pinned - SoftwareDistribution: Disk Cleanup only clears the Download subfolder (2 GB), not the DataStore (21 GB of accumulated update metadata) - WinSxS: Disk Cleanup "marks" superseded components for removal but doesn't actually run DISM to delete them - search index: 4.2 GB Windows.edb file. Disk Cleanup won't touch it at all The bloat Windows doesn't want to clean by default was exactly what we needed to remove. We had to get aggressive. ## Resolution Ran our aggressive cleanup script via RMM. It goes well beyond what Disk Cleanup touches: stops services to release file locks, runs DISM directly for real WinSxS cleanup, and nukes the search index to force a clean rebuild. ```disk_cleanup.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Disk Cleanup v1.2.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\disk_cleanup.ps1 ================================================================================ FILE : disk_cleanup.ps1 DESCRIPTION : Runs Windows Disk Cleanup plus SoftwareDistribution/WinSxS cleanup -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Runs Windows Disk Cleanup utility on all fixed local drives, plus targeted cleanups for SoftwareDistribution, WinSxS, and Search Index to free up disk space. Shows disk space before/after and progress updates during long-running operations. DATA SOURCES & PRIORITY 1) Hardcoded values (CleanupProfile defined within the script body) 2) System query (WMI to enumerate fixed drives) REQUIRED INPUTS - CleanupProfile : 'verylow' (hardcoded) - Determines cleanup aggressiveness. - CleanmgrTimeout : 3600 (hardcoded) - Maximum seconds to wait for cleanmgr. - StatusInterval : 60 (hardcoded) - Seconds between progress updates. SETTINGS - DriveType filter: 3 (fixed local disks only) - Cleanups run sequentially to avoid conflicts - DISM targets system-wide (C: only) - Progress updates shown every 60 seconds during cleanmgr BEHAVIOR 1. Checks/elevates to admin 2. Queries WMI for fixed drives 3. Shows free space before cleanup 4. Runs cleanmgr.exe per drive with /verylowdisk flag (with progress updates) 5. Performs targeted cleanups on C: (SoftwareDistribution, WinSxS, Search Index) 6. Shows free space after cleanup and total freed PREREQUISITES - Windows 10/11 with cleanmgr.exe and DISM - WMI service running - Admin privileges for DISM/services SECURITY NOTES - No secrets in logs - Targeted deletions only (no user files) EXIT CODES - 0: Success - 1: Failure (validation/WMI/admin check) EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Cleanup Profile : verylow Cleanmgr Timeout : 1800 seconds Status Interval : 60 seconds Running as Admin : True [RUN] DISK CLEANUP ============================================================== Querying fixed local drives... Found 2 drive(s) : C:, D: [RUN] CLEANUP DRIVE C: ============================================================== Free space before : 45.2 GB Running cleanmgr /verylowdisk (this may take 10-30 minutes)... [still running... 1 min elapsed] [still running... 2 min elapsed] Free space after : 52.8 GB Freed : 7.6 GB [RUN] TARGETED CLEANUPS ============================================================== Running SoftwareDistribution cleanup... Completed (folder cleared) Running WinSxS cleanup via DISM... Completed (superseded components removed) Rebuilding Search Index... Completed (DB deleted; will rebuild automatically) [INFO] RESULT ============================================================== Status : Success Drives Processed : 2 of 2 Targeted Cleanups : 3 of 3 Total Freed : 8.4 GB [OK] FINAL STATUS ============================================================== Disk cleanup completed successfully. Reboot recommended for full effect. [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-29 v1.2.3 Increase cleanmgr timeout from 30 to 60 minutes per drive 2026-01-29 v1.2.2 Fix Get-FreeSpaceBytes calls using incorrect named parameter 2026-01-29 v1.2.1 Increased script timeout to 180 minutes in metadata 2026-01-29 v1.2.0 Added progress updates and disk space reporting during cleanup 2025-12-23 v1.1.1 Updated to Limehawk Script Framework 2025-11-29 v1.1.0 Added SoftwareDistribution, DISM WinSxS, Search Index; admin check 2025-10-28 v1.0.0 Initial implementation ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE ==== $errorOccurred = $false $errorText = "" $processedCount = 0 $targetedCleanups = 0 $totalTargeted = 3 # SoftwareDist, WinSxS, Search $totalFreedBytes = 0 # ==== HARDCODED INPUTS ==== $CleanupProfile = 'verylow' $CleanmgrTimeout = 3600 # 60 minutes max per drive $StatusInterval = 60 # Show status every 60 seconds # ==== HELPER FUNCTIONS ==== function Get-FreeSpaceBytes { $DriveLetter = $args[0] $drive = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$DriveLetter'" -ErrorAction SilentlyContinue if ($drive) { return $drive.FreeSpace } return 0 } function Format-Bytes { $Bytes = [long]$args[0] if ($Bytes -ge 1GB) { return "{0:N1} GB" -f ($Bytes / 1GB) } if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) } if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) } return "$Bytes bytes" } # ==== ADMIN CHECK ==== $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") if (-not $isAdmin) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host "Script requires admin privileges for DISM and services." Write-Host "Please relaunch as Administrator." exit 1 } # ==== VALIDATION ==== if ([string]::IsNullOrWhiteSpace($CleanupProfile)) { $errorOccurred = $true $errorText += "- CleanupProfile is required." } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host $errorText exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "Cleanup Profile : $CleanupProfile" Write-Host "Cleanmgr Timeout : $CleanmgrTimeout seconds" Write-Host "Status Interval : $StatusInterval seconds" Write-Host "Running as Admin : $isAdmin" Write-Host "" Write-Host "[RUN] DISK CLEANUP" Write-Host "==============================================================" # Query fixed local drives Write-Host "Querying fixed local drives..." try { $drives = Get-CimInstance Win32_LogicalDisk -ErrorAction Stop | Where-Object { $_.DriveType -eq 3 } if ($null -eq $drives -or @($drives).Count -eq 0) { $errorOccurred = $true $errorText += "- No fixed local drives found." } else { $driveList = ($drives | ForEach-Object { $_.DeviceID }) -join ', ' $driveCount = @($drives).Count Write-Host "Found $driveCount drive(s) : $driveList" } } catch { $errorOccurred = $true $errorText += "- CIM query failed: $($_.Exception.Message)" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host $errorText exit 1 } # Run cleanmgr for each drive with progress reporting foreach ($drive in $drives) { $driveLetter = $drive.DeviceID Write-Host "" Write-Host "[RUN] CLEANUP DRIVE $driveLetter" Write-Host "==============================================================" # Get free space before $freeSpaceBefore = Get-FreeSpaceBytes $driveLetter Write-Host "Free space before : $(Format-Bytes $freeSpaceBefore)" Write-Host "Running cleanmgr /verylowdisk (this may take 10-30 minutes)..." try { # Start cleanmgr as background process $cleanmgrProcess = Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/verylowdisk", "/d", $driveLetter -PassThru -WindowStyle Hidden -ErrorAction Stop # Poll for completion with status updates $elapsed = 0 while (-not $cleanmgrProcess.HasExited) { Start-Sleep -Seconds $StatusInterval $elapsed += $StatusInterval $elapsedMin = [math]::Floor($elapsed / 60) Write-Host " [still running... $elapsedMin min elapsed]" if ($elapsed -ge $CleanmgrTimeout) { Write-Host " [WARN] Timeout reached ($CleanmgrTimeout seconds). Stopping cleanmgr..." $cleanmgrProcess | Stop-Process -Force -ErrorAction SilentlyContinue break } } # Get free space after $freeSpaceAfter = Get-FreeSpaceBytes $driveLetter $freedBytes = $freeSpaceAfter - $freeSpaceBefore $totalFreedBytes += $freedBytes Write-Host "Free space after : $(Format-Bytes $freeSpaceAfter)" if ($freedBytes -gt 0) { Write-Host "Freed : $(Format-Bytes $freedBytes)" } elseif ($freedBytes -lt 0) { Write-Host "Note : Space decreased (files may have been created during cleanup)" } else { Write-Host "Freed : 0 bytes (drive was already clean)" } $processedCount++ } catch { Write-Host "[WARN] cleanmgr failed for $driveLetter : $($_.Exception.Message)" } } # Targeted cleanups (C: only) if ($drives | Where-Object { $_.DeviceID -eq 'C:' }) { Write-Host "" Write-Host "[RUN] TARGETED CLEANUPS" Write-Host "==============================================================" # 1. SoftwareDistribution Write-Host "Running SoftwareDistribution cleanup..." try { $services = @('wuauserv', 'cryptSvc', 'bits', 'msiserver') foreach ($svc in $services) { Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue } Remove-Item -Path "C:\Windows\SoftwareDistribution\*" -Recurse -Force -ErrorAction SilentlyContinue foreach ($svc in $services) { Start-Service -Name $svc -ErrorAction SilentlyContinue } $targetedCleanups++ Write-Host " Completed (folder cleared)" } catch { Write-Host " [WARN] $($_.Exception.Message)" } # 2. WinSxS via DISM Write-Host "Running WinSxS cleanup via DISM..." try { $dismProcess = Start-Process -FilePath "Dism.exe" -ArgumentList "/Online", "/Cleanup-Image", "/StartComponentCleanup" -Wait -NoNewWindow -PassThru -ErrorAction Stop if ($dismProcess.ExitCode -eq 0) { $targetedCleanups++ Write-Host " Completed (superseded components removed)" } else { Write-Host " [WARN] DISM exited with code $($dismProcess.ExitCode)" } } catch { Write-Host " [WARN] $($_.Exception.Message)" } # 3. Search Index Rebuild Write-Host "Rebuilding Search Index..." try { Stop-Service -Name "WSearch" -Force -ErrorAction SilentlyContinue Remove-Item -Path "C:\ProgramData\Microsoft\Search\Data\Applications\Windows\Windows.edb" -Force -ErrorAction SilentlyContinue Start-Service -Name "WSearch" -ErrorAction SilentlyContinue $targetedCleanups++ Write-Host " Completed (DB deleted; will rebuild automatically)" } catch { Write-Host " [WARN] $($_.Exception.Message)" } } else { Write-Host "" Write-Host "[WARN] C: drive not found; skipping targeted cleanups." } Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" $overallStatus = if ($processedCount -eq $driveCount -and $targetedCleanups -eq $totalTargeted) { "Success" } else { "Partial Success" } Write-Host "Status : $overallStatus" Write-Host "Drives Processed : $processedCount of $driveCount" Write-Host "Targeted Cleanups : $targetedCleanups of $totalTargeted" Write-Host "Total Freed : $(Format-Bytes $totalFreedBytes)" Write-Host "" if ($overallStatus -eq "Success") { Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Disk cleanup completed successfully for all drives and targets." Write-Host "Reboot recommended to finalize changes." } else { Write-Host "[WARN] FINAL STATUS" Write-Host "==============================================================" Write-Host "Disk cleanup completed with warnings. Check output above." } Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/disk_cleanup.ps1 ## Why this works The script attacks the four major space hogs that standard cleanup misses. What gets cleaned: 1. cleanmgr /verylowdisk: runs Disk Cleanup in emergency mode, auto-selecting all safe options without prompting 2. SoftwareDistribution: stops Windows Update services, deletes ENTIRE folder contents (not just Download), restarts services 3. DISM /StartComponentCleanup: actually removes superseded WinSxS components (Disk Cleanup only marks them) 4. Windows.edb: deletes the search index database, forcing a clean rebuild Key insight: the real space savings come from WinSxS component cleanup. On this server, DISM recovered 31 GB of superseded system file versions accumulated from 5 years of in-place upgrades, files Windows was keeping "just in case" but would never actually use. ## Outcome Script ran for 23 minutes (DISM is slow, and that's normal; it's walking every component in the store). Total space recovered: 47.3 GB. C: drive went from 0.7% free to 19.2% free. Rebooted the server, the pending Windows Update installed successfully, application crashes stopped. Server has been running clean ever since. - space recovered: 47.3 GB from Windows bloat - free space after: 49.1 GB (19.2% of C:) - script runtime: 23 minutes (mostly DISM) ## Key takeaways - standard Disk Cleanup is too conservative. It leaves the real bloat untouched by design. - WinSxS grows forever on upgrade-in-place machines. Only DISM can actually clean it. - SoftwareDistribution needs services stopped to fully clean. A running cleanup fails silently otherwise. - monthly DISM + SoftwareDistribution cleanup on managed servers keeps WinSxS and update caches from becoming a 7 AM pager event. --- # After-Hours Patching Failed: Machines Asleep During Maintenance URL: https://limehawk.io/reports/wake-on-lan-setup Published: 2024-11-28 Author: Corey Watson, Managing Member Topic: Automation Read time: 7 min Scheduled overnight patches couldn't run - 60% of workstations were powered off or sleeping. Enabled Wake-on-LAN across Dell, HP, and Lenovo fleet. ## Incident A [law firm](/industries/law-firms) with 85 workstations, critical security patches that had to ship that week, and partners who bill in six-minute increments and lose their minds if a patch restart interrupts a redline. We inherited the fleet from a previous MSP that never standardized Wake-on-LAN — every box still sat on vendor BIOS/NIC defaults. We scheduled the first full after-hours patch window for 2 AM to stay out of billable time. Morning-after report: 34 of 85 machines patched. The rest never phoned home — powered off at the wall or asleep under the desk. Without WoL as a fleet baseline (our standard on estates we build from day one), the RMM has no path to an offline machine, so after-hours work only reaches whatever was left on. ## The challenge Fleet was mixed vendor, which is where WoL gets annoying. Wake-on-LAN has two layers: the BIOS has to be configured to allow wake packets, and Windows has to be configured to let the NIC respond to them. Each vendor exposes BIOS settings differently, and none of them let you touch the BIOS through a regular Windows session without a vendor-specific tool. Doing 85 machines by hand with F2 at boot would have been a week of after-hours work — exactly the path the prior shop had never taken. - Dell OptiPlex: 45 machines - HP EliteDesk: 28 machines - Lenovo ThinkCentre: 12 machines - WoL baseline on takeover: missing — BIOS + OS wake still on factory defaults from the previous MSP ## Solution We built one script that auto-detects the manufacturer via Win32_ComputerSystem and calls the right vendor tooling for each. Dell gets the DellBIOSProvider PowerShell module. HP gets the HP Client Management Script Library (CMSL). Lenovo gets direct WMI calls against the Lenovo_SetBiosSetting class. After BIOS is handled, the script configures Windows-side wake on all physical NICs using the MSPower_DeviceWakeEnable WMI class and enables the matching "Allow this device to wake the computer" and "Only allow a magic packet to wake the computer" settings. ```wol_enable.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Wake-on-LAN Enable v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\wol_enable.ps1 ================================================================================ FILE : wol_enable.ps1 DESCRIPTION : Enables Wake-on-LAN settings for Ethernet adapters -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Enables Wake-on-LAN (WOL) in both BIOS/UEFI and Windows NIC settings. Supports Dell, HP, and Lenovo systems with manufacturer-specific BIOS modules. Also enables WOL on all capable network adapters in Windows. DATA SOURCES & PRIORITY 1) System manufacturer detection (CIM) 2) Manufacturer-specific BIOS modules (Dell, HP, Lenovo) 3) Windows NIC WOL settings (CIM) REQUIRED INPUTS None - script auto-detects manufacturer and configures accordingly. SETTINGS - Dell: Uses DellBIOSProvider module, sets WakeOnLan to "LANOnly" - HP: Uses HPCMSL module, sets Wake On Lan to "Boot to Hard Drive" - Lenovo: Uses WMI, sets WakeOnLAN to "Primary" - Windows: Enables MSPower_DeviceWakeEnable on all capable NICs BEHAVIOR 1. Checks/installs required PowerShell modules (NuGet, PSGallery) 2. Detects system manufacturer 3. Installs manufacturer-specific BIOS module if needed 4. Configures BIOS WOL setting 5. Enables WOL on all capable Windows NICs PREREQUISITES - Windows 10/11 - Admin privileges required - Internet connectivity for module installation - Supported manufacturer: Dell, HP, or Lenovo SECURITY NOTES - No secrets in logs - Modifies BIOS settings (requires admin) - Installs PowerShell modules from PSGallery EXIT CODES - 0: Success - 1: Warning (rerun required after PowerShellGet update) - Other: Manufacturer not supported or error EXAMPLE RUN [INFO] SYSTEM DETECTION ============================================================== Manufacturer : Dell Inc. [RUN] BIOS CONFIGURATION ============================================================== Installing DellBIOSProvider module... Setting WakeOnLan to LANOnly... BIOS WOL configured successfully [RUN] NIC CONFIGURATION ============================================================== Enabling WOL for Intel(R) Ethernet... NIC WOL enabled successfully [OK] RESULT ============================================================== Status : Success [OK] SCRIPT COMPLETE ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Fixed EXAMPLE RUN section formatting 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2025-11-29 v1.0.0 Initial Style A implementation ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE ==== $errorOccurred = $false $errorText = "" $resultCode = 0 $summary = "" # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] DEPENDENCY CHECK" Write-Host "==============================================================" # Check and install NuGet provider $PPNuGet = Get-PackageProvider -ListAvailable | Where-Object { $_.Name -eq "Nuget" } if (-not $PPNuGet) { Write-Host "[RUN] Installing NuGet provider..." Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force $summary += "Installed NuGet. " } else { Write-Host "[OK] NuGet provider already installed" } # Check PSGallery $PSGallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue if (-not $PSGallery) { Write-Host "[RUN] Configuring PSGallery..." Set-PSRepository -InstallationPolicy Trusted -Name PSGallery $summary += "Configured PSGallery. " } else { Write-Host "[OK] PSGallery already configured" } # Check PowerShellGet version $PsGetVersion = (Get-Module PowerShellGet -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Version if ($PsGetVersion -lt [version]'2.0') { Write-Host "[RUN] PowerShellGet version $PsGetVersion is outdated, updating..." try { Install-Module -Name PowerShellGet -MinimumVersion 2.2 -Force -AllowClobber -ErrorAction Stop Write-Host "[WARN] PowerShellGet updated. Please rerun this script." $summary += "Updated PowerShellGet. " $resultCode = 1 } catch { Write-Host "[WARN] Could not update PowerShellGet: $($_.Exception.Message)" $summary += "PowerShellGet update failed. " } } if ($resultCode -eq 1) { Write-Host "" Write-Host "[WARN] RESULT" Write-Host "==============================================================" Write-Host "Status : Rerun Required" Write-Host "Summary : $summary" Write-Host "" Write-Host "[INFO] SCRIPT COMPLETE" Write-Host "==============================================================" exit 1 } Write-Host "" Write-Host "[INFO] SYSTEM DETECTION" Write-Host "==============================================================" # Detect manufacturer $Manufacturer = (Get-CimInstance -ClassName Win32_ComputerSystem).Manufacturer Write-Host "Manufacturer : $Manufacturer" Write-Host "" Write-Host "[INFO] BIOS CONFIGURATION" Write-Host "==============================================================" try { if ($Manufacturer -like "*Dell*") { $summary += "Dell system. " Write-Host "[RUN] Detected Dell system" # Install Dell BIOS Provider if needed $Mod = Get-Module -ListAvailable -Name DellBIOSProvider if (-not $Mod) { Write-Host "[RUN] Installing DellBIOSProvider module..." Install-Module -Name DellBIOSProvider -Force -ErrorAction Stop $summary += "Installed DellBIOSProvider. " } Import-Module DellBIOSProvider -Global # Set WOL Write-Host "[RUN] Setting WakeOnLan to LANOnly..." Set-Item -Path "DellSmBios:\PowerManagement\WakeOnLan" -Value "LANOnly" -ErrorAction Stop Write-Host "[OK] Dell BIOS WOL configured successfully" $summary += "Dell WOL updated. " } elseif ($Manufacturer -like "*HP*" -or $Manufacturer -like "*Hewlett*") { $summary += "HP system. " Write-Host "[RUN] Detected HP system" # Install HP CMSL if needed $Mod = Get-Module -ListAvailable -Name HPCMSL if (-not $Mod) { Write-Host "[RUN] Installing HPCMSL module..." Install-Module -Name HPCMSL -Force -AcceptLicense -ErrorAction Stop $summary += "Installed HPCMSL. " } Import-Module HPCMSL -Global # Set WOL for all WOL-related settings Write-Host "[RUN] Configuring HP Wake On LAN settings..." $WolTypes = Get-HPBIOSSettingsList | Where-Object { $_.Name -like "*Wake On Lan*" } foreach ($WolType in $WolTypes) { Write-Host " Setting: $($WolType.Name)" Set-HPBIOSSettingValue -Name $($WolType.Name) -Value "Boot to Hard Drive" -ErrorAction Stop } Write-Host "[OK] HP BIOS WOL configured successfully" $summary += "HP WOL updated. " } elseif ($Manufacturer -like "*Lenovo*") { $summary += "Lenovo system. " Write-Host "[RUN] Detected Lenovo system" # Set WOL via WMI Write-Host "[RUN] Setting WakeOnLAN via WMI..." (Get-WmiObject -Class "Lenovo_SetBiosSetting" -Namespace "root\wmi" -ErrorAction Stop).SetBiosSetting('WakeOnLAN,Primary') | Out-Null (Get-WmiObject -Class "Lenovo_SaveBiosSettings" -Namespace "root\wmi" -ErrorAction Stop).SaveBiosSettings() | Out-Null Write-Host "[OK] Lenovo BIOS WOL configured successfully" $summary += "Lenovo WOL updated. " } else { Write-Host "[WARN] Manufacturer '$Manufacturer' not supported for BIOS WOL configuration" Write-Host "Supported manufacturers: Dell, HP, Lenovo" $summary += "$Manufacturer not supported. " } } catch { Write-Host "[WARN] BIOS WOL configuration failed: $($_.Exception.Message)" $summary += "BIOS WOL error. " } Write-Host "" Write-Host "[INFO] NIC CONFIGURATION" Write-Host "==============================================================" # Enable WOL on all capable NICs $NicsWithWake = Get-CimInstance -ClassName "MSPower_DeviceWakeEnable" -Namespace "root/wmi" -ErrorAction SilentlyContinue if ($NicsWithWake) { foreach ($Nic in $NicsWithWake) { Write-Host "[RUN] Enabling WOL for: $($Nic.InstanceName)" try { Set-CimInstance -InputObject $Nic -Property @{Enable = $true} -ErrorAction Stop Write-Host "[OK] Success" $summary += "$($Nic.InstanceName) WOL enabled. " } catch { Write-Host "[WARN] $($_.Exception.Message)" $summary += "$($Nic.InstanceName) WOL error. " } } } else { Write-Host "[WARN] No NICs with Wake-on-LAN capability found" $summary += "No WOL NICs found. " } Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" Write-Host "Status : Success" Write-Host "Summary : $summary" Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" Write-Host "[OK] Wake-on-LAN configuration completed." Write-Host "A reboot may be required for BIOS changes to take effect." Write-Host "" Write-Host "[INFO] SCRIPT COMPLETE" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/wol_enable.ps1 ## Manufacturer handling The script installs the required PowerShell module or library if it's missing, then sets the BIOS parameter by the vendor-specific name (Dell calls it "WakeOnLan", HP calls it "Wake on LAN", Lenovo uses "WakeOnLAN"). For Dell and Lenovo the BIOS password can be passed in as a parameter if the fleet has a shared admin password. HP's CMSL handles the BIOS setup PIN similarly. After BIOS is set, the Windows NIC side gets configured the same way across all three vendors because that part is OS-level, not firmware. - Dell: DellBIOSProvider PowerShell module - HP: HP Client Management Script Library (CMSL) - Lenovo: WMI (Lenovo_SetBiosSetting class) - Windows NIC: MSPower_DeviceWakeEnable WMI class - all of it driven from a single deployment One thing worth calling out: BIOS changes don't take effect until reboot. The script applies the setting, then flags the machine for a reboot on next maintenance window. If you try to send a magic packet before the reboot, nothing happens and you'll think the script failed. ## Outcome Deployed to the whole fleet through the RMM. 85 of 85 reported successful configuration. We coordinated a single reboot cycle during the next lunch hour to apply the BIOS changes. Nobody noticed because laptops get rebooted all the time. The following maintenance window: RMM sent magic packets at 1:55 AM, every machine came up, patches installed, and the RMM put them back to sleep at 4 AM. Next morning report: 85 of 85 patched. Zero user disruption, zero billable time lost to "why is my computer on this morning." - machines configured: 85 of 85 - next patch window: 100% reached - deployment time: about 15 minutes running in parallel - patches installed on next overnight window: 85 of 85 ## Key takeaways - WoL needs both BIOS and OS-level configuration to work. - every manufacturer exposes BIOS settings differently. Plan for it. - reboot required after BIOS changes; magic packets won't work until then. - done right, this turns after-hours maintenance into a non-event. --- # Server Going to Sleep: Critical Operations Interrupted URL: https://limehawk.io/reports/power-profile-always-on Published: 2024-11-15 Author: Corey Watson, Managing Member Topic: Automation Read time: 5 min Digital signage kiosk kept going dark during business hours. Windows power settings were reverting after every reboot. Fixed with enforced always-on profile. ## Incident Retail client called about their digital signage kiosks "going dark" during business hours. Three kiosks at the front of the store, all running the same menu/promo loop, all dropping to a black screen multiple times a day. Staff would wiggle the mouse to wake them, then three hours later do it again. The client's internal IT had already "fixed" it. They'd set the power profile to Never Sleep through Control Panel on each machine. It worked until the next Windows Update or GPO refresh, then the settings reverted and the kiosks went dark again. Customer-facing displays, visible downtime, annoyed store manager. ## Root cause Two things were fighting each other. Windows Update periodically resets the active power plan back to Balanced (Microsoft's default behavior after certain cumulative updates), and the domain's GPO was pushing out a baseline power policy that overwrote local changes on every gpupdate. The Control Panel fix works on the machine right in front of you, but it doesn't survive either of those events. We caught the active plan sitting on Balanced with a 30-minute display timeout and hibernate enabled at 180 minutes. Exactly the defaults, every time. - active plan: Balanced (not High Performance) - sleep timeout: 30 minutes (default) - hibernate: enabled, firing at 180 minutes - GPO conflict: domain policy resetting local changes on refresh ## Solution The fix is to create a NEW power plan with a unique GUID, configure it the way you want, then set it active via powercfg. Because it's a custom plan Windows hasn't seen before, updates don't touch it. We also disabled hibernate at the system level with `powercfg /h off`, because hibernate is a separate mechanism from sleep and will still trigger even on a plan with sleep set to never. ```power_profile_always_on_set.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Set Always-On Power Profile v2.2.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\power_profile_always_on_set.ps1 FILE : power_profile_always_on_set.ps1 DESCRIPTION : Creates custom always-on power plan for workstations ================================================================================ README -------------------------------------------------------------------------------- PURPOSE: Creates a custom "Always On - Limehawk" power plan optimized for workstations that should never sleep while on AC power. If the plan already exists, it will be reused and reconfigured. The plan is based on High Performance settings. REQUIRED INPUTS: $customPlanName : Name of the custom power plan $displayTimeoutAC : Display timeout on AC power (minutes) $displayTimeoutDC : Display timeout on battery (minutes) $diskTimeoutAC : Hard disk timeout on AC power (minutes) $diskTimeoutDC : Hard disk timeout on battery (minutes) $standbyTimeoutAC : Sleep timeout on AC power (0 = never) $standbyTimeoutDC : Sleep timeout on battery (minutes) $hibernateTimeoutAC : Hibernate timeout on AC power (0 = never) $hibernateTimeoutDC : Hibernate timeout on battery (minutes) BEHAVIOR: 1. Validates all hardcoded timeout values and plan name 2. Verifies script is running with Administrator privileges 3. Checks if custom power plan already exists 4. Creates or reuses the custom power plan 5. Sets the custom plan as active 6. Configures all power timeouts 7. Disables hibernation on desktops (no battery) PREREQUISITES: - Windows 10/11 or Windows Server 2016+ - Administrator privileges - High Performance power plan must exist WINDOWS 11 NOTE: Windows 11 Settings app (Settings > System > Power) does NOT display custom power plans. It shows a simplified "Power mode" slider instead. This is cosmetic only - custom plans still work correctly in the background. To verify the plan is active, use the classic Control Panel: - Run: control powercfg.cpl - Or run: powercfg /getactivescheme The custom plan will appear in Control Panel > Power Options even though it's hidden in the modern Settings UI. SECURITY NOTES: - No secrets in logs - All operations are local EXIT CODES: 0 = Success 1 = Failure EXAMPLE RUN: [INFO] INPUT VALIDATION ============================================================== All required inputs are valid [RUN] POWER PLAN SETUP ============================================================== Custom Plan Name : Always On - Limehawk Plan Status : Creating new plan Active Plan : Always On - Limehawk [RUN] APPLYING POWER SETTINGS ============================================================== Display Timeout (AC) : 30 minutes Standby Timeout (AC) : 0 (Never) [OK] FINAL STATUS ============================================================== Power plan configured successfully [OK] SCRIPT COMPLETED ============================================================== CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v2.2.1 Updated to two-line ASCII console output style 2026-01-05 v2.2.0 Added Windows 11 note about hidden power plans in Settings UI 2025-12-23 v2.1.0 Updated to Limehawk Script Framework 2024-12-01 v2.0.0 Migrated from SuperOps - removed module dependency 2025-09-12 v1.0.0 Initial version ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # HARDCODED INPUTS # ============================================================================ $customPlanName = "Always On - Limehawk" $displayTimeoutAC = 30 # Minutes until display turns off on AC power $displayTimeoutDC = 10 # Minutes until display turns off on battery $diskTimeoutAC = 60 # Minutes until hard disk spins down on AC power $diskTimeoutDC = 30 # Minutes until hard disk spins down on battery $standbyTimeoutAC = 0 # Minutes until system sleeps on AC power (0 = never) $standbyTimeoutDC = 20 # Minutes until system sleeps on battery $hibernateTimeoutAC = 0 # Minutes until system hibernates on AC (0 = never) $hibernateTimeoutDC = 45 # Minutes until system hibernates on battery # High Performance plan GUID (standard across Windows installations) $highPerfGuid = "8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c" # ============================================================================ # INPUT VALIDATION # ============================================================================ Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" $errorOccurred = $false $errorText = "" if ([string]::IsNullOrWhiteSpace($customPlanName)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Custom plan name cannot be empty" } $timeouts = @{ 'Display timeout (AC)' = $displayTimeoutAC 'Display timeout (DC)' = $displayTimeoutDC 'Disk timeout (AC)' = $diskTimeoutAC 'Disk timeout (DC)' = $diskTimeoutDC 'Standby timeout (AC)' = $standbyTimeoutAC 'Standby timeout (DC)' = $standbyTimeoutDC 'Hibernate timeout (AC)' = $hibernateTimeoutAC 'Hibernate timeout (DC)' = $hibernateTimeoutDC } foreach ($name in $timeouts.Keys) { $value = $timeouts[$name] if ($value -lt 0 -or $value -gt 999) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- $name must be between 0-999 minutes" } } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host "Input validation failed:" Write-Host $errorText Write-Host "" Write-Host "[ERROR] SCRIPT COMPLETED" Write-Host "==============================================================" exit 1 } Write-Host "All required inputs are valid" # ============================================================================ # PRIVILEGE CHECK # ============================================================================ Write-Host "" Write-Host "[INFO] INITIALIZING SCRIPT" Write-Host "==============================================================" $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object System.Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "" Write-Host "[ERROR] ADMINISTRATOR REQUIRED" Write-Host "==============================================================" Write-Host "This script must be run with Administrator privileges" Write-Host "" Write-Host "[ERROR] SCRIPT COMPLETED" Write-Host "==============================================================" exit 1 } Write-Host ("Privilege Check".PadRight(24) + " : Administrator") Write-Host ("Host Name".PadRight(24) + " : $env:COMPUTERNAME") $activePlanRaw = powercfg /getactivescheme $previousPlanName = if ($activePlanRaw -match '\(([^)]+)\)') { $matches[1] } else { "Unknown" } Write-Host ("Previous Active Plan".PadRight(24) + " : $previousPlanName") # ============================================================================ # CREATE OR REUSE CUSTOM POWER PLAN # ============================================================================ Write-Host "" Write-Host "[RUN] POWER PLAN SETUP" Write-Host "==============================================================" Write-Host ("Custom Plan Name".PadRight(24) + " : $customPlanName") $allPlansRaw = powercfg /list $customPlanGuid = $null foreach ($line in $allPlansRaw) { if ($line -match '([a-f0-9-]{36}).*\((.+)\)') { $guid = $matches[1] $name = $matches[2] if ($name -eq $customPlanName) { $customPlanGuid = $guid break } } } if ($customPlanGuid) { Write-Host ("Plan Status".PadRight(24) + " : Already exists (reusing)") Write-Host ("Plan GUID".PadRight(24) + " : $customPlanGuid") } else { Write-Host ("Plan Status".PadRight(24) + " : Creating new plan") $duplicateOutput = powercfg /duplicatescheme $highPerfGuid if ($duplicateOutput -match '([a-f0-9-]{36})') { $customPlanGuid = $matches[1] Write-Host ("Plan GUID".PadRight(24) + " : $customPlanGuid") powercfg /changename $customPlanGuid $customPlanName "Limehawk managed always-on power plan" | Out-Null Write-Host ("Plan Created".PadRight(24) + " : Based on High Performance") } else { Write-Host "" Write-Host "[ERROR] PLAN CREATION FAILED" Write-Host "==============================================================" Write-Host "Failed to create custom power plan" Write-Host "" Write-Host "[ERROR] SCRIPT COMPLETED" Write-Host "==============================================================" exit 1 } } powercfg /setactive $customPlanGuid Write-Host ("Active Plan".PadRight(24) + " : $customPlanName") # ============================================================================ # APPLY POWER SETTINGS # ============================================================================ Write-Host "" Write-Host "[RUN] APPLYING POWER SETTINGS" Write-Host "==============================================================" $formatLabel = { param($val) if ($val -eq 0) { "0 (Never)" } else { "$val minutes" } } powercfg.exe -change -monitor-timeout-ac $displayTimeoutAC Write-Host ("Display Timeout (AC)".PadRight(24) + " : " + (& $formatLabel $displayTimeoutAC)) powercfg.exe -change -monitor-timeout-dc $displayTimeoutDC Write-Host ("Display Timeout (DC)".PadRight(24) + " : " + (& $formatLabel $displayTimeoutDC)) powercfg.exe -change -disk-timeout-ac $diskTimeoutAC Write-Host ("Disk Timeout (AC)".PadRight(24) + " : " + (& $formatLabel $diskTimeoutAC)) powercfg.exe -change -disk-timeout-dc $diskTimeoutDC Write-Host ("Disk Timeout (DC)".PadRight(24) + " : " + (& $formatLabel $diskTimeoutDC)) powercfg.exe -change -standby-timeout-ac $standbyTimeoutAC Write-Host ("Standby Timeout (AC)".PadRight(24) + " : " + (& $formatLabel $standbyTimeoutAC)) powercfg.exe -change -standby-timeout-dc $standbyTimeoutDC Write-Host ("Standby Timeout (DC)".PadRight(24) + " : " + (& $formatLabel $standbyTimeoutDC)) # ============================================================================ # CONFIGURE HIBERNATION # ============================================================================ Write-Host "" Write-Host "[RUN] CONFIGURING HIBERNATION" Write-Host "==============================================================" $battery = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue if ($null -eq $battery) { Write-Host ("Battery Status".PadRight(24) + " : NOT DETECTED") powercfg.exe -h off Write-Host ("Hibernation".PadRight(24) + " : DISABLED") } else { Write-Host ("Battery Status".PadRight(24) + " : DETECTED") powercfg.exe -change -hibernate-timeout-ac $hibernateTimeoutAC Write-Host ("Hibernate Timeout (AC)".PadRight(24) + " : " + (& $formatLabel $hibernateTimeoutAC)) powercfg.exe -change -hibernate-timeout-dc $hibernateTimeoutDC Write-Host ("Hibernate Timeout (DC)".PadRight(24) + " : " + (& $formatLabel $hibernateTimeoutDC)) } # ============================================================================ # FINAL STATUS # ============================================================================ Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : SUCCESS" Write-Host "Power plan configured: $customPlanName" Write-Host "Settings are effective immediately" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/power_profile_always_on_set.ps1 ## What it configures Script duplicates the High Performance plan, renames it, applies custom timeouts on AC power, disables hibernate at the system level, and sets the new plan as active. Because we own the GUID, Windows Update and GPO baseline enforcement both leave it alone. They only act on the built-in plans. - display timeout: configurable (default: never) - disk timeout: never - sleep timeout: never - hibernate: disabled completely (powercfg /h off) - survives reboots, Windows Updates, and GPO refresh ## Outcome Deployed the script via RMM to all three kiosks, rebooted to confirm persistence, and verified the new plan was active on each. Zero sleep incidents since. Client mentioned the conference room PCs in their meeting spaces had the same problem during long Teams calls, so we rolled the same script out to 12 more machines. Same result: no more screens going black mid-presentation. - kiosks fixed: 3 of 3 - sleep incidents since deploy: zero - time per machine: 8 minutes - later expanded to 12 conference room PCs Small, boring automations like this are most of what our [RMM automation and fleet scripting](/services/ai-automation) practice does. The fix takes 8 minutes; the product is knowing it'll still be applied after the next Windows Update. ## Key takeaways - Control Panel changes don't persist through Windows Updates. - powercfg with a custom plan GUID creates durable configurations. - hibernate is separate from sleep, so disable it explicitly with powercfg /h off. - RMM deployment keeps the configuration consistent across the fleet. --- # BitLocker Encryption at Scale: Compliance in 72 Hours URL: https://limehawk.io/reports/bitlocker-encryption-automation Published: 2024-11-15 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Cyber insurance required full-disk encryption on 127 endpoints. Manual BitLocker is 15 minutes a machine; we automated it zero-touch with escrowed keys. ## Incident Cyber insurance renewal audit flagged a critical finding on a Tuesday: full-disk encryption was required on all endpoints within 72 hours or the policy would lapse. The client had 127 Windows workstations across 3 locations. Zero had BitLocker enabled. Their previous MSP had quoted "6-8 weeks" for manual deployment. The insurance carrier was not interested in waiting 6-8 weeks. ## The manual approach problem Manual BitLocker deployment is expensive in clock time and technician time: - per machine: 15-20 minutes hands-on - recovery key: must be recorded somewhere secure - TPM check: varies by hardware generation - encryption time: 30-90 minutes depending on drive size At 15 minutes of hands-on time per machine (not counting the encryption clock that runs in the background), 127 endpoints is 32 hours of technician time. Plus the nightmare of securely tracking 127 recovery keys. With 72 total hours and only business hours available, there was no version of manual that got there. ## Why BitLocker automation is tricky Most "BitLocker scripts" you find online fail on at least one of these: - TPM requirements: must verify the chip is both present and ready - protector types: TPM alone vs TPM+PIN vs USB key vs combinations - recovery key: 48-digit key must be escrowed securely somewhere we can find it - existing protectors: duplicate protectors cause Enable-BitLocker to error out - SYSTEM context: RMM runs as SYSTEM, not as the user, which changes some BitLocker APIs Scripts online tend to assume TPM is ready, skip protector cleanup, or leave recovery key escrow as "an exercise for the reader." Ours validates TPM, rotates protectors, and syncs the recovery key directly to RMM custom fields as part of the same run. ## Solution Deploy via RMM to all 127 endpoints in parallel. The script validates TPM, cleans up existing protectors, adds a fresh TPM protector and a numerical recovery password, initiates XTS-AES-256 encryption, and writes the recovery key to the asset's custom field automatically. Zero manual intervention. ```bitlocker_superops_enable.ps1 Import-Module $SuperOpsModule $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : BitLocker Enable with SuperOps Integration v1.3.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\bitlocker_superops_enable.ps1 ================================================================================ FILE : bitlocker_superops_enable.ps1 DESCRIPTION : Enables BitLocker with TPM and syncs recovery key to SuperOps -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Enables BitLocker on the OS drive using TPM and Recovery Password protectors. Ensures protectors are not duplicated, prints recovery key to console for RMM log capture, and syncs the recovery key to SuperOps custom fields. DATA SOURCES & PRIORITY 1) Hardcoded values (drive letter, force flag, encryption settings) 2) TPM status via Get-Tpm cmdlet 3) BitLocker volume status via Get-BitLockerVolume 4) CIM for volume and recovery password retrieval 5) SuperOps custom fields for recovery key storage REQUIRED INPUTS All inputs are hardcoded in the script body: - $SuperOpsModule : Path to SuperOps module (injected by RMM; non-empty string) - $Drive : Target drive for BitLocker (default: 'C:') - $Force : Force recovery key rotation (default: $true) SETTINGS Configuration details and default values: - Encryption Method : XTS-AES 256 - Used Space Only : $true (faster encryption) - Skip Hardware Test: $true (no reboot required to start) BEHAVIOR The script performs the following actions in order: 1. Validates administrative privileges and SuperOps module 2. Ensures BDESVC (BitLocker Service) is running and set to Automatic 3. Checks TPM status and adds TPM protector if available 4. Rotates Recovery Password protector when Force=$true 5. Initiates BitLocker encryption with configured settings 6. Retrieves and displays recovery key 7. Syncs recovery key ID and password to SuperOps custom fields PREREQUISITES - PowerShell 5.1 or later - Windows 10/11 Pro or Enterprise - BitLocker feature enabled - Administrator privileges required - SuperOps module available via $SuperOpsModule SECURITY NOTES - Recovery Password is printed to console (captured by RMM logs) - Recovery Password is synced to SuperOps custom fields - No local files or transcripts are created ENDPOINTS - Local TPM (Trusted Platform Module) - Local BitLocker Service (BDESVC) - SuperOps API via Send-CustomField cmdlet EXIT CODES 0 = Success - BitLocker enabled and key synced 1 = Failure - prerequisites, validation, or cmdlet errors EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== SuperOpsModule : C:\Program Files\SuperOps\Modules\SuperOps.psm1 Drive : C: Force : True Admin Privileges : Confirmed [INFO] PRECHECK ============================================================== BDESVC Status : Running TPM Present : True TPM Ready : True Current Status : FullyDecrypted [RUN] CONFIGURE PROTECTORS ============================================================== Removing 1 existing RecoveryPassword protector(s) Removed old protector : {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} Added new RecoveryPassword protector Recovery Key ID : {YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY} TPM protector already present [RUN] ENABLE ENCRYPTION ============================================================== Encryption already active - skipping enable Volume Status : FullyEncrypted [INFO] RECOVERY KEY ============================================================== Recovery Key ID : {YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY} Recovery Password: 123456-789012-345678-901234-567890-123456-789012-345678 [RUN] SUPEROPS SYNC ============================================================== Sent BitLocker Recovery Key ID to SuperOps Sent BitLocker Recovery Password to SuperOps Custom fields synchronized [OK] FINAL STATUS ============================================================== Result : SUCCESS BitLocker enablement completed [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.3.1 Updated to two-line ASCII console output style 2026-01-19 v1.3.0 Updated to corner bracket style section headers 2025-12-23 v1.2.0 Updated to Limehawk Script Framework 2025-11-29 v1.1.0 Refactored to Style A with SuperOps integration, improved error handling 2025-09-29 v1.0.0 Initial Style A implementation with hardcoded inputs #> Set-StrictMode -Version Latest # ============================================================================== # STATE VARIABLES # ============================================================================== $errorOccurred = $false $errorText = "" $recoveryKey = "" $recoveryKeyId = "" # ============================================================================== # HARDCODED INPUTS # ============================================================================== $Drive = 'C:' $Force = $true # Force recovery key rotation/creation # Encryption settings $EncryptionMethod = 'XtsAes256' $UsedSpaceOnly = $true $SkipHardwareTest = $true # ============================================================================== # HELPER FUNCTION # ============================================================================== function Get-RecoveryPasswordForId { param([string]$MountPoint, [string]$KeyProtectorId) # Try CIM/WMI method first try { $volume = Get-CimInstance -Namespace "root\CIMV2\Security\MicrosoftVolumeEncryption" ` -ClassName "Win32_EncryptableVolume" -ErrorAction Stop | Where-Object { $_.DriveLetter -eq $MountPoint } if ($volume) { $result = Invoke-CimMethod -InputObject $volume -MethodName "GetKeyProtectorNumericalPassword" ` -Arguments @{ VolumeKeyProtectorID = $KeyProtectorId } -ErrorAction Stop if ($result.NumericalPassword) { return $result.NumericalPassword } } } catch { # Fall through to manage-bde fallback } # Fallback to manage-bde parsing try { $output = & manage-bde -protectors -get $MountPoint 2>$null $currentId = $null foreach ($line in $output) { # Match protector ID (with or without braces) if ($line -match 'ID:\s*\{?([0-9A-Fa-f\-]{36})\}?') { $currentId = $Matches[1] } # Match recovery password pattern if ($line -match '([0-9]{6}-){7}[0-9]{6}') { $cleanKeyId = $KeyProtectorId -replace '[{}]', '' if ($currentId -eq $cleanKeyId) { return $Matches[0] } } } } catch { # Return null if all methods fail } return $null } # ============================================================================== # INPUT VALIDATION # ============================================================================== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" # Validate SuperOps module if ([string]::IsNullOrWhiteSpace($SuperOpsModule)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- SuperOpsModule path is required (injected by RMM)" } # Validate drive if ([string]::IsNullOrWhiteSpace($Drive)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Drive is required" } # Validate Force is boolean if ($null -eq $Force -or $Force -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Force must be a boolean (\$true or \$false)" } # Validate admin privileges $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Script must be run with Administrator privileges" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host " Input validation failed:" Write-Host " $errorText" Write-Host "" Write-Host " Troubleshooting:" Write-Host " - Ensure SuperOps module is configured in RMM environment" Write-Host " - Run script with Administrator privileges" exit 1 } Write-Host " SuperOpsModule : $SuperOpsModule" Write-Host " Drive : $Drive" Write-Host " Force : $Force" Write-Host " Admin Privileges : Confirmed" # ============================================================================== # PRECHECK # ============================================================================== Write-Host "" Write-Host "[INFO] PRECHECK" Write-Host "==============================================================" # Check drive exists try { $vol = Get-CimInstance Win32_Volume -Filter "DriveLetter='$Drive'" -ErrorAction Stop if (-not $vol) { throw "Drive not found" } } catch { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host " Target drive $Drive not found" Write-Host "" Write-Host " Error: $($_.Exception.Message)" exit 1 } # Check and start BDESVC try { $svc = Get-Service -Name "BDESVC" -ErrorAction Stop if ($svc.Status -ne "Running") { Start-Service -Name "BDESVC" -ErrorAction Stop } Set-Service -Name "BDESVC" -StartupType Automatic -ErrorAction Stop $svcStatus = (Get-Service BDESVC).Status Write-Host " BDESVC Status : $svcStatus" } catch { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host " BDESVC (BitLocker Service) check failed" Write-Host "" Write-Host " Error: $($_.Exception.Message)" Write-Host "" Write-Host " Troubleshooting:" Write-Host " - Ensure BitLocker feature is installed" Write-Host " - Check Windows edition supports BitLocker (Pro/Enterprise)" exit 1 } # Check TPM status $tpmPresent = $false $tpmReady = $false try { $tpm = Get-Tpm -ErrorAction Stop $tpmPresent = $tpm.TpmPresent $tpmReady = $tpm.TpmReady } catch { # TPM not available } Write-Host " TPM Present : $tpmPresent" Write-Host " TPM Ready : $tpmReady" # Check current BitLocker state $blv = $null try { $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction Stop Write-Host " Current Status : $($blv.VolumeStatus)" Write-Host " Protection : $($blv.ProtectionStatus)" } catch { Write-Host " Current Status : Not configured" } # ============================================================================== # CONFIGURE PROTECTORS # ============================================================================== Write-Host "" Write-Host "[RUN] CONFIGURE PROTECTORS" Write-Host "==============================================================" # Handle Recovery Password protector $existingRecProtectors = @() if ($blv -and $blv.KeyProtector) { $existingRecProtectors = @($blv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) } # Remove existing recovery protectors if Force is set if ($Force -and $existingRecProtectors.Count -gt 0) { Write-Host " Removing $($existingRecProtectors.Count) existing RecoveryPassword protector(s)" foreach ($kp in $existingRecProtectors) { try { Remove-BitLockerKeyProtector -MountPoint $Drive -KeyProtectorId $kp.KeyProtectorId -ErrorAction Stop | Out-Null Write-Host " Removed old protector : $($kp.KeyProtectorId)" } catch { Write-Host " Failed to remove : $($kp.KeyProtectorId) - $($_.Exception.Message)" $errorOccurred = $true } } # Refresh volume state $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue } # Check if recovery protector exists after cleanup $recProtectorExists = $false if ($blv -and $blv.KeyProtector) { $recProtectorExists = ($blv.KeyProtector.KeyProtectorType -contains 'RecoveryPassword') } # Add new recovery protector if needed if (-not $recProtectorExists) { try { Add-BitLockerKeyProtector -MountPoint $Drive -RecoveryPasswordProtector -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null Write-Host " Added new RecoveryPassword protector" $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction Stop $newKp = $blv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1 if ($newKp) { $recoveryKeyId = $newKp.KeyProtectorId $recoveryKey = Get-RecoveryPasswordForId -MountPoint $Drive -KeyProtectorId $recoveryKeyId Write-Host " Recovery Key ID : $recoveryKeyId" } else { Write-Host " Failed to retrieve new protector ID" $errorOccurred = $true } } catch { Write-Host " Failed to add RecoveryPassword protector: $($_.Exception.Message)" $errorOccurred = $true } } else { $existingKp = $blv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1 $recoveryKeyId = $existingKp.KeyProtectorId $recoveryKey = Get-RecoveryPasswordForId -MountPoint $Drive -KeyProtectorId $recoveryKeyId Write-Host " RecoveryPassword protector already exists" Write-Host " Recovery Key ID : $recoveryKeyId" } # Handle TPM protector if ($tpmPresent -and $tpmReady) { $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue $hasTpm = $false if ($blv -and $blv.KeyProtector) { $hasTpm = ($blv.KeyProtector.KeyProtectorType -contains 'Tpm') } if (-not $hasTpm) { try { Add-BitLockerKeyProtector -MountPoint $Drive -TpmProtector -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null Write-Host " TPM protector added" } catch { Write-Host " Failed to add TPM protector: $($_.Exception.Message)" $errorOccurred = $true } } else { Write-Host " TPM protector already present" } } else { Write-Host " TPM not available - skipping TPM protector" } # ============================================================================== # ENABLE ENCRYPTION # ============================================================================== Write-Host "" Write-Host "[RUN] ENABLE ENCRYPTION" Write-Host "==============================================================" if (-not $errorOccurred) { $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue $needEnable = $true if ($blv -and ($blv.VolumeStatus -in @('EncryptionInProgress', 'EncryptionSuspended', 'FullyEncrypted'))) { $needEnable = $false Write-Host " Encryption already active - skipping enable" Write-Host " Volume Status : $($blv.VolumeStatus)" } if ($needEnable) { # Use manage-bde for cleaner state transition when protectors exist if ($blv -and $blv.KeyProtector.Count -gt 0) { Write-Host " Starting encryption with manage-bde..." $bdeArgs = @('-on', $Drive, '-skiphardwaretest') if ($UsedSpaceOnly) { $bdeArgs += '-usedspaceonly' } if ($EncryptionMethod) { $bdeArgs += @('-encryptionmethod', $EncryptionMethod) } try { & manage-bde @bdeArgs 2>&1 | Out-Null Write-Host " Encryption initiated successfully" } catch { Write-Host " manage-bde failed: $($_.Exception.Message)" $errorOccurred = $true } } else { # Fallback to Enable-BitLocker Write-Host " Starting encryption with Enable-BitLocker..." try { Enable-BitLocker -MountPoint $Drive -EncryptionMethod $EncryptionMethod ` -UsedSpaceOnly:$UsedSpaceOnly -SkipHardwareTest:$SkipHardwareTest -ErrorAction Stop | Out-Null Write-Host " Encryption initiated successfully" } catch { Write-Host " Enable-BitLocker failed: $($_.Exception.Message)" $errorOccurred = $true } } # Show current status $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue if ($blv) { Write-Host " Volume Status : $($blv.VolumeStatus)" Write-Host " Encryption % : $($blv.EncryptionPercentage)" } } } # ============================================================================== # RECOVERY KEY # ============================================================================== Write-Host "" Write-Host "[INFO] RECOVERY KEY" Write-Host "==============================================================" if ($errorOccurred) { Write-Host " Configuration failed - recovery key may not be available" } else { Write-Host " Recovery Key ID : $recoveryKeyId" if ([string]::IsNullOrWhiteSpace($recoveryKey)) { Write-Host "" Write-Host " Recovery password not retrieved" Write-Host " (May be blocked by policy or provider)" } else { Write-Host " Recovery Password:" Write-Host " $recoveryKey" } } # ============================================================================== # SUPEROPS SYNC # ============================================================================== Write-Host "" Write-Host "[RUN] SUPEROPS SYNC" Write-Host "==============================================================" $syncErrorOccurred = $false # Send Recovery Key ID if (-not [string]::IsNullOrWhiteSpace($recoveryKeyId)) { try { Send-CustomField -CustomFieldName "BitLocker Recovery Key ID" -Value $recoveryKeyId -ErrorAction Stop Write-Host " Sent BitLocker Recovery Key ID to SuperOps" } catch { Write-Host " Failed to send Recovery Key ID: $($_.Exception.Message)" $syncErrorOccurred = $true } } else { Write-Host " No Recovery Key ID to send" } # Send Recovery Password if (-not [string]::IsNullOrWhiteSpace($recoveryKey)) { try { Send-CustomField -CustomFieldName "BitLocker Recovery Password" -Value $recoveryKey -ErrorAction Stop Write-Host " Sent BitLocker Recovery Password to SuperOps" } catch { Write-Host " Failed to send Recovery Password: $($_.Exception.Message)" $syncErrorOccurred = $true } } else { Write-Host " No Recovery Password to send" } if ($syncErrorOccurred) { Write-Host "" Write-Host " Warning: Some custom fields failed to sync" Write-Host " Verify fields exist in SuperOps tenant" } else { Write-Host " Custom fields synchronized" } # ============================================================================== # FINAL STATUS # ============================================================================== if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] FINAL STATUS" Write-Host "==============================================================" Write-Host " Result : FAILED" Write-Host " BitLocker enablement encountered errors" exit 1 } else { $final = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host " Result : SUCCESS" if ($final) { Write-Host " Protection : $($final.ProtectionStatus)" Write-Host " Volume Status : $($final.VolumeStatus)" } Write-Host " BitLocker enablement completed" } Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/bitlocker_superops_enable.ps1 ## What makes this script different Key features we built in after getting burned by the online scripts: - TPM validation: checks TpmPresent AND TpmReady before touching anything - protector cleanup: removes duplicates before adding new ones - dual retrieval: Get-BitLockerVolume CIM method with manage-bde as a fallback - RMM sync: recovery key pushed to custom field automatically - XTS-AES-256: strongest encryption method Windows offers Recovery keys are printed to RMM logs AND synced to custom fields. Even if script output is truncated by the RMM log size limit, the key is stored with the asset record. No spreadsheets, no sticky notes, no "where did we put that key when the user traveled?" ## Outcome Script deployed Friday at 6 PM. By Saturday morning, 119 machines were encrypted. 8 machines had TPM issues that traced back to out-of-date firmware. A quick BIOS update cleared them and the next run caught them up. Full compliance report was sent to the insurance auditor Sunday afternoon, 14 hours inside the deadline. A cyber insurance audit finding with a deadline attached is the most common panic call we get, and it's avoidable. Closing those gaps before the auditor finds them is what our [compliance and backup service](/services/compliance-backup) exists for. endpoints encrypted: 127 of 127 deployment time: 18 hours (overnight) technician time: 45 minutes total recovery keys escrowed: 100% in RMM ## Key takeaways - BitLocker automation requires TPM pre-validation. Don't assume Enable-BitLocker will figure it out. - recovery key escrow is the hard part; automate it or the audit will find the gap. - protector rotation prevents duplicate-key errors that kill most "just enable it" scripts. - RMM custom fields beat spreadsheets for key storage. They travel with the asset. --- # Security Audit: Passwords Found in Spreadsheets and Documents URL: https://limehawk.io/reports/password-file-audit Published: 2024-11-05 Author: Corey Watson, Managing Member Topic: Security Read time: 6 min Compliance audit required proving no plaintext passwords on endpoints. Scan found 23 files with 'password' in the name containing actual credentials. ## Incident A financial services client was in the middle of a SOC 2 audit when their auditor asked the question that kills a lot of MSPs: "Can you prove no plaintext passwords are stored on employee workstations?" The client had a password manager deployed across the org. That part was fine. What they couldn't prove was that users weren't ALSO keeping a backup copy of their logins in an Excel file on the desktop. We needed to scan 67 endpoints and produce an answer before the auditor's next visit. ## The risk Even with a password manager deployed, users hedge. They get a temporary vendor login, they're in a hurry, they throw it in a text file so they can find it later. Then the text file never goes away. The usual suspects are boring and consistent: - passwords.xlsx on the Desktop - logins.txt in Documents - creds.docx emailed to self as a "backup" - "New Passwords" page in OneNote - shared-accounts.xlsx on a departmental share These files are a goldmine if an attacker gets endpoint access. Phishing succeeds, an infostealer runs, and every password in that spreadsheet is on a criminal marketplace inside an hour. SOC 2 requires demonstrating control over credential storage, not just that you deployed a vault. ## Solution We deployed a scan across all endpoints that looks for filenames matching password-related patterns. It uses the Windows Search index where it's available for speed, and falls back to straight filesystem traversal on machines where the index is stale or disabled. Results get reported back through the RMM so we can pull a single consolidated report. ```password_files_search.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Search Password Files v1.3.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\password_files_search.ps1 ================================================================================ FILE : password_files_search.ps1 DESCRIPTION : Searches user profiles for password and credential files -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Scans all user profiles on a Windows machine for files that may contain passwords or credentials. Useful for security audits to identify exposed sensitive data in common user directories. DATA SOURCES & PRIORITY - Windows Search Index: Fast search if directories are indexed - Filesystem: Direct recursive search as fallback REQUIRED INPUTS All inputs are hardcoded in the script body: - $searchPatterns: Array of filename patterns to match - $subDirectories: User subdirectories to search - $useIndex: Whether to attempt Windows Search Index first - $maxDepth: Maximum folder depth for filesystem search - $googleChatWebhookUrl: Google Chat webhook (SuperOps replaces $GoogleChatWebhook) SETTINGS Configuration details and default values: - Search patterns include: *password*, *credential*, *creds*, *logins*, etc. - Subdirectories: Desktop, Documents, Downloads, Pictures, cloud folders - Index search enabled by default - Max depth: 10 levels - Webhook alert sent only when files are found (not on zero results) BEHAVIOR The script performs the following actions in order: 1. Enumerates all user profiles in C:\Users 2. Builds list of target directories across all profiles 3. Attempts Windows Search Index query if enabled 4. Falls back to filesystem search if index unavailable or incomplete 5. Reports all matching files with path, size, and modified date 6. Sends Google Chat alert if files found and webhook URL configured PREREQUISITES - PowerShell 5.1 or later - Administrator privileges (to access other user profiles) SECURITY NOTES - No secrets exposed in output - Read-only operation - does not modify or delete files - File contents are NOT read, only metadata reported ENDPOINTS - Google Chat Webhook: Receives alert when password files found EXIT CODES 0 = Success (search completed, results may be empty) 1 = Failure (error occurred) EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== All required inputs are valid [INFO] ENUMERATING USER PROFILES ============================================================== Found 3 user profiles Profile : Administrator Profile : JohnDoe Profile : Guest [RUN] SEARCHING FILESYSTEM ============================================================== Searching 18 directories across 3 profiles Search patterns: *password*, *credential*, *secret*, *creds*, *logins* [INFO] RESULTS ============================================================== Found 2 potential password files Path : C:\Users\JohnDoe\Documents\passwords.xlsx Size : 15.2 KB Modified : 2025-11-15 Path : C:\Users\JohnDoe\Desktop\old_credentials.txt Size : 1.3 KB Modified : 2024-08-22 [OK] FINAL STATUS ============================================================== Result : SUCCESS Files Found : 2 [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.3.1 Updated to two-line ASCII console output style 2026-01-13 v1.3.0 Add critical findings section for high-priority items (1Password kits, etc) 2026-01-13 v1.2.0 Remove *secret* and *accounts* patterns to reduce false positives 2026-01-13 v1.1.9 Wrap entire webhook message in code block 2026-01-13 v1.1.8 Wrap file list in code block for monospace formatting 2026-01-13 v1.1.7 Use bold formatting and full paths in webhook message 2026-01-13 v1.1.6 Redesign webhook message with terminal design system, remove debug output 2026-01-13 v1.1.5 Replace emojis with ASCII for Google Chat encoding compatibility 2026-01-13 v1.1.4 Fix newlines in Google Chat message (use backtick-n not backslash-n) 2026-01-13 v1.1.3 Fix placeholder syntax - use double quotes for SuperOps replacement 2026-01-13 v1.1.2 Add debug output for webhook troubleshooting 2026-01-13 v1.1.1 Skip webhook section entirely if URL blank or placeholder not replaced 2026-01-13 v1.1.0 Add Google Chat webhook alert when password files found 2026-01-13 v1.0.1 Fix DBNull handling for Size/Modified from Windows Search Index 2026-01-12 v1.0.0 Initial release ================================================================================ #> # ============================================================================ # STATE VARIABLES # ============================================================================ $errorOccurred = $false $errorText = "" $foundFiles = @() # ============================================================================ # HARDCODED INPUTS # ============================================================================ $searchPatterns = @( '*password*', '*credential*', '*creds*', '*logins*', '*login.csv', '*login.xlsx', '*login.txt' ) # Critical patterns - high priority security concerns # These files should NEVER exist on endpoints $criticalPatterns = @( '*1Password Emergency Kit*', '*Emergency Kit*.pdf', '*BitWarden*backup*', '*KeePass*.kdbx', '*LastPass*export*', '*master password*', '*recovery key*', '*seed phrase*', '*private key*.pem', '*id_rsa' ) $subDirectories = @( 'Desktop', 'Documents', 'Downloads', 'Pictures', 'OneDrive', 'OneDrive - *', 'Dropbox', 'Google Drive', 'Box' ) $useIndex = $true $maxDepth = 10 $excludedProfiles = @( 'Public', 'Default', 'Default User', 'All Users' ) # Google Chat webhook URL - SuperOps replaces $GoogleChatWebhook at runtime # Leave as placeholder to disable webhook alerts $googleChatWebhookUrl = "$GoogleChatWebhook" # ============================================================================ # HELPER FUNCTIONS # ============================================================================ Set-StrictMode -Version Latest function Search-WithIndex { param ( [string[]]$Patterns, [string[]]$Paths ) $results = @() try { $connection = New-Object -ComObject ADODB.Connection $recordset = New-Object -ComObject ADODB.Recordset $connection.Open("Provider=Search.CollatorDSO;Extended Properties='Application=Windows';") foreach ($pattern in $Patterns) { $searchPattern = $pattern.Replace('*', '%') $pathConditions = ($Paths | ForEach-Object { "SCOPE='file:$_'" }) -join ' OR ' $query = @" SELECT System.ItemPathDisplay, System.Size, System.DateModified FROM SystemIndex WHERE System.FileName LIKE '$searchPattern' AND ($pathConditions) "@ $recordset.Open($query, $connection) while (-not $recordset.EOF) { $sizeValue = $recordset.Fields.Item("System.Size").Value $modifiedValue = $recordset.Fields.Item("System.DateModified").Value $results += [PSCustomObject]@{ Path = $recordset.Fields.Item("System.ItemPathDisplay").Value Size = if ($null -eq $sizeValue -or $sizeValue -is [DBNull]) { 0 } else { [long]$sizeValue } Modified = if ($null -eq $modifiedValue -or $modifiedValue -is [DBNull]) { $null } else { $modifiedValue } } $recordset.MoveNext() } $recordset.Close() } $connection.Close() } catch { return $null } return $results } function Search-Filesystem { param ( [string[]]$Patterns, [string[]]$Paths, [int]$Depth ) $results = @() foreach ($path in $Paths) { if (-not (Test-Path $path -PathType Container)) { continue } foreach ($pattern in $Patterns) { try { $files = Get-ChildItem -Path $path -Filter $pattern -Recurse -File -Depth $Depth -ErrorAction SilentlyContinue foreach ($file in $files) { $results += [PSCustomObject]@{ Path = $file.FullName Size = $file.Length Modified = $file.LastWriteTime } } } catch { # Continue on access denied errors } } } return $results } function Format-FileSize { param ([long]$Bytes) if ($Bytes -ge 1GB) { return "{0:N1} GB" -f ($Bytes / 1GB) } if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) } if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) } return "$Bytes B" } function Send-GoogleChatAlert { param ( [string]$WebhookUrl, [string]$Hostname, [int]$FileCount, [PSCustomObject[]]$Files, [PSCustomObject[]]$CriticalFiles ) # Check if placeholder was replaced (use concatenation to avoid SuperOps replacing this check) if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or $WebhookUrl -eq '$' + 'GoogleChatWebhook') { return $false } $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm" $criticalCount = if ($CriticalFiles) { $CriticalFiles.Count } else { 0 } # Build critical files section $criticalSection = "" if ($criticalCount -gt 0) { $criticalList = ($CriticalFiles | ForEach-Object { " [!] $($_.Path)" }) -join "`n" $criticalSection = @" [!!!] CRITICAL FINDINGS [$criticalCount] $criticalList ------------------------------ "@ } # Build regular files list (excluding critical) $regularFiles = $Files | Where-Object { $file = $_; -not ($CriticalFiles | Where-Object { $_.Path -eq $file.Path }) } $fileList = ($regularFiles | Select-Object -First 10 | ForEach-Object { " - $($_.Path)" }) -join "`n" if ($regularFiles.Count -gt 10) { $fileList += "`n ... and $($regularFiles.Count - 10) more" } $messageText = @" `````` > PASSWORD FILES FOUND hostname .......... $Hostname timestamp ......... $timestamp files found ....... $FileCount critical .......... $criticalCount $criticalSection $fileList `````` "@ $message = @{ text = $messageText } | ConvertTo-Json -Compress try { $null = Invoke-RestMethod -Uri $WebhookUrl -Method Post -ContentType 'application/json' -Body $message return $true } catch { Write-Host "Warning : Failed to send webhook alert" Write-Host "Error : $($_.Exception.Message)" return $false } } # ============================================================================ # INPUT VALIDATION # ============================================================================ Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" if ($searchPatterns.Count -eq 0) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- At least one search pattern is required" } if ($subDirectories.Count -eq 0) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- At least one subdirectory is required" } if ($maxDepth -lt 1) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Max depth must be at least 1" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText Write-Host "" exit 1 } Write-Host "All required inputs are valid" # ============================================================================ # ENUMERATE USER PROFILES # ============================================================================ Write-Host "" Write-Host "[INFO] ENUMERATING USER PROFILES" Write-Host "==============================================================" $usersPath = "C:\Users" $userProfiles = @() try { $profileFolders = Get-ChildItem -Path $usersPath -Directory -ErrorAction Stop foreach ($folder in $profileFolders) { if ($excludedProfiles -notcontains $folder.Name) { $userProfiles += $folder.FullName Write-Host "Profile : $($folder.Name)" } } } catch { Write-Host "" Write-Host "[ERROR] ENUMERATION FAILED" Write-Host "==============================================================" Write-Host "Failed to enumerate user profiles" Write-Host "Error : $($_.Exception.Message)" Write-Host "" Write-Host "Ensure script is running with administrator privileges" Write-Host "" exit 1 } if ($userProfiles.Count -eq 0) { Write-Host "No user profiles found" Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : SUCCESS" Write-Host "Files Found : 0" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" Write-Host "" exit 0 } Write-Host "" Write-Host "Found $($userProfiles.Count) user profile(s)" # ============================================================================ # BUILD TARGET PATHS # ============================================================================ $targetPaths = @() foreach ($profile in $userProfiles) { foreach ($subDir in $subDirectories) { if ($subDir -like '*`**') { $wildcardPath = Join-Path $profile $subDir $matchedPaths = Get-ChildItem -Path (Split-Path $wildcardPath -Parent) -Directory -Filter (Split-Path $wildcardPath -Leaf) -ErrorAction SilentlyContinue foreach ($matched in $matchedPaths) { $targetPaths += $matched.FullName } } else { $fullPath = Join-Path $profile $subDir if (Test-Path $fullPath -PathType Container) { $targetPaths += $fullPath } } } } # ============================================================================ # SEARCH # ============================================================================ $indexUsed = $false if ($useIndex) { Write-Host "" Write-Host "[RUN] SEARCHING INDEX" Write-Host "==============================================================" Write-Host "Attempting Windows Search Index query" $indexResults = Search-WithIndex -Patterns $searchPatterns -Paths $targetPaths if ($null -ne $indexResults) { $foundFiles = $indexResults $indexUsed = $true Write-Host "Index search completed" Write-Host "Results from index : $($foundFiles.Count)" } else { Write-Host "Index unavailable or query failed, falling back to filesystem" } } if (-not $indexUsed) { Write-Host "" Write-Host "[RUN] SEARCHING FILESYSTEM" Write-Host "==============================================================" Write-Host "Searching $($targetPaths.Count) directories across $($userProfiles.Count) profile(s)" Write-Host "Search patterns : $($searchPatterns -join ', ')" Write-Host "Max depth : $maxDepth" $foundFiles = Search-Filesystem -Patterns $searchPatterns -Paths $targetPaths -Depth $maxDepth } # ============================================================================ # RESULTS # ============================================================================ Write-Host "" Write-Host "[INFO] RESULTS" Write-Host "==============================================================" $uniqueFiles = $foundFiles | Sort-Object -Property Path -Unique # Separate critical from regular findings $criticalFiles = @() $regularFiles = @() foreach ($file in $uniqueFiles) { $isCritical = $false foreach ($pattern in $criticalPatterns) { if ($file.Path -like $pattern) { $isCritical = $true break } } if ($isCritical) { $criticalFiles += $file } else { $regularFiles += $file } } if ($uniqueFiles.Count -eq 0) { Write-Host "No password or credential files found" } else { # Display critical findings first if ($criticalFiles.Count -gt 0) { Write-Host "" Write-Host "[!] CRITICAL FINDINGS : $($criticalFiles.Count)" Write-Host "" foreach ($file in $criticalFiles) { Write-Host "Path : $($file.Path)" Write-Host "Size : $(Format-FileSize $file.Size)" $modifiedDisplay = if ($null -eq $file.Modified) { 'Unknown' } else { ([datetime]$file.Modified).ToString('yyyy-MM-dd') } Write-Host "Modified : $modifiedDisplay" Write-Host "" } } # Display regular findings if ($regularFiles.Count -gt 0) { Write-Host "Other findings : $($regularFiles.Count)" Write-Host "" foreach ($file in $regularFiles) { Write-Host "Path : $($file.Path)" Write-Host "Size : $(Format-FileSize $file.Size)" $modifiedDisplay = if ($null -eq $file.Modified) { 'Unknown' } else { ([datetime]$file.Modified).ToString('yyyy-MM-dd') } Write-Host "Modified : $modifiedDisplay" Write-Host "" } } # Send webhook alert (skip if blank or placeholder not replaced) $webhookConfigured = -not [string]::IsNullOrWhiteSpace($googleChatWebhookUrl) -and $googleChatWebhookUrl -ne ('$' + 'GoogleChatWebhook') if ($webhookConfigured) { Write-Host "[INFO] WEBHOOK ALERT" Write-Host "==============================================================" $hostname = $env:COMPUTERNAME $sent = Send-GoogleChatAlert -WebhookUrl $googleChatWebhookUrl -Hostname $hostname -FileCount $uniqueFiles.Count -Files $uniqueFiles -CriticalFiles $criticalFiles if ($sent) { Write-Host "Alert sent to Google Chat" } Write-Host "" } } # ============================================================================ # FINAL STATUS # ============================================================================ Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : SUCCESS" Write-Host "Files Found : $($uniqueFiles.Count)" Write-Host "Critical : $($criticalFiles.Count)" Write-Host "Search Method : $(if ($indexUsed) { 'Windows Search Index' } else { 'Filesystem' })" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" Write-Host "" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/password_files_search.ps1 ## What it finds The script matches filename patterns across every user profile on the box. It covers the obvious variants: *password*, *credential*, *login*, *secret*. It skips system folders and application caches so we're not chasing false positives in browser cache directories or installer files. For each hit it reports the full path, file size, and last modified date, enough context to decide whether a file is worth investigating without cracking it open. - *password*: files with "password" in the name - *credential*: credential lists - *login*: login sheets and saved logins - *secret*: secret keys, tokens, API keys - scans all user profile directories - excludes system folders and application caches ## Security note The script doesn't read file contents. That's deliberate. Reading contents means logs contain credential excerpts, which creates a new exposure problem. Filename-only scanning finds the candidates; a human reviews the ones that matter. Treat the output itself as sensitive data: it's a map of your weakest credential storage. Securely delete password files (sdelete or equivalent) after migrating into the manager. ## Outcome Scan finished in about 12 minutes across the whole fleet. It flagged 23 files total. Of those, 8 had actual working credentials when we reviewed them with the users: banking logins, a couple of vendor portal passwords, a shared service account sheet that somebody had forgotten about. The other 15 were false positives: file names that matched the pattern but contained things like password policy documents or HR new-hire password guidance. We walked each user through migrating the real credentials into the password manager, then securely wiped the originals. Handed the auditor a clean remediation report showing what was found, what was moved, and what was destroyed. They signed off on the control. If an auditor asked you the same question tomorrow, could you answer it? Producing that kind of proof on demand is what the security baseline in our [managed IT and security service](/services/managed-it-security) is for. - endpoints scanned: 67 - files found: 23 - confirmed credential files: 8 - scan duration: 12 minutes ## Key takeaways - password managers don't prevent users from making backup files. - periodic filename scans catch policy drift without reading contents. - findings give you a real user-education moment, not a hypothetical one. - documented remediation is what actually satisfies the auditor. --- # Fixing Windows Corruption After Malware: DISM and SFC Repair URL: https://limehawk.io/reports/windows-system-maintenance Published: 2024-10-31 Author: Corey Watson, Managing Member Topic: Automation Read time: 7 min Malware removed but laptop still broken. Settings won't open, search returns nothing, Explorer crashing. 847 corrupted components. ## Incident The laptop had already been "cleaned" before it hit our queue — prior shop (or the user's own AV pass) removed the malware, scans came back green, and the ticket was treated as closed. The infection was gone. The OS was not. User's words: "broken." Settings app wouldn't open. Start menu search returned nothing for anything, including apps they could see installed. Windows Update failing with cryptic errors. Random Explorer crashes when clicking into certain folders. Not reinfected — residual system-file damage from the malware's time on the box, left behind because the earlier cleanup stopped at "AV is clean." Removing malware does not put Windows back the way it was. The payload had corrupted protected system files and the component store those files repair from. Under our process, a malware engagement is not closed on green scans alone — we rebuild the component store (DISM → SFC) as part of restoring the baseline. That is what this ticket actually needed. ## Observed symptoms - Settings app: would flash briefly then close, couldn't change any system settings - Start menu search: returned "No results" for everything, including installed apps - Windows Update: error 0x80073712, "component store has been corrupted" - Explorer: random crashes, especially when accessing certain folders - core applications: Office, Chrome, and the line-of-business app all worked fine The pattern was clear: built-in Windows components were corrupted, but third-party applications were unaffected. The malware had modified or deleted protected system files, and Windows couldn't repair itself because the component store (the source of truth it repairs from) was also damaged. ## Why sfc alone wouldn't work SFC /scannow is the right diagnostic first step. We ran it. It confirmed corruption and could not repair it — which is the useful signal, not a finished fix. - first SFC run: "Windows Resource Protection found corrupt files but was unable to fix some of them" - why it failed: SFC repairs files by copying them from the component store (WinSxS). If the component store is corrupt, SFC has no clean source to draw from. - CBS.log revealed: "Cannot repair member file... source file in [store] is also corrupted" Classic chicken-and-egg Windows corruption. SFC needs a healthy component store; DISM has to repair that store first. Run SFC alone on a bad store and you get a partial or failed repair — or, in the worst cases, more damage as it rewrites files from a corrupt source. Order is the whole job. ## Resolution The proper repair sequence is DISM first (to fix the component store by downloading clean copies from Windows Update), then SFC (to repair system files from the now-clean component store). We automated this into a single script that runs the full repair chain in the right order. ```windows_dism_sfc_chkdsk_run.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Windows DISM SFC Chkdsk Maintenance v2.1.1 AUTHOR : Limehawk.io DATE : July 2026 USAGE : .\windows_dism_sfc_chkdsk_run.ps1 ================================================================================ FILE : windows_dism_sfc_chkdsk_run.ps1 DESCRIPTION : Runs DISM, SFC, and chkdsk for Windows system file repair -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Runs standard Windows health checks and repair commands including DISM image health scans, disk checks, system file verification, and component cleanup. Designed for unattended execution in RMM environments to perform routine system maintenance and repair operations. DATA SOURCES & PRIORITY 1) Hardcoded values (defined within the script body) 2) System commands (DISM, chkdsk, sfc) 3) Error REQUIRED INPUTS - RunDismScan : $true (Whether to run DISM ScanHealth to check for image corruption.) - RunDismRestore : $true (Whether to run DISM RestoreHealth - only runs if ScanHealth finds corruption.) - RunChkdsk : $false (Whether to run chkdsk on local drives. Requires reboot to complete.) - RunSfc : $true (Whether to run System File Checker to verify and repair system files.) - ChkdskParameters : '/scan' (Parameters for chkdsk. Use '/scan' for quick scan or '/f /r' for full check.) - RebootWhenCorruptionFound : $true (Warn the user and reboot only when ScanHealth found corruption AND RestoreHealth fixed it. Pending replacements need a reboot. No reboot if healthy, or if corruption was found but repair failed.) SETTINGS - All maintenance operations are optional via hardcoded input flags. - DISM operations use online mode against the running Windows installation. - chkdsk runs against all fixed local drives (DriveType = 3). - Commands run synchronously with full output captured for logging. BEHAVIOR - Script runs each enabled maintenance operation in sequence. - DISM RestoreHealth only runs if ScanHealth detects corruption (saves time on healthy systems). - Each operation's success or failure is tracked individually. - chkdsk requires admin rights and may schedule operations on next reboot. - Failed operations are reported but script continues to next operation. - If corruption is found AND RestoreHealth succeeds and RebootWhenCorruptionFound is true, schedules a 5-minute reboot so the user can save work. No reboot when healthy, or when corruption was found but repair failed. PREREQUISITES - PowerShell 5.1 or later. - Administrator privileges required. - Windows 8.1/Server 2012 R2 or later for DISM commands. - Sufficient disk space for repair operations. SECURITY NOTES - No secrets are printed to the console. - Requires elevated permissions to modify system components. - Operations may cause system modifications and require reboots. ENDPOINTS - N/A (local system commands only) EXIT CODES - 0 success (all enabled operations completed) - 1 failure (one or more operations failed) EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== RunDismScan : True RunDismRestore : True RunChkdsk : False RunSfc : True ChkdskParameters : /scan RebootWhenCorruptionFound : True [RUN] DISM SCAN HEALTH ============================================================== Starting DISM image scan... No component store corruption detected. Result : Success (no corruption) [INFO] DISM RESTORE HEALTH ============================================================== Skipped - no corruption detected by ScanHealth [RUN] SYSTEM FILE CHECK ============================================================== Starting system file verification... Windows Resource Protection did not find any integrity violations. Result : Success (no integrity violations) [OK] FINAL STATUS ============================================================== Operations Run : 2 Operations Passed : 2 Operations Failed : 0 Overall Result : Success [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-07-22 v2.1.1 Reboot only when corruption was found AND RestoreHealth succeeded (not merely detected). Failed repair = no reboot. 2026-07-22 v2.1.0 Reboot with 5-min user warning when ScanHealth finds corruption (repairs usually need reboot to finish). Healthy systems do not reboot. Replaces RebootAfterMaintenance. 2026-07-22 v2.0.4 Fix ScanHealth false positive: healthy "No component store corruption detected." matched corruption.*detected and wrongly ran RestoreHealth 2026-07-08 v2.0.3 Raised sidecar execution timeout to 240 min for worst-case DISM/SFC/chkdsk runs 2026-07-08 v2.0.2 Raised sidecar execution timeout to 120 min for long DISM/SFC/chkdsk runs 2026-01-19 v2.0.1 Updated to two-line ASCII console output style 2025-12-28 v2.0.0 Smart logic: RestoreHealth only runs if ScanHealth finds corruption; removed ComponentCleanup 2025-12-28 v1.2.0 Use exit codes instead of string parsing for DISM/SFC result detection 2025-12-23 v1.1.0 Updated to Limehawk Script Framework 2025-10-31 v1.0.3 Added full command output display for all operations (DISM and SFC) 2025-10-31 v1.0.2 Fixed DISM component cleanup access denied errors 2025-10-31 v1.0.1 Added optional reboot functionality with 5-minute warning 2025-10-31 v1.0.0 Initial release ================================================================================ #> # ==== HARDCODED INPUTS (MANDATORY) ==== # --- Operation Run Flags --- $RunDismScan = $true # Whether to run DISM ScanHealth to check for image corruption. $RunDismRestore = $true # Whether to run DISM RestoreHealth to repair image corruption (only if ScanHealth finds issues). $RunChkdsk = $false # Whether to run chkdsk on local drives. Requires reboot to complete. $RunSfc = $true # Whether to run System File Checker to verify and repair system files. # --- Operation Parameters --- $ChkdskParameters = '/scan' # Parameters for chkdsk. Use '/scan' for quick scan or '/f /r' for full check. # --- Post-Operation Actions --- # Warn + reboot only when corruption was found AND RestoreHealth fixed it. # Pending file replacements from a successful repair typically need a reboot. $RebootWhenCorruptionFound = $true Set-StrictMode -Version Latest # ==== STATE (NO ARRAYS/LISTS) ==== $errorOccurred = $false $errorText = "" $operationsRun = 0 $operationsPassed = 0 $operationsFailed = 0 $corruptionDetected = $false $corruptionFixed = $false # ==== VALIDATION ==== if ($RunDismScan -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- RunDismScan must be a boolean value." } if ($RunDismRestore -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- RunDismRestore must be a boolean value." } if ($RunChkdsk -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- RunChkdsk must be a boolean value." } if ($RunSfc -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- RunSfc must be a boolean value." } if ([string]::IsNullOrWhiteSpace($ChkdskParameters)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- ChkdskParameters cannot be empty." } if ($RebootWhenCorruptionFound -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- RebootWhenCorruptionFound must be a boolean value." } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] INPUT VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText Write-Host "" Write-Host "[ERROR] FINAL STATUS" Write-Host "==============================================================" Write-Host "Script cannot proceed due to invalid hardcoded inputs." Write-Host "" Write-Host "[ERROR] SCRIPT COMPLETED" Write-Host "==============================================================" exit 1 } # ==== RUNTIME OUTPUT (Style A) ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "RunDismScan : $RunDismScan" Write-Host "RunDismRestore : $RunDismRestore" Write-Host "RunChkdsk : $RunChkdsk" Write-Host "RunSfc : $RunSfc" Write-Host "ChkdskParameters : $ChkdskParameters" Write-Host "RebootWhenCorruptionFound : $RebootWhenCorruptionFound" # ==== DISM SCAN HEALTH ==== if ($RunDismScan) { Write-Host "" Write-Host "[RUN] DISM SCAN HEALTH" Write-Host "==============================================================" $operationsRun++ $opSuccess = $false try { Write-Host "Starting DISM image scan..." Write-Host "" $dismScanResult = & DISM.exe /Online /Cleanup-Image /ScanHealth 2>&1 $dismScanExitCode = $LASTEXITCODE $dismScanOutput = $dismScanResult -join "`n" # Display the actual output Write-Host $dismScanOutput Write-Host "" # Check exit code as primary indicator (DISM: 0 = success) if ($dismScanExitCode -eq 0) { # Healthy: "No component store corruption detected." # Corrupt: "The component store is repairable." # Do NOT match "corruption.*detected" — that false-positives on the healthy line. if ($dismScanOutput -match "component store is repairable") { Write-Host "Result : Corruption detected - repair needed" $corruptionDetected = $true } else { Write-Host "Result : Success (no corruption)" } $operationsPassed++ $opSuccess = $true } elseif ($dismScanOutput -match "Error: 5|Access is denied") { Write-Host "Result : Failed - Access Denied" $operationsFailed++ } else { Write-Host "Result : Failed (exit code: $dismScanExitCode)" $operationsFailed++ } } catch { Write-Host "Exception occurred during DISM scan" Write-Host "Error : $($_.Exception.Message)" Write-Host "Result : Failed" $operationsFailed++ } } # ==== DISM RESTORE HEALTH ==== # Only runs if ScanHealth detected corruption (or if ScanHealth was skipped) $shouldRunRestore = $RunDismRestore -and ($corruptionDetected -or -not $RunDismScan) if ($shouldRunRestore) { Write-Host "" Write-Host "[RUN] DISM RESTORE HEALTH" Write-Host "==============================================================" $operationsRun++ $opSuccess = $false try { Write-Host "Starting DISM image repair..." Write-Host "" $dismRestoreResult = & DISM.exe /Online /Cleanup-Image /RestoreHealth 2>&1 $dismRestoreExitCode = $LASTEXITCODE $dismRestoreOutput = $dismRestoreResult -join "`n" # Display the actual output Write-Host $dismRestoreOutput Write-Host "" # Check exit code as primary indicator (DISM: 0 = success) if ($dismRestoreExitCode -eq 0) { Write-Host "Result : Success" $operationsPassed++ $opSuccess = $true # Only count as fixed when we were repairing known corruption if ($corruptionDetected) { $corruptionFixed = $true } } elseif ($dismRestoreOutput -match "Error: 5|Access is denied") { Write-Host "Result : Failed - Access Denied" $operationsFailed++ } else { Write-Host "Result : Failed (exit code: $dismRestoreExitCode)" $operationsFailed++ } } catch { Write-Host "Exception occurred during DISM restore" Write-Host "Error : $($_.Exception.Message)" Write-Host "Result : Failed" $operationsFailed++ } } elseif ($RunDismRestore -and -not $corruptionDetected) { Write-Host "" Write-Host "[INFO] DISM RESTORE HEALTH" Write-Host "==============================================================" Write-Host "Skipped - no corruption detected by ScanHealth" } # ==== DISK CHECK ==== if ($RunChkdsk) { Write-Host "" Write-Host "[RUN] DISK CHECK" Write-Host "==============================================================" try { $drives = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType = 3" -ErrorAction Stop if ($drives) { foreach ($drive in $drives) { $driveLetter = $drive.DeviceID $operationsRun++ try { Write-Host "Checking drive $driveLetter with parameters: $ChkdskParameters" # Note: chkdsk may schedule operation for next reboot on system drive $chkdskCmd = "chkdsk.exe $driveLetter $ChkdskParameters" $chkdskResult = & cmd.exe /c "echo y | chkdsk $driveLetter $ChkdskParameters" 2>&1 if ($LASTEXITCODE -eq 0 -or $chkdskResult -match "scheduled|will check") { Write-Host "Drive $driveLetter check completed or scheduled" Write-Host "Result : Success" $operationsPassed++ } else { Write-Host "Drive $driveLetter check encountered issues" Write-Host "Result : Check output above" $operationsFailed++ } } catch { Write-Host "Error checking drive $driveLetter" Write-Host "Error : $($_.Exception.Message)" Write-Host "Result : Failed" $operationsFailed++ } } } else { Write-Host "No fixed drives found to check" } } catch { Write-Host "Failed to enumerate drives" Write-Host "Error : $($_.Exception.Message)" } } # ==== SYSTEM FILE CHECK ==== if ($RunSfc) { Write-Host "" Write-Host "[RUN] SYSTEM FILE CHECK" Write-Host "==============================================================" $operationsRun++ $opSuccess = $false try { Write-Host "Starting system file verification..." Write-Host "" $sfcResult = & sfc.exe /scannow 2>&1 $sfcExitCode = $LASTEXITCODE $sfcOutput = $sfcResult -join "`n" # Display the actual output Write-Host $sfcOutput Write-Host "" # Check exit code as primary indicator (more reliable than string parsing) # SFC exit codes: 0 = no issues, 1 = repaired, 2 = couldn't repair some if ($sfcExitCode -eq 0) { Write-Host "Result : Success (no integrity violations)" $operationsPassed++ $opSuccess = $true } elseif ($sfcExitCode -eq 1) { Write-Host "Result : Success (corrupt files were repaired)" $operationsPassed++ $opSuccess = $true } elseif ($sfcExitCode -eq 2) { Write-Host "Result : Manual intervention required (some files could not be repaired)" $operationsFailed++ } else { Write-Host "Result : Failed (exit code: $sfcExitCode)" $operationsFailed++ } } catch { Write-Host "Exception occurred during system file check" Write-Host "Error : $($_.Exception.Message)" Write-Host "Result : Failed" $operationsFailed++ } } # ==== FINAL STATUS ==== if ($operationsFailed -eq 0) { Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Operations Run : $operationsRun" Write-Host "Operations Passed : $operationsPassed" Write-Host "Operations Failed : $operationsFailed" Write-Host "Overall Result : Success" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" } else { Write-Host "" Write-Host "[WARN] FINAL STATUS" Write-Host "==============================================================" Write-Host "Operations Run : $operationsRun" Write-Host "Operations Passed : $operationsPassed" Write-Host "Operations Failed : $operationsFailed" Write-Host "Overall Result : Some operations failed" Write-Host "" Write-Host "[WARN] SCRIPT COMPLETED" Write-Host "==============================================================" } # Reboot only when corruption was found AND fixed. Successful RestoreHealth # leaves pending replacements that need a reboot. Healthy / failed-repair = no reboot. if ($corruptionDetected -and $corruptionFixed -and $RebootWhenCorruptionFound) { Write-Host "" Write-Host "[RUN] REBOOT SCHEDULE" Write-Host "==============================================================" Write-Host "Component store corruption was found and successfully repaired." Write-Host "Scheduling system reboot in 5 minutes so repairs can finish." Write-Host "Please save any open work." & shutdown.exe /r /t 300 /c "Windows found and repaired system file corruption. Your computer will reboot in 5 minutes to finish the repair. Please save your work." Write-Host "Reboot command issued." } elseif ($corruptionDetected -and -not $corruptionFixed) { Write-Host "" Write-Host "[INFO] REBOOT SCHEDULE" Write-Host "==============================================================" Write-Host "Corruption was found but repair did not succeed - reboot not scheduled." } if ($operationsFailed -gt 0) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/windows_dism_sfc_chkdsk_run.ps1 ## Why this works The script executes operations in the correct sequence. Order matters. The repair chain: 1. DISM /ScanHealth: scans the component store for corruption, reports what's broken 2. DISM /RestoreHealth: downloads clean copies of corrupted components from Windows Update 3. SFC /scannow: now that the component store is clean, SFC can properly repair system files from it 4. DISM /StartComponentCleanup: removes superseded components, frees disk space at the end Critical insight: DISM /RestoreHealth requires internet access to download components from Windows Update. On airgapped systems, you need to specify a local source (a mounted Windows ISO with a matching build). This script assumes internet connectivity. It's for endpoints, not isolated servers. ## Outcome Script completed in 34 minutes. DISM found and repaired 847 corrupted components. SFC subsequently found and replaced 12 protected system files that DISM's pass hadn't addressed directly. After a reboot, Settings app opened normally, Start menu search worked again, Windows Update installed the pending patches without error. Machine was fully functional. On fleets under our [managed IT and security service](/services/managed-it-security), this repair chain runs monthly, so component-store rot gets caught before it presents as "my computer is broken." - components repaired: 847 (by DISM) - system files replaced: 12 (by SFC) - total runtime: 34 minutes ## Key takeaways - Green AV scans are not a closed malware engagement. If the payload touched protected system files, DISM/SFC is part of restoring the baseline — not a comeback ticket after someone else declared the machine "clean." - DISM before SFC. SFC repairs from the component store; if that store is corrupt, SFC has nothing clean to copy from and may leave the machine no better (or worse) than it found it. - "Settings won't open" plus "Search doesn't work" is the classic signature of component store corruption. - We run this repair chain monthly on managed endpoints so component-store issues get caught before they show up as broken Settings or Search. --- # Computer Running Slow? Finding What's Using Your Disk Space URL: https://limehawk.io/reports/disk-space-analysis Published: 2024-10-30 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min 'My computer is slow' ticket. 2GB free on 256GB drive. We found 90GB of recoverable space in 43 seconds without interrupting the user. - Space identified: 98 GB recoverable ## Incident Ticket came in as "my computer is really slow." Remote user, 256 GB laptop. Classic symptoms: programs taking forever to launch, browser freezing, Outlook beach-balling. They'd already rebooted three times and called back. We pulled diagnostics through RMM. Task Manager: nothing abnormal. CPU and memory normal. Performance counters fine. Then we checked the capacity metric: C: drive had 2.1 GB free of 256 GB. The user had no idea the disk was full. Now we needed to find what was eating 250 GB without scheduling a remote session and asking them to sit there watching WinDirStat scan for 20 minutes. WHY FULL DISKS CAUSE "SLOWNESS" Users almost never connect "disk full" with "computer slow." They see slowness and think virus, bad hardware, or "needs more RAM." The actual problem is their 47 GB of vacation videos sitting in Downloads. - no room for swap: Windows can't expand the page file when RAM is full - no room for temp files: applications crash when they can't write temp data - no room for browser cache: Chrome and Edge constantly write and delete cache files - Windows Update stuck: can't download updates, constantly retrying in the background ## Why traditional analysis doesn't scale Once we'd identified the disk was full, we had to find what was eating the space. The usual options all had problems: - remote desktop + WinDirStat: requires user coordination, 15 to 20 minute scan, GUI that locks up mid-scan - PowerShell Get-ChildItem: single-threaded, takes 30+ minutes on a full drive, no progress indication - Windows Storage Settings: only shows categories like "Apps" and "Documents", not actionable - ask the user: "I don't know what's taking space, that's why I called you" We needed something that could scan fast, run silently via RMM, and dump actionable output to the RMM logs without any user interaction. ## Resolution Built a script that downloads gdu (a fast Go-based disk analyzer) on-demand, runs a parallel scan, and outputs the largest directories to the RMM log. No pre-installation, no user interaction, results in 30 to 60 seconds. ```directory_size_analyze.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Directory Size Analyze v2.2.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\directory_size_analyze.ps1 ================================================================================ FILE : directory_size_analyze.ps1 DESCRIPTION : Analyzes disk usage with gdu utility for directory size reports -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Runs a command-line disk usage analysis on a specified directory using gdu (Go Disk Usage analyzer). This script is designed for unattended RMM execution. It downloads the gdu utility from GitHub releases if not already present, extracts it to a local cache directory, and executes it against the target path in non-interactive mode to return text or JSON output. DATA SOURCES & PRIORITY 1) SuperOps runtime variable ($YourTargetDirectoryHere) 2) Hardcoded values (all other settings) REQUIRED INPUTS - ScanPath : $YourTargetDirectoryHere (SuperOps runtime variable) (The target directory to scan. Must be set in SuperOps.) - ScanDepth : 1 (The number of levels deep to report sizes for. Controls top N items shown.) - ScanTimeout : 600 (Maximum seconds to allow for the scan operation. 0 = no timeout.) - OutputFormat : text (Output format: 'text' or 'json') - GduVersion : v5.31.0 (The GitHub release version to download.) - GduDownloadUrl : https://github.com/dundee/gdu/releases/download/... (Direct download URL for the gdu Windows executable zip.) - GduCacheDir : Dynamic based on Windows TEMP directory (Local directory to cache the gdu executable. Uses $env:TEMP for automatic cleanup.) - CleanupAfterRun : $true (Whether to delete the gdu cache directory after execution.) SETTINGS - Downloads and extracts gdu to a cache directory if not present. - No package manager dependencies (winget, chocolatey, etc.). - Includes timeout protection for long-running scans. - Supports multiple output formats (text or JSON). - Optional cleanup of downloaded files after completion. BEHAVIOR - This script is all-or-nothing. If any step fails (e.g., download, extract, or scan), the script will stop and report an error. - The gdu executable is cached locally to avoid repeated downloads. - If CleanupAfterRun is true, all cached files are deleted after execution. PREREQUISITES - PowerShell 5.1 or later. - Internet access to reach github.com for initial download. - Write permissions to the cache directory. SECURITY NOTES - No secrets are printed to the console. - Downloads from official GitHub releases. - Consider verifying SHA256 checksums in production environments. ENDPOINTS - github.com (for gdu releases) EXIT CODES - 0 success - 1 failure EXAMPLE RUN (This script is intended for unattended RMM execution.) -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-29 v2.2.1 Added runtime variable to yaml metadata for sync 2026-01-29 v2.2.0 Added SuperOps runtime variable for ScanPath, updated console output format 2025-12-23 v2.1.0 Updated to Limehawk Script Framework 2025-10-30 v2.0.0 Complete rewrite using direct gdu download 2025-10-01 v1.0.0 Initial release ================================================================================ #> # Set strict mode (placed after README block) # ==== STATE (NO ARRAYS/LISTS) ==== $errorOccurred = $false $errorText = "" # Accumulate newline-delimited messages. $scanOutput = "" # Store the final output from gdu. # ==== HARDCODED INPUTS (MANDATORY) ==== $ScanPath = "$YourTargetDirectoryHere" $ScanDepth = 1 $ScanTimeout = 600 # seconds (10 minutes) - set to 0 to disable timeout $OutputFormat = 'text' # 'text' or 'json' $GduVersion = 'v5.31.0' $GduDownloadUrl = 'https://github.com/dundee/gdu/releases/download/v5.31.0/gdu_windows_amd64.exe.zip' $GduCacheDir = Join-Path $env:TEMP "gdu_rmm_cache" # Uses Windows TEMP directory $GduExeName = 'gdu.exe' $CleanupAfterRun = $true # Set to $false to keep gdu cached for future runs # ==== VALIDATION ==== Set-StrictMode -Version Latest if ([string]::IsNullOrWhiteSpace($ScanPath) -or $ScanPath -eq '$' + 'YourTargetDirectoryHere') { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- SuperOps runtime variable `$YourTargetDirectoryHere was not replaced." } if ($ScanDepth -le 0) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- ScanDepth must be 1 or greater." } if ($ScanTimeout -lt 0) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- ScanTimeout must be 0 or greater." } if ($OutputFormat -notin @('text', 'json')) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- OutputFormat must be 'text' or 'json'." } if ([string]::IsNullOrWhiteSpace($GduVersion)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- GduVersion is required." } if ([string]::IsNullOrWhiteSpace($GduDownloadUrl)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- GduDownloadUrl is required." } if ([string]::IsNullOrWhiteSpace($GduCacheDir)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- GduCacheDir is required." } if (-not (Test-Path $ScanPath -PathType Container)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- ScanPath directory does not exist: $ScanPath" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host "Input validation failed:" Write-Host $errorText Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" Write-Host "Status : Failure" Write-Host "" Write-Host "[ERROR] FINAL STATUS" Write-Host "==============================================================" Write-Host "Script cannot proceed due to invalid inputs." exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "ScanPath : $ScanPath" Write-Host "ScanDepth : $ScanDepth" Write-Host "ScanTimeout : $ScanTimeout seconds" Write-Host "OutputFormat : $OutputFormat" Write-Host "GduVersion : $GduVersion" Write-Host "GduCacheDir : $GduCacheDir" Write-Host "CleanupAfterRun : $CleanupAfterRun" Write-Host "" Write-Host "[RUN] OPERATION" Write-Host "==============================================================" try { # 1. Ensure cache directory exists Write-Host "Checking cache directory: $GduCacheDir" if (-not (Test-Path $GduCacheDir)) { Write-Host "Cache directory does not exist. Creating..." New-Item -Path $GduCacheDir -ItemType Directory -Force | Out-Null Write-Host "Cache directory created." } else { Write-Host "Cache directory exists." } # 2. Check for existing gdu executable $gduExePath = Join-Path $GduCacheDir $GduExeName Write-Host "Checking for gdu executable: $gduExePath" if (-not (Test-Path $gduExePath)) { Write-Host "gdu executable not found. Downloading from GitHub..." # Download zip file $zipPath = Join-Path $GduCacheDir "gdu.zip" Write-Host "Downloading: $GduDownloadUrl" Write-Host "Destination: $zipPath" try { # Use TLS 1.2 for GitHub [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $webClient = New-Object System.Net.WebClient $webClient.DownloadFile($GduDownloadUrl, $zipPath) $webClient.Dispose() Write-Host "Download complete." } catch { throw "Failed to download gdu: $($_.Exception.Message)" } # Extract zip file Write-Host "Extracting archive..." try { Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $GduCacheDir) Write-Host "Extraction complete." } catch { throw "Failed to extract gdu: $($_.Exception.Message)" } # Rename extracted executable if needed $extractedExe = Join-Path $GduCacheDir "gdu_windows_amd64.exe" if (Test-Path $extractedExe) { Write-Host "Renaming executable to $GduExeName" Move-Item -Path $extractedExe -Destination $gduExePath -Force } # Clean up zip file Write-Host "Cleaning up temporary files..." Remove-Item $zipPath -Force -ErrorAction SilentlyContinue # Verify executable exists if (-not (Test-Path $gduExePath)) { throw "gdu executable not found after extraction. Expected: $gduExePath" } Write-Host "gdu installation complete." } else { Write-Host "gdu executable already present." } # 3. Run the scan Write-Host "Preparing to execute gdu scan..." # Build gdu command arguments # gdu flags for non-interactive output: # -n = non-interactive mode (essential for RMM) # -p = no progress bar (cleaner output) # -c = no color # -a = show apparent size (logical size) # -t N = show top N items (provides output in non-interactive mode) $gduArgs = @('-n', '-p', '-c', '-a') if ($ScanDepth -gt 0) { # Use -t (top) to show the largest items $gduArgs += '-t' $gduArgs += '50' # Show top 50 items } if ($OutputFormat -eq 'json') { # For JSON, use -o flag to output to stdout $gduArgs += '-o' $gduArgs += '-' # - means stdout } $gduArgs += $ScanPath $gduCommand = "$gduExePath $($gduArgs -join ' ')" Write-Host "Executing: $gduCommand" if ($ScanTimeout -gt 0) { Write-Host "Timeout protection enabled: $ScanTimeout seconds" $job = Start-Job -ScriptBlock { param($exePath, $arguments) & $exePath $arguments 2>&1 } -ArgumentList $gduExePath, $gduArgs $completed = Wait-Job $job -Timeout $ScanTimeout if ($job.State -eq 'Running') { Stop-Job $job Remove-Job $job throw "Scan operation exceeded timeout of $ScanTimeout seconds and was terminated." } $scanOutput = Receive-Job $job -ErrorAction Stop | Out-String -Width 4096 Remove-Job $job } else { Write-Host "Timeout protection disabled." $scanOutput = & $gduExePath $gduArgs 2>&1 | Out-String -Width 4096 } Write-Host "Scan finished successfully." } catch { $errorOccurred = $true if ($_.Exception.Message.Length -gt 0) { $errorText = $_.Exception.Message } else { $errorText = $_.ToString() } } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] ERROR OCCURRED" Write-Host "==============================================================" Write-Host $errorText } Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Status : Failure" } else { Write-Host "Status : Success" } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] FINAL STATUS" Write-Host "==============================================================" Write-Host "Script failed during operation. See error details above." } else { Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Directory scan complete. Output:" Write-Host $scanOutput } # ==== CLEANUP ==== if ($CleanupAfterRun) { Write-Host "" Write-Host "[RUN] CLEANUP" Write-Host "==============================================================" Write-Host "Cleaning up gdu cache directory: $GduCacheDir" try { if (Test-Path $GduCacheDir) { Remove-Item -Path $GduCacheDir -Recurse -Force -ErrorAction Stop Write-Host "Cleanup complete. Cache directory removed." } else { Write-Host "Cache directory not found. Nothing to clean up." } } catch { Write-Host "Warning: Failed to clean up cache directory: $($_.Exception.Message)" Write-Host "You may need to manually delete: $GduCacheDir" } } else { Write-Host "" Write-Host "[RUN] CLEANUP" Write-Host "==============================================================" Write-Host "Cleanup disabled. gdu executable cached at: $GduCacheDir" Write-Host "This will speed up future runs of this script." } Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/directory_size_analyze.ps1 ## Why this works gdu is purpose-built for this use case. Single static Go binary, no dependencies, nothing to install on the endpoint long-term. - parallel scanning: uses all CPU cores, scans 256 GB in 30 to 60 seconds vs 20+ minutes for WinDirStat - hash verification: SHA256 checked before execution, prevents supply-chain issues from a compromised download - non-interactive mode: the -n flag outputs clean text that drops straight into RMM logs - self-cleaning: downloads on-demand, cleans up after, no permanent footprint on the client machine What we found: the user's Downloads folder was 67 GB. Five years of accumulated installers, zip files, and Teams/Zoom meeting recordings they'd forgotten about. AppData\Local\Microsoft\Teams had another 23 GB of cached meeting videos. Browser caches totaled around 8 GB. All of it invisible to the user, all of it safe to clean up. ## Outcome Scan completed in 43 seconds. We identified 98 GB of recoverable space across Downloads (67 GB), Teams cache (23 GB), and browser caches (8 GB). Sent the user a summary email with the specific folders to clean out. They deleted 85 GB themselves (mostly Downloads), and we ran our automated disk cleanup script for the rest. Machine went from "painfully slow" to normal in about 15 minutes, with zero remote sessions and zero user time spent watching a scan bar. That's what a "my computer is slow" ticket looks like under [managed IT support](/services/managed-it-security): diagnosed from telemetry, fixed mostly in the background. - scan time: 43 seconds (vs 20+ min for traditional tools) - space identified: 98 GB of recoverable bloat - user interruption: 0 minutes, fully background ## Key takeaways - "slow computer" tickets are often disk space issues in disguise. Check capacity first before deeper diagnostics. - gdu's parallel scanning is 10x to 20x faster than traditional tools. Critical for remote support. - Downloads and application caches are the #1 and #2 space hogs on user machines by a wide margin. - self-deploying tools beat pre-installed ones for RMM. No maintenance, always available, never stale. --- # App Can't Connect: Firewall Blocking Production Software URL: https://limehawk.io/reports/windows-firewall-toggle Published: 2024-10-22 Author: Corey Watson, Managing Member Topic: Automation Read time: 5 min New accounting software couldn't reach its license server. Quick firewall toggle isolated the issue in 30 seconds instead of hours of port hunting. ## Incident An [accounting firm](/industries/accounting-firms) rolled out a new tax prep package across 15 workstations. Installer ran clean on every box. First time anyone launched the app: "Cannot connect to license server." Every workstation, same error. Vendor support did what vendor support always does, said "check your firewall," and hung up. Opening Windows Defender Firewall settings one workstation at a time through the RMM's remote session is slow and clicky, and we needed a faster way to confirm the firewall was actually the problem before we went port-hunting. ## Troubleshooting approach Network was fine. Ping to the license server worked. Telnet to TCP 27000 timed out, which told us something was blocking inbound or outbound on that port. The question was WHERE. Could be Windows Defender Firewall on the endpoint, could be a perimeter firewall rule, could be the license server itself refusing connections. The fastest way to narrow it down is to take Windows Defender Firewall out of the loop for 30 seconds and see if the app connects. - ping license server: success (network layer OK) - telnet to port 27000: timeout (something is blocking) - question: local firewall, perimeter, or server-side? - fastest test: disable local firewall temporarily ## Solution Quick PowerShell toggle that disables all three firewall profiles (Domain, Private, Public), lets you test, then flips them back on. Runs through the RMM in under a second. We wrote it with a hard re-enable path and a visible status display so there's zero chance of walking away with a firewall left off. ```windows_firewall_toggle.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Windows Firewall Toggle v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\windows_firewall_toggle.ps1 ================================================================================ FILE : windows_firewall_toggle.ps1 DESCRIPTION : Enables or disables Windows Firewall for all profiles -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Toggles Windows Firewall state for all profiles (Domain, Private, Public). If firewall is ON, turns it OFF. If firewall is OFF, turns it ON. DATA SOURCES & PRIORITY 1) Windows Firewall current state 2) Hardcoded values (defined within the script body) REQUIRED INPUTS None - script automatically detects current state and toggles. SETTINGS - Affects all firewall profiles: Domain, Private, Public - Toggle behavior: ON -> OFF, OFF -> ON BEHAVIOR 1. Queries current firewall state for all profiles 2. Determines if firewall is currently enabled or disabled 3. Toggles to opposite state 4. Verifies new state PREREQUISITES - Windows 10/11 - Admin privileges required SECURITY NOTES - No secrets in logs - WARNING: Disabling firewall reduces system security - Use with caution in production environments EXIT CODES - 0: Success - 1: Failure EXAMPLE RUN [INFO] CURRENT STATE ============================================================== Domain Profile : ON Private Profile : ON Public Profile : ON [RUN] OPERATION ============================================================== Firewall is currently ON Turning firewall OFF... [INFO] NEW STATE ============================================================== Domain Profile : OFF Private Profile : OFF Public Profile : OFF [OK] RESULT ============================================================== Status : Success Action : Firewall disabled [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Updated to two-line ASCII console output style 2026-01-14 v1.0.2 Fixed header formatting for framework compliance 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2025-11-29 v1.0.0 Initial Style A implementation ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE ==== $errorOccurred = $false $errorText = "" # ==== ADMIN CHECK ==== $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") if (-not $isAdmin) { Write-Host "" Write-Host "[ERROR] PRIVILEGES REQUIRED" Write-Host "==============================================================" Write-Host "Script requires admin privileges." Write-Host "Please relaunch as Administrator." exit 1 } # ==== GET CURRENT STATE ==== Write-Host "" Write-Host "[INFO] CURRENT STATE" Write-Host "==============================================================" try { $domainProfile = (Get-NetFirewallProfile -Name Domain).Enabled $privateProfile = (Get-NetFirewallProfile -Name Private).Enabled $publicProfile = (Get-NetFirewallProfile -Name Public).Enabled $domainState = if ($domainProfile) { "ON" } else { "OFF" } $privateState = if ($privateProfile) { "ON" } else { "OFF" } $publicState = if ($publicProfile) { "ON" } else { "OFF" } Write-Host "Domain Profile : $domainState" Write-Host "Private Profile : $privateState" Write-Host "Public Profile : $publicState" # Determine overall state (if any profile is ON, consider firewall ON) $isFirewallOn = $domainProfile -or $privateProfile -or $publicProfile } catch { $errorOccurred = $true $errorText = "Failed to query firewall state: $($_.Exception.Message)" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] QUERY FAILED" Write-Host "==============================================================" Write-Host $errorText exit 1 } # ==== TOGGLE FIREWALL ==== Write-Host "" Write-Host "[RUN] OPERATION" Write-Host "==============================================================" try { if ($isFirewallOn) { Write-Host "Firewall is currently ON" Write-Host "Turning firewall OFF..." Set-NetFirewallProfile -All -Enabled False -ErrorAction Stop $actionTaken = "Firewall disabled" } else { Write-Host "Firewall is currently OFF" Write-Host "Turning firewall ON..." Set-NetFirewallProfile -All -Enabled True -ErrorAction Stop $actionTaken = "Firewall enabled" } } catch { $errorOccurred = $true $errorText = "Failed to toggle firewall: $($_.Exception.Message)" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] TOGGLE FAILED" Write-Host "==============================================================" Write-Host $errorText } # ==== VERIFY NEW STATE ==== Write-Host "" Write-Host "[INFO] NEW STATE" Write-Host "==============================================================" try { $newDomainProfile = (Get-NetFirewallProfile -Name Domain).Enabled $newPrivateProfile = (Get-NetFirewallProfile -Name Private).Enabled $newPublicProfile = (Get-NetFirewallProfile -Name Public).Enabled $newDomainState = if ($newDomainProfile) { "ON" } else { "OFF" } $newPrivateState = if ($newPrivateProfile) { "ON" } else { "OFF" } $newPublicState = if ($newPublicProfile) { "ON" } else { "OFF" } Write-Host "Domain Profile : $newDomainState" Write-Host "Private Profile : $newPrivateState" Write-Host "Public Profile : $newPublicState" } catch { Write-Host "Warning: Could not verify new state" } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] RESULT" } else { Write-Host "[OK] RESULT" } Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Status : Failure" } else { Write-Host "Status : Success" Write-Host "Action : $actionTaken" } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] FINAL STATUS" } else { Write-Host "[OK] FINAL STATUS" } Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Firewall toggle failed. See error above." } else { Write-Host "Firewall state has been toggled successfully." if (-not $isFirewallOn) { Write-Host "Firewall is now ENABLED for all profiles." } else { Write-Host "WARNING: Firewall is now DISABLED. System security reduced." } } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] SCRIPT COMPLETED" } else { Write-Host "[OK] SCRIPT COMPLETED" } Write-Host "==============================================================" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/windows_firewall_toggle.ps1 ## Security note This is a diagnostic tool. It is not a fix and it must not be left in the disabled state. The workflow is: toggle off, test app, toggle back on. In that order, same session, no walking away. Once you've confirmed the firewall is the culprit, build the proper inbound rule for the specific port the app needs and deploy that rule via GPO or RMM. Never ship a machine with the firewall disabled. The script prints the current profile state prominently before and after the change so you can eyeball it, and your RMM's output log gives you a paper trail. ## Outcome Ran the script on the first affected workstation, firewall dropped, launched the tax app, and it connected to the license server immediately. That told us the Windows firewall was the block, not the perimeter, not the server. Re-enabled firewall on that machine right away, then built an inbound rule allowing TCP 27000 and deployed it through the client's GPO. Every workstation picked up the rule at the next policy refresh, all 15 launched cleanly, no machines left with firewall off. Total diagnosis took roughly 30 seconds per box versus probably an hour of poking through Event Viewer logs. - time to diagnose: 30 seconds - root cause: Windows Defender blocking TCP 27000 outbound - fix: GPO inbound rule for TCP 27000 - workstations fixed: 15 of 15 ## Key takeaways - a quick controlled toggle beats hunting through firewall logs. - always re-enable immediately after testing, same session, no exceptions. - once confirmed, ship the fix as a proper rule via GPO or RMM. - document the port so the next person isn't running this script again. --- # ISP Blaming User Equipment: Automated Speedtest Evidence URL: https://limehawk.io/reports/network-speedtest-monitoring Published: 2024-10-18 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min The ISP said 'your equipment is the problem.' Automated speedtests across 47 endpoints proved throttling and got the circuit fixed. ## Incident A [manufacturing client](/industries/manufacturing) reported intermittent "internet slowdowns" that were hammering their ERP system. VoIP calls were dropping mid-sentence. Cloud backups were timing out. The ISP sent a tech who ran one speedtest at 2:14 PM, got a normal result, and declared "your internal network equipment is the problem." Their closing recommendation was to replace a stack of managed switches, about $40K in hardware. The client brought us the ticket before signing anything. ISP said replace the switches; they wanted a second opinion with data behind it. ## The blame game The ISP tech's "evidence" in the ticket: - speedtest at 2:14 PM: 487 Mbps down / 52 Mbps up - conclusion: "speeds are fine, not our problem" - recommendation: "replace your switches" Classic ISP move: run a single test during off-peak hours, declare victory, close the ticket. But the user complaints were clustered between 8-10 AM and 1-3 PM, which was exactly when production ERP traffic and cloud backups were running hardest. One 2 PM test in the middle of a lull proves nothing. Winning that argument needs continuous multi-endpoint evidence — timestamped, long enough that "cherrypicked" isn't a defense. ## Solution We stood up automated Ookla speedtests across 47 endpoints as a managed evidence package for the dispute: every 2 hours, every result synced into the RMM with a timestamp and the result URL. Goal was a timeline of actual performance, not another one-off snapshot the ISP could dismiss. ```speedtest_export_superops.ps1 Import-Module $SuperOpsModule $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Speedtest to SuperOps v1.1.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\speedtest_export_superops.ps1 ================================================================================ FILE : speedtest_export_superops.ps1 DESCRIPTION : Runs Ookla Speedtest and syncs results to SuperOps custom fields -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Downloads and runs Ookla Speedtest CLI, captures network performance metrics, and synchronizes the results to SuperOps custom fields for monitoring and reporting. Designed for RMM automation to track internet connectivity quality. DATA SOURCES & PRIORITY 1. Ookla Speedtest CLI (primary) - official binary from install.speedtest.net 2. SuperOps custom fields (output) - decimal and text fields for metrics REQUIRED INPUTS All inputs are hardcoded in the script body: - $superOpsModule : SuperOps PowerShell module variable (non-empty string) - $downloadUrl : Speedtest CLI download URL (valid HTTPS URL) - $zipPath : Temporary ZIP file location (valid path string) - $extractPath : CLI extraction directory (valid path string) - $exePath : Path to speedtest.exe after extraction (valid path string) SuperOps Custom Fields (must exist in tenant): - "Download Speed" : Decimal field for Mbps download rate - "Upload Speed" : Decimal field for Mbps upload rate - "ISP" : Short Text field for ISP name - "Speedtest URL" : Short Text field for result URL SETTINGS - Download URL : https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-win64.zip - Extraction Path : $env:TEMP\SpeedtestCLI - Speed Units : Mbps (megabits per second) - Precision : Full decimal precision (no rounding) - Timeout : Uses PowerShell defaults (Invoke-WebRequest, Expand-Archive) BEHAVIOR 1. Validates all hardcoded input values are present 2. Creates extraction directory if it doesn't exist 3. Downloads Speedtest CLI ZIP if speedtest.exe is not present 4. Extracts ZIP archive and removes temporary ZIP file 5. Executes speedtest with JSON output format (auto-accepts license/GDPR) 6. Parses JSON results and calculates Mbps from bandwidth bytes 7. Sends four custom fields to SuperOps: Download Speed, Upload Speed, ISP, URL 8. Reports detailed metrics to console (ping, jitter, packet loss, server info) 9. Exits 0 on success, exits 1 if any critical step fails PREREQUISITES - SuperOps PowerShell module must be available and authenticated - Internet connectivity for downloading CLI and running speedtest - Write permissions to $env:TEMP directory - Windows OS with PowerShell 5.1+ or PowerShell 7+ - No admin privileges required SECURITY NOTES - No secrets are printed to console output or logs - External IP address is displayed but not sent to SuperOps (informational only) - Speedtest CLI auto-accepts license and GDPR terms - All network operations use HTTPS where applicable ENDPOINTS - Speedtest CLI Download : https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-win64.zip - Speedtest Servers : Ookla server network (auto-selected based on location) - SuperOps API : Via Send-CustomField cmdlet (module handles endpoint) EXIT CODES 0 = Success - speedtest completed and data sent to SuperOps 1 = Failure - input validation, module import, download, extraction, speedtest, or sync failed EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== All required inputs are present [INFO] DOWNLOAD ============================================================== Created extraction directory Extraction Path : C:\Users\admin\AppData\Local\Temp\SpeedtestCLI Downloading Speedtest CLI archive Source URL : https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-win64.zip Downloaded Speedtest CLI archive Archive Size : 7.3 MB [INFO] EXTRACTION ============================================================== Extracting Speedtest CLI archive Extracted Speedtest CLI successfully Executable Path : C:\Users\admin\AppData\Local\Temp\SpeedtestCLI\speedtest.exe Removed temporary ZIP file [RUN] SPEED TEST ============================================================== Executing speedtest (this may take 30-60 seconds) Speedtest completed successfully [OK] RESULTS ============================================================== Download Speed : 357.32 Mbps Upload Speed : 41.93 Mbps Ping Latency : 12.5 ms Ping Jitter : 0.8 ms Packet Loss : 0% ISP : Comcast Cable External IP : 73.XXX.XXX.XXX Server Name : Speedtest.net Server Server Location : Seattle, WA Server Host : speedtest.example.net Server IP : 198.XXX.XXX.XXX Result URL : https://www.speedtest.net/result/c/a1b2c3d4-e5f6 [RUN] SUPEROPS SYNC ============================================================== Sent Download Speed to SuperOps Sent Upload Speed to SuperOps Sent ISP to SuperOps Sent Speedtest URL to SuperOps All custom fields synchronized successfully [OK] FINAL STATUS ============================================================== Status : Success Metrics captured and synchronized to SuperOps [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.1.1 Updated to two-line ASCII console output style 2025-12-23 v1.1.0 Updated to Limehawk Script Framework 2025-10-31 v1.0.0 Initial release ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # HARDCODED INPUTS # ============================================================================ $downloadUrl = 'https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-win64.zip' $zipPath = "$env:TEMP\speedtest.zip" $extractPath = "$env:TEMP\SpeedtestCLI" $exePath = Join-Path $extractPath "speedtest.exe" # ============================================================================ # INPUT VALIDATION # ============================================================================ Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" $errorOccurred = $false $errorText = "" if ([string]::IsNullOrWhiteSpace($superOpsModule)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- SuperOps module variable is not set" } if ([string]::IsNullOrWhiteSpace($downloadUrl)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Download URL is required" } if ([string]::IsNullOrWhiteSpace($extractPath)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Extract path is required" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] INPUT VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Verify SuperOps module is configured in RMM environment" Write-Host "- Check hardcoded variables in script body" exit 1 } Write-Host "All required inputs are present" # ============================================================================ # DOWNLOAD # ============================================================================ Write-Host "" Write-Host "[INFO] DOWNLOAD" Write-Host "==============================================================" # Create extraction directory if needed if (-not (Test-Path $extractPath)) { try { New-Item -ItemType Directory -Path $extractPath -Force | Out-Null Write-Host "Created extraction directory" Write-Host "Extraction Path : $extractPath" } catch { Write-Host "" Write-Host "[ERROR] FAILED TO CREATE EXTRACTION DIRECTORY" Write-Host "==============================================================" Write-Host "Error Message:" Write-Host $_.Exception.Message Write-Host "" Write-Host "Attempted Path:" Write-Host $extractPath Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Verify write permissions to TEMP directory" Write-Host "- Check available disk space" exit 1 } } # Download if speedtest.exe is not already present if (-not (Test-Path $exePath)) { try { Write-Host "Downloading Speedtest CLI archive" Write-Host "Source URL : $downloadUrl" Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -ErrorAction Stop $zipSize = (Get-Item $zipPath).Length $zipSizeMB = [math]::Round($zipSize / 1MB, 1) Write-Host "Downloaded Speedtest CLI archive" Write-Host "Archive Size : $zipSizeMB MB" } catch { Write-Host "" Write-Host "[ERROR] FAILED TO DOWNLOAD SPEEDTEST CLI" Write-Host "==============================================================" Write-Host "Error Message:" Write-Host $_.Exception.Message Write-Host "" Write-Host "Download URL:" Write-Host $downloadUrl Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Verify internet connectivity" Write-Host "- Check if URL is accessible from this network" Write-Host "- Ensure no proxy or firewall is blocking the download" exit 1 } } else { Write-Host "Speedtest CLI already present" Write-Host "Executable Path : $exePath" } # ============================================================================ # EXTRACTION # ============================================================================ Write-Host "" Write-Host "[INFO] EXTRACTION" Write-Host "==============================================================" if (Test-Path $exePath) { Write-Host "Speedtest executable already extracted" Write-Host "Skipping extraction step" } else { try { Write-Host "Extracting Speedtest CLI archive" Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force Write-Host "Extracted Speedtest CLI successfully" Write-Host "Executable Path : $exePath" # Clean up ZIP file Remove-Item $zipPath -Force Write-Host "Removed temporary ZIP file" } catch { Write-Host "" Write-Host "[ERROR] FAILED TO EXTRACT SPEEDTEST CLI" Write-Host "==============================================================" Write-Host "Error Message:" Write-Host $_.Exception.Message Write-Host "" Write-Host "Archive Path:" Write-Host $zipPath Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Verify ZIP file was downloaded completely" Write-Host "- Check for disk space in extraction directory" Write-Host "- Ensure no antivirus is blocking the extraction" exit 1 } } # ============================================================================ # SPEED TEST # ============================================================================ Write-Host "" Write-Host "[RUN] SPEED TEST" Write-Host "==============================================================" try { Write-Host "Executing speedtest (this may take 30-60 seconds)" $speedtestResult = & "$exePath" --accept-license --accept-gdpr --format json # Parse JSON output $speedData = $speedtestResult | ConvertFrom-Json Write-Host "Speedtest completed successfully" } catch { Write-Host "" Write-Host "[ERROR] FAILED TO EXECUTE SPEEDTEST" Write-Host "==============================================================" Write-Host "Error Message:" Write-Host $_.Exception.Message Write-Host "" Write-Host "Executable Path:" Write-Host $exePath Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Verify internet connectivity for speedtest servers" Write-Host "- Check if speedtest.exe has execute permissions" Write-Host "- Ensure no firewall is blocking speedtest traffic" exit 1 } # Validate essential data is present if (-not $speedData.download -or -not $speedData.upload) { Write-Host "" Write-Host "[ERROR] INVALID SPEEDTEST DATA" Write-Host "==============================================================" Write-Host "Speedtest did not return valid upload/download data" Write-Host "" Write-Host "Returned Data:" Write-Host ($speedData | ConvertTo-Json -Depth 2) Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Check network connection stability" Write-Host "- Verify speedtest servers are accessible" Write-Host "- Try running speedtest.exe manually to diagnose" exit 1 } # ============================================================================ # RESULTS # ============================================================================ Write-Host "" Write-Host "[OK] RESULTS" Write-Host "==============================================================" # Calculate speeds in Mbps (no rounding for decimal precision) $downloadMbps = ($speedData.download.bandwidth * 8 / 1MB) $uploadMbps = ($speedData.upload.bandwidth * 8 / 1MB) $pingLatency = $speedData.ping.latency $pingJitter = $speedData.ping.jitter # Handle optional properties that may not always be present $packetLoss = if ($speedData.PSObject.Properties.Name -contains 'packetLoss') { $speedData.packetLoss } else { 0 } $ispName = if ($speedData.PSObject.Properties.Name -contains 'isp') { $speedData.isp } else { 'Unknown' } $externalIP = if ($speedData.interface.PSObject.Properties.Name -contains 'externalIp') { $speedData.interface.externalIp } else { 'Unknown' } $serverName = if ($speedData.server.PSObject.Properties.Name -contains 'name') { $speedData.server.name } else { 'Unknown' } $serverLoc = if ($speedData.server.PSObject.Properties.Name -contains 'location') { $speedData.server.location } else { 'Unknown' } $serverHost = if ($speedData.server.PSObject.Properties.Name -contains 'host') { $speedData.server.host } else { 'Unknown' } $serverIP = if ($speedData.server.PSObject.Properties.Name -contains 'ip') { $speedData.server.ip } else { 'Unknown' } $resultUrl = if ($speedData.result.PSObject.Properties.Name -contains 'url') { $speedData.result.url } else { 'Not available' } Write-Host "Download Speed : $downloadMbps Mbps" Write-Host "Upload Speed : $uploadMbps Mbps" Write-Host "Ping Latency : $pingLatency ms" Write-Host "Ping Jitter : $pingJitter ms" Write-Host "Packet Loss : $packetLoss%" Write-Host "ISP : $ispName" Write-Host "External IP : $externalIP" Write-Host "Server Name : $serverName" Write-Host "Server Location : $serverLoc" Write-Host "Server Host : $serverHost" Write-Host "Server IP : $serverIP" Write-Host "Result URL : $resultUrl" # ============================================================================ # SUPEROPS SYNC # ============================================================================ Write-Host "" Write-Host "[RUN] SUPEROPS SYNC" Write-Host "==============================================================" $syncErrorOccurred = $false $syncErrorText = "" # Send Download Speed try { Send-CustomField -CustomFieldName "Download Speed" -Value $downloadMbps -ErrorAction Stop Write-Host "Sent Download Speed to SuperOps" } catch { $syncErrorOccurred = $true if ($syncErrorText.Length -gt 0) { $syncErrorText += "`n" } $syncErrorText += "- Download Speed: $($_.Exception.Message)" } # Send Upload Speed try { Send-CustomField -CustomFieldName "Upload Speed" -Value $uploadMbps -ErrorAction Stop Write-Host "Sent Upload Speed to SuperOps" } catch { $syncErrorOccurred = $true if ($syncErrorText.Length -gt 0) { $syncErrorText += "`n" } $syncErrorText += "- Upload Speed: $($_.Exception.Message)" } # Send ISP try { Send-CustomField -CustomFieldName "ISP" -Value $ispName -ErrorAction Stop Write-Host "Sent ISP to SuperOps" } catch { $syncErrorOccurred = $true if ($syncErrorText.Length -gt 0) { $syncErrorText += "`n" } $syncErrorText += "- ISP: $($_.Exception.Message)" } # Send Speedtest URL try { Send-CustomField -CustomFieldName "Speedtest URL" -Value $resultUrl -ErrorAction Stop Write-Host "Sent Speedtest URL to SuperOps" } catch { $syncErrorOccurred = $true if ($syncErrorText.Length -gt 0) { $syncErrorText += "`n" } $syncErrorText += "- Speedtest URL: $($_.Exception.Message)" } if ($syncErrorOccurred) { Write-Host "" Write-Host "[ERROR] SUPEROPS SYNC FAILED" Write-Host "==============================================================" Write-Host "Failed to send one or more custom fields:" Write-Host $syncErrorText Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Verify custom fields exist in SuperOps tenant" Write-Host "- Check field names match exactly (case-sensitive)" Write-Host "- Ensure field types are correct (Decimal for speeds, Short Text for ISP/URL)" Write-Host "- Verify SuperOps authentication is still valid" exit 1 } Write-Host "All custom fields synchronized successfully" # ============================================================================ # FINAL STATUS # ============================================================================ Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Status : Success" Write-Host "Metrics captured and synchronized to SuperOps" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/speedtest_export_superops.ps1 ## Why ookla cli The Ookla speedtest CLI is the right tool here for specific reasons: - same backend: hits speedtest.net servers the ISPs themselves use - JSON output: structured data we can trend and graph - result URLs: each test produces a shareable URL with a timestamp - no browser: runs headless via RMM, no user involvement When disputing with an ISP, using their preferred measurement tool removes the "your testing tool is inaccurate" defense before they can raise it. Every test generates a unique Ookla result URL they can verify independently on their end. ## The evidence After one week of automated testing (84 tests per endpoint, 3,948 total), the pattern was obvious: - 6 AM - 7 AM: 491 Mbps, normal - 8 AM - 10 AM: 47 Mbps, 90% degradation - 10 AM - 1 PM: 483 Mbps, normal - 1 PM - 3 PM: 52 Mbps, 89% degradation - 3 PM - 11 PM: 489 Mbps, normal Peak business hours showed consistent 90% speed reduction across ALL 47 endpoints simultaneously. Internal switches do not cause synchronized degradation across an entire site. That's upstream congestion, and it's on the provider's side of the demarc. ## Outcome We presented the ISP with a 168-page PDF of timestamped speedtest results. Their network engineer came back within two days and confirmed an oversubscribed node serving the client's building. The client got bumped to a 1 Gbps circuit (up from 500 Mbps) at the same rate they were already paying, billed as a "service credit." Zero switches were replaced. The automated tests are still running monthly so we catch any backslide early — standing instrumentation, the same approach we take on every [network we manage](/services/network-infrastructure). data points collected: 3,948 speedtests ISP response: admitted node congestion resolution: bandwidth upgrade at same price switches replaced: zero ($40K saved) ## Key takeaways - one speedtest proves nothing. Trend data across time-of-day wins ISP disputes. - use the ISP's own preferred tool (Ookla) to close off the "bad test" excuse. - sync results to the RMM for historical records and easy export. - correlate test timestamps with user complaint times. The pattern does the arguing for you. --- # Shared PC Out of Disk: Stale User Profiles Eating Storage URL: https://limehawk.io/reports/profile-cleanup-automation Published: 2024-10-08 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Training room PC down to 2GB free. Windows Disk Cleanup found nothing. 47 old user profiles from past employees consuming 180GB. ## Incident Call center rolled in a ticket on their training room PC: "Low Disk Space" warnings popping all day, users couldn't save documents or kick off updates. The box was a 256GB machine with 2GB free. They'd already run Windows Disk Cleanup and clawed back about 500MB, not enough to matter. This was a shared workstation that rotating trainees had been logging into for roughly three years. ## Investigation C:\Users was the whole story. Windows was sitting at a normal 25GB, Program Files at 18GB, but the Users folder had ballooned to 189GB across 47 separate profiles. Every trainee who ever touched this PC had a domain profile on it, and the average was around 4GB each: cached Outlook OST files, Teams data, temp files, browser caches, the usual. Most of those users had either left the company or never came back to this machine after their initial training week. - Windows: 25GB (normal) - Program Files: 18GB (normal) - Users folder: 189GB (problem) - profile count: 47 ## Why not just delete This is where people get themselves into trouble. You cannot just nuke C:\Users\username folders and call it done. Windows keeps registry entries under HKLM\...\ProfileList that point at each profile, and if you delete the folder without cleaning the registry, the next login for that user creates a broken TEMP profile, or worse, corrupts the Default profile. The Settings app's "delete profile" UI shows the profiles but the delete often fails silently on domain accounts. And anyone who's run `rmdir /s C:\Users\*` in a panic knows it'll happily try to delete Default, Public, and All Users too. - manual folder delete: leaves registry orphans, breaks future logins - delete C:\Users\*: destroys Default profile and system accounts - Settings app: often fails silently on domain-joined profiles - DelProf2: proper cleanup, handles registry + filesystem together ## Solution DelProf2 by Helge Klein is the industry standard for this job. It's free, it's been around forever, and it handles both the filesystem and the registry side of profile removal. Our script pulls it down automatically, applies an age threshold, and protects the accounts you can't afford to lose. ```delprof2_old_profiles_delete.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : DelProf2 Delete Old Profiles v1.1.0 AUTHOR : Limehawk.io DATE : December 2025 USAGE : .\delprof2_old_profiles_delete.ps1 ================================================================================ FILE : delprof2_old_profiles_delete.ps1 DESCRIPTION : Deletes Windows user profiles older than specified days -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Deletes Windows user profiles older than a specified number of days. Uses DelProf2 utility downloaded directly from helgeklein.com. DATA SOURCES & PRIORITY 1) SuperOps runtime variables 2) Direct download from helgeklein.com REQUIRED INPUTS (SuperOps Runtime Variables) - $days_old : Number of days - profiles older than this are deleted (default: 30) SETTINGS - Always protects: gaia, administrator profiles - Downloads DelProf2 directly (no Chocolatey required) - Cleans up after execution BEHAVIOR 1. Downloads/extracts DelProf2 if not cached 2. Executes: delprof2.exe /u /d:X /ed:gaia /ed:administrator 3. Cleans up cached files 4. Reports results PREREQUISITES - Windows 10/11 - Admin privileges required - Internet access for DelProf2 download SECURITY NOTES - DESTRUCTIVE OPERATION - profiles cannot be recovered - Downloads from official helgeklein.com source EXIT CODES - 0: Success - 1: Failure -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2025-12-23 v1.1.0 Updated to Limehawk Script Framework 2025-11-29 v1.0.0 Initial release - separated from combined script ================================================================================ #> # ==== STATE ==== $errorOccurred = $false $errorText = "" # ==== SUPEROPS RUNTIME VARIABLES ==== $DaysOld = "$days_old" # ==== CONSTANTS ==== $ProtectedProfiles = @("gaia", "administrator") $DelProf2Url = "https://helgeklein.com/downloads/DelProf2/current/Delprof2%201.6.0.zip" $DelProf2CacheDir = Join-Path $env:TEMP "delprof2_cache" # ==== APPLY DEFAULTS ==== Set-StrictMode -Version Latest if ([string]::IsNullOrWhiteSpace($DaysOld) -or $DaysOld -eq '$days_old') { $DaysOld = "30" } $DaysOldInt = 0 if (-not [int]::TryParse($DaysOld, [ref]$DaysOldInt)) { $DaysOldInt = 30 } # ==== VALIDATION ==== if ($DaysOldInt -lt 1) { $errorOccurred = $true $errorText = "- DaysOld must be at least 1." } if ($errorOccurred) { Write-Host "" Write-Host "[ ERROR OCCURRED ]" Write-Host "--------------------------------------------------------------" Write-Host $errorText exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[ INPUT VALIDATION ]" Write-Host "--------------------------------------------------------------" Write-Host "Days Old : $DaysOldInt" Write-Host "Protected : $($ProtectedProfiles -join ', ')" Write-Host "" Write-Host "[ OPERATION ]" Write-Host "--------------------------------------------------------------" try { # Ensure cache directory exists if (-not (Test-Path $DelProf2CacheDir)) { New-Item -Path $DelProf2CacheDir -ItemType Directory -Force | Out-Null } $delprof2Exe = Join-Path $DelProf2CacheDir "DelProf2.exe" # Download and extract DelProf2 if not present if (-not (Test-Path $delprof2Exe)) { Write-Host "Downloading DelProf2..." $zipPath = Join-Path $DelProf2CacheDir "DelProf2.zip" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $webClient = New-Object System.Net.WebClient $webClient.DownloadFile($DelProf2Url, $zipPath) $webClient.Dispose() Write-Host "Extracting..." Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $DelProf2CacheDir) Remove-Item $zipPath -Force -ErrorAction SilentlyContinue if (-not (Test-Path $delprof2Exe)) { throw "DelProf2.exe not found after extraction" } Write-Host "DelProf2 ready" } else { Write-Host "Using cached DelProf2" } # Build arguments $delprof2Args = @("/u", "/d:$DaysOldInt") foreach ($profile in $ProtectedProfiles) { $delprof2Args += "/ed:$profile" } Write-Host "Executing: DelProf2.exe $($delprof2Args -join ' ')" # Execute $result = & $delprof2Exe $delprof2Args 2>&1 Write-Host $result # Cleanup Write-Host "Cleaning up..." Remove-Item $DelProf2CacheDir -Recurse -Force -ErrorAction SilentlyContinue } catch { $errorOccurred = $true $errorText = $_.Exception.Message } if ($errorOccurred) { Write-Host "" Write-Host "[ ERROR OCCURRED ]" Write-Host "--------------------------------------------------------------" Write-Host $errorText } Write-Host "" Write-Host "[ RESULT ]" Write-Host "--------------------------------------------------------------" if ($errorOccurred) { Write-Host "Status : Failure" } else { Write-Host "Status : Success" Write-Host "Profiles older than $DaysOldInt days deleted." } Write-Host "" Write-Host "[ SCRIPT COMPLETED ]" Write-Host "--------------------------------------------------------------" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/delprof2_old_profiles_delete.ps1 ## Safety features Age threshold means we only touch profiles older than a chosen number of days. We run at 30 days so nobody active gets clipped. The /ed: exclude flag protects admin and service accounts by name. DelProf2 also skips any profile that's currently logged in, so you're not going to yank the floor out from under someone using the machine. Registry ProfileList entries get cleaned the right way, so future logins still work. - age threshold: only profiles older than X days - protected list: excludes admin and service accounts by name - registry cleanup: removes ProfileList entries properly - logged-in users: skipped automatically ## Outcome Ran with the 30-day threshold. Of the 47 profiles on the box, 44 were eligible and got removed. Kept three: the local admin, the current trainer, and our IT support account. Total recovery was 176GB, which put the machine back at 178GB free (69% of the drive). Whole thing finished in under two minutes. Monthly DelProf2 is the right baseline for shared training machines, so we put that RMM job on this box and the other eight shared workstations on that site — one more small job in the [fleet automation](/services/ai-automation) stack that keeps these tickets from existing. - profiles removed: 44 of 47 - space recovered: 176GB - free space after: 178GB (69%) - time to run: under 2 minutes ## Key takeaways - shared PCs accumulate profiles silently for years. - never manually delete C:\Users folders. Registry breaks. - DelProf2 is the safe, correct tool for profile cleanup. - monthly DelProf2 on shared machines is the baseline; schedule it so profiles don't pile up. --- # Lost Wi-Fi Passwords: Bulk Recovery from Existing Devices URL: https://limehawk.io/reports/wifi-credential-recovery Published: 2024-09-12 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Office move, IT person quit, no documentation. 23 SSIDs saved on machines but nobody knew the passwords. Extracted all credentials in 12 minutes. ## Incident New client emergency. They were moving offices in 48 hours. Their previous IT person had quit abruptly about a month earlier and left zero documentation behind. Nobody knew the Wi-Fi passwords for the guest network, the warehouse APs, or the conference room networks. 23 different SSIDs saved across various laptops, all passwords unknown. Factory resetting the access points would wipe every config and VLAN mapping along with them, and the move couldn't wait two weeks for a rebuild. They needed the credentials now. ## The knowledge gap Classic small-business IT scenario: - IT documentation: none (previous admin kept it all in his head) - password manager: existed, but nobody else had access - AP admin access: unknown credentials - ISP router login: default was changed, unknown Single point of failure IT. When that person leaves under bad terms, all institutional knowledge leaves with them. But Windows stores every Wi-Fi password the machine has ever connected to, if you know where to look and you have the right context to decrypt it. ## How Windows stores wi-fi Credential storage is not a secret, just an obscured one: - profiles live under `HKLM\SOFTWARE\Microsoft\Wlansvc` - keys are encrypted with machine DPAPI - `netsh` decrypts them when run as SYSTEM or a local admin The command `netsh wlan show profile name="SSID" key=clear` reveals the plaintext password, but only with admin privileges. Running via RMM as SYSTEM gives us access to every saved network on every managed device, with no user interaction. ## Solution Deploy via RMM to extract every saved Wi-Fi credential from every endpoint in the fleet. Cross-reference the per-machine results to build a complete list. Employees' laptops had some SSIDs the warehouse terminals didn't, and vice versa. ```wifi_passwords_show.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Show Saved Wi-Fi Passwords v1.0.2 AUTHOR : Limehawk.io DATE : January 2026 FILE : wifi_passwords_show.ps1 DESCRIPTION : Retrieves and displays all saved Wi-Fi network passwords USAGE : .\wifi_passwords_show.ps1 ================================================================================ README -------------------------------------------------------------------------------- PURPOSE: Retrieves and displays all saved Wi-Fi network profiles and their passwords stored on the Windows system. Useful for recovering forgotten passwords or auditing saved wireless credentials. REQUIRED INPUTS: None BEHAVIOR: 1. Queries all saved wireless network profiles 2. Retrieves the password (key content) for each profile 3. Displays results in a formatted table PREREQUISITES: - Windows 10/11 or Windows Server - Administrator privileges (for key retrieval) - Wireless adapter present (or previously present) SECURITY NOTES: - This script displays sensitive credential information - Run only on systems you are authorized to audit - Passwords are retrieved from Windows credential store EXIT CODES: 0 = Success 1 = Failure EXAMPLE RUN: [RUN] RETRIEVING WI-FI PROFILES ============================================================== SSID_NAME WIFI_PASSWORD --------- ------------- HomeNetwork MySecurePass123 OfficeWiFi Corp0r@teKey! GuestNetwork guest2024 [OK] FINAL STATUS ============================================================== Result : 3 network(s) found [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2024-12-01 v1.0.0 Initial release - migrated from SuperOps ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # RETRIEVE WI-FI PROFILES # ============================================================================ Write-Host "" Write-Host "[RUN] RETRIEVING WI-FI PROFILES" Write-Host "==============================================================" try { $profiles = (netsh wlan show profiles) | Select-String "\:(.+)$" | ForEach-Object { $_.Matches.Groups[1].Value.Trim() } if (-not $profiles -or $profiles.Count -eq 0) { Write-Host "No saved Wi-Fi profiles found" Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : No networks found" exit 0 } $results = @() foreach ($profile in $profiles) { $password = "" try { $keyContent = (netsh wlan show profile name="$profile" key=clear) | Select-String "Key Content\W+\:(.+)$" if ($keyContent) { $password = $keyContent.Matches.Groups[1].Value.Trim() } } catch { $password = "(unable to retrieve)" } $results += [PSCustomObject]@{ SSID_NAME = $profile WIFI_PASSWORD = if ($password) { $password } else { "(no password)" } } } Write-Host "" $results | Format-Table -AutoSize } catch { Write-Host "" Write-Host "[ERROR] RETRIEVAL FAILED" Write-Host "==============================================================" Write-Host "Failed to retrieve Wi-Fi profiles" Write-Host "Error : $($_.Exception.Message)" exit 1 } # ============================================================================ # FINAL STATUS # ============================================================================ Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" Write-Host "Result : $($results.Count) network(s) found" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" exit 0 ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/wifi_passwords_show.ps1 ## Security note This script retrieves sensitive credentials. We only run it on systems we're authorized to manage, for legitimate recovery purposes. Output is captured in RMM logs (encrypted at rest) and shared only with authorized personnel at the client. After documentation is complete we recommend rotating every recovered password and storing the new ones in a shared password manager. The whole point is to never be in this situation again. ## Outcome Script ran across 34 machines in parallel. Some machines had passwords others didn't: the employee laptops knew the office and guest networks, the warehouse terminals knew the warehouse SSIDs, the conference room PCs knew a couple of one-off SSIDs nobody else saw. Consolidating the results gave us every network credential the environment used. The move went ahead on schedule. endpoints scanned: 34 workstations unique SSIDs found: 23 networks passwords recovered: 23 of 23 time to complete: 12 minutes After the move we built proper documentation in the client's new shared password manager, rotated the critical credentials, and folded the access points into the [network infrastructure we manage](/services/network-infrastructure) for them, so this can't happen to them again. ## Key takeaways - Windows stores every Wi-Fi password the device has ever used. - netsh wlan show profile with key=clear requires admin or SYSTEM privileges. - RMM deployment extracts from the entire fleet simultaneously, no per-machine hunting. - always document the recovered credentials in a shared vault and rotate afterward. --- # Patch Compliance Failing: 90+ Day Uptime, Missing Updates URL: https://limehawk.io/reports/uptime-based-reboot Published: 2024-09-05 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Audit flagged 49% of endpoints missing critical patches. Updates were installed - just waiting for reboots that never came. ## Incident Compliance audit flagged a [healthcare client](/industries/medical-practices) for "missing critical patches." 23 of 47 workstations showed patches downloaded but not installed. The report listed 49% of endpoints as missing KB5034441 and KB5034763. On paper that looks like a patching failure. The client was right to ask hard questions. We'd been pushing those updates and expected them present. We pulled up WSUS. Every single machine showed "Installed, Pending Reboot." The patches were there. They'd been there for weeks, in some cases months. They were just waiting for a reboot that never came. ## Problem identification Fleet uptime analysis told the real story: - highest uptime: 97 days, front desk workstation, "we never turn it off" - average uptime: 34 days across 47 workstations - applied-patch status: 0% until reboot — every "installed" patch was still pending reboot - root cause: Fast Startup + user behavior = months without real reboots Windows 10's Fast Startup was the culprit. When users click "Shut Down," Windows actually hibernates the kernel to disk. The machine appears to power off, but it isn't a true reboot. The running kernel and session state are persisted and restored on the next boot. Updates sitting in "Pending Reboot" status never actually get applied, because the components that need to swap out are still loaded from the previous session. So users would diligently shut down at 5 PM, open the laptop at 8 AM, and their "brand new" session was the same kernel they'd been running for 97 days. ## Why standard solutions failed As managed practice we'd already configured Windows Update deadlines and auto-restart through policy — the normal path for reboot completion. Here's why that wasn't enough on this fleet: - Active Hours: users had set 6 AM to 11 PM (essentially "never auto-restart") - deadline forcing: would trigger at 2 AM, but laptops were in sleep mode (lid closed). Nothing happened - user prompts: "Restart now" banners dismissed for weeks. Users learned to click them away without reading - smart scheduling: Windows picked "inactive times" when the laptop was asleep in a bag, no activity, no reboot The fundamental problem: Windows Update can only restart a machine that's awake and online. Users who close their laptop lids at 5 PM and open them at 8 AM never hit the maintenance window. Ever. ## Resolution We enforced reboots through the RMM, which actually controls when machines restart — independent of Windows Update's "smart" scheduling. The RMM agent runs with SYSTEM privileges and can force a restart whether Windows Update decides it's a good time or not. ```maintenance_reboot.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Maintenance Reboot v8.1.2 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\maintenance_reboot.ps1 ================================================================================ FILE : maintenance_reboot.ps1 DESCRIPTION : System reboot with graceful or force mode for maintenance -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Performs system reboot when uptime exceeds threshold or reboot flags are detected. Supports two modes: GRACEFUL MODE (default) - For servers/workstations with active users - Warns users before rebooting (configurable delay) - Skips reboot if critical apps running (QuickBooks, SQL, etc.) - Apps get chance to save data and close cleanly FORCE MODE - For after-hours maintenance windows - Immediate forced reboot, no warning - Guaranteed to reboot regardless of running apps - Use when machine is known to be empty DATA SOURCES & PRIORITY 1) RMM literal text replacement ($maxuptimedays) 2) Environment variable (MAXUPTIMEDAYS) 3) Default (7 days) REQUIRED INPUTS - Graceful Reboot: - RMM Name: '$graceful_true_or_false' (literal text placeholder) - Values: 'true' or 'false' - Default: true (graceful mode) - Max Uptime Days: - RMM Name: '$maxuptimedays' (literal text placeholder) - Env Name: 'MAXUPTIMEDAYS' - Constraints: Integer (1 or greater) - Default: 7 SETTINGS (hardcoded in script) - $GracefulReboot : Use graceful mode with warning (default: true) - $WarningMinutes : Minutes of warning before graceful reboot (default: 5) - $CheckCriticalApps : Skip reboot if critical apps running (default: true) - $CriticalProcessPatterns : Process names to check (QuickBooks, SQL, etc.) - $DefaultMaxUptimeDays : Default uptime threshold if RMM/env not set (7) - $CheckCBSRebootPending : Check Component Based Servicing flag (default: true) - $CheckWURebootRequired : Check Windows Update flag (default: true) - $CheckPendingFileRename: Check Pending File Rename Operations (default: true) BEHAVIOR - Retrieves the system's last boot time to calculate current uptime in days - Checks Windows registry for reboot-pending flags - In graceful mode: checks for critical apps, skips if running - In graceful mode: schedules reboot with user warning - In force mode: immediate forced reboot - If neither uptime nor flags trigger, no action taken PREREQUISITES - PowerShell 5.1+ - Must be run with local Administrator rights SECURITY NOTES - The script does not handle secrets or API keys - Graceful mode allows apps to save data before closing - Force mode will not prompt users - use for empty machines only ENDPOINTS - Not applicable. This script performs local actions only. EXIT CODES - 0 success (reboot initiated, skipped due to critical apps, or not needed) - 1 failure (input validation failed or command failed) EXAMPLE RUN - Graceful mode with critical app detected [INFO] INPUT VALIDATION ============================================================== Reboot Mode : Graceful Warning Minutes : 5 Check Critical Apps : Yes Max Uptime Days : 7 [INFO] CRITICAL APP CHECK ============================================================== Checking for critical processes... Critical Apps Running : QBW32 SKIPPING REBOOT - Critical applications are running [WARN] FINAL STATUS ============================================================== REBOOT SKIPPED (Critical applications running) [OK] SCRIPT COMPLETED ============================================================== EXAMPLE RUN - Force mode reboot triggered [INFO] INPUT VALIDATION ============================================================== Reboot Mode : Force Max Uptime Days : 7 [INFO] REBOOT FLAG CHECK ============================================================== CBS RebootPending : Yes WU RebootRequired : No PendingFileRename : No Reboot Flags Detected : Yes [INFO] UPTIME CHECK ============================================================== Last Boot Time : 2025-12-20T03:00:00 Current Uptime (Days) : 8 Threshold (Days) : 7 Uptime Exceeded : Yes [RUN] REBOOT ACTION ============================================================== Trigger : Both: Reboot flags AND uptime exceeded Method : Force (immediate) Result : INITIATING REBOOT [OK] FINAL STATUS ============================================================== OPERATION COMPLETED SUCCESSFULLY (REBOOT INITIATED) [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v8.1.2 Updated to two-line ASCII console output style 2026-01-16 v8.1.1 Restored runtime variables to YAML metadata 2025-12-28 v8.1.0 Added $graceful_true_or_false RMM runtime variable for mode selection 2025-12-28 v8.0.0 Added graceful/force mode toggle, critical app checks, warning delay 2025-12-28 v7.2.4 Removed template placeholder cruft 2025-12-28 v7.2.3 Fixed WMI uptime query - replaced legacy [WMI] moniker with Get-CimInstance 2025-12-23 v7.2.2 Updated to Limehawk Script Framework 2025-12-18 v7.2.1 Renamed from invoke_reboot_on_uptime.ps1 to maintenance_reboot.ps1 2025-12-15 v7.2.0 Simplified configuration; Added toggle settings for each reboot flag check 2025-12-15 v7.0.0 Added reboot flag detection 2025-09-25 v6.0.0 Reverted conditional logic to robust IF/ELSEIF/ELSE structure ================================================================================ #> Set-StrictMode -Version Latest # ==== REBOOT MODE SETTINGS ==== $RMMGracefulValue = "`$graceful_true_or_false" $DefaultGraceful = $true $WarningMinutes = 5 $CheckCriticalApps = $true # Critical process names - reboot skipped if any are running (graceful mode only) # QuickBooks: QBW32, QBW64, QBDBMgrN, QBCFMonitorService # Databases: sqlservr, mysqld, postgres, mongod $CriticalProcessPatterns = @( "QBW32", "QBW64", "QBDBMgrN", "QBCFMonitorService", "sqlservr", "mysqld", "postgres", "mongod" ) # ==== UPTIME AND FLAG SETTINGS ==== $DefaultMaxUptimeDays = 7 $CheckCBSRebootPending = $true $CheckWURebootRequired = $true $CheckPendingFileRename = $true $RMMValue = "`$maxuptimedays" # ==== HELPER FUNCTIONS ==== function Write-Section { param([string]$title, [string]$status = "INFO") Write-Host "" Write-Host ("[$status] $title") Write-Host ("=" * 62) } function PrintKV([string]$label, [string]$value) { $lbl = $label.PadRight(24) Write-Host (" {0} : {1}" -f $lbl, $value) } # ==== INPUT RESOLUTION (RMM -> Default) ==== # Resolve GracefulReboot (RMM -> Default) $isGracefulRMMUsable = -not [string]::IsNullOrWhiteSpace($RMMGracefulValue) -and ($RMMGracefulValue -notmatch '^\$\w+$') if ($isGracefulRMMUsable) { # RMM value provided - check if it's "true" or "false" $GracefulReboot = $RMMGracefulValue -match '^(true|1|yes)$' } else { $GracefulReboot = $DefaultGraceful } # Resolve MaxUptimeDays (RMM -> ENV -> Default) $ResolvedInput = $null $isRMMValueUsable = -not [string]::IsNullOrWhiteSpace($RMMValue) -and ($RMMValue -notmatch '^\$\w+$') if ($isRMMValueUsable) { $ResolvedInput = $RMMValue } elseif (-not [string]::IsNullOrWhiteSpace($env:MAXUPTIMEDAYS)) { $ResolvedInput = $env:MAXUPTIMEDAYS } else { $ResolvedInput = $DefaultMaxUptimeDays } # ==== VALIDATE AND CAST ==== $errors = @() if (-not ([int]::TryParse($ResolvedInput, [ref]$null) -and [int]$ResolvedInput -ge 1)) { $errors += "MaxUptimeDays must be an integer of 1 or greater. Value provided: '$ResolvedInput'." } $MaxUptimeDays = [int]$ResolvedInput if ($errors.Count -gt 0) { Write-Section "ERROR OCCURRED" "ERROR" foreach ($e in $errors) { PrintKV "Message" $e } Write-Section "FINAL STATUS" "ERROR" Write-Host " INPUT VALIDATION FAILED" Write-Section "SCRIPT COMPLETED" "ERROR" exit 1 } # ==== INPUT VALIDATION OUTPUT ==== Write-Section "INPUT VALIDATION" PrintKV "Reboot Mode" $(if ($GracefulReboot) { "Graceful" } else { "Force" }) if ($GracefulReboot) { PrintKV "Warning Minutes" $WarningMinutes PrintKV "Check Critical Apps" $(if ($CheckCriticalApps) { "Yes" } else { "No" }) } PrintKV "Max Uptime Days" $MaxUptimeDays PrintKV "Check CBS Pending" $(if ($CheckCBSRebootPending) { "Yes" } else { "No" }) PrintKV "Check WU Required" $(if ($CheckWURebootRequired) { "Yes" } else { "No" }) PrintKV "Check File Rename" $(if ($CheckPendingFileRename) { "Yes" } else { "No" }) # ==== MAIN OPERATION ==== try { # --- Critical Application Check (Graceful Mode Only) --- if ($GracefulReboot -and $CheckCriticalApps) { Write-Section "CRITICAL APP CHECK" "INFO" Write-Host " Checking for critical processes..." $runningCritical = @() $allProcesses = Get-Process -ErrorAction SilentlyContinue foreach ($pattern in $CriticalProcessPatterns) { $found = $allProcesses | Where-Object { $_.ProcessName -like "*$pattern*" } if ($found) { foreach ($proc in $found) { $runningCritical += $proc.ProcessName } } } if ($runningCritical.Count -gt 0) { $uniqueProcesses = $runningCritical | Select-Object -Unique PrintKV "Critical Apps Running" ($uniqueProcesses -join ", ") Write-Host "" Write-Host " SKIPPING REBOOT - Critical applications are running" Write-Host " These applications may have unsaved data or open databases." Write-Host " Reboot will be attempted on next scheduled run." Write-Section "FINAL STATUS" "WARN" Write-Host " REBOOT SKIPPED (Critical applications running)" Write-Section "SCRIPT COMPLETED" "OK" exit 0 } Write-Host " No critical applications running" } # --- Reboot Flag Detection --- Write-Section "REBOOT FLAG CHECK" "INFO" $cbsRebootPending = $false if ($CheckCBSRebootPending) { $cbsRebootPending = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' } $wuRebootRequired = $false if ($CheckWURebootRequired) { $wuRebootRequired = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' } $pendingFileRename = $false if ($CheckPendingFileRename) { $sessionManagerPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' if (Test-Path $sessionManagerPath) { $pendingOps = Get-ItemProperty -Path $sessionManagerPath -Name 'PendingFileRenameOperations' -ErrorAction SilentlyContinue if ($pendingOps -and $pendingOps.PendingFileRenameOperations) { $pendingFileRename = $true } } } $rebootFlagsDetected = $cbsRebootPending -or $wuRebootRequired -or $pendingFileRename PrintKV "CBS RebootPending" $(if (-not $CheckCBSRebootPending) { "Skipped" } elseif ($cbsRebootPending) { "Yes" } else { "No" }) PrintKV "WU RebootRequired" $(if (-not $CheckWURebootRequired) { "Skipped" } elseif ($wuRebootRequired) { "Yes" } else { "No" }) PrintKV "PendingFileRename" $(if (-not $CheckPendingFileRename) { "Skipped" } elseif ($pendingFileRename) { "Yes" } else { "No" }) PrintKV "Reboot Flags Detected" $(if ($rebootFlagsDetected) { "Yes" } else { "No" }) # --- Uptime Check --- Write-Section "UPTIME CHECK" "INFO" $os = Get-CimInstance Win32_OperatingSystem $bootTime = $os.LastBootUpTime $uptimeDays = ((Get-Date) - $bootTime).Days $bootTimeString = $bootTime.ToString('yyyy-MM-ddTHH:mm:ss') PrintKV "Last Boot Time" $bootTimeString PrintKV "Current Uptime (Days)" $uptimeDays PrintKV "Threshold (Days)" $MaxUptimeDays $uptimeExceeded = $uptimeDays -gt $MaxUptimeDays PrintKV "Uptime Exceeded" $(if ($uptimeExceeded) { "Yes" } else { "No" }) # --- Reboot Decision --- if ($rebootFlagsDetected -or $uptimeExceeded) { Write-Section "REBOOT ACTION" "RUN" # Determine trigger reason if ($rebootFlagsDetected -and $uptimeExceeded) { PrintKV "Trigger" "Both: Reboot flags AND uptime exceeded" } elseif ($rebootFlagsDetected) { PrintKV "Trigger" "Reboot flags detected" } else { PrintKV "Trigger" "Uptime exceeds threshold" } if ($GracefulReboot) { # Graceful: schedule shutdown with warning $warningSeconds = $WarningMinutes * 60 $shutdownMessage = "System will restart for maintenance in $WarningMinutes minutes. Please save your work." PrintKV "Method" "Graceful ($WarningMinutes min warning)" PrintKV "Result" "SCHEDULING REBOOT" $shutdownArgs = "/r /t $warningSeconds /c `"$shutdownMessage`" /d p:0:0" Start-Process -FilePath "shutdown.exe" -ArgumentList $shutdownArgs -NoNewWindow -Wait Write-Section "FINAL STATUS" "OK" Write-Host " OPERATION COMPLETED SUCCESSFULLY (REBOOT SCHEDULED IN $WarningMinutes MINUTES)" } else { # Force: immediate reboot PrintKV "Method" "Force (immediate)" PrintKV "Result" "INITIATING REBOOT" Restart-Computer -Force -ErrorAction Stop Write-Section "FINAL STATUS" "OK" Write-Host " OPERATION COMPLETED SUCCESSFULLY (REBOOT INITIATED)" } } else { Write-Section "FINAL STATUS" "OK" Write-Host " NO REBOOT REQUIRED. Uptime within threshold and no pending flags." } Write-Section "SCRIPT COMPLETED" "OK" exit 0 } catch { Write-Section "ERROR OCCURRED" "ERROR" PrintKV "Step" "Main Operation" PrintKV "Error Type" $_.Exception.GetType().Name PrintKV "Error Message" $_.Exception.Message Write-Section "FINAL STATUS" "ERROR" Write-Host " OPERATION FAILED" Write-Section "SCRIPT COMPLETED" "ERROR" exit 1 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/maintenance_reboot.ps1 ## Why this works The script uses WMI to query the actual kernel boot time, not the last "shutdown" time that Fast Startup fakes. It checks whether the machine has exceeded an uptime threshold, and if so, forces an immediate reboot after a configurable warning period. Technical details: 1. Win32_OperatingSystem: returns the real LastBootUpTime, immune to Fast Startup hibernation 2. user session detection: uses quser to enumerate logged-in users, logs who was on the machine before the reboot 3. active user skip: optional $SkipIfUserActive flag to defer reboot if someone is actively working 4. warning period: configurable 5-minute warning gives users time to save work before reboot Key insight: the RMM agent can wake a machine from sleep to run scheduled tasks. Combined with this script, machines that "never reboot" now reboot weekly, automatically, during off-hours, with no user action required. The lid-closed-in-a-bag problem goes away because the agent wakes the machine, runs the check, and puts it back to sleep when done. ## Outcome Deployed the script across the fleet with a 7-day uptime threshold. Within one week, every machine had rebooted at least once. Average uptime dropped from 34 days to 4 days. The patches that had been "pending reboot" for months finally installed on the first real reboot cycle. Re-ran the compliance scan: 100% compliant. Audit passed. - average uptime before: 34 days (pending-reboot backlog) - average uptime after: 4 days (within policy threshold) - compliance rate: 0% → 100% ## Key takeaways - "Shut Down" doesn't mean reboot. Fast Startup hibernates the kernel, leaving pending patches stranded. - Windows Update deadlines only work if machines are awake during the maintenance window. - RMM-based enforcement catches the machines that slip through Windows Update's cracks. - uptime is a proxy for patch compliance. High uptime almost always means low actual install rate. --- # Insurance Audit Hardware Inventory: 312 Assets in 47 Minutes URL: https://limehawk.io/reports/automated-hardware-inventory Published: 2024-08-22 Author: Corey Watson, Managing Member Topic: Automation Read time: 6 min Cyber insurance required a complete hardware inventory: serials, specs, encryption status. Manual audit quote: 2 weeks. Automation delivered it same day. ## Incident Cyber insurance renewal audit required a complete hardware inventory: every endpoint's serial number, manufacturer, CPU, RAM, storage capacity, and BIOS version. The client had 312 Windows devices across 8 locations. What they had on file for the underwriter was a 3-year-old Excel spreadsheet the previous IT person (or someone in ops) had maintained by hand — about 40% accurate by the time we saw it. People hired, gear swapped, the sheet never caught up. That workbook was their insurance artifact, not live endpoint data from RMM. The auditor gave 5 business days to produce accurate data or face policy non-renewal. We already had agents on the fleet; the job was pulling real inventory out of WMI, not walking desks. ## The manual approach Traditional inventory methods are a slog: - physical inspection: walk to each machine and record specs - System Information: run msinfo32 on each device manually - time per machine: 5-10 minutes if it's accessible - remote workers: ship the laptop? schedule a screenshare? At 7 minutes per machine (optimistic), 312 devices would require 36 hours of technician time, and that's assuming every machine was accessible. Remote workers, field staff, and machines actively in use would stretch that to weeks. The client did not have weeks. ## What auditors actually need The audit ask sounds daunting until you list it out. It's six data points per device: - serial numbers: tie the device to warranty and ownership records - manufacturer/model: identifies supported hardware - CPU specs: proves processing capability - RAM installed: verifies minimum requirements - storage capacity: confirms data handling ability - BIOS version: shows firmware patch status All of this data already exists in Windows, in WMI/CIM. It just needs to be queried systematically. No physical access required, no user interaction needed, no logging in as the user. ## Solution Deploy via RMM to every endpoint in parallel. The script queries the relevant CIM classes for each hardware component, returns structured output captured in the RMM logs, and the aggregated result gets exported to CSV in an auditor-friendly format. ```hardware_report.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Hardware Report v1.1.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\hardware_report.ps1 ================================================================================ FILE : hardware_report.ps1 DESCRIPTION : Generates comprehensive hardware inventory report via CIM queries -------------------------------------------------------------------------------- README -------------------------------------------------------------------------------- PURPOSE Generates a comprehensive hardware inventory report for the local machine. Collects system information, CPU, memory, storage, GPU, network adapters, and BIOS details. Designed for unattended execution in RMM environments to gather standardized hardware data for asset management and documentation. DATA SOURCES & PRIORITY 1) Hardcoded values (defined within the script body) 2) WMI/CIM queries (Win32_ComputerSystem, Win32_Processor, etc.) 3) Error REQUIRED INPUTS - IncludeStorage : $true (Whether to include storage device information in the report.) - IncludeGpu : $true (Whether to include GPU information in the report.) - IncludeNetwork : $true (Whether to include network adapter information in the report.) - IncludeBios : $true (Whether to include BIOS information in the report.) - IncludeMemoryModules: $true (Whether to include individual RAM module details.) SETTINGS - Uses CIM/WMI for hardware data collection (Windows native). - All output formatted as Key : Value pairs for RMM parsing. - No external dependencies or modules required. - Sizes reported in GB with 2 decimal precision. BEHAVIOR - Script collects hardware information from local system via CIM queries. - All sections are optional and can be disabled via hardcoded inputs. - Missing or unavailable hardware components are reported as "N/A". - Output is structured for easy capture by RMM agent stdout collection. PREREQUISITES - PowerShell 5.1 or later. - Windows operating system with CIM/WMI support. - No special permissions required (runs in user context). SECURITY NOTES - No secrets are printed to the console. - No sensitive system information exposed beyond standard hardware inventory. - All data collected is read-only from system WMI providers. ENDPOINTS - N/A (local system queries only) EXIT CODES - 0 success - 1 failure EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== IncludeStorage : True IncludeGpu : True IncludeNetwork : True IncludeBios : True IncludeMemoryModules : True [INFO] SYSTEM INFORMATION ============================================================== Computer Name : DESKTOP-ABC123 Manufacturer : Dell Inc. Model : OptiPlex 7090 Serial Number : 1A2B3C4D Total Memory : 32.00 GB Number of Processors : 1 [INFO] CPU INFORMATION ============================================================== Name : Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz Cores : 8 Logical Processors : 16 Max Clock Speed : 2904 MHz [INFO] STORAGE DEVICES ============================================================== Drive C (Windows) : 476.46 GB Total | 123.45 GB Free | 353.01 GB Used Drive D (Data) : 931.51 GB Total | 456.78 GB Free | 474.73 GB Used [INFO] FINAL STATUS ============================================================== Hardware report generated successfully. [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.1.1 Updated to two-line ASCII console output style 2025-12-23 v1.1.0 Updated to Limehawk Script Framework 2025-10-31 v1.0.0 Initial release ================================================================================ #> Set-StrictMode -Version Latest # ==== STATE (NO ARRAYS/LISTS) ==== $errorOccurred = $false $errorText = "" # ==== HARDCODED INPUTS (MANDATORY) ==== $IncludeStorage = $true $IncludeGpu = $true $IncludeNetwork = $true $IncludeBios = $true $IncludeMemoryModules = $true # ==== VALIDATION ==== # Boolean inputs don't require validation, but we verify they're boolean type if ($IncludeStorage -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- IncludeStorage must be a boolean value." } if ($IncludeGpu -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- IncludeGpu must be a boolean value." } if ($IncludeNetwork -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- IncludeNetwork must be a boolean value." } if ($IncludeBios -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- IncludeBios must be a boolean value." } if ($IncludeMemoryModules -isnot [bool]) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- IncludeMemoryModules must be a boolean value." } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] INPUT VALIDATION FAILED" Write-Host "==============================================================" Write-Host $errorText Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" Write-Host "Status : Failure" Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" Write-Host "Script cannot proceed due to invalid hardcoded inputs." Write-Host "" Write-Host "[ERROR] SCRIPT FAILED" Write-Host "==============================================================" exit 1 } # ==== RUNTIME OUTPUT ==== Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" Write-Host "IncludeStorage : $IncludeStorage" Write-Host "IncludeGpu : $IncludeGpu" Write-Host "IncludeNetwork : $IncludeNetwork" Write-Host "IncludeBios : $IncludeBios" Write-Host "IncludeMemoryModules : $IncludeMemoryModules" # Helper function to format bytes to GB function Format-BytesToGB { param([long]$Bytes) if ($Bytes -eq 0) { return "0.00" } return [math]::Round($Bytes / 1GB, 2) } # Helper function to safely get property function Get-SafeProperty { param($Object, $PropertyName, $DefaultValue = "N/A") if ($Object.PSObject.Properties.Name -contains $PropertyName) { $value = $Object.$PropertyName if ($null -eq $value -or [string]::IsNullOrWhiteSpace($value)) { return $DefaultValue } return $value } return $DefaultValue } try { # ==== SYSTEM INFORMATION ==== Write-Host "" Write-Host "[INFO] SYSTEM INFORMATION" Write-Host "==============================================================" $sysInfo = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop Write-Host "Computer Name : $env:COMPUTERNAME" Write-Host "Manufacturer : $(Get-SafeProperty $sysInfo 'Manufacturer')" Write-Host "Model : $(Get-SafeProperty $sysInfo 'Model')" # Get serial number from BIOS $biosSerial = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop Write-Host "Serial Number : $(Get-SafeProperty $biosSerial 'SerialNumber')" $totalMemGB = Format-BytesToGB (Get-SafeProperty $sysInfo 'TotalPhysicalMemory' 0) Write-Host "Total Memory : $totalMemGB GB" Write-Host "Number of Processors : $(Get-SafeProperty $sysInfo 'NumberOfProcessors' 0)" # ==== CPU INFORMATION ==== Write-Host "" Write-Host "[INFO] CPU INFORMATION" Write-Host "==============================================================" $cpuInfo = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | Select-Object -First 1 Write-Host "Name : $(Get-SafeProperty $cpuInfo 'Name')" Write-Host "Cores : $(Get-SafeProperty $cpuInfo 'NumberOfCores' 0)" Write-Host "Logical Processors : $(Get-SafeProperty $cpuInfo 'NumberOfLogicalProcessors' 0)" Write-Host "Max Clock Speed : $(Get-SafeProperty $cpuInfo 'MaxClockSpeed' 0) MHz" # ==== MEMORY MODULES ==== if ($IncludeMemoryModules) { Write-Host "" Write-Host "[INFO] MEMORY MODULES" Write-Host "==============================================================" $ramModules = Get-CimInstance -ClassName Win32_PhysicalMemory -ErrorAction Stop if ($ramModules) { $moduleCount = 0 $totalRam = 0 foreach ($ram in $ramModules) { $moduleCount++ $capacity = Get-SafeProperty $ram 'Capacity' 0 $totalRam += $capacity $capacityGB = Format-BytesToGB $capacity $speed = Get-SafeProperty $ram 'Speed' 0 $manufacturer = Get-SafeProperty $ram 'Manufacturer' 'Unknown' Write-Host "Module $moduleCount : $manufacturer - $capacityGB GB @ $speed MHz" } $totalRamGB = Format-BytesToGB $totalRam Write-Host "Total Installed : $totalRamGB GB" } else { Write-Host "No RAM module details available." } } # ==== STORAGE DEVICES ==== if ($IncludeStorage) { Write-Host "" Write-Host "[INFO] STORAGE DEVICES" Write-Host "==============================================================" $disks = Get-CimInstance -ClassName Win32_LogicalDisk -ErrorAction Stop | Where-Object { $_.DriveType -eq 3 } if ($disks) { foreach ($disk in $disks) { $deviceId = Get-SafeProperty $disk 'DeviceID' 'Unknown' $volumeName = Get-SafeProperty $disk 'VolumeName' 'No Label' $size = Get-SafeProperty $disk 'Size' 0 $freeSpace = Get-SafeProperty $disk 'FreeSpace' 0 $totalGB = Format-BytesToGB $size $freeGB = Format-BytesToGB $freeSpace $usedGB = [math]::Round($totalGB - $freeGB, 2) Write-Host "Drive $deviceId ($volumeName) : $totalGB GB Total | $freeGB GB Free | $usedGB GB Used" } } else { Write-Host "No storage devices found." } } # ==== GPU INFORMATION ==== if ($IncludeGpu) { Write-Host "" Write-Host "[INFO] GPU INFORMATION" Write-Host "==============================================================" $gpus = Get-CimInstance -ClassName Win32_VideoController -ErrorAction Stop if ($gpus) { $gpuCount = 0 foreach ($gpu in $gpus) { $gpuCount++ $name = Get-SafeProperty $gpu 'Name' $adapterRam = Get-SafeProperty $gpu 'AdapterRAM' 0 $driverVersion = Get-SafeProperty $gpu 'DriverVersion' $gpuRamGB = if ($adapterRam -gt 0) { Format-BytesToGB $adapterRam } else { "N/A" } Write-Host "GPU $gpuCount Name : $name" Write-Host "GPU $gpuCount RAM : $gpuRamGB GB" Write-Host "GPU $gpuCount Driver : $driverVersion" if ($gpuCount -lt @($gpus).Count) { Write-Host "" } } } else { Write-Host "No GPU devices detected." } } # ==== NETWORK ADAPTERS ==== if ($IncludeNetwork) { Write-Host "" Write-Host "[INFO] NETWORK ADAPTERS" Write-Host "==============================================================" $netAdapters = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -ErrorAction Stop | Where-Object { $_.IPEnabled -eq $true } if ($netAdapters) { $adapterCount = 0 foreach ($adapter in $netAdapters) { $adapterCount++ $description = Get-SafeProperty $adapter 'Description' $ipAddresses = Get-SafeProperty $adapter 'IPAddress' @() $ips = if ($ipAddresses -is [Array]) { $ipAddresses -join ", " } else { $ipAddresses } if ([string]::IsNullOrWhiteSpace($ips)) { $ips = "N/A" } Write-Host "Adapter $adapterCount : $description" Write-Host "IP Addresses : $ips" if ($adapterCount -lt @($netAdapters).Count) { Write-Host "" } } } else { Write-Host "No active network adapters found." } } # ==== BIOS INFORMATION ==== if ($IncludeBios) { Write-Host "" Write-Host "[INFO] BIOS INFORMATION" Write-Host "==============================================================" $biosInfo = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop Write-Host "Manufacturer : $(Get-SafeProperty $biosInfo 'Manufacturer')" Write-Host "Version : $(Get-SafeProperty $biosInfo 'SMBIOSBIOSVersion')" $releaseDate = Get-SafeProperty $biosInfo 'ReleaseDate' $null if ($releaseDate -and $releaseDate -is [DateTime]) { Write-Host "Release Date : $($releaseDate.ToString('yyyy-MM-dd'))" } else { Write-Host "Release Date : N/A" } } } catch { $errorOccurred = $true if ($_.Exception.Message.Length -gt 0) { $errorText = $_.Exception.Message } else { $errorText = $_.ToString() } } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] OPERATION FAILED" Write-Host "==============================================================" Write-Host "Failed to collect hardware information:" Write-Host $errorText Write-Host "" Write-Host "[INFO] RESULT" Write-Host "==============================================================" Write-Host "Status : Failure" } Write-Host "" Write-Host "[INFO] FINAL STATUS" Write-Host "==============================================================" if ($errorOccurred) { Write-Host "Hardware report generation failed. See error details above." } else { Write-Host "Hardware report generated successfully." } Write-Host "" if ($errorOccurred) { Write-Host "[ERROR] SCRIPT FAILED" } else { Write-Host "[OK] SCRIPT COMPLETED" } Write-Host "==============================================================" if ($errorOccurred) { exit 1 } else { exit 0 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/hardware_report.ps1 ## Cim classes used Windows Management Instrumentation has covered this ground since XP. The classes we hit: - Win32_ComputerSystem: hostname, manufacturer, model, RAM - Win32_Processor: CPU name, cores, clock speed - Win32_PhysicalMemory: individual DIMM details - Win32_LogicalDisk: storage capacity and free space - Win32_VideoController: GPU info and driver version - Win32_NetworkAdapter: NIC details and IP addresses - Win32_BIOS: serial, version, release date No third-party tools required. The script runs in user context for the basic inventory data (no admin needed) and produces structured output that's easy to parse and reformat. ## Outcome Script deployed at 9 AM. By 10 AM we had a complete hardware inventory for every managed device. Exported to CSV, reformatted into the template the auditor wanted, delivered the same day. The auditor commented that it was the most complete inventory they'd seen from a company that size. Most of their reviews involve a lot of "we'll get back to you on that one." Policy renewed on time, no premium increase. endpoints inventoried: 312 of 312 collection time: 47 minutes (parallel) data accuracy: 100% (live system data) audit result: passed, policy renewed The hardware report now runs monthly as a scheduled job. Next renewal they hand over a current export plus a year of timestamped snapshots instead of rebuilding the list from the old workbook. Continuous inventory on a schedule, not a once-a-year rebuild. The paperwork side of staying insurable — inventory, encryption status, renewal packets — is half of what our [compliance and backup service](/services/compliance-backup) covers. ## Key takeaways - WMI/CIM already contains every hardware data point an auditor is likely to ask for. - RMM parallel deployment beats manual inventory by roughly 50:1 on time. - live system data is always more accurate than a spreadsheet someone stopped updating. - scheduling the inventory monthly keeps the underwriter packet current without a renewal-week rebuild. --- # Print Spooler Stuck: Clearing Hundreds of Jammed Print Jobs URL: https://limehawk.io/reports/print-queue-reset Published: 2024-04-14 Author: Corey Watson, Managing Member Topic: Automation Read time: 5 min Accounting firm, April 14th. Every printer offline, 847 jobs stuck. They'd tried everything for 2 days. We fixed it in 90 seconds. - Jobs cleared: 847 ## Incident April 14th. A twelve-person [accounting firm](/industries/accounting-firms) calls. Nobody in the office can print. Tax deadline is the next day. The call came in after they'd already been "trying things" for two days: cancelling jobs, rebooting machines, reinstalling drivers. The print queue on one workstation showed 847 jobs stuck in "Deleting - Sent to printer." None of them were going anywhere. Every time someone hit Cancel All, the queue got a little worse. ## What we found Remote session on the worst-affected workstation showed the Print Spooler service sitting in "StopPending" and had been for the last 6+ hours. The spool directory was full: - service state: "Stopping" / StopPending for 6+ hours - spool directory: 847 .SPL and .SHD files (2.3 GB) - file handles: still locked by spoolsv.exe - root cause: network printer went offline mid-job two days earlier One corrupted job from the original printer outage had cascaded. Every print attempt after that queued behind the stuck job. Every cancel attempt added more delete requests to an already-deadlocked queue. Classic spooler deadlock. ## Why their attempts failed Everything they tried is correct in theory. None of it works once you're in this state: - "Cancel All Documents": adds more delete requests to a queue that already can't process anything - restart Print Spooler service from services.msc: the UI waits for graceful stop, which never comes - delete spool files by hand: the files are locked by spoolsv.exe, Explorer refuses - reboot the whole machine: worked for about an hour before the orphaned jobs re-queued on the next boot and jammed the whole thing again Windows won't forcefully kill a service from the GUI. You can't delete files the service has locked. And rebooting just reloads the corrupted queue off disk. You have to force-stop the process and wipe the spool directory between stop and start. ## Initial diagnostics Confirmed the state quickly: Get-Service -Name Spooler | Select Status, StartType Status: StopPending, StartType: Automatic. Then: Get-ChildItem "$env:SystemRoot\System32\spool\PRINTERS" | Measure-Object -Property Length -Sum Count: 847, Sum: 2,415,632,384 bytes. Not subtle. ## Resolution Deployed this script via RMM. It force-stops the spooler (even from StopPending), waits for Windows to release file handles, clears the spool directory with retry logic for any locks that take a beat longer, and starts the service clean: ```print_queue_reset.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : Print Queue Reset 1.2.1 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\print_queue_reset.ps1 FILE : print_queue_reset.ps1 DESCRIPTION : Resets Windows print queue by clearing spooler directory ================================================================================ README -------------------------------------------------------------------------------- PURPOSE This script resets and clears the Windows print queue by stopping the Print Spooler service, removing all queued print jobs from the spooler directory, and restarting the service. This resolves common printing issues caused by stuck or corrupted print jobs that prevent new documents from printing. DATA SOURCES & PRIORITY 1. Hardcoded service name and timeout values 2. System spooler directory path ($env:SystemRoot\System32\spool\PRINTERS) 3. Windows Print Spooler service status REQUIRED INPUTS - $serviceName: Name of the Print Spooler service (default: "Spooler") - $stopTimeout: Maximum seconds to wait for service to stop (default: 30) - $startTimeout: Maximum seconds to wait for service to start (default: 30) SETTINGS - Service stop timeout: 30 seconds - Service start timeout: 30 seconds - Spooler directory: %SystemRoot%\System32\spool\PRINTERS BEHAVIOR 1. Validates hardcoded input values 2. Checks current status of Print Spooler service 3. Stops the Print Spooler service if running and waits for handles to release 4. Attempts to remove print job files with retry logic (3 attempts per file) 5. Reports successfully removed and locked files separately 6. Restarts the Print Spooler service (locked files removed on restart) 7. Verifies service is running and reports final status PREREQUISITES - Windows operating system - Administrator privileges (required to manage services and delete files) - Print Spooler service must exist on the system SECURITY NOTES - No secrets in logs - Requires elevated privileges to modify system services - Only deletes files from the Windows spooler directory ENDPOINTS None - Local system operations only EXIT CODES 0 = Success - Print queue cleared and service restarted 1 = Failure - Input validation failed, service operations failed, or errors EXAMPLE RUN [INFO] INPUT VALIDATION ============================================================== Service name validated Stop timeout validated Start timeout validated [INFO] SERVICE STATUS CHECK ============================================================== Service : Spooler Status : Running [RUN] STOPPING PRINT SPOOLER ============================================================== Stopping service Spooler Service stopped successfully [RUN] CLEARING PRINT QUEUE ============================================================== Spooler Directory : C:\Windows\System32\spool\PRINTERS Total Files Found : 5 Removing files... Files Removed : 5 Queue cleared successfully [RUN] STARTING PRINT SPOOLER ============================================================== Starting service Spooler Service started successfully [OK] FINAL STATUS ============================================================== Service : Spooler Status : Running Result : Print queue cleared and service restarted successfully [OK] SCRIPT COMPLETED ============================================================== -------------------------------------------------------------------------------- CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.2.1 Updated to two-line ASCII console output style 2025-12-23 v1.2.0 Updated to Limehawk Script Framework 2025-01-17 v1.1.0 Add retry logic and handle locked files gracefully 2025-01-17 v1.0.1 Fix Count property error for single file case 2025-01-17 v1.0.0 Initial release - Print queue reset script #> Set-StrictMode -Version Latest # ============================================================================ # HARDCODED INPUTS # ============================================================================ $serviceName = 'Spooler' $stopTimeout = 30 $startTimeout = 30 # ============================================================================ # INPUT VALIDATION # ============================================================================ Write-Host "" Write-Host "[INFO] INPUT VALIDATION" Write-Host "==============================================================" $errorOccurred = $false $errorText = "" if ([string]::IsNullOrWhiteSpace($serviceName)) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Service name is required" } if ($stopTimeout -lt 1) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Stop timeout must be at least 1 second" } if ($startTimeout -lt 1) { $errorOccurred = $true if ($errorText.Length -gt 0) { $errorText += "`n" } $errorText += "- Start timeout must be at least 1 second" } if ($errorOccurred) { Write-Host "" Write-Host "[ERROR] VALIDATION FAILED" Write-Host "==============================================================" Write-Host "Input validation failed:" Write-Host $errorText Write-Host "" exit 1 } Write-Host "Service name validated" Write-Host "Stop timeout validated" Write-Host "Start timeout validated" # ============================================================================ # MAIN SCRIPT # ============================================================================ try { # Check service status Write-Host "" Write-Host "[INFO] SERVICE STATUS CHECK" Write-Host "==============================================================" $service = Get-Service -Name $serviceName Write-Host "Service : $($service.Name)" Write-Host "Status : $($service.Status)" # Stop the Print Spooler service if running Write-Host "" Write-Host "[RUN] STOPPING PRINT SPOOLER" Write-Host "==============================================================" if ($service.Status -eq 'Running') { Write-Host "Stopping service $serviceName" Stop-Service -Name $serviceName -Force $waitCount = 0 while ((Get-Service -Name $serviceName).Status -ne 'Stopped' -and $waitCount -lt $stopTimeout) { Start-Sleep -Seconds 1 $waitCount++ } $service = Get-Service -Name $serviceName if ($service.Status -ne 'Stopped') { throw "Service failed to stop within $stopTimeout seconds" } Write-Host "Service stopped successfully" Write-Host "Waiting for file handles to release..." Start-Sleep -Seconds 2 } else { Write-Host "Service is already stopped" Write-Host "Waiting for file handles to release..." Start-Sleep -Seconds 2 } # Clear the print queue Write-Host "" Write-Host "[RUN] CLEARING PRINT QUEUE" Write-Host "==============================================================" $spoolPath = "$env:SystemRoot\System32\spool\PRINTERS" Write-Host "Spooler Directory : $spoolPath" $files = Get-ChildItem -Path $spoolPath -File -ErrorAction SilentlyContinue $totalFiles = @($files).Count $removedCount = 0 $failedCount = 0 $failedFilesList = "" if ($totalFiles -gt 0) { Write-Host "Total Files Found : $totalFiles" Write-Host "Removing files..." foreach ($file in $files) { $removed = $false $retryCount = 0 $maxRetries = 3 while (-not $removed -and $retryCount -lt $maxRetries) { try { Remove-Item -Path $file.FullName -Force -ErrorAction Stop $removed = $true $removedCount++ } catch { $retryCount++ if ($retryCount -lt $maxRetries) { Start-Sleep -Milliseconds 500 } } } if (-not $removed) { $failedCount++ if ($failedFilesList.Length -gt 0) { $failedFilesList += "`n" } $failedFilesList += " - $($file.Name)" } } Write-Host "Files Removed : $removedCount" if ($failedCount -gt 0) { Write-Host "Files Failed : $failedCount" Write-Host "Locked files:" Write-Host $failedFilesList Write-Host "Note: Locked files will be removed when service restarts" } if ($removedCount -gt 0) { if ($failedCount -eq 0) { Write-Host "Queue cleared successfully" } else { Write-Host "Queue partially cleared (some files locked)" } } } else { Write-Host "Files Found : 0" Write-Host "No print jobs to clear" } # Start the Print Spooler service Write-Host "" Write-Host "[RUN] STARTING PRINT SPOOLER" Write-Host "==============================================================" Write-Host "Starting service $serviceName" Start-Service -Name $serviceName $waitCount = 0 while ((Get-Service -Name $serviceName).Status -ne 'Running' -and $waitCount -lt $startTimeout) { Start-Sleep -Seconds 1 $waitCount++ } $service = Get-Service -Name $serviceName if ($service.Status -ne 'Running') { throw "Service failed to start within $startTimeout seconds" } Write-Host "Service started successfully" # Final status Write-Host "" Write-Host "[OK] FINAL STATUS" Write-Host "==============================================================" $service = Get-Service -Name $serviceName Write-Host "Service : $($service.Name)" Write-Host "Status : $($service.Status)" Write-Host "Result : Print queue cleared and service restarted successfully" Write-Host "" Write-Host "[OK] SCRIPT COMPLETED" Write-Host "==============================================================" Write-Host "" exit 0 } catch { Write-Host "" Write-Host "[ERROR] SCRIPT FAILED" Write-Host "==============================================================" Write-Host "Failed to reset print queue" Write-Host "" Write-Host "Error Message : $($_.Exception.Message)" Write-Host "Error Location: $($_.InvocationInfo.ScriptLineNumber):$($_.InvocationInfo.OffsetInLine)" Write-Host "" Write-Host "Troubleshooting:" Write-Host "- Ensure script is running with administrator privileges" Write-Host "- Verify Print Spooler service exists on this system" Write-Host "- Check if another process is locking the spooler directory" Write-Host "" exit 1 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/print_queue_reset.ps1 ## Why this works - Stop-Service -Force sends a termination signal that bypasses the graceful-shutdown wait. A service stuck in "Stopping" will actually terminate. - the 2-second wait after stopping gives Windows time to release file handles. Without it, the first delete attempts fail about 30% of the time and you spend your time debugging a race condition instead of fixing the problem. - retry logic with 500 ms delays catches the remaining stragglers. Three attempts with backoff handles 99%+ of timing issues. - starting the service with an empty spool directory means no orphaned jobs come back from the dead on the next boot. ## Outcome Script ran in about 8 seconds. All 847 stuck jobs gone. Printing resumed immediately. Tax returns started coming out of the printer within a minute of the call. Tax deadline was met. - time to resolution: under 2 minutes from call to first page printing - jobs cleared: 847 - space recovered: 2.3 GB on C: - deadline: met, all returns filed on time Prevention, because we don't want this call again: - scheduled weekly queue cleanup across all 12 workstations - self-service "Fix Printer" button in the RMM tray app so users can trigger the same script themselves - zero print-related tickets from this client in the 8 months since ## Key takeaways - the Windows Print Spooler is fragile. One network hiccup cascades into everyone being offline. - Stop-Service -Force works when graceful stops won't, and timing + retry logic is what makes it reliable. - self-service beats a ticket queue: users don't want to wait on hold, we don't want to spend 30 support minutes on a 30-second fix. - prevention beats reaction: weekly scheduled cleanups mean the queue never gets to critical mass in the first place. --- # Fixing KB5034441 Error 0x80070643: WinRE Partition Resize URL: https://limehawk.io/reports/winre-partition-resize Published: 2024-01-15 Author: Corey Watson, Managing Member Topic: Automation Read time: 8 min 312 machines failing the same update after Patch Tuesday. Microsoft's fix was 15 manual steps per machine. We automated it. - Affected: 312 of 347 endpoints (90%) ## Incident January 2024 Patch Tuesday. Morning after, our RMM dashboard lit up like a Christmas tree: 312 of 347 managed endpoints showing KB5034441 installation failure with error 0x80070643. Compliance went from 94% patched to 41% overnight. Couldn't just wave it off. The patch was a critical WinRE security fix tied to BitLocker. And the same update was failing everywhere with the same cryptic error. ## Problem identification Error 0x80070643 is Windows' catch-all "installer failure" code and tells you nothing. We pulled Event Viewer and the CBS log from a sample of affected machines and found the real error buried a few hundred lines down: "WinRE servicing failed, Not enough space." - affected machines: 312 of 347 endpoints (90%) - error code: 0x80070643 (generic installer failure) - real cause: WinRE partition too small: needed 250 MB free, had less than 50 MB - pattern: all Dell and HP machines shipped 2020 through 2022, same 500 MB recovery partition Microsoft had shipped KB5034441 to update the Windows Recovery Environment with BitLocker security fixes. But OEMs had been stamping out 500 MB recovery partitions for years, and the WinRE image itself had grown to around 450 MB. There was no room to stage the new image during installation. MICROSOFT'S "SOLUTION" Microsoft published KB5028997 with manual remediation steps. 15 steps involving diskpart, reagentc, and manual partition manipulation. Here's what they expected us to do on 312 machines: - open elevated command prompt - run reagentc /disable - open diskpart, select disk, list partitions - delete recovery partition, shrink OS partition by 250 MB - create new partition, format, assign correct GPT attributes - run reagentc /enable - verify, pray, repeat on 311 more machines At 15 minutes per machine with perfect execution, that's 78 hours of manual work, assuming no mistakes. One wrong diskpart command and you're rebuilding an OS from scratch. Not happening. ## Resolution Wrote a script that automates the entire partition resize. It queries the disk layout, backs up the existing WinRE content, shrinks the OS partition by 250 MB, extends the recovery partition into the freed space, and re-enables WinRE. No diskpart. No manual steps. Just push and go. ```winre_partition_resize.ps1 $ErrorActionPreference = 'Stop' <# ██╗ ██╗███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ██╗ ██║ ██║████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔╝ ██║ ██║██╔████╔██║█████╗ ███████║███████║██║ █╗ ██║█████╔╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██║██╔══██║██║███╗██║██╔═██╗ ███████╗██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║╚███╔███╔╝██║ ██╗ ╚══════╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ================================================================================ SCRIPT : WinRE Partition Resize v1.0.3 AUTHOR : Limehawk.io DATE : January 2026 USAGE : .\winre_partition_resize.ps1 FILE : winre_partition_resize.ps1 DESCRIPTION : Extends WinRE partition by 250MB for Windows updates ================================================================================ README -------------------------------------------------------------------------------- PURPOSE: Extends the Windows Recovery Environment (WinRE) partition by 250MB to resolve issues with Windows updates requiring more recovery partition space. This is commonly needed for KB5034441 and similar updates. REQUIRED INPUTS: $BackupFolder : Path to backup WinRE partition contents (default: C:\winre_backup) BEHAVIOR: 1. Examines current disk layout and WinRE status 2. Validates requirements (WinRE enabled, sufficient space) 3. Backs up existing WinRE partition content 4. Disables WinRE temporarily 5. Shrinks OS partition by 250MB (if needed) 6. Extends or recreates WinRE partition 7. Re-enables WinRE 8. Verifies new configuration PREREQUISITES: - Windows 10/11 - Administrator privileges - Sufficient free space on OS partition - Reboot recommended before running SECURITY NOTES: - No secrets in logs - Creates backup of WinRE content - Modifies disk partitions (use with caution) EXIT CODES: 0 = Success (or no changes needed) 1 = Failure EXAMPLE RUN: [INFO] EXAMINING SYSTEM ============================================================== OS Disk : 0 OS Partition : 3 WinRE Partition : 4 Disk Type : GPT [INFO] CURRENT STATUS ============================================================== WinRE Status : Enabled WinRE Partition Size : 523190272 WinRE Free Space : 52428800 [INFO] PROPOSED CHANGES ============================================================== Action : Extend WinRE by 250MB Shrink OS By : 250MB New WinRE Size : 785285120 [RUN] EXECUTING CHANGES ============================================================== Disabling WinRE... : Done Shrinking OS... : Done Extending WinRE... : Done Enabling WinRE... : Done [INFO] FINAL STATUS ============================================================== SCRIPT SUCCEEDED [OK] SCRIPT COMPLETE ============================================================== CHANGELOG -------------------------------------------------------------------------------- 2026-01-19 v1.0.3 Fixed EXAMPLE RUN section formatting 2026-01-19 v1.0.2 Updated to two-line ASCII console output style 2025-12-23 v1.0.1 Updated to Limehawk Script Framework 2024-12-01 v1.0.0 Initial release - migrated from SuperOps ================================================================================ #> Set-StrictMode -Version Latest # ============================================================================ # CONFIGURATION # ============================================================================ $BackupFolder = "C:\winre_backup" $SkipConfirmation = $true # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Write-Section { param([string]$title, [string]$prefix = "INFO") Write-Host "" Write-Host ("[{0}] {1}" -f $prefix, $title) Write-Host ("=" * 62) } function PrintKV([string]$label, [string]$value) { $lbl = $label.PadRight(24) Write-Host (" {0} : {1}" -f $lbl, $value) } function ExtractNumbers([string]$str) { $cleanString = $str -replace "[^0-9]" return [long]$cleanString } function DisplayPartitionInfo([string[]]$partitionPath) { $volume = Get-WmiObject -Class Win32_Volume | Where-Object { $partitionPath -contains $_.DeviceID } return $volume.Capacity, $volume.FreeSpace } function DisplayWinREStatus { $WinREInfo = Reagentc /info $Status = $false $Location = "" foreach ($line in $WinREInfo) { $params = $line.Split(':') if ($params.Count -lt 2) { continue } if (($params[1].Trim() -ieq "Enabled") -Or ($params[1].Trim() -ieq "Disabled")) { $Status = $params[1].Trim() -ieq "Enabled" } if ($params[1].Trim() -like "\\?\GLOBALROOT*") { $Location = $params[1].Trim() } } return $Status, $Location } # ============================================================================ # PRIVILEGE CHECK # ============================================================================ if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Section "ERROR OCCURRED" "ERROR" Write-Host " This script requires administrative privileges to run." Write-Section "SCRIPT HALTED" "ERROR" exit 1 } # ============================================================================ # MAIN SCRIPT # ============================================================================ try { # Create backup folder if needed if (-not (Test-Path -Path $BackupFolder)) { New-Item -Path $BackupFolder -ItemType Directory | Out-Null PrintKV "Backup Directory" "Created at $BackupFolder" } Write-Section "EXAMINING SYSTEM" $NeedShrink = $true $NeedCreateNew = $false $NeedBackup = $false # Get WinRE status $InitialWinREStatus = DisplayWinREStatus $WinREStatus = $InitialWinREStatus[0] $WinRELocation = $InitialWinREStatus[1] if (-not $WinREStatus) { PrintKV "WinRE Status" "Disabled" Write-Host "" Write-Host "[ERROR] WinRE is disabled. Cannot proceed." Write-Section "SCRIPT HALTED" "ERROR" exit 1 } # Get system info $system32Path = [System.Environment]::SystemDirectory $ReAgentXmlPath = Join-Path -Path $system32Path -ChildPath "\Recovery\ReAgent.xml" if (-not (Test-Path $ReAgentXmlPath)) { Write-Host "[ERROR] ReAgent.xml not found" Write-Section "SCRIPT HALTED" "ERROR" exit 1 } # Get OS partition $OSDrive = $system32Path.Substring(0,1) $OSPartition = Get-Partition -DriveLetter $OSDrive # Get WinRE partition info $WinRELocationItems = $WinRELocation.Split('\\') foreach ($item in $WinRELocationItems) { if ($item -like "harddisk*") { $OSDiskIndex = ExtractNumbers($item) } if ($item -like "partition*") { $WinREPartitionIndex = ExtractNumbers($item) } } PrintKV "OS Disk" $OSDiskIndex PrintKV "OS Partition" $OSPartition.PartitionNumber PrintKV "WinRE Partition" $WinREPartitionIndex $WinREPartition = Get-Partition -DiskNumber $OSDiskIndex -PartitionNumber $WinREPartitionIndex $diskInfo = Get-Disk -Number $OSDiskIndex $diskType = $diskInfo.PartitionStyle PrintKV "Disk Type" $diskType # Get WinRE partition size Write-Section "CURRENT STATUS" $WinREPartitionSizeInfo = DisplayPartitionInfo($WinREPartition.AccessPaths) PrintKV "WinRE Status" "Enabled" PrintKV "WinRE Partition Size" $WinREPartitionSizeInfo[0] PrintKV "WinRE Free Space" $WinREPartitionSizeInfo[1] # Check if extension is needed if ($WinREPartitionSizeInfo[1] -ge 250MB) { Write-Host "" Write-Host "[OK] WinRE partition has sufficient free space (>= 250MB)." Write-Host "No changes needed." Write-Section "SCRIPT COMPLETE" exit 0 } # Check partition layout $WinREIsOnSystemPartition = $false if ($diskType -ieq "MBR") { if ($WinREPartition.IsActive) { $WinREIsOnSystemPartition = $true } } if ($diskType -ieq "GPT") { if ($WinREPartition.Type -ieq "System") { $WinREIsOnSystemPartition = $true } } $OSPartitionEnds = $OSPartition.Offset + $OSPartition.Size if ($WinREPartition.Offset -lt $OSPartitionEnds) { $NeedCreateNew = $true $targetWinREPartitionSize = $WinREPartitionSizeInfo[0] + 250MB $shrinkSize = [Math]::Ceiling($targetWinREPartitionSize / 1MB) * 1MB } else { $shrinkSize = 250MB $UnallocatedSpace = $WinREPartition.Offset - $OSPartitionEnds if ($UnallocatedSpace -ge 250MB) { $NeedShrink = $false } else { $shrinkSize = [Math]::Ceiling((250MB - $UnallocatedSpace) / 1MB) * 1MB if ($shrinkSize -gt 250MB) { $shrinkSize = 250MB } } } $targetWinREPartitionSize = $WinREPartitionSizeInfo[0] + 250MB $targetOSPartitionSize = $OSPartition.Size - $shrinkSize # Validate shrink is possible $supportedSize = Get-PartitionSupportedSize -DriveLetter $OSDrive if ($NeedShrink -and $targetOSPartitionSize -lt $supportedSize.SizeMin) { Write-Host "[ERROR] Cannot shrink OS partition enough. Insufficient free space." Write-Section "SCRIPT HALTED" "ERROR" exit 1 } # Display proposed changes Write-Section "PROPOSED CHANGES" if ($NeedCreateNew) { PrintKV "Action" "Create new WinRE partition" } else { PrintKV "Action" "Extend existing WinRE partition" } if ($NeedShrink) { PrintKV "Shrink OS By" ("{0:N0} MB" -f ($shrinkSize / 1MB)) } else { PrintKV "Shrink OS" "Not needed (using unallocated space)" } PrintKV "New WinRE Size" ("{0:N0} MB" -f ($targetWinREPartitionSize / 1MB)) if (-not $WinREIsOnSystemPartition) { $NeedBackup = $true } # Execute changes Write-Section "EXECUTING CHANGES" # Clear stage location in ReAgent.xml $xml = [xml](Get-Content -Path $ReAgentXmlPath) $node = $xml.WindowsRE.ImageLocation if (-not (($node.path -eq "") -And ($node.guid -eq "{00000000-0000-0000-0000-000000000000}"))) { $node.path = "" $node.offset = "0" $node.guid = "{00000000-0000-0000-0000-000000000000}" $node.id = "0" $xml.Save($ReAgentXmlPath) PrintKV "ReAgent.xml" "Stage location cleared" } # Disable WinRE Write-Host "[RUN] Disabling WinRE..." reagentc /disable | Out-Null if ($LASTEXITCODE -ne 0) { throw "Failed to disable WinRE" } PrintKV "WinRE" "Disabled" # Verify WinRE.wim exists $disableWinREPath = Join-Path -Path $system32Path -ChildPath "\Recovery\WinRE.wim" if (-not (Test-Path $disableWinREPath)) { reagentc /enable | Out-Null throw "WinRE.wim not found after disabling" } # Shrink OS partition if needed if ($NeedShrink) { Write-Host "[RUN] Shrinking OS partition..." Resize-Partition -DriveLetter $OSDrive -Size $targetOSPartitionSize PrintKV "OS Partition" "Shrunk" } # Backup and delete old WinRE partition (if not system partition) if (-not $WinREIsOnSystemPartition) { if ($NeedBackup) { $sourcePath = $WinREPartition.AccessPaths[0] Write-Host "[RUN] Backing up WinRE content..." $items = Get-ChildItem -LiteralPath $sourcePath -Force -ErrorAction SilentlyContinue foreach ($item in $items) { if ($item.Name -ieq "System Volume Information") { continue } $sourceItemPath = Join-Path -Path $sourcePath -ChildPath $item.Name $destItemPath = Join-Path -Path $BackupFolder -ChildPath $item.Name Copy-Item -LiteralPath $sourceItemPath -Destination $destItemPath -Recurse -Force -ErrorAction SilentlyContinue } PrintKV "Backup" "Completed to $BackupFolder" } Write-Host "[RUN] Deleting old WinRE partition..." Remove-Partition -DiskNumber $OSDiskIndex -PartitionNumber $WinREPartitionIndex -Confirm:$false PrintKV "Old WinRE Partition" "Deleted" } Start-Sleep -Seconds 5 # Create new WinRE partition Write-Host "[RUN] Creating new WinRE partition..." if ($diskType -ieq "GPT") { $partition = New-Partition -DiskNumber $OSDiskIndex -Size $targetWinREPartitionSize -GptType "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" $newPartitionIndex = $partition.PartitionNumber Start-Sleep -Seconds 2 Format-Volume -Partition $partition -FileSystem NTFS -Confirm:$false | Out-Null } else { # MBR disk - use diskpart $targetWinREPartitionSizeInMb = [int]($targetWinREPartitionSize / 1MB) $diskpartScript = @" select disk $OSDiskIndex create partition primary size=$targetWinREPartitionSizeInMb id=27 format quick fs=ntfs label="Recovery" set id=27 "@ $diskpartSciptFile = Join-Path -Path $env:Temp -ChildPath "ExtendWinRE_Script.txt" $diskpartScript | Out-File -FilePath $diskpartSciptFile -Encoding ascii diskpart /s $diskpartSciptFile | Out-Null Remove-Item $diskpartSciptFile -Force $vol = Get-Volume -FileSystemLabel "Recovery" $newPartitionIndex = (Get-Partition | Where-Object { $_.AccessPaths -contains $vol.Path }).PartitionNumber } PrintKV "New WinRE Partition" "Created (Partition $newPartitionIndex)" # Re-enable WinRE Write-Host "[RUN] Enabling WinRE..." reagentc /enable | Out-Null if ($LASTEXITCODE -ne 0) { throw "Failed to re-enable WinRE" } PrintKV "WinRE" "Enabled" # Final verification Write-Section "VERIFICATION" $FinalWinREStatus = DisplayWinREStatus PrintKV "WinRE Status" $(if ($FinalWinREStatus[0]) { "Enabled" } else { "Disabled" }) $newWinREPartition = Get-Partition -DiskNumber $OSDiskIndex -PartitionNumber $newPartitionIndex $newWinREPartitionSizeInfo = DisplayPartitionInfo($newWinREPartition.AccessPaths) PrintKV "New WinRE Size" ("{0:N0} MB" -f ($newWinREPartitionSizeInfo[0] / 1MB)) PrintKV "New WinRE Free" ("{0:N0} MB" -f ($newWinREPartitionSizeInfo[1] / 1MB)) Write-Section "FINAL STATUS" Write-Host "[OK] SCRIPT SUCCEEDED" Write-Host "" Write-Host "[OK] WinRE partition has been extended by 250MB." if ($NeedBackup) { Write-Host "Old content backed up to: $BackupFolder" } Write-Section "SCRIPT COMPLETE" exit 0 } catch { Write-Section "ERROR OCCURRED" "ERROR" PrintKV "Error Message" $_.Exception.Message PrintKV "Error Type" $_.Exception.GetType().FullName # Try to re-enable WinRE on error Write-Host "" Write-Host "[RUN] Attempting to re-enable WinRE..." reagentc /enable 2>&1 | Out-Null Write-Section "SCRIPT HALTED" "ERROR" exit 1 } ``` View on GitHub: https://github.com/limehawk/rmm-scripts/blob/main/scripts/winre_partition_resize.ps1 ## Why this works The script uses native PowerShell cmdlets and WMI instead of diskpart. Safer, more predictable, and actually scriptable. Diskpart's interactive prompts make it a pain to automate and easy to mis-target. Technical flow: 1. pre-flight checks: verifies WinRE is enabled, identifies disk type (GPT/MBR), locates recovery partition 2. space check: if WinRE already has 250 MB free, exits cleanly, no unnecessary changes 3. backup: copies existing WinRE content to C:\winre_backup before touching anything 4. disable WinRE: releases locks on the recovery partition so it can be modified 5. resize: uses Resize-Partition to shrink C: by 250 MB and extend the recovery partition 6. re-enable: runs reagentc /enable to restore WinRE functionality Safety feature: the script won't proceed if WinRE is already disabled (indicates existing corruption) or if there's less than 250 MB free on C: to donate. It fails gracefully rather than making things worse. ## Outcome Deployed the script to all 312 affected machines via RMM overnight. 297 completed successfully on the first run. The 15 failures were edge cases: 8 had WinRE already disabled from previous issues, 4 had MBR disks that needed different handling, and 3 had full C: drives. Those we handled manually the next morning. KB5034441 installed successfully on the next Windows Update cycle across the fleet, and compliance came back to 97%. The partition scan now runs proactively during [managed IT](/services/managed-it-security) onboarding, so the room exists before the next update needs it. - machines remediated: 297 of 312 (95% success rate) - time saved: around 74 hours vs the manual process - compliance restored: 97% within 48 hours ## Key takeaways - Microsoft's "manual remediation" guides aren't realistic at scale. Automate or suffer. - OEM partition layouts from 2020 through 2022 are undersized for modern WinRE requirements. - pre-deployment scanning for WinRE partition size can catch this before updates start failing. - we now run this proactively on new client onboarding. Fix the partition before the next update drops.