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

php dev.to

TL;DR — I took a small, intentionally vulnerable PHP application (a vehicle-workshop search and report system), ran Psalm — an open-source static analysis tool listed in the OWASP Source Code Analysis Tools catalog — with taint analysis enabled, and it caught SQL Injection, XSS and Path Traversal without executing a single line of code. Then I automated the scan with GitHub Actions so vulnerable code can never be merged again. Full demo repo: https://github.com/GianfrancoArocutipa/psalm-sast-demo(#)

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>";
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 1 — Install and configure Psalm

composer require --dev vimeo/psalm
vendor/bin/psalm --init src 4
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(#)

Source: dev.to

arrow_back Back to Tutorials