DEV Community

Applying SAST to a PHP Application with Psalm's Taint Analysis

What is SAST, in one paragraph?

Static Application Security Testing (SAST) analyzes the source code of an application - not the running app - looking for patterns that lead to vulnerabilities. Think of it as a security-focused code review performed by a machine: it runs in seconds, it never gets tired, and it can be wired into your CI pipeline so every commit is inspected before it reaches production. This is the "shift-left" idea at the heart of DevSecOps: find the bug when it costs 5 minutes to fix, not when it is being exploited.

Why Psalm?

Psalm, created by Vimeo, is best known as a PHP type checker, but since version 3 it ships a powerful taint analysis engine. Taint analysis tracks how untrusted data (a source, like $_GET) flows through the program until it reaches a dangerous function (a sink, like an SQL query or echo). If tainted data reaches a sink without passing through a sanitizer, Psalm raises an error and prints the entire data-flow path. I chose it because it is open source, PHP-native, extremely fast, and produces SARIF reports that integrate natively with GitHub Code Scanning.

The victim: a small workshop app

The demo simulates two endpoints of a vehicle-workshop management system. Here is the vulnerable search endpoint:

<?php
// src/vulnerable/search.php
$pdo = new PDO('mysql:host=localhost;dbname=workshop', 'app', 'secret');
$term = $_GET['q']; // tainted source
$stmt = $pdo->query("SELECT plate, owner, model FROM vehicles WHERE plate LIKE '%$term%'"); // SQL sink ๐Ÿ’ฅ
echo "<h2>Results for: " . $term . "</h2>"; // HTML sink ๐Ÿ’ฅ
foreach ($stmt as $row) {
    echo "<li>{$row['plate']} - {$row['owner']} ({$row['model']})</li>";
}

Two classic OWASP Top 10 issues in 15 lines: the query string is concatenated into SQL (A03: Injection) and echoed back raw (XSS). A third endpoint, report.php, reads a file whose name comes from $_GET['file'] - textbook Path Traversal:

$file = $_GET['file']; // tainted source
header('Content-Type: application/pdf');
readfile('/var/app/reports/' . $file); // file sink ๐Ÿ’ฅ
// ?file=../../etc/passwd

Step 1 - Install and configure Psalm

composer require --dev vimeo/psalm
vendor/bin/psalm --init src 4

--init generates a psalm.xml config pointing at the src directory with error level 4 (relaxed typing - we only care about security here).

Step 2 - Run the taint analysis

vendor/bin/psalm --taint-analysis --no-cache

Psalm answers in about two seconds (output trimmed):

ERROR: TaintedSql at src/vulnerable/search.php:17:5
Detected tainted SQL (see https://psalm.dev/244)
  $_GET['q'] - src/vulnerable/search.php:14:9
  $term - src/vulnerable/search.php:14:1
  concat - src/vulnerable/search.php:17:5
  call to PDO::query - src/vulnerable/search.php:16:15

ERROR: TaintedHtml at src/vulnerable/search.php:20:6
Detected tainted HTML (see https://psalm.dev/245)
  $_GET['q'] โ†’ $term โ†’ concat โ†’ echo

ERROR: TaintedFile at src/vulnerable/report.php:15:10
Detected tainted file handling (see https://psalm.dev/255)
  $_GET['file'] โ†’ $file โ†’ concat โ†’ call to readfile

This is the killer feature: Psalm does not just say "line 17 is dangerous" - it reconstructs the full path the attacker-controlled value travels, from the superglobal to the sink. In a real codebase that flow might cross five files and three function calls; Psalm follows it anyway.

Step 3 - Fix the code and watch the errors disappear

The remediation is exactly what the taint model expects: cut the flow or sanitize it.

<?php
// src/fixed/search.php
$term = (string)($_GET['q'] ?? '');

// SQLi fix: prepared statement - data never touches the SQL grammar
$stmt = $pdo->prepare('SELECT plate, owner, model FROM vehicles WHERE plate LIKE :term');
$stmt->execute([':term' => '%' . $term . '%']);

// XSS fix: encode before it reaches HTML
$safeTerm = htmlspecialchars($term, ENT_QUOTES, 'UTF-8');
echo "<h2>Results for: {$safeTerm}</h2>";

For the path traversal, the fixed version never lets the user control a path at all - it validates a strict report ID (RPT-2026-00042) with a regex and maps it internally.

Re-running the scan:

------------------------------
No errors found!
------------------------------
Psalm was able to infer types for 100% of the codebase

Psalm knows PDO::prepare + bound parameters and htmlspecialchars are sanitizing boundaries, so the taint chain is broken and the errors vanish.

Step 4 - Automate it: SAST on every push with GitHub Actions

A scan you run manually is a scan you will eventually forget. This workflow runs the taint analysis on every push and pull request, fails the build if anything is found, and uploads a SARIF report so findings show up in GitHub's Security โ†’ Code scanning tab as inline PR annotations:

# .github/workflows/sast-psalm.yml
name: SAST - Psalm Taint Analysis
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
permissions:
  contents: read
  security-events: write
jobs:
  psalm-taint-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: composer
      - run: composer install --no-progress --prefer-dist
      - run: vendor/bin/psalm --taint-analysis --no-cache --report=results.sarif
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: results.sarif

The result: if a teammate opens a PR reintroducing string concatenation into a query, the pipeline goes red, the finding is annotated on the exact line of the diff, and the merge is blocked. That is DevSecOps in practice - security as a gate, not as an afterthought.

Limitations worth knowing

No SAST tool is magic. Psalm's taint analysis can produce false positives (e.g., a custom sanitizer it does not recognize - you can teach it with @psalm-taint-escape annotations) and false negatives (dynamic constructs like $$var or eval confuse static analysis). It also cannot find business-logic flaws, misconfigurations or vulnerable dependencies - those need DAST, SCA and human review. SAST is one layer of defense, not the whole wall.

Conclusion

In under ten minutes we installed an open-source SAST tool, found three real OWASP Top 10 vulnerability classes with full data-flow traces, fixed them, and locked the door with a CI pipeline. If your project touches PHP, there is very little excuse not to have psalm --taint-analysis running on every commit.

Demo repository (code + CI workflow): https://github.com/GianfrancoArocutipa/psalm-sast-demo

Comments

No comments yet. Start the discussion.