Check EC2 Patch Compliance With Systems Manager

Open server rack with technician tools laid out on a workbench in front of it

Photo by Elena Rouame on Unsplash

An SSM patch compliance report comes from DescribeInstancePatchStates, which returns per-node counts of missing, failed and installed-pending-reboot patches, the baseline used and when the last scan or install finished. List managed nodes with DescribeInstanceInformation first, request patch states in batches of 50, and treat nodes with no patch state as never scanned.

The Systems Manager console shows a compliance percentage, but it leaves out the nodes that matter most: the ones that were never scanned, the ones whose last scan is weeks old and the ones that patched but never rebooted. A node that never reports can’t show up as non-compliant.

This example builds an SSM patch compliance report with the AWS SDK for JavaScript v3. It lists every managed node in the Regions you choose, joins it with its patch state and baseline, and flags what needs attention. It’s report only. If you first need to know which EC2 instances Systems Manager can see at all, run the script to find EC2 instances not managed by Systems Manager, then come back here.

What makes a node non-compliant in Patch Manager?

Patch Manager compares each node against a patch baseline and gives every patch a state. AWS documents which states count against the node:

Patch state Meaning Compliance
MISSING Approved in the baseline but not installed, including patches found by a Scan Non-compliant
FAILED Approved, but installation failed Non-compliant
INSTALLED_PENDING_REBOOT Installed, but the node hasn’t rebooted since; often because RebootOption was NoReboot Non-compliant
INSTALLED_REJECTED Installed, but on the baseline’s rejected list Non-compliant
INSTALLED / INSTALLED_OTHER Installed, in or outside the baseline Compliant

DescribeInstancePatchStates rolls those states up into counts per node: MissingCount, FailedCount, InstalledPendingRebootCount, InstalledRejectedCount, plus CriticalNonCompliantCount and SecurityNonCompliantCount for prioritizing. It also returns BaselineId, PatchGroup, Operation (Scan or Install) and OperationEndTime.

What does the script do?

  1. Lists managed nodespaginateDescribeInstanceInformation returns every node registered with Systems Manager, with its platform and PingStatus (Online, ConnectionLost or Inactive).
  2. Names the baselinespaginateDescribePatchBaselines maps baseline IDs to names so the report says prod-linux-baseline instead of pb-….
  3. Reads patch states in batchesDescribeInstancePatchStates accepts at most 50 node IDs per request, so the script chunks the list and pages each chunk.
  4. Prints the account-level summaryListComplianceSummaries filtered to the Patch compliance type gives the compliant and non-compliant totals the console shows.
  5. Gives each node a verdictNEVER SCANNED, FAILED, NON-COMPLIANT, REBOOT, STALE (no patch operation within --max-age-days, default 7) and OFFLINE, combined when several apply. Compliant nodes are hidden unless you pass --all.

Prerequisites

  • Node.js 18 or later, npm, tsx and @aws-sdk/client-ssm.
  • Nodes registered with Systems Manager and at least one AWS-RunPatchBaseline run, from a patch policy, a maintenance window or State Manager. Without a run, every node reads NEVER SCANNED.
  • A read-only profile; the guide to AWS SDK v3 credential providers with fromIni and fromSSO shows how to select it.

Which IAM permissions does it need?

ssm-patch-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadPatchCompliance",
      "Effect": "Allow",
      "Action": [
        "ssm:DescribeInstanceInformation",
        "ssm:DescribeInstancePatchStates",
        "ssm:DescribePatchBaselines",
        "ssm:ListComplianceSummaries"
      ],
      "Resource": "*"
    }
  ]
}

Everything here is read-only. If you extend the script, the free IAM policy generator for TypeScript code lists the extra actions it needs.

The script to build an SSM patch compliance report

ssm-patch-compliance-report.ts

// ssm-patch-compliance-report.ts
// Builds a patch compliance report for every Systems Manager managed node: missing, failed and
// pending-reboot patches, the baseline used, when the node was last scanned, and nodes never scanned.
// Report only: it never runs AWS-RunPatchBaseline or installs anything.
// Usage:
//   npx tsx ssm-patch-compliance-report.ts [--regions us-east-1,eu-west-1] [--max-age-days 7] [--all]
import {
  SSMClient,
  paginateDescribeInstanceInformation,
  paginateDescribeInstancePatchStates,
  paginateDescribePatchBaselines,
  paginateListComplianceSummaries,
  type InstanceInformation,
  type InstancePatchState,
} from "@aws-sdk/client-ssm";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);
const maxAgeDays = Number(flag("--max-age-days") ?? "7");
const showAll = args.includes("--all"); // include compliant nodes in the table

interface Row {
  Region: string;
  Node: string;
  Platform: string;
  Ping: string;
  PatchGroup: string;
  Baseline: string;
  Missing: number;
  Failed: number;
  PendingReboot: number;
  Critical: number;
  Security: number;
  LastOp: string;
  LastRun: string;
  Verdict: string;
}

