DEV Community

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

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

Top comments (1)

Collapse
 
fabrizioperezperalta profile image
FabrizioPerezPeralta

Student: Fabrizio Salvador Elias Perez Peralta

Abstract & Observation: Why Developer Experience is the Secret Weapon in SAST
Abstract
In the article "Applying SAST to a PHP Application with Psalm's Taint Analysis," Gianfranco Arocutipa demonstrates how to effectively implement Static Application Security Testing in a vulnerable PHP environment. By leveraging Psalm—an open-source static analysis tool—the author illustrates how taint analysis can automatically trace untrusted user input (sources) to dangerous functions (sinks) without executing the code. The demonstration successfully detects critical OWASP Top 10 vulnerabilities, including SQL Injection, Cross-Site Scripting (XSS), and Path Traversal. Furthermore, the article highlights the importance of DevSecOps by automating the scan through GitHub Actions, ensuring that vulnerabilities are blocked at the Pull Request level via SARIF reports.

An Important Observation: Detection is Only Half the Battle; Workflow is Everything
While the technical capability of Psalm's taint analysis to reconstruct full data-flow paths is impressive, the most critical takeaway from this demonstration lies in the workflow integration.

When developing custom vulnerability analyzers, such as Anzencore, or expanding them into IDE extensions and plugins, one quickly realizes that the underlying detection engine is only as effective as its integration. A common pitfall in security tooling is generating massive, disconnected reports that security teams must manually hand over to developers. This creates immense friction and delays deployment.

By outputting results in SARIF format and integrating directly into GitHub Code Scanning, the implementation showcased in the article shifts security entirely to the left. The vulnerability is no longer an audit finding weeks later; it becomes an inline, compiler-like error that a developer must fix before merging.

However, as the author correctly points out, SAST is not magic. The inherent limitation of static analysis is the risk of false positives or confusion with dynamic constructs (like $$var). Therefore, the success of a tool like Psalm relies heavily on how easily developers can define custom sanitizers using annotations like @psalm-taint-escape. The true value of this implementation isn't just in finding the bug, but in doing so natively, quickly, and with actionable feedback right where the developer lives: the code repository.