Summarize High-Severity GuardDuty Findings Across Regions

A dark room with several monitors showing network graphs and alert dashboards

Photo by Tasha Kostyuk on Unsplash

GuardDuty high severity findings have a severity from 7.0 to 8.9, and critical ones from 9.0 to 10.0. GuardDuty keeps findings per Region, so call ListDetectors in each Region, then ListFindings with severity at or above 7 and service.archived equal to false, and fetch details with GetFindings in batches of 50. Group the results by type and resource.

GuardDuty is enabled per Region and its console shows one Region at a time. On a busy account that means a dozen tabs and a lot of low-severity noise between you and the two findings that matter. What you usually need after an alert, or before a weekly review, is one list of the high and critical findings still open anywhere in the account.

This example gives you a report-only TypeScript script for the AWS SDK for JavaScript v3 that collects active GuardDuty high severity findings from every Region, groups repeated activity, and writes a CSV for your ticket or incident channel. It assumes GuardDuty is on; the script to check GuardDuty is enabled in every AWS Region covers that, and this one tells you what it found.

Which GuardDuty findings count as high severity?

Every finding has a numeric severity from 1.0 to 10.0. The GuardDuty severity levels page splits that range into four bands:

Band Value range What AWS says it indicates
Critical 9.0 to 10.0 An attack sequence may be in progress or recently happened, and resources may already be compromised
High 7.0 to 8.9 The resource is compromised and actively used for unauthorized purposes
Medium 4.0 to 6.9 Suspicious activity that deviates from normal behavior
Low 1.0 to 3.9 Attempted activity that didn’t compromise anything, such as a port scan

So --min-severity 7, the default, returns high and critical findings together. Critical findings include the Extended Threat Detection attack sequence types such as AttackSequence:IAM/CompromisedCredentials, which correlate several signals into one finding. Some types have variable severity, so filter on the number, not the type name.

What does the script do?

  1. Picks RegionsUses --regions if given, otherwise DescribeRegions for every Region enabled in the account.
  2. Finds the detectorpaginateListDetectors returns the Region’s detector ID. Regions without one are listed at the end.
  3. Lists matching findingspaginateListFindings with a FindingCriteria of severity GreaterThanOrEqual 7 and service.archived Equals false, sorted by severity. --days 30 adds an updatedAt filter in epoch milliseconds.
  4. Fetches details in batchesGetFindings takes at most 50 finding IDs per call.
  5. Groups and exportsGroups by Region, type and resource, sums Service.Count (occurrences of the activity) and with --csv writes one row per finding, including the account ID.

Report only. The script never archives findings, creates suppression rules or sends feedback. Archiving is a decision for the person who investigated the finding.

Prerequisites

  • Node.js 18 or later, npm, tsx, @aws-sdk/client-guardduty and @aws-sdk/client-ec2.
  • GuardDuty enabled in the Regions you care about. In an AWS Organizations setup, run it from the GuardDuty delegated administrator account to see member accounts’ findings in one place.
  • A read-only profile; the guide to AWS SDK v3 credential providers with fromIni and fromSSO covers SSO and assume-role profiles.

Which IAM permissions does it need?

guardduty-findings-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadGuardDutyFindings",
      "Effect": "Allow",
      "Action": [
        "guardduty:ListDetectors",
        "guardduty:ListFindings",
        "guardduty:GetFindings",
        "ec2:DescribeRegions"
      ],
      "Resource": "*"
    }
  ]
}

All four actions are read-only. Finding details can include IP addresses, access key IDs and user names, so treat the CSV like any other security record. To check a variant of the script, paste it into the free IAM policy generator for TypeScript code.

The script to summarize GuardDuty high severity findings

summarize-guardduty-findings.ts

// summarize-guardduty-findings.ts
// Summarizes active (unarchived) GuardDuty findings at or above a severity across Regions, grouped
// by finding type and resource, and optionally writes every finding to a CSV file.
// Report only: it never archives, suppresses or changes findings.
// Usage:
//   npx tsx summarize-guardduty-findings.ts [--regions us-east-1,eu-west-1] [--min-severity 7] [--days 30] [--csv findings.csv]
import { writeFileSync } from "node:fs";
import { DescribeRegionsCommand, EC2Client } from "@aws-sdk/client-ec2";
import {
  GetFindingsCommand,
  GuardDutyClient,
  paginateListDetectors,
  paginateListFindings,
  type Finding,
  type FindingCriteria,
} from "@aws-sdk/client-guardduty";

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 minSeverity = Number(flag("--min-severity") ?? "7");
const days = flag("--days") ? Number(flag("--days")) : undefined;
const csvPath = flag("--csv");

// GuardDuty severity bands: Critical 9.0-10.0, High 7.0-8.9, Medium 4.0-6.9, Low 1.0-3.9.
const band = (s: number) => (s >= 9 ? "Critical" : s >= 7 ? "High" : s >= 4 ? "Medium" : "Low");

function resourceOf(f: Finding): string {
  const r = f.Resource;
  return (
    r?.InstanceDetails?.InstanceId ??
    r?.AccessKeyDetails?.UserName ??
    r?.S3BucketDetails?.[0]?.Name ??
    r?.EksClusterDetails?.Name ??
    r?.EcsClusterDetails?.Name ??
    r?.LambdaDetails?.FunctionName ??
    r?.RdsDbInstanceDetails?.DbInstanceIdentifier ??
    r?.ResourceType ??
    "?"
  );
}

async function listRegions(): Promise<string[]> {
  const fromFlag = flag("--regions");
  if (fromFlag) return fromFlag.split(",").map((s) => s.trim()).filter(Boolean);
  const ec2 = new EC2Client({ region: process.env.AWS_REGION ?? "us-east-1" });
  const out = await ec2.send(new DescribeRegionsCommand({})); // Regions enabled for this account
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

async function findingsInRegion(region: string): Promise<Finding[] | "no detector"> {
  const gd = new GuardDutyClient({ region });
  let detectorId: string | undefined;
  for await (const page of paginateListDetectors({ client: gd }, {})) {
    detectorId ??= page.DetectorIds?.[0];
  }
  if (!detectorId) return "no detector";

  const criteria: FindingCriteria = {
    Criterion: {
      severity: { GreaterThanOrEqual: minSeverity },
      "service.archived": { Equals: ["false"] },
      ...(days ? { updatedAt: { GreaterThanOrEqual: Date.now() - days * 86_400_000 } } : {}),
    },
  };
  const ids: string[] = [];
  for await (const page of paginateListFindings(
    { client: gd },
    { DetectorId: detectorId, FindingCriteria: criteria, SortCriteria: { AttributeName: "severity", OrderBy: "DESC" } },
  )) {
    ids.push(...(page.FindingIds ?? []));
  }

  // GetFindings accepts at most 50 finding IDs per call.
  const findings: Finding[] = [];
  for (let i = 0; i < ids.length; i += 50) {
    const out = await gd.send(new GetFindingsCommand({ DetectorId: detectorId, FindingIds: ids.slice(i, i + 50) }));
    findings.push(...(out.Findings ?? []));
  }
  return findings;
}

const csvCell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;

async function main(): Promise<void> {
  const regions = await listRegions();
  const all: Finding[] = [];
  const skipped: string[] = [];
  for (const region of regions) {
    try {
      const res = await findingsInRegion(region);
      if (res === "no detector") skipped.push(region);
      else all.push(...res);
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }

  // Group by Region + finding type + resource so repeated activity collapses into one line.
  const groups = new Map<string, { Region: string; Band: string; MaxSev: number; Type: string; Resource: string; Findings: number; Occurrences: number; LastSeen: string }>();
  for (const f of all) {
    const key = `${f.Region}|${f.Type}|${resourceOf(f)}`;
    const g = groups.get(key) ?? { Region: f.Region ?? "?", Band: "", MaxSev: 0, Type: f.Type ?? "?", Resource: resourceOf(f), Findings: 0, Occurrences: 0, LastSeen: "" };
    g.MaxSev = Math.max(g.MaxSev, f.Severity ?? 0);
    g.Band = band(g.MaxSev);
    g.Findings += 1;
    g.Occurrences += f.Service?.Count ?? 1;
    if ((f.UpdatedAt ?? "") > g.LastSeen) g.LastSeen = f.UpdatedAt ?? "";
    groups.set(key, g);
  }
  const rows = [...groups.values()].sort((a, b) => b.MaxSev - a.MaxSev || b.Occurrences - a.Occurrences);
  if (rows.length) console.table(rows.map((r) => ({ ...r, LastSeen: r.LastSeen.slice(0, 10) })));

  const count = (name: string) => all.filter((f) => band(f.Severity ?? 0) === name).length;
  console.log(`${all.length} active findings at severity >= ${minSeverity} in ${regions.length - skipped.length} Regions ` + `(Critical ${count("Critical")}, High ${count("High")}).`);
  if (skipped.length) console.log(`No GuardDuty detector in: ${skipped.join(", ")}`);

  if (csvPath) {
    const header = ["Account", "Region", "Severity", "Band", "Type", "Resource", "Count", "UpdatedAt", "Title", "Id"];
    const lines = all.map((f) =>
      [f.AccountId ?? "", f.Region ?? "", f.Severity ?? 0, band(f.Severity ?? 0), f.Type ?? "", resourceOf(f), f.Service?.Count ?? 1, f.UpdatedAt ?? "", f.Title ?? "", f.Id ?? ""]
        .map(csvCell)
        .join(","),
    );
    writeFileSync(csvPath, [header.join(","), ...lines].join("\n") + "\n");
    console.log(`Wrote ${all.length} findings to ${csvPath}`);
  }
}

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

How do you run it?

Terminal

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

# High and critical findings updated in the last 30 days, all enabled Regions
AWS_PROFILE=security-audit npx tsx summarize-guardduty-findings.ts --days 30 --csv guardduty-high.csv

# Only critical findings in two Regions
AWS_PROFILE=security-audit npx tsx summarize-guardduty-findings.ts --regions us-east-1,eu-west-1 --min-severity 9

Sample output

Output

┌─────────┬─────────────┬────────────┬────────┬────────────────────────────────────────────────────────────────────────┬─────────────────────────┬──────────┬─────────────┬──────────────┐
│ (index) │ Region      │ Band       │ MaxSev │ Type                                                                   │ Resource                │ Findings │ Occurrences │ LastSeen     │
├─────────┼─────────────┼────────────┼────────┼────────────────────────────────────────────────────────────────────────┼─────────────────────────┼──────────┼─────────────┼──────────────┤
│ 0       │ 'us-east-1' │ 'Critical' │ 9.2    │ 'AttackSequence:IAM/CompromisedCredentials'                            │ 'ci-deployer'           │ 1        │ 1           │ '2027-08-26' │
│ 1       │ 'eu-west-1' │ 'High'     │ 8      │ 'CryptoCurrency:EC2/BitcoinTool.B!DNS'                                 │ 'i-0c3f9e8a51b27d460'   │ 1        │ 212         │ '2027-08-26' │
│ 2       │ 'eu-west-1' │ 'High'     │ 8      │ 'Backdoor:EC2/C&CActivity.B!DNS'                                       │ 'i-0c3f9e8a51b27d460'   │ 1        │ 37          │ '2027-08-26' │
│ 3       │ 'us-east-1' │ 'High'     │ 8      │ 'UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS' │ 'web-app-role'          │ 1        │ 4           │ '2027-08-26' │
│ 4       │ 'eu-west-1' │ 'High'     │ 7.5    │ 'Impact:EC2/PortSweep'                                                 │ 'i-07aa41d2c9e0b3f18'   │ 1        │ 3           │ '2027-08-22' │
│ 5       │ 'us-east-1' │ 'High'     │ 7      │ 'Policy:S3/BucketAnonymousAccessGranted'                               │ 'marketing-assets-prod' │ 1        │ 1           │ '2027-08-24' │
└─────────┴─────────────┴────────────┴────────┴────────────────────────────────────────────────────────────────────────┴─────────────────────────┴──────────┴─────────────┴──────────────┘
6 active findings at severity >= 7 in 2 Regions (Critical 1, High 5).
No GuardDuty detector in: ap-south-1
Wrote 6 findings to guardduty-high.csv

IDs and names are illustrative; the finding types are real. The critical attack sequence on ci-deployer comes first. The same EC2 instance in eu-west-1 has both a crypto-mining and a command-and-control finding, which together point at one compromised host rather than two problems. The 212 occurrences are one finding that GuardDuty kept updating, not 212 separate findings.

What should you do with each high severity finding?

  1. Start with shared resourcesTwo or more findings on one instance or one principal usually describe one incident. Work resource by resource, not finding by finding.
  2. Contain credentials firstFor IAM findings, deactivate or rotate the access key and review what it did; the scripts to find IAM access keys older than 90 days or never used and to check CloudTrail is logging in every Region help with both.
  3. Isolate instancesFor EC2 findings, move the instance to a restrictive security group and snapshot its volumes before you terminate anything.
  4. Close configuration findingsPolicy:S3/BucketAnonymousAccessGranted is a setting, not an attacker. Fix the bucket policy; the script to find public and private S3 buckets shows the others.
  5. Archive with a noteOnce resolved, archive the finding. Recurring expected activity belongs in a suppression rule, which archives matching new findings automatically; this script’s service.archived = false filter then leaves them out.

Two limits to plan around: GuardDuty retains findings for 90 days, so export them to an S3 bucket if you need history, and findings are also published to Amazon EventBridge, which is the better trigger for real-time alerts than a scheduled script. The guide to publish an SNS message with AWS SDK v3 helps if you forward them yourself. For a count without fetching details, GetFindingsStatistics with GroupBy set to FINDING_TYPE returns totals per type.

Troubleshooting

  • A Region errors with an opt-in or authorization message. The Region is enabled for the account but blocked by a service control policy, or the profile’s credentials aren’t valid there. Pass --regions to skip it.
  • Findings you expect are missing. They may be archived, possibly by a suppression rule, or older than your --days window. Drop --days and check the filters in the GuardDuty console.
  • Member accounts’ findings don’t appear. You’re running from a member account. Use the delegated administrator account.
  • Access denied on ListFindings. The guide to troubleshoot AWS IAM access denied errors walks through identity policies, SCPs and permission boundaries.

Ask ChatWithCloud instead

During an incident you may just want an answer: ask ChatWithCloud “What high-severity GuardDuty findings are open in eu-west-1?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the findings in plain English; the guide to analyze your AWS security posture with an AI CLI shows more questions like it. Generated code runs without a confirmation step, so read the ChatWithCloud security model and use a read-only profile.

Frequently asked questions

What severity is high in GuardDuty?

High is 7.0 to 8.9. Critical is 9.0 to 10.0, medium 4.0 to 6.9 and low 1.0 to 3.9.

How do I list GuardDuty findings from all Regions?

GuardDuty keeps findings per Region, so loop over Regions, call ListDetectors in each, then ListFindings and GetFindings with that detector ID. A delegated administrator account sees member findings, but still per Region.

How long does GuardDuty keep findings?

90 days. Export findings to an S3 bucket to keep them longer.

How do I exclude archived GuardDuty findings?

Add service.archived with Equals ["false"] to the finding criteria. Without it, ListFindings returns archived and active findings.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud