Skip to content

PHP: EXTRACTED corroboration matches the file path only, ignoring the declared namespace #14

Description

@filipechagas

Parent

Spec: #1 (upstream: Graphify-Labs#1682). Found by the red-team session against 2e68ec6. Repro scripts: /tmp/rt6/ (harness.py, wave2.py, wave3.py).

Problem

_php_qualified_corroborates (extract.py:3058) promotes an inline-new member call from INFERRED 0.8 to EXTRACTED 1.0 when the written FQN corroborates the resolved node. The only evidence it consults is the tail of the file path, casefolded:

parts[-1] = parts[-1].rsplit(".", 1)[0]      # drop extension
parts = [p.casefold() for p in parts]
return len(parts) >= len(want) and parts[-len(want):] == want

Its docstring justifies this with "PHP nodes carry no namespace" — but the namespace declaration is present in the source file and is simply not read. PSR-4 is a convention, not an invariant, so path and namespace can diverge. Two exploits follow.

Exploit A — declared namespace differs from the PSR-4 path (worse)

// app/Services/Client.php   <-- path says App\Services
namespace App\Vendor;                            // <-- declaration says App\Vendor
class Client { public function post(): void {} }

// app/Runner.php
namespace App;
class Runner {
    public function go(): void { (new \App\Services\Client())->post(); }
}

Actual:

app/Runner.php:.go()  ->  app/Services/Client.php:.post()   EXTRACTED 1.0

The class the source names — App\Services\Clientdoes not exist anywhere in the corpus. The edge points at App\Vendor\Client and is stamped at maximum confidence. This is a wrong target at 1.0, not merely confidence inflation.

Realistic triggers: PSR-0 leftovers, custom/classmap autoloaders, files moved without their namespace updated, and generated code.

Repro: /tmp/rt6/wave2.py::B5_ns_path_mismatch.

Exploit B — root-namespace FQN matches as a path suffix

// app/Services/Client.php
namespace App\Services;
class Client { public function post(): void {} }

// caller
(new \Services\Client())->post();      // root namespace \Services, NOT App\Services

Actual: EXTRACTED 1.0. The comparison only requires the written segments to equal the tail of the path, so a proper suffix of the real FQN corroborates. \Services\Client and App\Services\Client are different classes; the former does not exist. A missing use plus a leading \ is a common real bug, and it gets rewarded with maximum confidence.

Repro: /tmp/rt6/wave2.py::B6_root_ns_tail.

Controls (behaviour that is correct today)

From /tmp/rt6/wave3.py:

  • \App\Services\Client against namespace App\Services; at app/Services/Client.phpEXTRACTED 1.0 (F3_control_ns_matches) — the intended promotion
  • (new Client()) bare name → INFERRED 0.8 (F3_control_bare_name) — no namespace written, no evidence
  • \Acme\Sdk\Client (path does not match) → INFERRED 0.8 (F3_wrong_ns_no_path_match) — correct downgrade

So the mechanism is purely path-based, exactly as documented; the exploits come from path/namespace divergence and from suffix rather than whole-name matching.

What to build

Read the namespace declaration during PHP extraction and use it as the corroborating fact instead of (or in addition to) the path:

  • Carry the declaring namespace on the PHP class node (or on the per-file result, keyed to the class) at extraction time — namespace_definition is already parsed for imports.
  • In _php_qualified_corroborates, compare the written FQN against declared_namespace + '\' + class_name, requiring a whole-name match rather than a path-tail suffix match, so \Services\Client no longer corroborates App\Services\Client.
  • Keep the current behaviour for a bare name (no namespace written → INFERRED) and keep non-matching qualifiers as a downgrade rather than a refusal, per PHP: (new Service())->method() resolves as EXTRACTED with FQN corroboration #3's shipped policy.

If threading the namespace is deferred, an interim mitigation is to require the written segment count to match the full path-derived prefix (closing exploit B) and to refuse promotion when the file declares a namespace that disagrees with its path (closing exploit A).

Acceptance criteria

  • Declared namespace differing from the PSR-4 path does not promote to EXTRACTED (exploit A)
  • A root-namespace or otherwise-truncated FQN does not corroborate a longer real FQN (exploit B)
  • Matching FQN still promotes to EXTRACTED 1.0; bare name still INFERRED; mismatched-but-plausible qualifier still downgrades rather than refusing
  • Full test suite green

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions