Find Plaintext Secrets in ECS Task Definitions

To find secrets in ECS task definitions, list the ACTIVE revisions with ListTaskDefinitions, call DescribeTaskDefinition on each, and check every container’s environment array for values that look like credentials. Values in environment are stored in plaintext in the task definition; secrets belong in the secrets array as a valueFrom reference to Secrets Manager or Parameter Store.

An ECS task definition is a JSON document that anyone with ecs:DescribeTaskDefinition can read in full, and it is versioned: each change registers a new revision, and the old ones stay ACTIVE until someone deregisters them. A password pasted into environment once can therefore sit in a dozen revisions long after the service moved to Secrets Manager. This example is for platform engineers who need to find secrets in ECS task definitions across Regions and clean them up.

The TypeScript script below uses the AWS SDK for JavaScript v3, prints variable names with masked hints only, and never writes anything. It follows the same report-first approach as the other AWS SDK v3 security audit examples.

Environment vs secrets: what’s the difference?

A container definition has two ways to set environment variables:

Field What’s stored in the task definition Who can read the value
environment The literal value, as {"name", "value"} pairs Anyone who can describe the task definition, plus the running container
secrets A valueFrom ARN (or a parameter name in the same Region) The task execution role at container start, then the running container
environmentFiles An S3 object ARN of a .env file Anyone who can read that S3 object

With secrets, ECS reads the value when the container starts. The ECS guide to passing Secrets Manager secrets as environment variables notes two consequences: a rotated secret reaches the container only when a new task starts, and applications, logs and debugging tools inside the container still see the variable.

What does the script do?

  1. Lists ACTIVE revisions per RegionListTaskDefinitions with status: ACTIVE and sort: DESC, so the newest revision of each family comes first. --latest-only keeps just that one; --family-prefix= narrows to one family.
  2. Describes each revisionDescribeTaskDefinition returns the containerDefinitions.
  3. Scans environment valuesHIGH for known credential formats (AWS access key IDs starting AKIA or ASIA, GitHub tokens, PEM private keys, JWTs, URLs with a password), MEDIUM for secret-sounding names with a literal value, LOW for long random-looking values.
  4. Flags environmentFilesIt can’t see inside those S3 objects, so it lists them for you to review.
  5. Maps secret references to rolesEvery secrets and log secretOptions entry, plus repositoryCredentials, is turned into the IAM action and resource ARN the task execution role needs.

Hard-coded credentials are a known weakness class, CWE-798: Use of Hard-coded Credentials, and a task definition is configuration checked into an API, so the same reasoning applies. Lambda functions carry the same risk in their environment variables, which the script to find secrets in Lambda environment variables scans with the same patterns. The script never prints a value: the output row type has no field for it, and the Hint column shows only a public prefix such as AKIA and the length. Secrets aren’t the only risk in what a task runs: the image can ship vulnerable packages, so enable ECR image scanning for every repository your tasks pull from.

Prerequisites

Which IAM permissions does it need?

ecs-secrets-scan-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadTaskDefinitions",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "ecs:ListTaskDefinitions",
        "ecs:DescribeTaskDefinition"
      ],
      "Resource": "*"
    }
  ]
}

The scanner needs no Secrets Manager, SSM or KMS permissions: it reads references, not the secrets behind them. If you extend it, the IAM policy generator for TypeScript AWS SDK code drafts the updated policy for review.

The script to find secrets in ECS task definitions

find-secrets-in-ecs-task-definitions.ts

// find-secrets-in-ecs-task-definitions.ts
// Scans ACTIVE ECS task definition revisions in each Region for plaintext secrets in containerDefinitions
// "environment" (name/value pairs), and lists the "secrets"/"secretOptions" references each task execution
// role must be able to read. Report-only: it never registers, deregisters or deletes anything.
// It NEVER prints a value. Output holds variable names plus a masked hint (known prefix and length).
// Usage: npx tsx find-secrets-in-ecs-task-definitions.ts [--regions=us-east-1] [--family-prefix=api] [--latest-only]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
  ECSClient,
  DescribeTaskDefinitionCommand,
  paginateListTaskDefinitions,
  type TaskDefinition,
} from "@aws-sdk/client-ecs";

const args = process.argv.slice(2);
const opt = (name: string): string | undefined => args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const regionArg = opt("regions")?.split(",").map((s) => s.trim()).filter(Boolean);
const familyPrefix = opt("family-prefix");
const latestOnly = args.includes("--latest-only");