function judge(info: InstanceInformation, st: InstancePatchState | undefined): string {
  if (!st) return "NEVER SCANNED";
  const problems: string[] = [];
  if ((st.FailedCount ?? 0) > 0) problems.push("FAILED");
  if ((st.MissingCount ?? 0) > 0 || (st.InstalledRejectedCount ?? 0) > 0) problems.push("NON-COMPLIANT");
  if ((st.InstalledPendingRebootCount ?? 0) > 0) problems.push("REBOOT");
  const ageDays = st.OperationEndTime ? (Date.now() - st.OperationEndTime.getTime()) / 86_400_000 : Infinity;
  if (ageDays > maxAgeDays) problems.push("STALE");
  if (info.PingStatus !== "Online") problems.push("OFFLINE");
  return problems.length ? problems.join(" ") : "OK";
}

async function scanRegion(region: string): Promise<Row[]> {
  const ssm = new SSMClient({ region });

  const nodes: InstanceInformation[] = [];
  for await (const page of paginateDescribeInstanceInformation({ client: ssm }, {})) {
    nodes.push(...(page.InstanceInformationList ?? []));
  }

  const baselineName = new Map<string, string>();
  for await (const page of paginateDescribePatchBaselines({ client: ssm }, {})) {
    for (const b of page.BaselineIdentities ?? []) {
      if (b.BaselineId) baselineName.set(b.BaselineId, b.BaselineName ?? b.BaselineId);
    }
  }

  // DescribeInstancePatchStates takes at most 50 node IDs per request.
  const states = new Map<string, InstancePatchState>();
  const ids = nodes.map((n) => n.InstanceId ?? "").filter(Boolean);
  for (let i = 0; i < ids.length; i += 50) {
    const input = { InstanceIds: ids.slice(i, i + 50) };
    for await (const page of paginateDescribeInstancePatchStates({ client: ssm }, input)) {
      for (const s of page.InstancePatchStates ?? []) {
        if (s.InstanceId) states.set(s.InstanceId, s);
      }
    }
  }

  // The account-level count Systems Manager Compliance shows for the Patch type.
  for await (const page of paginateListComplianceSummaries(
    { client: ssm },
    { Filters: [{ Key: "ComplianceType", Values: ["Patch"], Type: "EQUAL" }] },
  )) {
    for (const s of page.ComplianceSummaryItems ?? []) {
      console.log(
        `${region} ${s.ComplianceType}: ${s.CompliantSummary?.CompliantCount ?? 0} compliant, ` +
          `${s.NonCompliantSummary?.NonCompliantCount ?? 0} non-compliant`,
      );
    }
  }

  return nodes.map((n) => {
    const st = states.get(n.InstanceId ?? "");
    return {
      Region: region,
      Node: n.ComputerName ? `${n.InstanceId} (${n.ComputerName.slice(0, 20)})` : n.InstanceId ?? "?",
      Platform: `${n.PlatformName ?? n.PlatformType ?? "?"} ${n.PlatformVersion ?? ""}`.trim().slice(0, 28),
      Ping: n.PingStatus ?? "?",
      PatchGroup: st?.PatchGroup ?? "",
      Baseline: st ? baselineName.get(st.BaselineId ?? "") ?? st.BaselineId ?? "" : "",
      Missing: st?.MissingCount ?? 0,
      Failed: st?.FailedCount ?? 0,
      PendingReboot: st?.InstalledPendingRebootCount ?? 0,
      Critical: st?.CriticalNonCompliantCount ?? 0,
      Security: st?.SecurityNonCompliantCount ?? 0,
      LastOp: st?.Operation ?? "",
      LastRun: st?.OperationEndTime ? st.OperationEndTime.toISOString().slice(0, 10) : "",
      Verdict: judge(n, st),
    };
  });
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  rows.sort((a, b) => Number(a.Verdict === "OK") - Number(b.Verdict === "OK") || b.Critical - a.Critical || b.Missing - a.Missing);
  const shown = showAll ? rows : rows.filter((r) => r.Verdict !== "OK");
  if (shown.length) console.table(shown);
  const bad = rows.filter((r) => r.Verdict !== "OK").length;
  console.log(`${rows.length} managed nodes in ${regions.join(", ")}; ${bad} need attention (stale = no patch operation in ${maxAgeDays} days).`);
  console.log("Report only. Patch through a maintenance window or a Patch Manager patch policy.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

The describe calls use the SDK’s paginators; the guide to paginate any AWS API with AWS SDK v3 paginators explains the pattern and why the 50-ID batches still need paging.

How do you run it?

Terminal

npm install @aws-sdk/client-ssm
npm install --save-dev tsx typescript @types/node

# Nodes that need attention in two Regions
AWS_PROFILE=readonly npx tsx ssm-patch-compliance-report.ts --regions us-east-1,eu-west-1

# Every node, treating scans older than 14 days as stale
AWS_PROFILE=readonly npx tsx ssm-patch-compliance-report.ts --regions us-east-1 --max-age-days 14 --all

Sample output

Output

us-east-1 Patch: 50 compliant, 2 non-compliant
┌─────────┬─────────────┬──────────────────────────────────────────────┬────────────────────────────────┬──────────────────┬────────────┬─────────────────────────┬─────────┬────────┬───────────────┬──────────┬──────────┬───────────┬──────────────┬────────────────────────┐
│ (index) │ Region      │ Node                                         │ Platform                       │ Ping             │ PatchGroup │ Baseline                │ Missing │ Failed │ PendingReboot │ Critical │ Security │ LastOp    │ LastRun      │ Verdict                │
├─────────┼─────────────┼──────────────────────────────────────────────┼────────────────────────────────┼──────────────────┼────────────┼─────────────────────────┼─────────┼────────┼───────────────┼──────────┼──────────┼───────────┼──────────────┼────────────────────────┤
│ 0       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60001 (ip-10-0-1-12.ec2.int)' │ 'Amazon Linux 2023'            │ 'Online'         │ 'prod'     │ 'prod-linux-baseline'   │ 7       │ 0      │ 2             │ 3        │ 5        │ 'Scan'    │ '2026-09-28' │ 'NON-COMPLIANT REBOOT' │
│ 1       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60002'                        │ 'Microsoft Windows Server 202' │ 'ConnectionLost' │ 'prod'     │ 'prod-windows-baseline' │ 0       │ 0      │ 0             │ 0        │ 0        │ 'Install' │ '2026-09-09' │ 'STALE OFFLINE'        │
│ 2       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60003'                        │ 'Ubuntu 22.04'                 │ 'Online'         │ ''         │ ''                      │ 0       │ 0      │ 0             │ 0        │ 0        │ ''        │ ''           │ 'NEVER SCANNED'        │
└─────────┴─────────────┴──────────────────────────────────────────────┴────────────────────────────────┴──────────────────┴────────────┴─────────────────────────┴─────────┴────────┴───────────────┴──────────┴──────────┴───────────┴──────────────┴────────────────────────┘
53 managed nodes in us-east-1; 3 need attention (stale = no patch operation in 7 days).
Report only. Patch through a maintenance window or a Patch Manager patch policy.

IDs and names are illustrative. The Amazon Linux node has 7 missing patches, 3 of them critical, and 2 installed patches waiting for a reboot. The Windows node lost contact with Systems Manager, so its last install is 20 days old. The Ubuntu node is managed but has never been scanned, which is also why the console’s total counts 52 nodes, not 53.

How do you fix each verdict?

  • NEVER SCANNED. The node isn’t targeted by any patching operation. Add it to a Quick Setup patch policy or to a maintenance window target. Note that AWS documents patch groups aren’t used by patch-policy-based patching.
  • NON-COMPLIANT. Missing patches from a Scan operation stay missing until an Install runs. Schedule the install in a maintenance window instead of running it ad hoc.
  • REBOOT. Patches were installed with NoReboot or outside Patch Manager. Reboot the node in a planned window; until then, the old code may still be running.
  • FAILED. Read the output of the last AWS-RunPatchBaseline command for the node; the EC2 connection troubleshooting script helps when you need to get onto the box.
  • STALE or OFFLINE. The agent stopped reporting or the node is stopped. Nodes that no longer exist should be deregistered; nodes that should exist need their agent and instance profile checked.
  • Wrong baseline. A node without a Patch Group or PatchGroup tag, or with a group that isn’t registered to a baseline, falls back to the default baseline for its operating system.

NIST’s SP 800-40 Rev. 4 guide to enterprise patch management describes the whole cycle as identifying, prioritizing, acquiring, installing and verifying patches. This report covers the last step, verification. For vulnerabilities by CVE rather than by patch, the check to confirm Amazon Inspector is enabled in every Region is the companion.

Troubleshooting

  • Every node shows NEVER SCANNED. You’re in a Region with no patching operation, or the profile’s Region doesn’t match. Pass --regions explicitly.
  • The console total differs from the node count. ListComplianceSummaries summarizes compliance data that nodes have reported, so a node that has never been scanned has nothing to count.
  • Throttling errors on large fleets. The SDK retries automatically; raise the retry limit as the guide to configure retry and timeout settings in AWS SDK v3 shows.
  • Access denied errors. Check service control policies for the Region; the guide to troubleshoot AWS IAM access denied errors walks through it.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which EC2 instances are missing critical patches according to Systems Manager?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and explains the result; how ChatWithCloud runs AWS SDK code on your machine covers the loop. Generated code runs without a confirmation step, so connect ChatWithCloud with a read-only AWS profile and leave patching to Patch Manager.

Frequently asked questions

How do I get a patch compliance report from AWS Systems Manager?

Call DescribeInstancePatchStates for your managed node IDs, up to 50 at a time, and combine it with DescribeInstanceInformation so nodes without a patch state show up as never scanned.

Why does an instance show INSTALLED_PENDING_REBOOT?

A patch was installed but the node hasn’t rebooted since, usually because AWS-RunPatchBaseline ran with RebootOption set to NoReboot. AWS counts this state as non-compliant.

Does a Scan operation install patches?

No. A Scan reports patches as MISSING; only an Install operation installs them.

Are patch groups still needed?

Not with patch policies. AWS documents that patch groups aren’t used in patching operations based on Quick Setup patch policies. They still work with maintenance windows, but the console only supports them in account-Region pairs that used patch groups before December 22, 2022.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud