Backend 4 min read 747 words

Psalm 7: security analysis on by default for PHP

ES
Psalm 7: security analysis on by default for PHP

Daniil Gentili has announced Psalm 7, the new version of the PHP static analyser. The list of changes is huge, but one stands out above the rest: security analysis is on by default and runs in the same pass as type checking and dead code detection.

Until now, taint analysis (following data that comes from outside until it reaches a dangerous place) was something you had to switch on deliberately with --taint-analysis, and in practice almost nobody added it to CI because it was slow. Psalm 7 has rebuilt it from scratch: it stores taints as bitmasks and resolves the graph over compact summaries of each function. According to the announcement, it’s up to 10x faster, and the results no longer change between runs or with the thread count.

More sources and more paths

The analysis now follows data through many more places: closures and callable parameters, generators, by-reference parameters, traits, promoted constructor properties, __toString(), exceptions…

<?php
$run = function (string $c): void {
    shell_exec($c);
};
$run((string) $_GET['cmd']); // TaintedShell, through the closure

function body() {
    return json_decode((string) file_get_contents('php://input'), true);
}
eval((string) body()); // TaintedEval: php://input is a source

There are new sources, such as php://input, STDIN, $argv, DNS answers, uploaded file names or the client-sent entries of $_SERVER. And new taint kinds too:

KindWhat it detects
TaintedNosqlInjection into MongoDB queries
TaintedLlmPromptUser input that ends up in an LLM prompt
TaintedUrlComponent / TaintedUrlPathInjection into a URL’s query or path

The prompt one strikes me as particularly well judged. There’s more and more PHP code that builds a prompt from what the user types and hands it to a model, and treating it the same as an SQL query is exactly the right way to look at it.

To cut down on false positives, taints a type can’t carry are dropped (an int can’t inject HTML), and it ships stubs for Guzzle, PSR-7, Monolog, MongoDB, PhpSpreadsheet and a few more libraries, which only load if your code uses them.

Purity with capabilities

The other big change is the purity model. @psalm-pure, @psalm-mutation-free and friends are replaced by capabilities, borrowed from Hack: a function declares exactly which side effects it may have (io, time, read-props, write-props, read-globals…).

<?php
/** @psalm-capabilities time */
function today(): string {
    return date('Y-m-d'); // fine: it declares it uses time
}

/** @psalm-pure */
function todayWrong(): string {
    return date('Y-m-d'); // ImpureFunctionCall: requires time
}

It also has purity templates: array_map() is pure if the callback it gets is, and Psalm now understands that. And it can suggest and add the annotations itself with --alter. This has a side effect that isn’t obvious: if Psalm knows a function is pure, it can analyse its taints per call, so annotating your code also removes false positives from the security analysis.

The rest

  • Graph-based dead code: only what’s reachable from an entry point counts as used, so it finds classes that are only used by each other (cycles included) and exceptions that are created and never thrown.
  • Template defaults: @template TError of Throwable = RuntimeException, so Result<int> works and libraries can add templates without breaking anyone.
  • PHP 8.5 and 8.6: it types every step of the pipe operator and partial function application, so if a step doesn’t fit the previous one, it tells you.
  • Bundled plugins: the PHPUnit, Symfony, Laravel and Mockery ones come built in and switch on by themselves if your composer.json uses those libraries.
  • Daily releases and --init-ci to generate the GitHub Actions workflow.

Upgrading

It runs on 64-bit PHP 8.2 to 8.6, and for most projects this is enough:

composer require --dev vimeo/psalm:^7.0 -W

What you’ll notice most is that security warnings that weren’t there before suddenly show up. You can turn it off with runTaintAnalysis="false" in psalm.xml, but I wouldn’t. Better to leave it on, run it once, go through what comes out calmly and suppress only what really is a false positive.

My takeaway

For years, static analysis in PHP was an extra that few projects used. A tool like this starting to check security without being asked is an important shift: what used to be optional (and therefore almost never done) is now the norm, and turning it off is a conscious decision.

And with so much AI-generated code around, having something that follows every bit of $_GET to a shell_exec, an eval or a prompt, without getting tired and without trusting anyone, feels even more necessary than before.

Source: Announcing Psalm 7, Daniil Gentili.