SOX Compliance Testing Guide: IT Controls Testing for Sarbanes-Oxley

SOX Compliance Testing Guide: IT Controls Testing for Sarbanes-Oxley

SOX (Sarbanes-Oxley Act) Section 404 requires public companies to document and test internal controls over financial reporting. For software teams, this means testing IT General Controls (ITGCs) — access controls, change management, and data integrity controls — that support financial systems.

This guide covers what SOX compliance means for software testing, what controls to test, and how to build automated testing into your workflow.

What SOX Means for Software Teams

SOX compliance is primarily a concern for publicly traded companies (and their subsidiaries). The key sections affecting software:

  • Section 302: CEO/CFO certify the accuracy of financial reports
  • Section 404: Management must document and test internal controls over financial reporting
  • IT General Controls (ITGCs): The controls your auditors care about

ITGCs fall into three areas:

  1. Access management: Who can access financial systems and with what privileges
  2. Change management: How code changes to financial systems are reviewed and deployed
  3. Computer operations: Backup, recovery, job scheduling, monitoring

Change Management Controls

Change management is usually the highest-scrutiny area in SOX audits. Every change to a financial system must be:

  • Authorized before development
  • Tested before deployment
  • Reviewed by someone other than the developer
  • Deployed through a controlled process

What auditors look for:

  • Segregation of duties: developers cannot deploy their own code to production
  • No emergency changes without documented approval
  • Complete audit trail of what changed, when, by whom

Testing the change management pipeline:

# .github/workflows/sox-change-controls.yml
name: SOX Change Management Gates

on:
  pull_request:
    branches: [main]

jobs:
  required-reviews:
    runs-on: ubuntu-latest
    steps:
      - name: Verify required approvals
        uses: actions/github-script@v7
        with:
          script: |
            const reviews = await github.rest.pulls.listReviews({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
            });
            
            const approvals = reviews.data.filter(r => r.state === 'APPROVED');
            const approvedBy = approvals.map(r => r.user.login);
            
            // Must have at least one approval from non-author
            const prAuthor = context.payload.pull_request.user.login;
            const nonAuthorApprovals = approvedBy.filter(u => u !== prAuthor);
            
            if (nonAuthorApprovals.length < 1) {
              core.setFailed('SOX gate: PR requires approval from a reviewer other than the author');
            }
            
            // Log for audit trail
            console.log(`PR #${context.issue.number} approved by: ${nonAuthorApprovals.join(', ')}`);

Testing deployment controls:

// tests/sox/change-management.test.js

test('production deployments require approved PRs', async () => {
  // Query deployment history
  const recentDeployments = await getProductionDeployments({ days: 30 });
  
  for (const deployment of recentDeployments) {
    // Every deployment should reference an approved PR
    expect(deployment.pullRequestId).toBeDefined();
    
    const pr = await getPullRequest(deployment.pullRequestId);
    expect(pr.approvals.length).toBeGreaterThan(0);
    
    // Author cannot be the approver
    const authorIsApprover = pr.approvals.some(a => a.userId === pr.authorId);
    expect(authorIsApprover).toBe(false);
  }
});

test('no direct commits to main branch', async () => {
  const mainBranchCommits = await getCommits({ branch: 'main', days: 90 });
  
  // All commits should come from merged PRs, not direct pushes
  for (const commit of mainBranchCommits) {
    const pullRequest = await getPRForCommit(commit.sha);
    expect(pullRequest).not.toBeNull(
      `Commit ${commit.sha} by ${commit.author} was a direct push — no PR found`
    );
  }
});

test('emergency changes are documented', async () => {
  const emergencyDeployments = await getDeployments({ type: 'emergency', days: 90 });
  
  for (const deployment of emergencyDeployments) {
    // Emergency changes must have post-hoc documentation
    expect(deployment.emergencyApprovalId).toBeDefined();
    expect(deployment.businessJustification).toBeDefined();
    expect(deployment.postChangeReview).not.toBeNull();
  }
});

Access Management Controls

SOX requires that access to financial systems follows least privilege and is regularly reviewed.

What to test:

// tests/sox/access-controls.test.js

test('financial data access is role-restricted', async ({ request }) => {
  // Verify that standard users cannot access financial tables
  const response = await request.get('/api/financial/general-ledger', {
    headers: { Authorization: `Bearer ${standardUserToken}` }
  });
  expect(response.status()).toBe(403);
  
  // Finance team can access
  const financeResponse = await request.get('/api/financial/general-ledger', {
    headers: { Authorization: `Bearer ${financeUserToken}` }
  });
  expect(financeResponse.ok()).toBe(true);
});

test('developers cannot access production financial data', async ({ request }) => {
  // Engineers should be blocked from production financial records
  const response = await request.get('/api/financial/transactions', {
    headers: { Authorization: `Bearer ${developerToken}` }
  });
  expect(response.status()).toBe(403);
});

test('access logs capture all financial record operations', async ({ request }) => {
  const transactionId = 'TXN-TEST-001';
  
  await request.get(`/api/financial/transactions/${transactionId}`, {
    headers: { Authorization: `Bearer ${financeUserToken}` }
  });
  
  const log = await getAccessLog({ resourceId: transactionId });
  expect(log).toMatchObject({
    action: 'read',
    userId: expect.any(String),
    timestamp: expect.any(String),
    ipAddress: expect.any(String),
    success: true,
  });
});

test('privileged access review identifies stale accounts', async () => {
  // Users who haven't logged in for 90+ days should be flagged for review
  const accounts = await getFinancialSystemAccounts();
  
  const staleAccounts = accounts.filter(a => {
    const lastLogin = new Date(a.lastLoginAt);
    const daysSinceLogin = (Date.now() - lastLogin.getTime()) / (1000 * 60 * 60 * 24);
    return daysSinceLogin > 90;
  });
  
  // This test documents stale accounts for auditor review
  // In a live system, these would trigger an automated review workflow
  expect(staleAccounts.map(a => ({
    userId: a.id,
    lastLogin: a.lastLoginAt,
    roles: a.roles,
  }))).toMatchSnapshot(); // snapshot captures current state for audit record
});

Data Integrity Controls

Financial data must be accurate and unchanged from source to report.

// tests/sox/data-integrity.test.js

test('financial totals reconcile across systems', async () => {
  // Transactions in the source system should match the general ledger
  const sourceTotal = await getSourceSystemTotal({ month: 'current' });
  const ledgerTotal = await getGeneralLedgerTotal({ month: 'current' });
  
  // Allow for timing differences (e.g., pending transactions)
  const variance = Math.abs(sourceTotal - ledgerTotal);
  const variancePercent = variance / sourceTotal;
  
  expect(variancePercent).toBeLessThan(0.001); // <0.1% variance
});

test('financial records cannot be deleted', async ({ request }) => {
  // Completed financial transactions should be immutable
  const response = await request.delete('/api/financial/transactions/TXN-001', {
    headers: { Authorization: `Bearer ${adminToken}` }
  });
  
  expect(response.status()).toBe(405); // Method Not Allowed
});

test('financial record modifications are versioned', async ({ request }) => {
  const txnId = 'TXN-TEST-002';
  
  // Modify a pending transaction
  await request.patch(`/api/financial/transactions/${txnId}`, {
    headers: { Authorization: `Bearer ${financeManagerToken}` },
    data: { amount: 1500, note: 'Corrected amount' }
  });
  
  // Original should be preserved in audit trail
  const history = await getTransactionHistory(txnId);
  expect(history.length).toBeGreaterThan(1);
  expect(history[0].amount).toBe(1000); // original
  expect(history[1].amount).toBe(1500); // updated
  expect(history[1].modifiedBy).toBeDefined();
});

test('reporting extracts match source data', async () => {
  // Run a report and verify it matches the source query
  const reportData = await generateFinancialReport({ period: 'Q1', format: 'json' });
  const sourceQuery = await querySourceData({ period: 'Q1' });
  
  expect(reportData.totalRevenue).toBe(sourceQuery.revenue);
  expect(reportData.totalExpenses).toBe(sourceQuery.expenses);
  expect(reportData.netIncome).toBe(sourceQuery.revenue - sourceQuery.expenses);
});

Automated SOX Evidence Collection

SOX auditors want evidence. Automate evidence collection where possible:

// scripts/collect-sox-evidence.js

async function collectAccessReviewEvidence() {
  const accounts = await getFinancialSystemAccounts();
  
  const evidence = {
    collectionDate: new Date().toISOString(),
    collectedBy: 'automated-sox-collector',
    accounts: accounts.map(a => ({
      userId: a.id,
      name: a.name,
      roles: a.roles,
      lastLoginAt: a.lastLoginAt,
      createdAt: a.createdAt,
      isActive: a.isActive,
    })),
    summary: {
      total: accounts.length,
      active: accounts.filter(a => a.isActive).length,
      stale: accounts.filter(a => isStale(a)).length,
    },
  };
  
  // Write to evidence folder
  await writeEvidenceFile('access-review', evidence);
  return evidence;
}

async function collectChangeManagementEvidence(period) {
  const deployments = await getProductionDeployments(period);
  
  const evidence = {
    collectionDate: new Date().toISOString(),
    period,
    deployments: deployments.map(d => ({
      deploymentId: d.id,
      deployedAt: d.deployedAt,
      deployedBy: d.deployedBy,
      pullRequestId: d.pullRequestId,
      approvedBy: d.approvals.map(a => a.approverName),
      testsPassedAt: d.testsPassedAt,
    })),
    controls: {
      allDeploymentsApproved: deployments.every(d => d.approvals.length > 0),
      segregationOfDutiesViolations: deployments.filter(d =>
        d.approvals.some(a => a.approverId === d.deployedBy)
      ),
    },
  };
  
  await writeEvidenceFile('change-management', evidence);
  return evidence;
}

Run these scripts quarterly to produce evidence packages for auditors.

SOX Testing in the SDLC

SOX controls must be tested continuously, not just at audit time:

Per-PR:

  • Segregation of duties check (author cannot approve their own PR)
  • Branch protection rules enforced

Per deployment:

  • Deployment approval logged
  • Automated test results recorded

Daily:

  • Access review alerts for new privileged accounts
  • Reconciliation job between financial systems

Quarterly:

  • Full access review evidence collection
  • Change management evidence package
  • Penetration testing results for financial systems

The goal is that when auditors ask "show me evidence that control X operated effectively for the past year," you can produce automated evidence without a manual scramble.

Read more