Measured 2026-08-09 on nldesign at package 651e5c5bb3ba8764903e5d6fc5bac5a208bd67fc. Both findings verified by reading the methods, not by trusting the gate.
gate-7 reports 2 findings on nldesign, and both are structurally incapable of the vulnerability the gate exists to find.
The two methods, in full
#[NoAdminRequired]
public function tokenSets(): JSONResponse
{
return new JSONResponse(['tokenSets' => $this->tokenSetService->getPublicCatalogue()]);
}
No parameters at all. There is nothing a caller could substitute to reach someone else's data. The spec requires exactly this posture — any authenticated user may read the catalogue, and #[PublicPage] is explicitly ruled out (design.md decision 1: authenticated non-admin, not anonymous).
#[NoAdminRequired]
public function evaluate(): JSONResponse
{
$background = ($params['background'] ?? null); // string
$candidates = ($params['candidates'] ?? null); // array of {name, value, role}
// …strict shape validation, 400 on anything else…
$results = $this->contrastService->evaluate(candidates: $normalised, background: $background);
return new JSONResponse(['results' => $results]);
}
A pure function over caller-supplied colour values. It reads and writes no stored object. The inputs are values to compute on, not references to resources.
Why neither can be closed honestly in the app
Walking the recognised guard list against these two:
| guard the checker accepts |
why it does not apply |
OCSForbiddenException / isAdmin( / authorize* / require* |
there is no object and no owner to compare against — the check would compare nothing |
#[PublicPage] |
would make the endpoint anonymous, a real security regression the spec forbids |
Http::STATUS_FORBIDDEN |
returning 403 from a path that is not a denial is a status code chosen to satisfy a grep |
TemplateResponse |
these return JSON |
| Patterns 1–4 (delegated guards) |
there is nothing to delegate |
So the only route is exemption 4, @no-admin-idor-exempt <reason> — and the checker's own docs say it "mirrors the reason-bearing exclude conventions of gate-16 (@spec exclude) and gate-19 (@e2e exclude)". I have left the gate red rather than tag it, because a reason-tag on a finding that was never real is indistinguishable, six months later, from a reason-tag on one that was.
Suggested direction — make the checker able to see this
Exemptions 1–3 are already structural: __construct, preflightedCors*, and "body only sets Access-Control-* headers". This is the same shape and deserves the same treatment rather than a tag:
A #[NoAdminRequired] method is exempt when it takes no caller-supplied identifier and performs no object access.
Concretely, both conditions are cheaply checkable on the body the scanner already has:
- the method has no parameters AND does not read
$this->request->getParam(s)/getParams() into anything used as a lookup key; or every value it does read is passed only to a computation, never to a *Mapper, ObjectService, find*/get*/load*/save*/delete* call; and
- the body contains no data-layer call at all.
tokenSets() satisfies (1) trivially — zero parameters — and calls exactly one service method that takes no argument. evaluate() reads params but passes them only to ContrastService::evaluate(), which is arithmetic.
A narrower first cut that would clear the first case alone and is almost free: a routed method with zero parameters and zero getParam reads has no attack surface for IDOR, because IDOR requires the attacker to control a reference. That single rule removes a whole class of fleet false positives without weakening the gate anywhere — a method that takes nothing cannot be pointed at someone else's object.
Both methods are offered as control fixtures if useful: lib/Controller/CatalogController.php::tokenSets and lib/Controller/ContrastController.php::evaluate in ConductionNL/nldesign.
Unrelated observation from the same reading
evaluate() accepts an unbounded candidates array from any authenticated user and does per-candidate contrast maths on it. That is a resource-consumption concern (CWE-770), not an IDOR, and I have deliberately NOT "fixed" it here — bounding it would be a behaviour change needing spec agreement, and picking a 4xx that happens to match this gate's guard list would be exactly the wrong reason to do it.
Measured 2026-08-09 on nldesign at package
651e5c5bb3ba8764903e5d6fc5bac5a208bd67fc. Both findings verified by reading the methods, not by trusting the gate.gate-7 reports 2 findings on nldesign, and both are structurally incapable of the vulnerability the gate exists to find.
The two methods, in full
No parameters at all. There is nothing a caller could substitute to reach someone else's data. The spec requires exactly this posture — any authenticated user may read the catalogue, and
#[PublicPage]is explicitly ruled out (design.mddecision 1: authenticated non-admin, not anonymous).A pure function over caller-supplied colour values. It reads and writes no stored object. The inputs are values to compute on, not references to resources.
Why neither can be closed honestly in the app
Walking the recognised guard list against these two:
OCSForbiddenException/isAdmin(/authorize*/require*#[PublicPage]Http::STATUS_FORBIDDENTemplateResponseSo the only route is exemption 4,
@no-admin-idor-exempt <reason>— and the checker's own docs say it "mirrors the reason-bearing exclude conventions of gate-16 (@spec exclude) and gate-19 (@e2e exclude)". I have left the gate red rather than tag it, because a reason-tag on a finding that was never real is indistinguishable, six months later, from a reason-tag on one that was.Suggested direction — make the checker able to see this
Exemptions 1–3 are already structural:
__construct,preflightedCors*, and "body only setsAccess-Control-*headers". This is the same shape and deserves the same treatment rather than a tag:Concretely, both conditions are cheaply checkable on the body the scanner already has:
$this->request->getParam(s)/getParams()into anything used as a lookup key; or every value it does read is passed only to a computation, never to a*Mapper,ObjectService,find*/get*/load*/save*/delete*call; andtokenSets()satisfies (1) trivially — zero parameters — and calls exactly one service method that takes no argument.evaluate()reads params but passes them only toContrastService::evaluate(), which is arithmetic.A narrower first cut that would clear the first case alone and is almost free: a routed method with zero parameters and zero
getParamreads has no attack surface for IDOR, because IDOR requires the attacker to control a reference. That single rule removes a whole class of fleet false positives without weakening the gate anywhere — a method that takes nothing cannot be pointed at someone else's object.Both methods are offered as control fixtures if useful:
lib/Controller/CatalogController.php::tokenSetsandlib/Controller/ContrastController.php::evaluatein ConductionNL/nldesign.Unrelated observation from the same reading
evaluate()accepts an unboundedcandidatesarray from any authenticated user and does per-candidate contrast maths on it. That is a resource-consumption concern (CWE-770), not an IDOR, and I have deliberately NOT "fixed" it here — bounding it would be a behaviour change needing spec agreement, and picking a 4xx that happens to match this gate's guard list would be exactly the wrong reason to do it.