Analyzing ScoutSuite

Source: nccgroup/ScoutSuite @ 7909f2f

How It Works

ScoutSuite is a read-only multi-cloud security posture assessment tool. It authenticates to a cloud provider (AWS, Azure, GCP, Alibaba Cloud, Oracle, DigitalOcean, or Kubernetes), enumerates resource configurations via provider APIs, then evaluates those configurations against a library of JSON-encoded security rules. The output is a self-contained HTML report that opens locally in a browser—no server infrastructure required.

The system operates in a clear pipeline: authenticate → fetch → preprocess → analyze → postprocess → report. During the fetch phase, each provider’s services are gathered concurrently using Python asyncio. After fetching, a preprocessing step enriches the data with cross-service correlations (e.g., matching IAM roles to EC2 instances). The rule engine then walks the collected configuration tree, evaluating declarative conditions expressed as JSON path expressions and comparison operators. Findings are collected, optionally filtered through a user-supplied exceptions file, and rendered into a static HTML dashboard with drill-down capabilities.

Critically, ScoutSuite never modifies cloud resources. It is purely an inspection tool—making it safe to run against production environments without risk of side effects.

Architecture

The codebase is organized around a provider-plugin architecture with a shared core:

LayerLocationResponsibility
Core engineScoutSuite/core/CLI parsing, rule processing engine, condition evaluation, exceptions
ProvidersScoutSuite/providers/{aws,azure,gcp,...}/Per-cloud authentication, API facades, resource fetchers
RulesScoutSuite/providers/{cloud}/rules/JSON findings, filters, reusable conditions
OutputScoutSuite/output/HTML report generation, JSON/SQLite encoding

Each provider follows an identical structure:

providers/<cloud>/
├── authentication_strategy.py   # Credential acquisition
├── provider.py                  # Provider class (extends BaseProvider)
├── facade/                      # Thin API wrappers (boto3, azure sdk, etc.)
│   └── base.py                  # Session/client management
├── resources/                   # Resource fetchers (extend base Resources class)
│   └── <service>/base.py        # Hierarchical resource tree builder
├── services.py                  # ServicesConfig — wires facades to resources
├── rules/
│   ├── findings/                # Individual security rules (JSON)
│   ├── filters/                 # Display filter rules (JSON)
│   ├── conditions/              # Reusable condition snippets
│   └── rulesets/default.json    # Which rules to enable + severity levels
└── metadata.json                # UI metadata for report rendering

The resource fetching uses a recursive CompositeResources pattern. Each resource type is a dict subclass with a fetch_all() coroutine. CompositeResources declare _children — nested resource types — and fetch them concurrently via asyncio.wait(). This creates a tree: EC2 → Regions → VPCs → SecurityGroups → Rules → Protocols → Ports.

After fetching, the object tree is serialized to a plain dict (report.encoder.to_dict(self.services)) because the legacy rule engine and HTML report expect dictionary structures, not objects.

The Spine

A complete scan flows through these stages in __main__.py:

  1. run_from_cli() — Parses CLI arguments, delegates to run() which sets up an asyncio event loop with a Throttler for API rate limiting and a ThreadPoolExecutor.

  2. _run() — The async orchestrator. In sequence:

    • Authenticate — get_authentication_strategy(provider) returns a provider-specific strategy; authenticate() yields a credentials object.
    • Create provider — get_provider() instantiates the provider class (e.g., AWSProvider), which builds a ServicesConfig with facades and resource fetchers.
    • Fetch — await cloud_provider.fetch() triggers concurrent fetch_all() for each service, recursively gathering all resources.
    • Preprocess — cloud_provider.preprocessing() enriches cross-service data (e.g., correlating IAM policies with S3 buckets, mapping security groups to ELBs).
    • Analyze (findings) — Ruleset loads the default ruleset JSON; ProcessingEngine.run() walks the config tree, evaluating each rule’s conditions via core/utils.py:recurse() and core/conditions.py:pass_conditions().
    • Analyze (filters) — A second pass with filters.json applies display filters using the same engine.
    • Exceptions — RuleExceptions.process() removes user-exempted findings.
    • Postprocess — Updates metadata counts, summary statistics, last-run info.
    • Report — ScoutReport.save() serializes results to JSON, copies static HTML/CSS/JS assets, and generates a self-contained HTML file.
  3. Exit codes follow a convention: 0 = success, 101 = auth failure, 104 = fetch failure, 106 = rule engine failure, 200 = success with handled errors.

Key Patterns

Declarative rule definitions. Rules are JSON files containing a dot-path (cloudtrail.regions.id.trails.id), a set of conditions with operators (equal, notEqual, empty, containString, isCrossAccount, olderThan, etc.), and metadata (description, rationale, remediation, CIS compliance references). The condition DSL is remarkably expressive—supporting and/or logic, dynamic value resolution via _GET_VALUE_AT_(), IP range injection via _IP_RANGES_FROM_FILE_(), and reusable condition includes via _INCLUDE_().

Provider as a dictionary. After fetching, the provider’s service tree is a deeply nested dict. This is both a strength (the rule engine can traverse any path generically) and a legacy constraint (all rule evaluation operates on raw dicts rather than typed objects).

Facade pattern for API isolation. Each cloud service has a facade/<service>.py that wraps SDK calls with error handling and optional region iteration. Resources depend on facades, not on SDK clients directly, making the resource layer testable.

Proprietary service plugin system. AWS services like Cognito, ECR, ECS, EKS, GuardDuty, and SSM are imported with try/except NameError blocks, suggesting an optional proprietary add-on model layered on top of the open-source core.

Metadata-driven callbacks. The metadata.json files define resource paths and callback functions. During preprocessing, _process_metadata_callbacks() walks the metadata and invokes named callbacks on the provider instance — a form of declarative cross-service data enrichment.

Non-Obvious Details

The rule engine operates on serialized dicts, not live objects

After fetch() completes, BaseProvider.fetch() calls report.encoder.to_dict(self.services) to flatten the Resources object tree back into plain dictionaries. This means the rich Resources subclasses with their facades are discarded before analysis. The rule engine (ProcessingEngine) only ever sees dict data.

_STRIPDOTS_ and _ARG_ are inline template macros

Rule definitions use string substitution patterns like _ARG_0_ for parameterized arguments and _STRIPDOTS_(value) to remove dots from strings. These are resolved during Rule.set_definition() via regex replacement on the raw JSON string before parsing. It’s a preprocessor embedded in the rule loader.

policyuniverse is a key dependency

The condition evaluator uses policyuniverse for AWS IAM policy analysis — expanding wildcards in action statements and testing policy containment. This enables rules like containAction and notContainAction without re-implementing ARN matching.

Two nearly identical recursive traversal methods

BaseProvider has both _go_to_and_do() and _new_go_to_and_do(). The difference is that _new_go_to_and_do supports multiple callbacks per target and resolves callback names as method attributes. The older _go_to_and_do is still used in AWSProvider.preprocessing(). This appears to be an incomplete refactor.

Python 3.10 compatibility hack

The __main__.py includes collections.Callable = collections.abc.Callable — a workaround for asyncio_throttle relying on the removed collections.Callable alias.

Rule versioning via versions key

A single rule file can define multiple variants through the versions key in the ruleset, each with different arguments and a key_suffix. This allows one rule definition to generate multiple findings (e.g., checking multiple port ranges from the same template).

Assessment

Strengths:

  • Remarkably extensible rule system. Adding a new security check is often just creating a JSON file — no Python required. The condition DSL covers most common scenarios (equality, regex, CIDR matching, date comparison, IAM policy analysis). 578 total findings across all providers (147 for AWS alone) is substantial coverage.
  • Clean provider abstraction. Adding a new cloud provider requires implementing authentication, a facade, resource fetchers, and rules — all following well-defined patterns. The codebase supports 7 providers (AWS, Azure, GCP, Alibaba, OCI, DigitalOcean, Kubernetes).
  • Safe by design. Read-only operation means zero risk to production environments.
  • Self-contained HTML output. The report is a single-page app with all data embedded, requiring no server or network access to review.

Concerns:

  • The dict-everywhere pattern is fragile. The rule engine traverses raw dictionaries with string paths. There’s no schema validation on fetched data, so API changes or missing keys cause cryptic failures deep in recurse().
  • Incomplete refactor. The coexistence of _go_to_and_do and _new_go_to_and_do, the dict serialization after fetch, and the to_dict() workaround all suggest an architectural migration that stalled partway through.
  • Tight coupling between AWSProvider and preprocessing. The AWSProvider.preprocessing() method is ~500 lines of cross-service correlation logic that would benefit from being data-driven (like the metadata callback system) rather than hand-coded.
  • No unit tests for rules. The tests/ directory covers core mechanics but individual finding rules are not automatically tested. A rule with a malformed path or condition will silently fail at runtime.

Recommendations:

  1. Validate fetched resource structures against schemas before rule evaluation.
  2. Complete the _new_go_to_and_do migration and remove the legacy traversal method.
  3. Add automated testing for rule definitions — at minimum, a lint pass that verifies all paths referenced in rules exist in the metadata.
  4. Consider typing the resource tree (at minimum with TypedDict) to enable static analysis.

  • analyzing-nuclei
  • analyzing-pacu
  • analyzing-bloodhound
  • analyzing-spiderfoot