fix(security): resolve the CodeQL backlog, three fixes and three documented false positives (#1225)
Triage of all 9 CodeQL alerts surfaced on main by #1223. None were introduced by that PR. Fixed: the compliance-review artifact now unpacks to runner.temp instead of over the trusted checkout (actions/artifact-poisoning, critical); MCP LIKE patterns escape backslash first, which was a real correctness bug returning wrong rows for any search containing a backslash (js/incomplete-sanitization, 2 sites); and the mcp-oauth consent form action is HTML-escaped (js/reflected-xss, not exploitable because WHATWG URL already percent-encodes " < >, but & is not in that encode set). Dismissed as false positives with reasoning recorded at each site and in DECISIONS.md: sie-export escapeQuotes, where doubling backslashes would violate SIE 4B, corrupt files in conformant readers and skew #KSUMMA under BFL 7-year retention; hashApiKey, where SHA-256 is correct for a 256-bit CSPRNG token and changing it would invalidate every live gnubok_sk_ key; and the DuplicateBookingDialog href, which is a DB UUID behind a literal path prefix. Regression tests cover both behavioural fixes, including the escape ordering.
This commit is contained in:
@@ -517,6 +517,38 @@ describe('gnubok_query_journal: free-text search', () => {
|
||||
expect(ilikeCalls[0].pattern).toBe('%2\\_441\\%foo%')
|
||||
})
|
||||
|
||||
it('escapes a literal backslash so it does not swallow the next character', async () => {
|
||||
// `\` is LIKE's own escape character. Before this was handled, a search for
|
||||
// `a\b` reached Postgres as `%a\b%`, where `\b` means "literal b", so the
|
||||
// filter silently matched rows containing `ab` and missed the ones the user
|
||||
// actually asked for. Flagged by CodeQL as js/incomplete-sanitization.
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const { supabase, ilikeCalls } = makeQueueMock([
|
||||
{ data: [], count: 0 },
|
||||
{ data: [], count: 0 },
|
||||
])
|
||||
|
||||
await tool.execute({ text: 'a\\b', limit: 50 }, 'company-1', 'user-1', supabase)
|
||||
|
||||
expect(new Set(ilikeCalls.map((c) => c.pattern)).size).toBe(1)
|
||||
expect(ilikeCalls[0].pattern).toBe('%a\\\\b%')
|
||||
})
|
||||
|
||||
it('escapes backslash before the wildcard rules, not after', async () => {
|
||||
// Order matters: escaping `\` last would also double the backslashes the
|
||||
// % / _ rules just introduced, turning `50%` into `50\\%` (a literal
|
||||
// backslash followed by a wildcard) instead of `50\%` (a literal percent).
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const { supabase, ilikeCalls } = makeQueueMock([
|
||||
{ data: [], count: 0 },
|
||||
{ data: [], count: 0 },
|
||||
])
|
||||
|
||||
await tool.execute({ text: '50%', limit: 50 }, 'company-1', 'user-1', supabase)
|
||||
|
||||
expect(ilikeCalls[0].pattern).toBe('%50\\%%')
|
||||
})
|
||||
|
||||
it('does NOT flag truncated when an overlap row is hit by both legs and merged set fits limit', async () => {
|
||||
// Greptile / Compliance V2.3 regression: previously, dbMatched = sum of
|
||||
// leg counts and a row matching both legs would inflate the count and
|
||||
|
||||
@@ -6589,7 +6589,16 @@ export const tools: McpTool[] = [
|
||||
// searches the ENTRY description only (documented in the
|
||||
// schema); the two-leg line+entry union query_journal runs is
|
||||
// overkill for a write filter.
|
||||
const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_')
|
||||
//
|
||||
// Backslash is escaped FIRST, and the order matters: `\` is LIKE's
|
||||
// own escape character, so an unescaped one in the search term
|
||||
// swallows the character after it (searching `a\b` matched rows
|
||||
// containing `ab`). Escaping it last would instead double the
|
||||
// backslashes the % / _ rules just added.
|
||||
const escaped = text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/%/g, '\\%')
|
||||
.replace(/_/g, '\\_')
|
||||
e = e.ilike('description', `%${escaped}%`)
|
||||
}
|
||||
return e
|
||||
@@ -7262,7 +7271,16 @@ export const tools: McpTool[] = [
|
||||
// separator. The .ilike() path passes the pattern as a parameterised
|
||||
// filter operand where `,` is a literal: stripping would mangle
|
||||
// searches for real commas in line descriptions.
|
||||
const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_')
|
||||
//
|
||||
// Backslash is escaped FIRST, and the order matters: `\` is LIKE's own
|
||||
// escape character, so an unescaped one in the search term swallows the
|
||||
// character after it (searching `a\b` matched rows containing `ab`).
|
||||
// Escaping it last would instead double the backslashes the % / _ rules
|
||||
// just added.
|
||||
const escaped = text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/%/g, '\\%')
|
||||
.replace(/_/g, '\\_')
|
||||
const pattern = `%${escaped}%`
|
||||
|
||||
// Fetch up to 2× limit per leg to reduce global-ordering loss when
|
||||
|
||||
Reference in New Issue
Block a user