type Severity = "HIGH" | "MEDIUM" | "LOW" | "INFO";
interface Finding { Region: string; TaskDef: string; Container: string; Variable: string; Severity: Severity; Reason: string; Hint: string }

const VALUE_PATTERNS: { kind: string; re: RegExp; prefix?: (v: string) => string }[] = [
  { kind: "AWS access key ID", re: /^(AKIA|ASIA)[A-Z0-9]{16}$/, prefix: (v) => v.slice(0, 4) },
  { kind: "GitHub token", re: /^(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}$/, prefix: (v) => v.slice(0, v.indexOf("_") + 1) },
  { kind: "private key (PEM)", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, prefix: () => "-----BEGIN" },
  { kind: "JWT", re: /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, prefix: () => "eyJ" },
  { kind: "URL with password", re: /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]+@/i },
  { kind: "password= in value", re: /(password|passwd|pwd|secret|token)\s*[=:]\s*\S{4,}/i },
];
const SECRET_NAME = /(pass(word|wd)?|pwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key|credential|auth|client[_-]?secret|signing[_-]?key|conn(ection)?[_-]?str|dsn|database[_-]?url)/i;
const REFERENCE = /^(arn:aws[a-z-]*:(secretsmanager|ssm|kms):|\/[A-Za-z0-9_.\/-]+$)/;
const HARMLESS = /^(true|false|yes|no|on|off|enabled|disabled|none|null|\d+)$/i;

function entropy(s: string): number {
  const counts = new Map<string, number>();
  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
  let h = 0;
  for (const n of counts.values()) h -= (n / s.length) * Math.log2(n / s.length);
  return h;
}
// The only thing about a value that is ever printed: a public prefix (if any) and the length.
const mask = (value: string, prefix?: string): string => `${prefix ?? ""}${"*".repeat(8)} (${value.length} chars)`;

function classify(name: string, value: string): { severity: Severity; reason: string; hint: string } | undefined {
  const v = value.trim();
  if (!v || REFERENCE.test(v)) return undefined;
  for (const p of VALUE_PATTERNS) if (p.re.test(v)) return { severity: "HIGH", reason: p.kind, hint: mask(v, p.prefix?.(v)) };
  if (SECRET_NAME.test(name) && v.length >= 8 && !HARMLESS.test(v)) return { severity: "MEDIUM", reason: "secret-like name, plaintext value", hint: mask(v) };
  if (v.length >= 24 && !/\s/.test(v) && entropy(v) >= 4.0) return { severity: "LOW", reason: "long random-looking value", hint: mask(v) };
  return undefined;
}

// Turns a valueFrom into the IAM resource ARN the execution role needs, plus the action.
function secretResource(valueFrom: string, region: string, account: string): { action: string; resource: string } {
  if (valueFrom.startsWith("arn:") && valueFrom.split(":")[2] === "secretsmanager") {
    // arn:aws:secretsmanager:region:account:secret:name-AbCdEf[:json-key:version-stage:version-id]
    return { action: "secretsmanager:GetSecretValue", resource: valueFrom.split(":").slice(0, 7).join(":") };
  }
  if (valueFrom.startsWith("arn:")) return { action: "ssm:GetParameters", resource: valueFrom };
  // A bare parameter name is allowed when the parameter is in the task's Region.
  const name = valueFrom.startsWith("/") ? valueFrom.slice(1) : valueFrom;
  return { action: "ssm:GetParameters", resource: `arn:aws:ssm:${region}:${account}:parameter/${name}` };
}

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg;
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

const roleNeeds = new Map<string, Set<string>>(); // execution role -> "action resource"

function scan(region: string, td: TaskDefinition): Finding[] {
  const arn = td.taskDefinitionArn ?? "";
  const account = arn.split(":")[4] ?? "";
  const label = `${td.family}:${td.revision}`;
  const role = td.executionRoleArn?.split("/").pop() ?? "(no execution role)";
  const out: Finding[] = [];
  for (const c of td.containerDefinitions ?? []) {
    const base = { Region: region, TaskDef: label, Container: c.name ?? "?" };
    for (const kv of c.environment ?? []) {
      const hit = classify(kv.name ?? "", kv.value ?? "");
      if (hit) out.push({ ...base, Variable: kv.name ?? "?", Severity: hit.severity, Reason: hit.reason, Hint: hit.hint });
    }
    for (const f of c.environmentFiles ?? []) {
      out.push({ ...base, Variable: "(environmentFiles)", Severity: "INFO", Reason: `S3 env file not scanned: ${f.value ?? "?"}`, Hint: "-" });
    }
    const refs = [...(c.secrets ?? []), ...(c.logConfiguration?.secretOptions ?? [])];
    if (c.repositoryCredentials?.credentialsParameter) refs.push({ name: "(repositoryCredentials)", valueFrom: c.repositoryCredentials.credentialsParameter });
    for (const s of refs) {
      if (!s.valueFrom) continue;
      const need = secretResource(s.valueFrom, region, account);
      if (!roleNeeds.has(role)) roleNeeds.set(role, new Set());
      roleNeeds.get(role)?.add(`${need.action} ${need.resource}`);
    }
  }
  return out;
}

