Find Publicly Shared SSM Documents

An open metal padlock lying next to a small key on a wooden surface

Photo by Jaye Haych on Unsplash

SSM document public sharing makes a Systems Manager document you own readable and runnable by every AWS account in the same Region. To find these documents, list the documents you own and call DescribeDocumentPermission on each: a public one has the account ID all. Remove it with ModifyDocumentPermission, then turn on block public sharing.

Runbooks and Command documents collect the details of how your environment works: internal hostnames, bucket names, install scripts and, too often, a token pasted in to get something working. When a document is shared publicly, anyone can view that content, and every earlier version with it.

This example is for engineers tightening Systems Manager. It reports the Region’s block public sharing setting, lists every document you own that is shared publicly or privately, scans each version of the public ones for secret-looking strings without printing them, and on request removes the public permission and blocks new public shares. It’s the SSM version of the checks that find AMIs shared publicly with every AWS account.

Why is SSM document public sharing risky?

A publicly shared document is visible to anyone, and AWS’s own guidance is to review a document and remove sensitive information, such as AWS credentials, before sharing it. Several details make the exposure wider than it looks:

  • All versions are shared. When you share a document with all, every version of it is shared by default, not only the default one. A secret removed in version 5 is still readable in version 2.
  • It’s discoverable. Public documents show up when any account lists documents with the Owner=Public filter in that Region.
  • It’s runnable. Other accounts can run your document on their own instances by its ARN, so whatever it downloads and runs becomes something other accounts depend on.
  • Blocking isn’t retroactive. Turning on block public sharing stops new public shares, but doesn’t affect documents that are already public. You have to remove those one by one.

Treat anything that was ever in a public document as exposed. The OWASP Secrets Management Cheat Sheet covers what to do with a leaked secret: revoke and rotate it, then find out how it got there.

What limits apply to sharing SSM documents?

Rule Detail
Who can share Only the document owner. Amazon-owned AWS-* documents are public by design and aren’t yours to change.
Public or private A document can’t be shared publicly and privately at the same time.
Default quotas Up to 5 publicly shared documents and up to 1,000 accounts per document; both can be raised through Support.
Scope Same Region only. The block public sharing setting is also per Region.
Deleting You must stop sharing a document before you can delete it.

What does the script do?

  1. Reads the Region settingGetServiceSetting for /ssm/documents/console/public-sharing-permission. A value of Disable means public sharing is blocked.
  2. Lists your documentspaginateListDocuments with the filter Owner=Self, so AWS-owned and third-party documents are skipped.
  3. Checks sharingDescribeDocumentPermission with PermissionType: "Share", following NextToken. all (in any case) means public; account IDs mean a private share.
  4. Scans public versionspaginateListDocumentVersions and GetDocument per version, then pattern checks in memory for access key IDs, private keys, tokens, URLs with passwords and hard-coded credentials. Only the kind of match and the version are printed.
  5. Fixes, if asked--apply --names calls ModifyDocumentPermission with AccountIdsToRemove: ["all"] and re-reads the permissions. --block sets the Region setting to Disable with UpdateServiceSetting.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-ssm.
  • A profile for each Region you use documents in; the guide to credential providers in AWS SDK v3 covers profiles and SSO.
  • A list of who runs each public document. Any account that runs it by ARN loses access when you stop sharing.

Which IAM permissions does it need?

ListDocuments needs "*". Everything else is scoped to document and service setting ARNs. The last statement is only for --apply and --block.

public-ssm-documents-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListDocuments",
      "Effect": "Allow",
      "Action": "ssm:ListDocuments",
      "Resource": "*"
    },
    {
      "Sid": "ReadDocumentsAndSharing",
      "Effect": "Allow",
      "Action": [
        "ssm:DescribeDocumentPermission",
        "ssm:ListDocumentVersions",
        "ssm:GetDocument"
      ],
      "Resource": "arn:aws:ssm:*:111122223333:document/*"
    },
    {
      "Sid": "ReadBlockPublicSharingSetting",
      "Effect": "Allow",
      "Action": "ssm:GetServiceSetting",
      "Resource": "arn:aws:ssm:*:111122223333:servicesetting/ssm/documents/console/public-sharing-permission"
    },
    {
      "Sid": "FixOnlyWithApplyOrBlock",
      "Effect": "Allow",
      "Action": ["ssm:ModifyDocumentPermission", "ssm:UpdateServiceSetting"],
      "Resource": [
        "arn:aws:ssm:*:111122223333:document/*",
        "arn:aws:ssm:*:111122223333:servicesetting/ssm/documents/console/public-sharing-permission"
      ]
    }
  ]
}

To keep the block in place, attach a Deny on ssm:UpdateServiceSetting for that service setting ARN to everyone except your platform team, as AWS suggests. The review of IAM policies for least privilege helps spot roles that could still flip it back.

The script to find publicly shared SSM documents

find-public-ssm-documents.ts

// find-public-ssm-documents.ts
// Finds SSM documents you own that are shared publicly (account ID "all") or privately, scans every version of
// the public ones for secret-looking content without printing it, and reports the Region's block public
// sharing setting. --apply stops public sharing for the documents you name; --block turns on block public sharing.
// Usage:
//   npx tsx find-public-ssm-documents.ts [--region us-east-1]
//   npx tsx find-public-ssm-documents.ts --region us-east-1 --apply --names MyRunbook,MyPatchDoc [--block]
import {
  SSMClient,
  DescribeDocumentPermissionCommand,
  GetDocumentCommand,
  GetServiceSettingCommand,
  ModifyDocumentPermissionCommand,
  UpdateServiceSettingCommand,
  paginateListDocuments,
  paginateListDocumentVersions,
} 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 region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const apply = args.includes("--apply");
const block = args.includes("--block");
const toUnshare = (flag("--names") ?? "").split(",").map((n) => n.trim()).filter(Boolean);
const SETTING_ID = "/ssm/documents/console/public-sharing-permission";
const ssm = new SSMClient({ region });

const SECRET_HINTS: [RegExp, string][] = [
  [/\b(AKIA|ASIA)[A-Z0-9]{16}\b/, "AWS access key ID"],
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "private key block"],
  [/[a-z][a-z0-9+.-]*:\/\/[^\s:/@"]+:[^\s@"]+@/i, "URL with embedded password"],
  [/\b(ghp|gho|ghs|github_pat)_[A-Za-z0-9_]{20,}/, "GitHub token"],
  [/\bxox[abposr]-[A-Za-z0-9-]{10,}/, "Slack token"],
  [/(password|passwd|secret|api[_-]?key|token)\s*[=:]\s*['"]?[^\s'"{}$]{6,}/i, "hard-coded credential"],
];

interface Row {
  Document: string;
  Type: string;
  Sharing: string;
  Accounts: string;
  Versions: number;
  SecretHints: string;
}

const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

async function sharedWith(name: string): Promise<string[]> {
  const ids: string[] = [];
  let NextToken: string | undefined;
  do {
    const out = await ssm.send(new DescribeDocumentPermissionCommand({ Name: name, PermissionType: "Share", NextToken }));
    ids.push(...(out.AccountIds ?? []));
    NextToken = out.NextToken;
  } while (NextToken);
  return ids;
}

// Reads every version of a document and returns the kinds of secret found. Content is never printed.
async function scanVersions(name: string): Promise<{ versions: number; hints: string[] }> {
  const hints = new Set<string>();
  let versions = 0;
  for await (const page of paginateListDocumentVersions({ client: ssm }, { Name: name })) {
    for (const v of page.DocumentVersions ?? []) {
      versions++;
      const doc = await ssm.send(new GetDocumentCommand({ Name: name, DocumentVersion: v.DocumentVersion }));
      const content = doc.Content ?? "";
      for (const [re, label] of SECRET_HINTS) if (re.test(content)) hints.add(`${label} (v${v.DocumentVersion})`);
    }
  }
  return { versions, hints: [...hints] };
}

async function main(): Promise<void> {
  const setting = await ssm.send(new GetServiceSettingCommand({ SettingId: SETTING_ID }));
  const value = setting.ServiceSetting?.SettingValue ?? "unknown";
  console.log(`Block public sharing in ${region}: ${value === "Disable" ? "ON (public sharing blocked)" : `OFF (setting value ${value})`}`);

  const rows: Row[] = [];
  const owned: string[] = [];
  for await (const page of paginateListDocuments({ client: ssm }, { Filters: [{ Key: "Owner", Values: ["Self"] }] })) {
    for (const d of page.DocumentIdentifiers ?? []) {
      if (!d.Name) continue;
      owned.push(d.Name);
      const ids = await sharedWith(d.Name);
      if (!ids.length) continue;
      const isPublic = ids.some((id) => id.toLowerCase() === "all");
      const scan = isPublic ? await scanVersions(d.Name) : { versions: 0, hints: ["not scanned (private share)"] };
      rows.push({
        Document: d.Name,
        Type: d.DocumentType ?? "?",
        Sharing: isPublic ? "PUBLIC" : "private",
        Accounts: isPublic ? "everyone" : String(ids.length),
        Versions: scan.versions,
        SecretHints: scan.hints.join(", ") || "none found",
      });
    }
  }
  console.table(rows);
  const publicDocs = rows.filter((r) => r.Sharing === "PUBLIC").map((r) => r.Document);
  console.log(`${owned.length} documents owned in ${region}; ${publicDocs.length} public, ${rows.length - publicDocs.length} shared privately.`);

  if (!apply) {
    console.log("Report only: nothing was modified. Use --apply --names <a,b> to stop public sharing, --block to block it.");
    return;
  }
  for (const name of toUnshare) {
    if (!publicDocs.includes(name)) {
      console.error(`Skipping ${name}: not a public document you own in ${region}`);
      continue;
    }
    try {
      await ssm.send(new ModifyDocumentPermissionCommand({ Name: name, PermissionType: "Share", AccountIdsToRemove: ["all"] }));
      const left = await sharedWith(name);
      console.log(`Stopped public sharing of ${name}; remaining shares: ${left.length ? left.join(", ") : "none"}`);
    } catch (err) {
      console.error(`Could not update ${name}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }
  if (block) {
    await ssm.send(new UpdateServiceSettingCommand({ SettingId: SETTING_ID, SettingValue: "Disable" }));
    console.log(`Block public sharing turned on in ${region}.`);
  }
}

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

Content stays in memory: the script reads document content only to test patterns and prints the kind of match, never the matching text. Keep it that way if you extend it; CI logs and terminal history are where leaked secrets get found a second time.

How do you run it?

Terminal

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

# Report only
AWS_PROFILE=readonly npx tsx find-public-ssm-documents.ts --region us-east-1

# Stop public sharing of one document and block new public shares in this Region
AWS_PROFILE=ssm-admin npx tsx find-public-ssm-documents.ts --region us-east-1 \
  --apply --names Acme-InstallAgent --block

Sample output

Output (with –apply –block)

Block public sharing in us-east-1: OFF (setting value Enable)
┌─────────┬─────────────────────┬──────────────┬───────────┬────────────┬──────────┬───────────────────────────────┐
│ (index) │ Document            │ Type         │ Sharing   │ Accounts   │ Versions │ SecretHints                   │
├─────────┼─────────────────────┼──────────────┼───────────┼────────────┼──────────┼───────────────────────────────┤
│ 0       │ 'Acme-InstallAgent' │ 'Command'    │ 'PUBLIC'  │ 'everyone' │ 2        │ 'hard-coded credential (v1)'  │
│ 1       │ 'Acme-RotateLogs'   │ 'Automation' │ 'PUBLIC'  │ 'everyone' │ 1        │ 'none found'                  │
│ 2       │ 'Acme-Private'      │ 'Command'    │ 'private' │ '2'        │ 0        │ 'not scanned (private share)' │
└─────────┴─────────────────────┴──────────────┴───────────┴────────────┴──────────┴───────────────────────────────┘
4 documents owned in us-east-1; 2 public, 1 shared privately.
Stopped public sharing of Acme-InstallAgent; remaining shares: none
Block public sharing turned on in us-east-1.

Names are illustrative. Acme-InstallAgent had a hard-coded credential in version 1, so it was unshared, and the credential needs rotating, since earlier readers may have copied it. Acme-RotateLogs stays public until you decide; with block public sharing now on, it’s the last one that can be. Acme-Private is shared with two accounts; compare them against the list from the audit of IAM roles trusted by external accounts to confirm they’re partners you expect.

What should you do after unsharing a document?

  • Rotate anything it contained. A token or key in any version is burned. If it was an IAM access key, the script to find old and unused IAM access keys shows its last use.
  • Move secrets out of documents. Reference a SecureString parameter at run time instead of pasting values; the script to find plaintext SSM parameters checks that those parameters are encrypted.
  • Share privately if others need it. Add the specific account IDs with ModifyDocumentPermission and AccountIdsToAdd. Consumers should pass the document hash when they run it, so a changed document fails instead of running.
  • Repeat per Region. Both sharing and the block setting are Regional. Check other resource types too: the audit for public EBS and RDS snapshots is the usual next stop.

Troubleshooting

  • InvalidDocument on DescribeDocumentPermission. The document doesn’t exist or isn’t available to the caller. It can happen if a document is deleted between listing and checking; run again.
  • InvalidDocumentOperation when deleting a document. It’s still shared. Stop sharing it first, publicly and privately, then delete it.
  • False positives in the scan. Parameter placeholders such as {{ Token }} are skipped, but a line like password=changeme in an example still matches. Read the version before you rotate anything.
  • The block setting shows Enable after --block. You’re looking at another Region, or a role in your organization changed it back.

Ask ChatWithCloud instead

To check sharing without running a script, ask ChatWithCloud “Which SSM documents that I own in us-east-1 are shared publicly?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the permissions it found. Don’t ask it to read document content that might hold secrets: the JSON returned by the calls is sent to the model, as ChatWithCloud’s security model and data flow explains. The walkthrough on how to analyze your AWS security posture with an AI CLI shows more questions like this one.

Frequently asked questions

How do I know if an SSM document is shared publicly?

Run DescribeDocumentPermission with PermissionType set to Share. If AccountIds contains all, the document is public in that Region.

Does blocking SSM document public sharing unshare existing documents?

No. The block public sharing setting only stops new public shares. Documents that are already public stay public until you remove all from their permissions.

Are older versions of a public SSM document visible?

Yes. Sharing a document with all shares every version by default, so check each version for sensitive content, not only the default.

How many SSM documents can I share publicly?

By default, up to five per account in a Region. You can request a higher quota through AWS Support, but most accounts should be at zero.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud