Skip to content

WING-404 — council findings NOT fixed in the initial commit

Council gate run 2026-08-06. Seats: Gronktayvius (Grok), Cedric (Codex gpt-5.6-sol high), Jenny (Gemini 3.1 Pro High). Clahadore seat skipped — advisory-only here (Claude authored the diff, so it never counted toward quorum) and the governor was RED with the box over its process cap.

Fixed in this commit, listed for context: Channel dropped on save (+ regression test with a verified negative control), false "Saved ✓" on failed save, Adzuna returning an EMPTY list when enrichment overran the fetch budget, stale-search response overwriting newer results, save-while-resolution- pending persisting an Unknown ATS, "ats": null causing a 500.

Everything below is REAL and DEFERRED. None of it is dismissed.

HIGH — DNS-rebinding SSRF on the resolver (Cedric)

RedirectChainAtsResolver.cs:127,139 · Program.cs:536-537

PublicDnsAsync resolves the hostname and validates the addresses, then HttpClient resolves the hostname again when it connects. A hostname under attacker control can answer with a public address during validation and a private/loopback/link-local/metadata address at connect time. The default handler may also use a proxy, which is a third independent resolution path.

This is a pre-existing weakness in the resolver, but WING-333 materially changes its exposure: before, the resolver only saw urls from our own configured job sources; now POST /api/jobs/search/resolve feeds it user-supplied urls.

Fix (as prescribed): SocketsHttpHandler with UseProxy = false and a ConnectCallback that connects to the single validated global address while preserving hostname/SNI; re-validate and re-pin on every redirect hop; add an actual rebinding regression test.

Why deferred: it changes a shared resolver used by every job source, so it deserves its own ticket, its own review, and a test that genuinely demonstrates rebinding rather than asserting the happy path. Bundling it into a feature commit at 04:00 is how a security fix gets shipped unverified.

MEDIUM — IsPublic misses IANA special-purpose ranges (Cedric)

RedirectChainAtsResolver.cs:206-222. Not covered: 198.18.0.0/15 (benchmarking), 192.0.0.0/24 (IETF protocol assignments), and IPv6 transition forms that embed IPv4 destinations (6to4 2002::/16, Teredo 2001::/32) beyond the IPv4-mapped case already handled.

Fix: reject every non-global IANA prefix and decode mapped/translated/6to4/Teredo forms before judging. Belongs with the rebinding ticket — same function, same test file.

HIGH — no rate limit on the outbound-fetch endpoint (Cedric)

JobsController.cs — search/resolve is the only endpoint that causes an outbound fetch, and it has no per-token rate limit, no bounded queue, and no cap on concurrent in-flight resolutions. Many distinct urls can accumulate uncancelled 20-second resolver flights.

Partially mitigated here (4 KiB url cap, and the resolver's own per-host 1 req/s + process-wide concurrency gate of 3). Still missing a real per-token limiter and a bulk-import cap.

MEDIUM — sensitive urls in logs and cache (Cedric)

Default HttpClientFactory logging records full request urls, and the resolution cache stores complete urls in plaintext. Aggregator trackers routinely carry signed tokens and session ids — we measured Adzuna's aztt JWT, valid 168 hours. The resolver's own code already avoids this in its exception path (host only, deliberately), so the codebase knows the rule; the surrounding infrastructure does not follow it.

Fix: default-redact HTTP logging for this client, and store a hash or minimal metadata rather than the full url.

MEDIUM — Channel hardcoded in the Adzuna mapper (Cedric)

AdzunaSource.cs — MapToJobPosting always stamps Channel = Search, including on the background FetchAsync sync path, which is really Scrape. The mapper is shared by both paths and cannot know which one called it.

Fix: pass the channel in from the calling path instead of hardcoding it in the mapper.

MEDIUM — imported rows save as "Untitled role / Unknown company" (Gronktayvius, Cedric — both)

The resolver is redirect/host only and never reads a page body, so import seeds title/company empty and resolution only rewrites Ats. Save then fills placeholders.

This is the single biggest product gap in the feature: the queue fills with rows that are technically resolved and practically unreadable, and dedupe by title/company becomes meaningless.

Fix options: derive title/company from known url shapes after host verification (Greenhouse slug, Lever company segment); or make the fields user-editable in the review table before Save; or fetch the posting page for metadata — which is exactly what the traversal labs in docs/technical/traversal-labs/ were built to specify.

MEDIUM — stop() marks in-flight rows done (Cedric)

import-jobs.ts — cancelling labels un-checked rows done, and done means "attempted", which enables saving. A cancelled row is not an attempted row.

Fix: add a distinct cancelled state, render it, and decide its save eligibility explicitly.

MEDIUM — quadratic client work on large pastes (Cedric)

import-jobs.ts — link count is unbounded and every state change maps the whole row array. Fine at 30 links, poor at 500.


Addendum — the WIP caveat in commit 41d3890d0 is RESOLVED

That commit's message flagged ApiSmokeTests.DataBackupExport_KnownDomain_ReturnsXlsxFile as possibly caused by adding JobPosting.Channel, and said to treat it that way until diffed against master. It has now been diffed. It is not caused by this work.

Evidence, in the order it was gathered — including the step where I was wrong:

Run Tree Result
full suite this branch FAIL (with Grok probe + Ollama)
single test, isolated baseline d6c98aed0 PASS — looked like proof the commit caused it
single test, isolated this branch PASS — contradicts that reading
single test × 4, isolated this branch 4 PASS / 0 FAIL

One pass on baseline plus one failure on the branch is not causation when the test also passes on the branch on rerun. It fails under full-suite load and passes alone: 4/4 isolated.

Same family as the other two full-suite failures, neither of which touches this diff: - Grok_ConcurrentGenerationSharesOneCapabilityProbe — Grok CLI probe timing out while council seats were hammering Grok all night. - LocalRagLiveSmokeTests...FromLiveOllama — needs a live Ollama; the generation model was unreachable. - JobSourcesResync_FromTrustedDevOrigin_ReturnsOk — failed in the full run, passed isolated.

This is the WW-86/WW-89 load-flakiness family: the failing SET rotates between runs, which is the signature of contention rather than a defect. Worth its own ticket — a suite whose failures rotate under load cannot gate anything, and it forced three separate investigations tonight.

Verdict on 41d3890d0: build 0 errors, frontend build 0, import-jobs 8/8, backend 5775/5778 with all 3 failures reproduced as load-dependent and none attributable to the diff.