Photo by Bernd 📷 Dittrich on Unsplash
To find an ECS task definition with a privileged container, list the task definition families, describe the latest ACTIVE revision of each, and check every container definition for privileged: true, a root user, added Linux capabilities and a writable root file system. Also check the task-level pidMode, ipcMode and networkMode for host. The script below does all of it and changes nothing.
A container is only as isolated as its task definition allows. One privileged flag, a SYS_ADMIN capability or a shared host PID namespace, and a compromised process in that container is much closer to the EC2 instance underneath, and to every other task on it.
This example is for platform and security engineers who run Amazon ECS on EC2 or ECS Anywhere and want a list of every ECS task definition privileged container setting before an audit finds it. It reads task definitions only, so a read-only profile is enough. It checks runtime privileges, not secrets: to catch passwords in environment blocks, run the separate script to find plaintext secrets in ECS task definitions.
Which ECS task definition settings make a container privileged?
Six settings decide how much of the host a container can reach. The ContainerDefinition API reference documents each one; this table shows how the script rates them:
| Setting | Where | What it does | Script rating |
|---|---|---|---|
privileged: true |
Container | Maps to docker run --privileged: elevated privileges on the container instance, similar to root |
HIGH |
linuxParameters.capabilities.add |
Container | Adds Linux capabilities on top of Docker’s defaults (--cap-add) |
HIGH for ALL, SYS_ADMIN, SYS_PTRACE, NET_ADMIN and similar; MEDIUM otherwise |
pidMode: host |
Task | Containers share the process namespace of the EC2 instance | HIGH |
user set to root or 0 |
Container | The process runs as UID 0 inside the container | MEDIUM |
networkMode or ipcMode host |
Task | Containers use the host network stack or IPC namespace | MEDIUM |
user unset, readonlyRootFilesystem not true |
Container | Runs as the image’s default user; the root file system is writable | LOW (shown with --include-low) |
An unset user isn’t automatically root. ECS passes it to Docker as --user, and Docker’s run reference says the default user in a container is root (UID 0) unless the image sets USER. That’s why the script reports it as LOW and leaves the image check to you.
Does AWS Fargate allow privileged containers?
No. The API reference states that privileged isn’t supported for Windows containers or tasks run on AWS Fargate, and ipcMode isn’t either. On Fargate, SYS_PTRACE is the only capability you can add and task is the only valid pidMode for Linux. So HIGH findings almost always belong to task definitions that run on EC2 container instances or ECS Anywhere, where the host is yours to protect.
What does the script do?
- Picks RegionsYour profile’s Region, a list with
--regions, or every Region with--all-regions. - Lists families
paginateListTaskDefinitionFamilieswithstatus: "ACTIVE"returns only families that still have an ACTIVE revision.--family-prefixnarrows it. - Describes the latest revision
DescribeTaskDefinitionwith just the family name returns its latest ACTIVE revision, so old revisions don’t flood the report. - Checks task and containersTask-level namespaces first, then each container’s privileges, capabilities, user and root file system.
- Reports onlySorts by severity, prints a table, writes a CSV with
--csv, and never registers or deregisters anything.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-ecsand@aws-sdk/client-ec2(the second only for--all-regions). - An AWS profile set up as in the guide to AWS SDK v3 credential providers like fromIni and fromSSO.
- A list of containers that genuinely need extra privileges, such as a monitoring agent or a log router, so you can mark those rows as accepted instead of fixing them.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadTaskDefinitions",
"Effect": "Allow",
"Action": [
"ecs:ListTaskDefinitionFamilies",
"ecs:DescribeTaskDefinition",
"ec2:DescribeRegions"
],
"Resource": "*"
}
]
}
All three actions are read-only. DescribeTaskDefinition returns the whole definition, including any plaintext environment values, so treat the output as sensitive. You can double-check the list with the free IAM policy generator for TypeScript code.
The script to find privileged ECS task definitions
// find-privileged-ecs-task-definitions.ts
// Report only. Reads the latest ACTIVE revision of every ECS task definition family and flags
// privileged containers, containers that run as root, writable root file systems, added Linux
// capabilities and host network / PID / IPC namespaces.
// Usage:
// npx tsx find-privileged-ecs-task-definitions.ts [--regions us-east-1,eu-west-1 | --all-regions]
// [--family-prefix web] [--include-low] [--csv ecs-privileged.csv]
import { writeFileSync } from "node:fs";
import { DescribeRegionsCommand, EC2Client } from "@aws-sdk/client-ec2";
import {
DescribeTaskDefinitionCommand,
ECSClient,
paginateListTaskDefinitionFamilies,
type ContainerDefinition,
type TaskDefinition,
} from "@aws-sdk/client-ecs";
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 familyPrefix = flag("--family-prefix");
const includeLow = args.includes("--include-low"); // also list LOW findings (user unset, writable root fs)
const csvPath = flag("--csv");
type Severity = "HIGH" | "MEDIUM" | "LOW";
const RANK: Record<Severity, number> = { HIGH: 3, MEDIUM: 2, LOW: 1 };
// Capabilities that give a container broad control over the host kernel or network stack.
const DANGEROUS_CAPS = new Set(["ALL", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_RAWIO", "NET_ADMIN", "DAC_READ_SEARCH"]);
interface Row {
Region: string;
TaskDefinition: string;
Container: string;
Severity: Severity;
Finding: string;
}
async function regionList(): Promise<string[]> {
const named = flag("--regions");
if (named) return named.split(",").map((r) => r.trim()).filter(Boolean);
if (!args.includes("--all-regions")) return [process.env.AWS_REGION ?? "us-east-1"];
const ec2 = new EC2Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const { Regions = [] } = await ec2.send(new DescribeRegionsCommand({}));
return Regions.map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
// "root", "0", "root:root", "0:0" and "0:1000" all run the process as UID 0.
function isRootUser(user: string): boolean {
const name = user.split(":")[0].trim();
return name === "root" || name === "0";
}
function checkContainer(c: ContainerDefinition): [Severity, string][] {
const out: [Severity, string][] = [];
if (c.privileged) out.push(["HIGH", "privileged: true"]);
const added = c.linuxParameters?.capabilities?.add ?? [];
const risky = added.filter((cap) => DANGEROUS_CAPS.has(cap.toUpperCase()));
if (risky.length) out.push(["HIGH", `capabilities added: ${risky.join(", ")}`]);
const other = added.filter((cap) => !DANGEROUS_CAPS.has(cap.toUpperCase()));
if (other.length) out.push(["MEDIUM", `capabilities added: ${other.join(", ")}`]);
if (c.user === undefined || c.user === "") out.push(["LOW", "user not set (image default, root unless the image sets USER)"]);
else if (isRootUser(c.user)) out.push(["MEDIUM", `runs as root (user: ${c.user})`]);
if (!c.readonlyRootFilesystem) out.push(["LOW", "root file system is writable"]);
return out;
}
function checkTask(td: TaskDefinition): [Severity, string][] {
const out: [Severity, string][] = [];
if (td.pidMode === "host") out.push(["HIGH", "pidMode: host (sees every process on the instance)"]);
if (td.ipcMode === "host") out.push(["MEDIUM", "ipcMode: host"]);
if (td.networkMode === "host") out.push(["MEDIUM", "networkMode: host"]);
return out;
}
async function scanRegion(region: string): Promise<Row[]> {
const ecs = new ECSClient({ region });
const rows: Row[] = [];
const pages = paginateListTaskDefinitionFamilies({ client: ecs }, { status: "ACTIVE", familyPrefix });
for await (const page of pages) {
for (const family of page.families ?? []) {
// Passing only the family name returns its latest ACTIVE revision.
const { taskDefinition: td } = await ecs.send(new DescribeTaskDefinitionCommand({ taskDefinition: family }));
if (!td) continue;
const name = `${td.family}:${td.revision}`;
for (const [severity, finding] of checkTask(td)) {
rows.push({ Region: region, TaskDefinition: name, Container: "(task)", Severity: severity, Finding: finding });
}
for (const c of td.containerDefinitions ?? []) {
for (const [severity, finding] of checkContainer(c)) {
rows.push({ Region: region, TaskDefinition: name, Container: c.name ?? "?", Severity: severity, Finding: finding });
}
}
}
}
return rows;
}
function toCsv(rows: Row[]): string {
const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
const cell = (v: string) => `"${v.replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of await regionList()) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
}
}
rows.sort((a, b) => RANK[b.Severity] - RANK[a.Severity] || a.TaskDefinition.localeCompare(b.TaskDefinition));
const shown = includeLow ? rows : rows.filter((r) => r.Severity !== "LOW");
if (shown.length) console.table(shown);
const count = (s: Severity) => rows.filter((r) => r.Severity === s).length;
console.log(`${count("HIGH")} high, ${count("MEDIUM")} medium, ${count("LOW")} low findings`);
if (csvPath && rows.length) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
console.log("Report only: nothing was modified.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ecs @aws-sdk/client-ec2
npm install --save-dev tsx typescript @types/node
# HIGH and MEDIUM findings in every Region, CSV for the ticket
AWS_PROFILE=readonly npx tsx find-privileged-ecs-task-definitions.ts --all-regions --csv ecs-privileged.csv
# One Region, one family, including LOW findings
AWS_PROFILE=readonly npx tsx find-privileged-ecs-task-definitions.ts --regions eu-west-1 --family-prefix payments --include-low
Sample output
┌─────────┬─────────────┬────────────────────┬───────────┬──────────┬──────────────────────────────────────────────────────┐
│ (index) │ Region │ TaskDefinition │ Container │ Severity │ Finding │
├─────────┼─────────────┼────────────────────┼───────────┼──────────┼──────────────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'node-exporter:12' │ '(task)' │ 'HIGH' │ 'pidMode: host (sees every process on the instance)' │
│ 1 │ 'us-east-1' │ 'legacy-batch:31' │ 'worker' │ 'HIGH' │ 'privileged: true' │
│ 2 │ 'us-east-1' │ 'legacy-batch:31' │ 'worker' │ 'HIGH' │ 'capabilities added: SYS_ADMIN' │
│ 3 │ 'us-east-1' │ 'legacy-batch:31' │ 'worker' │ 'MEDIUM' │ 'runs as root (user: root)' │
│ 4 │ 'eu-west-1' │ 'edge-proxy:7' │ '(task)' │ 'MEDIUM' │ 'networkMode: host' │
└─────────┴─────────────┴────────────────────┴───────────┴──────────┴──────────────────────────────────────────────────────┘
3 high, 2 medium, 41 low findings
Wrote 46 rows to ecs-privileged.csv
Report only: nothing was modified.
Names are illustrative. node-exporter is a monitoring agent that reads host process metrics, so pidMode: host may be intentional: record it as an accepted exception. legacy-batch is the real problem. A batch worker rarely needs privileged and SYS_ADMIN at the same time, and running as root on top of that removes the last layer.
How do you remove privileged mode from a task definition?
Task definition revisions are immutable, so every fix is a new revision followed by a deployment:
- Export the current revision
aws ecs describe-task-definition --task-definition legacy-batch --query taskDefinition > td.json, then remove read-only fields such astaskDefinitionArn,revision,status,requiresAttributes,compatibilities,registeredAtandregisteredBy. - Drop the privilegesDelete
privilegedand thecapabilities.addentries, setuserto a non-root UID such as"1000", and setreadonlyRootFilesystemtotruewith a volume for any path the app writes to. - Register and test
aws ecs register-task-definition --cli-input-json file://td.json, then run one task in staging. Permission errors on startup usually point to a file the image expects to own as root. - Roll out
aws ecs update-service --cluster prod --service legacy-batch --task-definition legacy-batch:32, then deregister the old revision once nothing uses it.
Warning: deregistering a revision doesn’t stop tasks already running from it. Check the services and scheduled tasks that reference the family before you call the finding closed.
The container image is the other half. Setting USER in the Dockerfile fixes the LOW rows at the source, and turning on ECR image scanning for every repository catches vulnerable packages in the images those tasks pull. While you’re in ECR, an ECR lifecycle policy that deletes old images keeps unscanned tags from piling up.
Troubleshooting
- A family you know exists is missing. It has no ACTIVE revision, so
status: "ACTIVE"skips it. Tasks can still run from INACTIVE revisions; list them withaws ecs list-task-definitions --status INACTIVE. - A running service uses an older revision than the one reported. The script checks the latest ACTIVE revision only. Compare with the
taskDefinitionfield fromaws ecs describe-services. AccessDeniedExceptionin one Region. A service control policy may block unused Regions. The script logs the error and carries on; the guide to troubleshoot IAM access denied errors step by step helps you tell an SCP from a missing permission.- Throttling on large accounts. One
DescribeTaskDefinitioncall per family adds up. The SDK retries with backoff; narrow the run with--family-prefixif it still fails.
Privileged containers are one layer of host exposure. For the instances underneath, the checks to find EC2 instances without IMDSv2 and require it and to find EC2 instances not managed by Systems Manager cover metadata access and patching. If you also run Kubernetes, finding EKS clusters with a public API endpoint is the matching control-plane check.
Ask ChatWithCloud instead
For a quick look without a script, ask ChatWithCloud “Which ECS task definitions in us-east-1 have privileged containers or run as root?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result, one profile and Region per session; how ChatWithCloud runs AWS SDK code on your machine covers the loop. It can be wrong, and it runs generated code without a confirmation step, so connect ChatWithCloud through a read-only AWS profile first. The guide to analyzing your AWS security posture with an AI CLI lists follow-up questions to try.
Frequently asked questions
What does privileged mean in an ECS task definition?
When privileged is true, the container gets elevated privileges on the container instance, similar to the root user. It maps to Docker’s --privileged option.
Can I run a privileged container on AWS Fargate?
No. Fargate doesn’t support the privileged parameter. On Fargate, the only Linux capability you can add is SYS_PTRACE.
Do ECS containers run as root by default?
If the task definition doesn’t set user, the container runs as the image’s default user, which is root unless the Dockerfile sets USER. Set user to a non-root UID to be explicit.
How do I check which ECS containers are privileged with the AWS CLI?
Run aws ecs describe-task-definition --task-definition my-family --query "taskDefinition.containerDefinitions[].[name,privileged,user]" for each family, or use the script above to cover every family at once.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud