Attack Surface Mapping Techniques

Uncharted entry points are the precondition for every successful breach: attackers enumerate what defenders haven’t. Attack surface mapping is the systematic identification and cataloging of all potential entry points, data flows, and external dependencies within a web application — converting invisible exposure into a versioned, auditable inventory. Without it, threat modeling operates on assumptions rather than facts. This guide is part of Threat Modeling Fundamentals & Methodology and covers how to combine manual architectural analysis with automated discovery pipelines to keep pace with ephemeral cloud environments. The techniques here feed directly into defining trust boundaries across service edges and inform STRIDE threat identification at each architectural layer.

Threat Anatomy

An attacker’s enumeration phase mirrors the defender’s mapping process — the difference is timing. Before exploitation, adversaries probe DNS records, crawl API schemas, scan cloud-provider IP ranges, and fingerprint third-party integrations. Each undiscovered asset is a potential pivot point that bypasses perimeter controls entirely.

The MITRE ATT&CK framework categorises this reconnaissance under T1595 (Active Scanning) and T1590 (Gather Victim Network Information). Specific sub-techniques — T1595.001 (Scanning IP Blocks), T1595.002 (Vulnerability Scanning), and T1590.004 (Network Topology) — map directly to the gaps that incomplete attack surface inventories leave open.

The attack sequence in a poorly mapped environment follows a predictable pattern:

Attacker Enumeration Sequence for Unmapped Attack Surface Flow diagram showing how an attacker moves from external reconnaissance through unmapped shadow assets and undocumented API endpoints to reach internal data stores, bypassing perimeter controls that only guard known assets. EXTERNAL SHADOW / UNMAPPED INTERNAL Attacker T1595 Active Scan Shadow IT Instance Unmanaged cloud resource Undocumented API Missing from OpenAPI spec Internal Data Store Protected — but reachable Perimeter controls guard known assets only ① Bypass ② Pivot through shadow asset ③ Exploit undocumented route ④ Reach internal data

The three highest-risk discovery gaps that enable this sequence are:

Discovery Gap MITRE Technique Attacker Capability Gained
Unregistered cloud instances (shadow IT) T1583.006 (Web Services) Bypasses WAF and perimeter controls entirely
Undocumented internal API routes T1590.002 (DNS / IP) Horizontal movement to privileged data
Stale third-party webhook receivers T1190 (Exploit Public App) Supply-chain injection into trusted pipelines
Unmapped inter-service channels T1021 (Remote Services) Lateral movement within service mesh

For the automated attack surface discovery with OWASP ZAP workflow that catches runtime gaps specifically, see the dedicated in-depth guide.

Prerequisites & Scope

Before beginning a mapping exercise, the following must be in place:

  • Infrastructure-as-Code source of truth. Terraform state files, CloudFormation templates, or Pulumi stacks must be accessible and reflect current deployed state. Mappings derived from stale IaC are unreliable.
  • API specification access. OpenAPI 3.x or Swagger 2.x specs for all owned APIs — including internal service-to-service interfaces, not just public-facing ones.
  • Dependency manifests. package.json, pom.xml, go.mod, or requirements.txt for every service in scope.
  • Network topology documentation. VPC peering diagrams, load balancer configs, DNS zone files, and CDN routing rules.
  • Defined scope boundary. Agreement on what is in-scope: first-party services only, or inclusive of third-party SaaS integrations and outbound data pipelines.
  • Staging environment access. Read-only access to a production-equivalent staging environment for dynamic scanning.

Mitigation Architecture

The secure mapping posture treats discovery as a continuous, automated pipeline rather than a periodic audit. The contrast between a periodic-audit approach and a continuous-discovery approach determines whether the inventory reflects current reality.

Aspect Periodic Audit (Vulnerable Pattern) Continuous Discovery (Hardened Pattern)
Trigger Quarterly manual review Every PR merge + nightly scheduled scan
Scope Known assets only Known + dynamic enumeration of shadow assets
IaC drift detection None Schema diff gates that fail builds on unauthorized new resources
Third-party integrations Manually cataloged at onboarding Webhook registry + outbound traffic analysis in staging
Audit trail Point-in-time snapshot Append-only versioned inventory with cryptographic signing
Compliance evidence Generated on demand Continuous, query-able at any time

The hardened pattern embeds discovery at three integration points: the IaC provisioning gate (pre-deploy), the CI/CD pipeline (post-merge), and a nightly scheduled scan (drift detection between deployments).

Step-by-Step Implementation

Step 1: Define Asset Classification Schema

Establish exposure tiers before any discovery runs. Every asset discovered must immediately receive a classification — without this, discovery outputs become unactionable lists.

OWASP ASVS V1.1.1 / NIST SP 800-53 CM-8

from enum import Enum
from dataclasses import dataclass, field
from typing import Optional

class ExposureLevel(str, Enum):
    PUBLIC = "Public"          # Reachable from the open internet
    PARTNER = "Partner"        # Restricted to allowlisted external IPs/tokens
    INTERNAL = "Internal"      # Reachable only within VPC/service mesh
    CONFIDENTIAL = "Confidential"  # Restricted data tier — explicit allow-list only

@dataclass
class AssetRecord:
    resource_type: str
    identifier: str
    exposure_level: ExposureLevel
    network_cidr: list[str] = field(default_factory=list)
    tags: dict[str, str] = field(default_factory=dict)
    auth_required: bool = True
    owner_team: Optional[str] = None
    discovery_source: str = "iac_parse"  # iac_parse | openapi | dynamic | manual

    def is_high_risk(self) -> bool:
        """Flag assets needing immediate threat model coverage."""
        return (
            self.exposure_level == ExposureLevel.PUBLIC
            and not self.auth_required
        )

Step 2: Parse IaC State for Infrastructure Assets

Extract resource identifiers, CIDR blocks, and IAM roles from Terraform state. This gives a deterministic view of provisioned infrastructure before any runtime analysis.

NIST SP 800-53 CA-7 (Continuous Monitoring)

import json
import sys
from pathlib import Path

def extract_public_assets(tfstate_path: str) -> list[dict]:
    """Parse Terraform state to identify public-facing infrastructure."""
    with open(tfstate_path, "r") as f:
        state = json.load(f)

    assets: list[dict] = []
    target_types = {
        "aws_security_group",
        "aws_lb",
        "aws_api_gateway_stage",
        "aws_lambda_function_url",
        "aws_cloudfront_distribution",
    }

    for resource in state.get("resources", []):
        r_type = resource.get("type", "")
        if r_type not in target_types:
            continue

        attrs = resource.get("instances", [{}])[0].get("attributes", {})
        is_public = bool(
            attrs.get("publicly_accessible")
            or attrs.get("invoke_mode") == "BUFFERED"
            or attrs.get("ingress")
        )

        assets.append({
            "resource_type": r_type,
            "identifier": attrs.get("id", "unknown"),
            "exposure_level": "Public" if is_public else "Internal",
            "network_cidr": attrs.get("cidr_blocks", []),
            "tags": attrs.get("tags", {}),
            "auth_required": not attrs.get("authorization_type") == "NONE",
        })

    return assets

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python iac_extractor.py <tfstate.json>", file=sys.stderr)
        sys.exit(1)
    try:
        inventory = extract_public_assets(sys.argv[1])
        print(json.dumps(inventory, indent=2))
    except (json.JSONDecodeError, KeyError) as e:
        print(f"Extraction failed: {e}", file=sys.stderr)
        sys.exit(1)

Step 3: Enumerate API Routes from OpenAPI Specs

Parse OpenAPI definitions to catalog every route, HTTP method, and authentication requirement. Flag routes present in the deployed service but absent from the spec — these undocumented endpoints are high-priority mapping targets.

OWASP ASVS V13.1.1 (API Surface Security)

import * as fs from "fs";
import * as yaml from "js-yaml";
import * as path from "path";

interface EndpointRecord {
  route: string;
  method: string;
  auth_required: boolean;
  auth_schemes: string[];
  parameters: string[];
  stride_category: string;
}

function strideCategory(method: string, auth_required: boolean): string {
  if (!auth_required && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
    return "Tampering";
  }
  if (!auth_required && method === "GET") {
    return "Information Disclosure";
  }
  return "Review Required";
}

function enumerateEndpoints(specPath: string): EndpointRecord[] {
  const raw = fs.readFileSync(path.resolve(specPath), "utf8");
  const spec = yaml.load(raw) as Record<string, unknown>;
  const paths = (spec.paths ?? {}) as Record<string, Record<string, unknown>>;
  const results: EndpointRecord[] = [];
  const httpMethods = new Set(["get","post","put","delete","patch","head","options"]);

  for (const [route, methods] of Object.entries(paths)) {
    for (const [method, details] of Object.entries(methods)) {
      if (!httpMethods.has(method.toLowerCase())) continue;
      const op = details as Record<string, unknown>;
      const securityEntries = (op.security ?? []) as Array<Record<string, unknown>>;
      const authSchemes = securityEntries.flatMap((s) => Object.keys(s));
      const auth_required = authSchemes.length > 0;
      const params = ((op.parameters ?? []) as Array<{name: string}>).map((p) => p.name);

      results.push({
        route,
        method: method.toUpperCase(),
        auth_required,
        auth_schemes: auth_required ? authSchemes : ["None"],
        parameters: params,
        stride_category: strideCategory(method.toUpperCase(), auth_required),
      });
    }
  }
  return results;
}

const specFile = process.argv[2] ?? "openapi.yaml";
try {
  const results = enumerateEndpoints(specFile);
  console.log(JSON.stringify(results, null, 2));
} catch (err) {
  console.error(`Failed to parse spec: ${(err as Error).message}`);
  process.exit(1);
}

Step 4: Integrate Discovery into CI/CD

Gate deployments on inventory diff validation. The pipeline runs discovery on every merge to main and compares output against the baseline — any unauthorized asset creation fails the build.

SOC 2 CC6.1 / NIST SP 800-53 SI-7

name: Attack Surface Discovery Pipeline
on:
  push:
    branches: [main]
  schedule:
    - cron: "0 2 * * *"  # Nightly drift detection

permissions:
  contents: read
  id-token: write  # OIDC for AWS credential federation

jobs:
  surface-mapping:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Extract IaC Inventory
        run: |
          python scripts/iac_extractor.py infra/terraform.tfstate \
            > artifacts/iac-inventory.json

      - name: Enumerate API Routes
        run: |
          npx ts-node scripts/enumerate-endpoints.ts openapi.yaml \
            > artifacts/api-inventory.json

      - name: Diff Against Baseline
        run: |
          python scripts/inventory_diff.py \
            --baseline inventories/baseline.json \
            --current artifacts/iac-inventory.json \
            --fail-on-new-public   # Fail build if new public assets appear
            --fail-on-auth-removed # Fail build if auth is removed from existing route

      - name: Schema Validation
        run: |
          npx ajv-cli validate \
            -s schemas/asset-inventory.schema.json \
            -d artifacts/iac-inventory.json

      - name: Upload Signed Inventory
        env:
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          # Sign with cosign for tamper-evident audit trail
          cosign sign-blob --key env://COSIGN_KEY artifacts/iac-inventory.json \
            > artifacts/iac-inventory.json.sig
          curl -sX POST https://threat-registry.internal/api/v1/assets \
            -H "Authorization: Bearer $REGISTRY_TOKEN" \
            -H "Content-Type: application/json" \
            -d @artifacts/iac-inventory.json

Edge Cases & Bypass Patterns

Incomplete mapping consistently leaves the same categories of assets undiscovered. Each represents a class of bypass that renders perimeter controls ineffective.

1. Ephemeral compute — serverless and container-on-demand

Lambda function URLs, Cloud Run services, and Azure Container Apps can be provisioned with public HTTP endpoints in seconds. Unless your IaC scanner parses aws_lambda_function_url and google_cloud_run_service resource types explicitly, these appear as gaps. Enforce IaC-only provisioning via SCPs (Service Control Policies) that deny lambda:CreateFunctionUrlConfig on resources without the iac-managed=true tag.

2. Developer preview and staging environments promoted to production traffic

Staging endpoints created for feature branches often receive production DNS entries and TLS certificates but lack WAF coverage or authentication enforcement. Map all certificate SANs (Subject Alternative Names) via Certificate Transparency logs — any SAN not in the official asset register is an undiscovered entry point.

3. Outbound webhooks as inbound attack vectors

Third-party SaaS platforms that push events to your application via webhook are inbound HTTP endpoints from your perimeter’s perspective. They are rarely documented in OpenAPI specs and frequently omit signature verification. Catalog all webhook receiver routes by grepping for framework-specific patterns (@app.route, router.post, app.post) and cross-referencing against the OpenAPI spec.

4. Internal microservice routes exposed via misconfigured service mesh

When trust boundaries between services rely solely on namespace isolation, a misconfigured Istio VirtualService or Kubernetes NetworkPolicy gap can expose internal-only gRPC and REST routes externally. Map all VirtualService and ServiceEntry resources as part of the surface inventory — not just Ingress and Service objects of type LoadBalancer.

5. SDK and dependency-introduced endpoints

Monitoring SDKs, feature flag clients, and APM agents frequently open listening ports or register HTTP routes for health checks, debug endpoints, or configuration polling. Dependencies like Spring Boot Actuator, Django Debug Toolbar, and Prometheus client libraries expose /actuator, /debug, and /metrics routes that must be enumerated in the asset inventory and explicitly disabled or access-restricted in production.

Automated Testing & CI Validation

The following test verifies that the inventory diff gate correctly detects new public assets and raises an error rather than silently passing.

import json
import pytest
from scripts.inventory_diff import compute_diff, DiffResult

BASELINE = [
    {"identifier": "lb-abc123", "exposure_level": "Public", "auth_required": True},
    {"identifier": "sg-def456", "exposure_level": "Internal", "auth_required": True},
]

def test_detects_new_public_asset():
    current = BASELINE + [
        {"identifier": "lambda-url-xyz", "exposure_level": "Public", "auth_required": False}
    ]
    result: DiffResult = compute_diff(baseline=BASELINE, current=current)
    assert len(result.new_public_assets) == 1
    assert result.new_public_assets[0]["identifier"] == "lambda-url-xyz"
    assert result.should_fail_build is True

def test_detects_auth_removal():
    current = [
        {"identifier": "lb-abc123", "exposure_level": "Public", "auth_required": False},
        {"identifier": "sg-def456", "exposure_level": "Internal", "auth_required": True},
    ]
    result: DiffResult = compute_diff(baseline=BASELINE, current=current)
    assert len(result.auth_downgraded) == 1
    assert result.should_fail_build is True

def test_passes_on_identical_inventory():
    result: DiffResult = compute_diff(baseline=BASELINE, current=BASELINE)
    assert result.should_fail_build is False
    assert result.new_public_assets == []
    assert result.auth_downgraded == []

Compliance Mapping

Framework Control Requirement Satisfied By
SOC 2 CC6.1 Logical and physical access controls with documented network boundaries Versioned asset register with exposure levels and auth requirements per resource
SOC 2 CC7.1 Monitoring of system components CI/CD diff gate + nightly scan producing audit-trail entries
OWASP ASVS V1.1.1 Verify high-level architecture documentation and security analysis is updated IaC-derived inventory committed to version control on every deploy
OWASP ASVS V13.1.1 Verify all application components are identified and secured OpenAPI enumeration script producing route-level auth coverage map
NIST SP 800-53 CM-8 Information System Component Inventory Append-only signed inventory with resource type, exposure, and owner
NIST SP 800-53 CA-7 Continuous Monitoring Scheduled nightly scan + build gate on every push to main
ISO 27001 A.8.1.2 Inventory of Assets Version-controlled asset register with classification, ownership, and exposure tags
PCI-DSS Req 1.2 Network boundary controls Validated CIDR documentation, segmentation diagrams, and third-party dependency log

Common Pitfalls Checklist

Frequently Asked Questions

How frequently should attack surface mapping run in agile environments?

Continuous mapping via CI/CD hooks is mandatory — every merge to main must trigger an inventory diff. Full architectural reviews should occur quarterly or after major infrastructure changes. The nightly scheduled scan catches drift that occurs outside the deployment pipeline: manual console actions, external SaaS configuration changes, and DNS record updates made outside IaC.

What is the difference between attack surface mapping and vulnerability scanning?

Mapping identifies and catalogs all potential entry points, assets, and data flows to establish scope. Vulnerability scanning tests those identified points for known weaknesses or misconfigurations. Mapping must precede scanning: running a scanner against an incomplete asset inventory produces false confidence because the scanner can only test what it is pointed at. Undiscovered assets are unreachable by design.

Can automated discovery tools fully replace manual architectural review?

No. Automated tools excel at runtime enumeration, dependency tracking, and configuration drift detection. Manual review remains essential for understanding business logic flows, implicit trust relationships between services, undocumented legacy integrations, and threat prioritization decisions that require organizational context. The correct model is automated discovery as the baseline, with human review focused on the diff and the highest-risk assets.

How does attack surface mapping satisfy SOC 2 and OWASP ASVS requirements?

SOC 2 CC6.1 requires documented network boundaries and endpoint authentication matrices — the asset register produced by IaC parsing and OpenAPI enumeration satisfies this directly. OWASP ASVS V1.1.1 requires verified architecture documentation updated on changes — the CI/CD gate that commits a new inventory artifact on every deploy provides the versioned evidence. NIST SP 800-53 CM-8 is satisfied by the append-only signed inventory with resource type, exposure, and owner fields.


Related

Up: Threat Modeling Fundamentals & Methodology