





















Socket proactively blocks malicious open source packages in your code.
Secure your dependencies with us
Security teams are already struggling to keep pace with the volume of vulnerability disclosures. Every week brings more CVEs, and the arrival of AI-assisted vulnerability research is only going to push that number higher. Teams that can't tell which disclosures actually matter for their application will fall behind quickly.
PHP carries more of this weight than most ecosystems. Composer ranks third for CVE volume among package ecosystems, behind only Maven and npm, and PHP still runs a substantial share of the web. WordPress, Laravel, Symfony, and the long tail of PHP applications built on top of them all pull in the same transitive dependencies, which means a single advisory can light up dashboards across thousands of codebases without telling anyone whether the vulnerable code is actually used.
Reachability analysis answers that question. By pinpointing which vulnerabilities can be exploited within a given application, it lets teams prioritize real risks and skip the rest. This approach has already saved teams significant time across JavaScript/TypeScript, Python, Ruby, and other ecosystems, and today we're bringing it to PHP in experimental.
Socket's reachability analysis for PHP builds on function-level call graph analysis. For each function in your application, the engine computes which other functions it may call. A vulnerable function is considered reachable if an application function can transitively invoke it.

Both Tier 1 (full application reachability, computed against your actual source code) and Tier 2 (pre-computed reachability against the dependency graph) are now available for PHP.
The analysis engine itself was built with researchers at Aarhus University, drawing on their work in static program analysis. We've already shipped function-level reachability for JavaScript/TypeScript, Python, and Ruby, and the PHP implementation inherits the lessons from each of those.
The analysis is deliberately conservative. When a call can't be fully resolved, it's marked reachable or unknown rather than unreachable, which protects exploitable paths from being filtered out by mistake.
Learn more about the analysis engines in the Static Reachability Analysis docs.
PHP presents unique challenges for static analysis. The language leans heavily on two dispatch patterns that break most off-the-shelf call graph analyzers.
__callclass RealService {
public function process(string $data): string {
return "processed: $data";
}
}
class Proxy {
public function __construct(private object $target) {}
public function __call(string $name, array $args): mixed {
return $this->target->$name(...$args);
}
}
$proxy = new Proxy(new RealService());
$result = $proxy->process("payload");$proxy->process(...) has no matching method on Proxy, so PHP routes the call to __call, which then re-dispatches through a variable method name against a field of type object. A naive analyzer sees three stacked unknowns (the class on $this->target, the method name in $name, and the resulting callee) and gives up. RealService::process gets marked unreachable, and every CVE inside it is triaged as "not exploitable" when it actually is.
Socket's PHP engine propagates the RealService instance through the constructor into $this->target, carries the string "process" into the $name parameter, resolves $target->$name(...) back to RealService::process, and lands the edge in the call graph.
The same shape shows up in Laravel Facades, Eloquent dynamic relationships, Doctrine lazy-loading proxies, and PHPUnit mock objects. Getting __call right is the difference between a real call graph and one full of holes.
class Logger { public function log(string $msg): void { /* ... */ } }
class Mailer { public function send(string $to): void { /* ... */ } }
class Container {
private array $bindings = [];
public function bind(string $abstract, string $concrete): void {
$this->bindings[$abstract] = $concrete;
}
public function make(string $abstract): object {
$concrete = $this->bindings[$abstract];
return new $concrete();
}
}
$c = new Container();
$c->bind('logger', Logger::class);
$c->bind('mailer', Mailer::class);
$c->make('logger')->log('hello');
$c->make('mailer')->send('ops@example.com');No other mainstream language leans on this pattern as heavily. A class name is a string, stored in an array keyed by another string, pulled out later, and passed to new $concrete(). Laravel's container, Symfony's DI, and PHP-DI all work this way, which means entire applications are wired through Container::make(). An analyzer that can't follow strings through array cells into new $var() sees $c->make('mailer')->send(...) as a method call on an unknown object and misses every sink behind it.
Socket's engine tracks class-name strings through binding, storage, lookup, and instantiation, so Logger::log and Mailer::send both show up as reachable.
The payoff for getting these patterns right shows up on real advisories. CVE-2022-29248 in guzzlehttp/guzzle is a good one, because Guzzle is a transitive dependency of nearly everything on Packagist. The vulnerable sink is CookieJar::extractCookies, which can leak Set-Cookie values across domains when a request is redirected.
Consider two applications pinned to the same affected version of Guzzle.
The first constructs a client with no cookie handling. CookieJar::extractCookies is never reached, and the advisory is not exploitable in that application.
The second constructs a client with a cookie jar attached.
The application never names extractCookies directly. Guzzle's cookie middleware, installed automatically in the default handler stack, calls it on every response through a chain of closures and a Promise::then callback. Two applications on the same Guzzle version get different verdicts, and both are right. That's the triage you can't do with a dependency graph alone.
To turn the application's single call to $client->send(...) into an edge into CookieJar::extractCookies, the analyzer has to follow a chain of seven hops through Guzzle's internals.
1. The app creates a Client with a CookieJar:
$this->http = new Client([
'cookies' => new CookieJar(),
]);
// ...
$this->http->send($request, $options);2. Client::__construct pulls in the default handler stack and merges it into config (vendor/guzzlehttp/guzzle/src/Client.php):
public function __construct(array $config = [])
{
if (!isset($config['handler'])) {
$config['handler'] = HandlerStack::create();
}
// ...
$this->configureDefaults($config); // sets $this->config = $config + $defaults
}3. HandlerStack::create() pushes four middlewares, including cookies (vendor/guzzlehttp/guzzle/src/HandlerStack.php):
public static function create(?callable $handler = null): self
{
$stack = new self($handler ?: Utils::chooseHandler());
$stack->push(Middleware::httpErrors(), 'http_errors');
$stack->push(Middleware::redirect(), 'allow_redirects');
$stack->push(Middleware::cookies(), 'cookies');
$stack->push(Middleware::prepareBody(), 'prepare_body');
return $stack;
}Each push appends a [callable, name] pair to $this->stack.
4. Client::send dispatches through sendAsync to transfer (vendor/guzzlehttp/guzzle/src/Client.php):
namespace GuzzleHttp;
use GuzzleHttp\Promise as P;
public function send(RequestInterface $request, array $options = []): ResponseInterface
{
$options[RequestOptions::SYNCHRONOUS] = true;
return $this->sendAsync($request, $options)->wait();
}
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
{
$options = $this->prepareDefaults($options); // $defaults = $this->config; ... return $defaults
return $this->transfer($request, $options);
}
private function transfer(RequestInterface $request, array $options): PromiseInterface
{
/** @var HandlerStack $handler */
$handler = $options['handler']; // pulled out of the merged options
return P\Create::promiseFor($handler($request, $options));
}5. Invoking the HandlerStack as a callable lands in __invoke, which calls resolve() (HandlerStack.php):
public function __invoke(RequestInterface $request, array $options)
{
$handler = $this->resolve();
return $handler($request, $options);
}6. HandlerStack::resolve() composes the stack via a reduce over [callable, name] pairs:
public function resolve(): callable
{
if ($this->cached === null) {
if (($prev = $this->handler) === null) { /* ... */ }
foreach (\array_reverse($this->stack) as $fn) {
$prev = $fn[0]($prev); // tuple-indexed callable on a property array
}
$this->cached = $prev;
}
return $this->cached;
}7. The composed callable is the cookies middleware's inner closure, wrapping the rest of the stack:
// vendor/guzzlehttp/guzzle/src/Middleware.php
public static function cookies(): callable
{
return static function (callable $handler): callable {
return static function ($request, array $options) use ($handler) {
if (empty($options['cookies'])) {
return $handler($request, $options);
}
$cookieJar = $options['cookies'];
$request = $cookieJar->withCookieHeader($request);
return $handler($request, $options)
->then(
static function (ResponseInterface $response) use ($cookieJar, $request): ResponseInterface {
$cookieJar->extractCookies($request, $response); // ← the sink
return $response;
}
);
};
};
}Three closures nested inside one another, the cookie jar pulled out of a string-keyed options array, and the vulnerable call buried inside a Promise::then callback that only fires once the response resolves.
Miss any one link in that chain and the sink is dead code. The advisory renders as "not reachable," real exposure gets triaged away, and the team moves on to the next alert.
The stack is eight frames deep. Only the top frame is application code. The other seven are inside Guzzle and its promises library.
The application calls send, which queues up a chain of middleware and returns a pending promise. Only when the promise resolves does Guzzle run the then callback that was installed by the cookie middleware, and that callback is where extractCookies gets called. The dashboard shows the resolution path because that's the shortest route from application code to the sink.

We've validated the PHP engine against a range of real-world codebases:
composer.lock.Measured against dynamically observed call graphs, our accuracy lands above 90% on PHPUnit, WordPress, and Flysystem, and in the mid-to-high 80s on Twig and Espo. The remaining gaps are concentrated in reflection-driven dispatch and runtime-generated code, and we’re actively working through them.
PHP reachability is launching in experimental, which means the engine is in active development. Coverage will expand and accuracy will improve as we work through the long tail of PHP patterns. We welcome feedback from the community on any incorrectly classified vulnerabilities.
PHP reachability is opt-in during the experimental phase. Reach out if you'd like it turned on for your organization.
Once enabled:
socket scan create --reach
For full setup instructions, see the Full Application Reachability docs.
PHP reachability is another step toward our goal of bringing precise, function-level analysis to every major ecosystem. We're excited for teams to try it out and see how much manual triage the engine can take off their plate.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。