Skip to content

Fix resolver correctness gaps (decoding, @version, bare search, URL templates) - #33

Merged
kurtseifried merged 4 commits into
mainfrom
fix/resolver-correctness
Sep 25, 2026
Merged

kurtseifried merged 4 commits into
mainfrom
fix/resolver-correctness

Conversation

@kurtseifried

@kurtseifried kurtseifried commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Changes since review

Four commits now, rebased onto main at 1651737 (after #38 and #39).

  1. Rebase. Three files conflicted, all at the import lines or call site:
    • src/mcp.ts: kept this PR's resolveQuery rename and main's FeedbackRateLimitedError import.
    • src/api.ts: resolveQuery(kv, decoded, { demand: c.env.secid_DEMAND, channel: "rest" }), which is this PR's rename with main's capture object.
    • src/kv-resolve.ts: this PR's toRegExp import next to main's recordDemandMiss.
    • Also: main now typechecks the tests, so three casts in test/resolver-correctness.test.ts became as unknown as.
  2. Demand capture: at most one data point per query (09c8e87). resolveQuery passes the capture through to the namespace-miss branch. Because it can try a query twice (as-is, then decoded), a miss whose query contained %XX wrote two Analytics Engine points. Each attempt now collects its misses, and only the attempt whose result is returned records them. New tests in test/demand.test.ts send a miss through REST and through the MCP resolve, lookup and describe tools (via worker.fetch with a fake secid_DEMAND). They check one point each, on the right channel. They fail if the capture is dropped.
  3. No message on corrected (528acfe). API-RESPONSE-FORMAT.md says message is only for not_found and error, so the case-only version correction and the dropped-version correction no longer carry one. The one exception is an ADR-015 redirect (see 4), which that ADR and REGISTRY-JSON-FORMAT.md define as corrected with the canonical SecID in the message.
  4. ADR-015 version aliases (ac20ef6).
    • An alias now resolves as its canonical version: aicm@1.1#LOG-15 and aicm@v1.1#LOG-15 return secid:control/cloudsecurityalliance.org/aicm@1.1.1#LOG-15, and aicm-caiq@1.1 returns @1.1.0. @1.1.0 is unchanged. secid_query still echoes the input.
    • Aliases come from versions_available[].aliases, which carries on_match. The tree is the fallback: a version node matched by a pattern other than its patterns[0] literal counts as an alias of that literal.
    • on_match: "redirect" returns corrected with empty results and the canonical SecID in the message. No registry entry uses it yet, so a synthetic registry tests it.
    • Behaviour change from what was reviewed: an unknown version with a subpath is now not_found, not related. This applies to version_required sources and to sources that list versions_available. ADR-015 rule 8 says the resolver never returns item data from a version the caller did not ask for, and the reviewed version returned the current version's item as "nearest". The message lists the known versions (status, date, aliases) and points to submit_feedback and https://github.com/CloudSecurityAlliance/SecID/issues, as ADR-015 and VERSIONING.md specify. Without a subpath, the query stays related with the version list.

Summary

Review findings L1, M2, L4, L5, L6, L2, L3. Each was reproduced before being fixed.

  • L1: double decoding. Hono decodes the query string, and api.ts then decoded it a second time. Reproduced: a literal % returned "Malformed percent-encoding", and %41 became A. REST and all three MCP tools now go through a new resolveQuery, which follows SPEC §8:

    • It resolves the input as-is first.
    • Only if that result isn't found or corrected, it percent-decodes once and keeps the decoded result if it ranks higher.
    • If the input can't be decoded, the as-is result stands.
    • secid_query always echoes what the client sent.
    • describe also cuts the subpath at an encoded %23.
  • M2: unknown @version accepted as found. Reproduced with secid:advisory/mitre.org/cve@bogus#CVE-2024-1234. I read SecID docs/reference/VERSIONING.md (four outcomes, "Version-Specific Resolution"). This change does not depend on SecID #134 (version aliases).

    Updated for ADR-015 (see above):

    Source Query version Result
    Any versioned source An alias (aicm@1.1) found with the canonical version in the result SecIDs (@1.1.1), or corrected with no results for on_match: "redirect"
    Lists versions_available, or version_required Unknown, with a subpath not_found, with known versions, submit_feedback and the issues link. Never another version's item.
    Lists versions_available, or version_required Unknown, no subpath related, with the source and its versions (top10@bogus previously returned found)
    Lists versions Differs only by case (@REV5) corrected to the source's spelling (@rev5), no message
    Lists no versions Any corrected: the version is dropped, no message

    Result SecIDs never carry a version that wasn't honoured.

    • All registry example fixtures still pass (versioned examples use listed versions).
  • L4: bare nonsense returned "Invalid type". A bare term with no secid: prefix that matches nothing is now not_found. The message explains what bare search covers and suggests a scoped query. If the term contains a slash, it also lists the valid types. An explicit secid:frobnicate/... still reports the invalid type.

  • L5: lookup_table[subpath] now uses an own-property check.

  • L6: namespace-scoped and type-scoped search now pass through note for URL-less items, matching matchChildrenAndResolve.

  • L2: compiled RegExps are cached by pattern string. toRegExp is exported and also used by the kv-resolve index scans. Patterns come only from the registry, so the cache is bounded by registry size.

  • L3: URL templates.

    • Non-absolute or non-http(s) templates now return null. The registry has none; I checked every template.

    • Substituted values are encoded according to where they land, in one left-to-right pass. That pass also fixes a small bug where a value containing {year} was re-expanded.

      Part Encoded
      Path ? # % \ whitespace, control characters
      Query # & + % \ whitespace, control characters
      Fragment # % \ whitespace, control characters
    • A literal . or .. segment in a path value makes the result null. % is encoded, so %2e%2e cannot smuggle a dot-segment.

    • : / & stay literal in paths, so RHSA-2024:1234, DOIs and A&A-01 URLs are unchanged.

    • Two existing buildUrl tests asserted the old pass-through behaviour, and I updated them.

Test plan

  • npm test: 667 passed (16 files). The registry snapshot was built from SecID main 7518fb9, which includes the AICM 1.1.1 version nodes. It is gitignored.
  • npm run typecheck is clean.
  • Demand-capture tests fail if resolveQuery drops the capture (6 failures) or records both attempts (1 failure). I mutation-checked this locally.
  • Regex safety: no registry patterns change. The new regexes (UNSAFE_IN, DOT_SEGMENT, the placeholder scan) are character classes or anchored alternations with no nested quantifiers. patternLiteral is a linear character scan, not a regex.

kurtseifried and others added 4 commits September 25, 2026 13:58
- Decoding (L1): the REST handler decoded a query Hono had already decoded,
  so a literal '%' failed as malformed and %41 became A. REST and MCP now go
  through resolveQuery: as-is first, one percent-decode only if that does not
  resolve (SPEC §8).
- Unknown @Version (M2): no longer returned as found. Listed-versions source
  -> related with the available list; case-only mismatch -> corrected to the
  source's spelling; source with no versions -> corrected, version dropped
  and explained; version_required miss -> related with versions_available
  (was not_found). Result SecIDs never carry an unhonoured version.
- Bare search (L4): a bare term matching nothing is not_found with search
  guidance instead of 'Invalid type'.
- lookup_table (L5): own-property check.
- Notes (L6): namespace- and type-scoped search pass a URL-less item's note.
- Regex cache (L2): compiled patterns are cached by source string.
- URL templates (L3): non-absolute or non-http(s) templates are refused;
  substituted values are encoded per URL part so they cannot add ?, #, &,
  backslash or percent-encoded dot-segments, and literal dot-segments in a
  path are refused. A value is never re-scanned for placeholders.
resolveQuery can resolve a query twice (as-is, then percent-decoded), and
both attempts received the demand capture, so a namespace miss whose query
contained a %XX escape wrote two Analytics Engine points. Each attempt now
collects its misses and only the attempt whose result is returned records.

Tests prove the capture still reaches the namespace-miss branch through
REST and all three MCP tools (resolve, lookup, describe).

Co-Authored-By: Claude <noreply@anthropic.com>
API-RESPONSE-FORMAT.md: message is present on not_found and error and
absent on found and corrected, because the results speak for themselves.
The version corrections (a case-only version difference, and a version
dropped from a source that lists none) now follow that contract; the
correction is visible in the result SecIDs.

Co-Authored-By: Claude <noreply@anthropic.com>
A version alias now resolves to the release it names. aicm@1.1#LOG-15 and
aicm@v1.1#LOG-15 return secid:.../aicm@1.1.1#LOG-15, the release the data
comes from; aicm-caiq@1.1 returns 1.1.0. secid_query still echoes what the
client sent. The alias comes from versions_available[].aliases (which
carries on_match), with the tree as a fallback: a version node matched by a
pattern other than its patterns[0] literal is an alias of that literal.

on_match "redirect" returns corrected with empty results and the canonical
SecID in the message, as ADR-015 and REGISTRY-JSON-FORMAT.md specify. No
registry entry uses redirect yet; a synthetic registry covers it.

An unknown version with a subpath is now not_found rather than related, for
both version_required sources and sources that list versions_available: the
resolver never returns another version's item (ADR-015 rule 8). The message
lists the known versions with status, date and aliases, and points to
submit_feedback and the SecID issues page. Without a subpath the query is a
discovery question and stays related, with the version list.

Co-Authored-By: Claude <noreply@anthropic.com>
@kurtseifried
kurtseifried force-pushed the fix/resolver-correctness branch from 46765c0 to ac20ef6 Compare September 25, 2026 20:03
@kurtseifried
kurtseifried merged commit c8b3a93 into main Sep 25, 2026
1 check passed
@kurtseifried
kurtseifried deleted the fix/resolver-correctness branch September 25, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant