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>";
}
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(#)