async function main(): Promise<void> {
  const findings: Finding[] = [];
  let revisions = 0;
  for (const region of await listRegions()) {
    const ecs = new ECSClient({ region });
    const seenFamilies = new Set<string>();
    try {
      // DESC lists the newest revision of each family first, which makes --latest-only a simple filter.
      const input = { status: "ACTIVE" as const, sort: "DESC" as const, ...(familyPrefix ? { familyPrefix } : {}) };
      for await (const page of paginateListTaskDefinitions({ client: ecs }, input)) {
        for (const tdArn of page.taskDefinitionArns ?? []) {
          const family = tdArn.split("/").pop()?.split(":")[0] ?? tdArn;
          if (latestOnly && seenFamilies.has(family)) continue;
          seenFamilies.add(family);
          const { taskDefinition } = await ecs.send(new DescribeTaskDefinitionCommand({ taskDefinition: tdArn }));
          if (!taskDefinition) continue;
          revisions++;
          findings.push(...scan(region, taskDefinition));
        }
      }
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? err.name : String(err)}`);
    }
  }

  console.table(findings);
  const high = findings.filter((f) => f.Severity === "HIGH" || f.Severity === "MEDIUM").length;
  console.log(`${revisions} ACTIVE revision(s) scanned; ${high} plaintext secret finding(s). No values were printed.`);
  console.log("\nSecret references each task execution role must be able to read:");
  for (const [role, needs] of roleNeeds) {
    console.log(`  ${role}`);
    for (const n of [...needs].sort()) console.log(`    ${n}`);
  }
  if (high) process.exitCode = 2;
}

main().catch((err) => {
  console.error(err instanceof Error ? err.name + ": " + err.message : err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-ecs @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# Every ACTIVE revision in every enabled Region
AWS_PROFILE=security-audit npx tsx find-secrets-in-ecs-task-definitions.ts

# Only the newest revision of the checkout families in one Region
AWS_PROFILE=security-audit npx tsx find-secrets-in-ecs-task-definitions.ts --regions=us-east-1 --family-prefix=checkout-api --latest-only

It sets exit code 2 on a HIGH or MEDIUM finding. Scanning every revision makes one DescribeTaskDefinition call per revision; use --latest-only for a quick pass and the full scan when you clean up.

Sample output

Output

┌─────────┬─────────────┬───────────────────┬───────────┬─────────────────────────┬──────────┬──────────────────────────────────────────────────────────────┬───────────────────────────┐
│ (index) │ Region      │ TaskDef           │ Container │ Variable                │ Severity │ Reason                                                       │ Hint                      │
├─────────┼─────────────┼───────────────────┼───────────┼─────────────────────────┼──────────┼──────────────────────────────────────────────────────────────┼───────────────────────────┤
│ 0       │ 'eu-west-1' │ 'legacy-cron:3'   │ 'cron'    │ '(environmentFiles)'    │ 'INFO'   │ 'S3 env file not scanned: arn:aws:s3:::acme-config/cron.env' │ '-'                       │
│ 1       │ 'us-east-1' │ 'checkout-api:14' │ 'app'     │ 'DB_PASSWORD'           │ 'MEDIUM' │ 'secret-like name, plaintext value'                          │ '******** (24 chars)'     │
│ 2       │ 'us-east-1' │ 'checkout-api:13' │ 'app'     │ 'DB_PASSWORD'           │ 'MEDIUM' │ 'secret-like name, plaintext value'                          │ '******** (24 chars)'     │
│ 3       │ 'us-east-1' │ 'report-worker:7' │ 'worker'  │ 'AWS_ACCESS_KEY_ID'     │ 'HIGH'   │ 'AWS access key ID'                                          │ 'AKIA******** (20 chars)' │
│ 4       │ 'us-east-1' │ 'report-worker:7' │ 'worker'  │ 'AWS_SECRET_ACCESS_KEY' │ 'MEDIUM' │ 'secret-like name, plaintext value'                          │ '******** (40 chars)'     │
└─────────┴─────────────┴───────────────────┴───────────┴─────────────────────────┴──────────┴──────────────────────────────────────────────────────────────┴───────────────────────────┘
27 ACTIVE revision(s) scanned; 4 plaintext secret finding(s). No values were printed.

Secret references each task execution role must be able to read:
  ecsTaskExecutionRole
    secretsmanager:GetSecretValue arn:aws:secretsmanager:us-east-1:123456789012:secret:checkout/stripe-AbCdEf
    ssm:GetParameters arn:aws:ssm:us-east-1:123456789012:parameter/checkout/db-host
  report-worker-exec
    ssm:GetParameters arn:aws:ssm:us-east-1:123456789012:parameter/reports/smtp-password

The names are illustrative. checkout-api moved some settings to Secrets Manager but still passes DB_PASSWORD in plaintext, in both revisions. report-worker carries an IAM user’s access key pair, which a task should never need: give the task a task role instead and deactivate the key, after checking it with the script to find IAM access keys older than 90 days or never used.

Which permissions does the task execution role need for secrets?

The last block of output is a ready-made list for the task execution role, the role the ECS agent uses to start the task (not the task role your code uses). Per the ECS documentation:

  • secretsmanager:GetSecretValue on each secret ARN referenced directly or through a Parameter Store parameter.
  • ssm:GetParameters on each Parameter Store parameter ARN.
  • kms:Decrypt on the key, only when the secret or parameter uses a customer managed KMS key rather than the default key.

Scope each statement to those ARNs rather than *. The guide to review a generated IAM policy for least privilege has a checklist, and the article on getting a Secrets Manager secret value with AWS SDK v3 covers reading secrets from code when injection at start isn’t enough.

How do you fix a finding?

  1. Rotate the valueIt was readable by everyone with describe access, so replace it at the source.
  2. Store itIn Secrets Manager, or in Parameter Store as a SecureString; the guide to get SSM Parameter Store values with AWS SDK v3 compares the two.
  3. Register a new revisionMove the variable from environment to secrets with valueFrom, and grant the execution role the permissions above.
  4. Remove the old revisionsDeregister them (they become INACTIVE), then delete them with DeleteTaskDefinitions, up to 10 per call. Running tasks and services keep working while a deleted revision is in DELETE_IN_PROGRESS. Secrets only old revisions referenced then go idle; the script to find unused Secrets Manager secrets across Regions picks them up.

Troubleshooting

  • AccessDeniedException. The Region is reported on stderr and skipped. Check the policy and any SCPs with the guide to troubleshoot AWS IAM access denied errors.
  • ThrottlingException on large accounts. The SDK retries automatically; raise maxAttempts or scan with --latest-only first.
  • Tasks fail after the move to secrets. The execution role is missing a permission, or the task can’t reach Secrets Manager or SSM from a private subnet without a NAT gateway or interface VPC endpoints. The script to find unused VPC interface endpoints shows which endpoints you already have.
  • INACTIVE revisions aren’t scanned. The script lists ACTIVE only. INACTIVE revisions can’t start new tasks, but they remain discoverable in your account until you delete them.

Ask ChatWithCloud instead

ChatWithCloud writes and runs AWS SDK for JavaScript v2 code with your profile and sends the JSON result to the AI model, so don’t ask it for environment values. It’s handy for the questions around this audit, such as “Which ECS services use task definition family checkout-api, and which revision?” Connect it with a read-only AWS profile for ChatWithCloud, since generated code runs without a confirmation step, and see the ChatWithCloud security model for exactly what is sent.

Frequently asked questions

Are ECS task definition environment variables encrypted?

Values in environment are part of the task definition, and DescribeTaskDefinition returns them as plaintext. Use secrets with Secrets Manager or Parameter Store for anything sensitive.

Does a rotated secret update running ECS tasks?

No. The value is injected when the container starts. Start new tasks, or force a new deployment of the service, to pick up a rotated value.

What is the difference between the task role and the task execution role?

The execution role is used by the ECS agent to pull images, send logs and fetch secrets. The task role is what your application code uses to call AWS APIs.

Does deregistering a task definition delete it?

No. Deregistering makes the revision INACTIVE. Call DeleteTaskDefinitions on INACTIVE revisions to delete them.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud