mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #2757 from DeusData/codex/codeql-main-alerts
fix: preserve literal input and static asset boundaries
This commit is contained in:
15 files changed
+191
-23
No files matched your search
@@ -343,3 +343,9 @@ describe('Spring service connection evidence', () => {
|
||||
expect(links(result)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['$endpoint', 'endpoint$'])('tracks a literal identifier through a transport wrapper: %s', parameter => {
|
||||
const compose = 'services:\n web:\n build: ./web\n api:\n image: example/api\n';
|
||||
const source = `function load(${parameter}) { return fetch(${parameter}); }\nload("http://api:8080/items");`;
|
||||
expect(links(buildContainerTopology([project(compose, { 'web/client.js': source })]))).toEqual(['web>api:http']);
|
||||
});
|
||||
@@ -300,7 +300,12 @@ function networkCall(name: string): boolean {
|
||||
|| /^(?:requests|http|https|axios|urllib\.request|urllib3|grpc|redis|psycopg2?|pymysql|mysql|amqp|amqplib|net|socket|ConnectionMultiplexer|Dns)\./i.test(name)
|
||||
&& /(?:get|post|put|patch|delete|request|urlopen|dial|newclient|connect|connectasync|createclient|createconnection|insecure_channel|secure_channel|gethostentry(?:async)?)$/i.test(name);
|
||||
}
|
||||
const wordIn = (code: string, name: string): boolean => new RegExp(`\\b${name.replace(/[$]/g, '\\$&')}\\b`).test(code);
|
||||
function wordIn(code: string, name: string): boolean {
|
||||
for (const [identifier] of code.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
||||
if (identifier === name) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
interface Wrapper { parameter: number; hint: string }
|
||||
/** Bounded local argument propagation to known transport APIs, not a function-name dictionary. */
|
||||
function connectionWrappers(code: string, calls: Call[]): Map<string, Wrapper> {
|
||||
|
||||
@@ -64,3 +64,10 @@ it('follows only actual call edges across components and retains their evidence'
|
||||
expect(paths[0].edges[0]).toBe(map.evidence[0]);
|
||||
expect(entryRoutes(graph.nodes[2], map.evidence)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps token boundaries when dropping markup from repository prose', () => {
|
||||
const quote = sourceQuote('README.md', 'This repository provides useful source browsing and <<script>script> examples for developers.');
|
||||
expect(quote?.text).not.toContain('<script>');
|
||||
expect(sourceQuote('README.md', 'This repository provides useful source<b>browsing</b> for developers.')?.text)
|
||||
.toBe('This repository provides useful source browsing for developers.');
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export function sourceCandidates(area: RepositoryArea): string[] {
|
||||
}
|
||||
|
||||
function plainMarkdown(text: string): string {
|
||||
return text.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/<[^>]*>/g, '').trim();
|
||||
return text.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Quote only repository-authored prose, never infer a responsibility from an identifier. */
|
||||
|
||||
@@ -102,3 +102,10 @@ describe('facts read from a configuration or text file (K12)', () => {
|
||||
expect(readerFacts(reader('manage.py', 'import os\n'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Removing a comment must not create a root tag that was absent from the input.
|
||||
it('does not join XML markup across removed declarations or comments', () => {
|
||||
expect(fileFactLines('pom.xml', '<<!-- ignored -->fake/><project/>')).toContain('Root element: `<project>`.');
|
||||
expect(fileFactLines('pom.xml', '<<?meta?>fake/><project/>')).toContain('Root element: `<project>`.');
|
||||
expect(fileFactLines('pom.xml', '<!-- <hidden/> --><project/>')).toContain('Root element: `<project>`.');
|
||||
});
|
||||
@@ -130,8 +130,12 @@ function patternFacts(text: string): string[] {
|
||||
}
|
||||
|
||||
function xmlFacts(text: string): string[] {
|
||||
const root = /<([A-Za-z_][\w:.-]*)[\s>/]/.exec(text.replace(/<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<![^>]*>/g, ''));
|
||||
return root ? [words.root(quote(`<${root[1]}>`))] : [];
|
||||
// Skip markup in the original input: deleting it could join fragments into a new tag.
|
||||
const tokens = /<\?[\s\S]*?(?:\?>|$)|<!--[\s\S]*?(?:-->|$)|<![^>]*>|<([A-Za-z_][\w:.-]*)[\s>/]/g;
|
||||
for (const match of text.matchAll(tokens)) {
|
||||
if (match[1]) return [words.root(quote(`<${match[1]}>`))];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The structure of the text, by the kind its name gives. */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Writable } from 'node:stream';
|
||||
import { literalPattern } from './literal-pattern.mjs';
|
||||
import { serveStaticAsset } from './smoke-static-assets.mjs';
|
||||
|
||||
const temporary = [];
|
||||
afterEach(() => { for (const path of temporary.splice(0)) rmSync(path, { recursive: true, force: true }); });
|
||||
|
||||
it.each(['a.b', 'a+b', '(a+)+$', '[project]', 'a\\b'])('treats a project name as literal text: %s', name => {
|
||||
const pattern = literalPattern(name, { start: true, end: true });
|
||||
expect(pattern.test(name)).toBe(true);
|
||||
expect(pattern.test(`prefix${name}`)).toBe(false);
|
||||
expect(pattern.test(`${name}suffix`)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not expand project-name wildcards', () => {
|
||||
expect(literalPattern('a.b', { start: true, end: true }).test('axb')).toBe(false);
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cbm-static-assets-')); temporary.push(root);
|
||||
mkdirSync(join(root, 'spike')); mkdirSync(join(root, 'node_modules')); mkdirSync(join(root, 'spike-extra'));
|
||||
writeFileSync(join(root, 'spike', 'index.html'), 'fixture');
|
||||
writeFileSync(join(root, 'outside.txt'), 'outside-root');
|
||||
writeFileSync(join(root, 'spike-extra', 'outside.txt'), 'sibling-root');
|
||||
return root;
|
||||
}
|
||||
function request(root, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = []; let status;
|
||||
const response = new Writable({ write(chunk, encoding, done) { chunks.push(Buffer.from(chunk)); done(); } });
|
||||
response.writeHead = code => { status = code; };
|
||||
response.on('error', reject);
|
||||
response.on('finish', () => resolve({ status, body: Buffer.concat(chunks).toString() }));
|
||||
try { serveStaticAsset(root, { url }, response); } catch (error) { reject(error); }
|
||||
});
|
||||
}
|
||||
|
||||
it('serves an allowed static fixture and rejects traversal and sibling prefixes', async () => {
|
||||
const root = fixture();
|
||||
expect(await request(root, '/spike/index.html')).toEqual({ status: 200, body: 'fixture' });
|
||||
expect((await request(root, '/spike/../outside.txt')).status).toBe(404);
|
||||
expect((await request(root, '/spike/%2e%2e/outside.txt')).status).toBe(404);
|
||||
expect((await request(root, '/spike-extra/outside.txt')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects a symlink escaping an allowed static root', async () => {
|
||||
const root = fixture();
|
||||
symlinkSync(join(root, 'outside.txt'), join(root, 'spike', 'escape.txt'));
|
||||
expect((await request(root, '/spike/escape.txt')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns not found for malformed URL encoding instead of throwing', async () => {
|
||||
expect((await request(fixture(), '/spike/%zz')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('allows a symlink whose real target stays inside the static root', async () => {
|
||||
const root = fixture();
|
||||
symlinkSync(join(root, 'spike', 'index.html'), join(root, 'spike', 'alias.html'));
|
||||
expect(await request(root, '/spike/alias.html')).toEqual({ status: 200, body: 'fixture' });
|
||||
});
|
||||
@@ -33,6 +33,7 @@
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { architectureNodeSelector, projectNamePattern } from './handtest-locators.mjs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
@@ -446,7 +447,7 @@ async function hoverSweep(page) {
|
||||
const hid = [];
|
||||
let shown = false;
|
||||
for (const id of ids.slice(0, 14)) {
|
||||
const chip = page.locator(`button.architecture-node-label[data-node-id="${id.replace(/"/g, '\\"')}"]`).first();
|
||||
const chip = page.locator(architectureNodeSelector(id)).first();
|
||||
if (!(await chip.isVisible().catch(() => false))) continue;
|
||||
await chip.hover({ timeout: 3000 }).catch(() => {});
|
||||
await wait(450);
|
||||
@@ -498,7 +499,7 @@ async function k21(page) {
|
||||
|
||||
async function switchProject(page, name) {
|
||||
await page.locator('.atlas-shell details summary', { hasText: PROJECT }).first().click();
|
||||
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${name}$`) }) }).first();
|
||||
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: projectNamePattern(name) }) }).first();
|
||||
await entry.waitFor({ timeout: 20000 });
|
||||
await entry.click();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* access to Hugging Face), to switch the project while the download runs; the cache is full again after it.
|
||||
*/
|
||||
|
||||
import { literalPattern } from './literal-pattern.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
@@ -843,7 +844,7 @@ const activeWorkspace = (page) => page.locator('[data-workspace-tab][aria-select
|
||||
const sameText = (left, right) => left.replace(/\d[\d,.:]*/g, '#').replace(/\s+/g, ' ') === right.replace(/\d[\d,.:]*/g, '#').replace(/\s+/g, ' ');
|
||||
async function switchTo(page, project) {
|
||||
await page.locator('.atlas-project-switcher summary').first().click();
|
||||
await page.locator('.atlas-project-picker button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${project}`) }) }).first().click();
|
||||
await page.locator('.atlas-project-picker button', { has: page.locator('.atlas-project-result-name', { hasText: literalPattern(project, { start: true }) }) }).first().click();
|
||||
}
|
||||
|
||||
const pageState = (page) => page.evaluate(() => ({ id: window.__probePage, origin: performance.timeOrigin, workers: window.__probeModelWorkers }));
|
||||
@@ -1064,7 +1065,7 @@ async function k24(page) {
|
||||
const downloads = modelRequests.length - requestsBefore;
|
||||
await save('K24', 'legs.json', legs);
|
||||
const legOk = (leg) => leg.samePage && leg.workspace.before === leg.workspace.after && leg.modelWorkersCreated === 0 && leg.prepares === 0 && leg.lamp.every((frame) => /Agent active/.test(frame.label ?? ''))
|
||||
&& new RegExp(leg.to).test(leg.project ?? '') && leg.requestsNamingOldProject.length === 0 && leg.views.length === K24_VIEWS.length && leg.views.every((view) => !view.stale && !view.unexpected.length) && leg.galaxySameAsFresh
|
||||
&& literalPattern(leg.to).test(leg.project ?? '') && leg.requestsNamingOldProject.length === 0 && leg.views.length === K24_VIEWS.length && leg.views.every((view) => !view.stale && !view.unexpected.length) && leg.galaxySameAsFresh
|
||||
&& (leg.to !== CONTROL || leg.chatHasOldTurn === false) && (leg.to !== PROJECT || leg.how !== 'switcher' || leg.chatKeptOwnTurn === true);
|
||||
check('K24', 'A project switch stays in the page: the model stays loaded (no reload, no new worker, lamp active), every workspace shows only the new project and the Galaxy its graph, no request names the old one; Back/Forward the same, all views checked',
|
||||
loaded && downloads === 0 && legs.every(legOk),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
* wird im Browser beantwortet und navigator.sendBeacon bleibt in der Seite.
|
||||
*/
|
||||
|
||||
import { literalPattern } from './literal-pattern.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
@@ -460,7 +461,7 @@ try {
|
||||
|
||||
// Projektwechsel nach cbm: frischer Verlauf
|
||||
await page.locator('.atlas-shell details summary', { hasText: PROJECT }).first().click();
|
||||
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${CONTROL}$`) }) }).first();
|
||||
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: literalPattern(CONTROL, { start: true, end: true }) }) }).first();
|
||||
await entry.waitFor({ timeout: 20000 });
|
||||
await entry.click();
|
||||
const fresh = { position: '1/1', back: { disabled: true, title: 'Nothing to go back to yet' }, forward: { disabled: true, title: 'Nothing to go forward to' }, recent: [] };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { literalPattern } from './literal-pattern.mjs';
|
||||
|
||||
/** Match one architecture chip by its graph node ID. */
|
||||
export function architectureNodeSelector(id) {
|
||||
// CSS strings need escapes for backslashes, quotes, and control characters.
|
||||
// Terminate each hex escape so the next character cannot extend its code point.
|
||||
const escaped = id.replace(/[\x00-\x1f\x7f"\\]/g, char => `\\${char.codePointAt(0).toString(16)} `);
|
||||
return `button.architecture-node-label[data-node-id="${escaped}"]`;
|
||||
}
|
||||
|
||||
/** Match a complete project name in the project chooser. */
|
||||
export function projectNamePattern(name) {
|
||||
return literalPattern(name, { start: true, end: true });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { architectureNodeSelector, projectNamePattern } from './handtest-locators.mjs';
|
||||
|
||||
it.each(['node', 'node"name', 'node\\name', 'node\\"name', 'node\nname', 'node\rname', 'node\fname', 'node], button'])('selects only the literal graph node ID: %j', id => {
|
||||
const { document } = new JSDOM('<button class="architecture-node-label" data-node-id="other"></button>').window;
|
||||
const target = document.createElement('button');
|
||||
target.className = 'architecture-node-label';
|
||||
target.setAttribute('data-node-id', id);
|
||||
document.body.append(target);
|
||||
expect(document.querySelectorAll(architectureNodeSelector(id))).toHaveLength(1);
|
||||
expect(document.querySelector(architectureNodeSelector(id))).toBe(target);
|
||||
});
|
||||
|
||||
it.each(['a.b', 'a+b', '(project)', '[project]', 'a\\b', '*', '(a+)+$'])('matches a complete literal project name: %j', name => {
|
||||
const pattern = projectNamePattern(name);
|
||||
expect(pattern.test(name)).toBe(true);
|
||||
expect(pattern.test(`prefix${name}`)).toBe(false);
|
||||
expect(pattern.test(`${name}suffix`)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a project-name dot as a wildcard', () => {
|
||||
expect(projectNamePattern('a.b').test('axb')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Build a pattern for literal text with optional start/end anchoring. */
|
||||
export function literalPattern(value, { start = false, end = false } = {}) {
|
||||
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`${start ? '^' : ''}${escaped}${end ? '$' : ''}`);
|
||||
}
|
||||
@@ -31,9 +31,10 @@ import { createServer } from 'node:http';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { mkdtemp, mkdir, writeFile, readFile, stat, rm } from 'node:fs/promises';
|
||||
import { existsSync, createReadStream } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { serveStaticAsset } from './smoke-static-assets.mjs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname, resolve, extname, normalize } from 'node:path';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
@@ -251,8 +252,6 @@ const MIME = {
|
||||
};
|
||||
|
||||
function startStaticProxy(port, cPort) {
|
||||
const allowedRoots = [join(ROOT, 'spike'), join(ROOT, 'node_modules')];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url.split('?')[0] === '/rpc') {
|
||||
const chunks = [];
|
||||
@@ -284,16 +283,7 @@ function startStaticProxy(port, cPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
||||
const candidate = resolve(join(ROOT, normalize(urlPath)));
|
||||
const allowed = allowedRoots.some((root) => candidate === root || candidate.startsWith(root + '/'));
|
||||
if (!allowed || !existsSync(candidate)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': MIME[extname(candidate)] ?? 'application/octet-stream' });
|
||||
createReadStream(candidate).pipe(res);
|
||||
serveStaticAsset(ROOT, req, res, MIME);
|
||||
});
|
||||
|
||||
return new Promise((resolveServer, rejectServer) => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createReadStream, realpathSync, statSync } from 'node:fs';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
|
||||
/** Serve the smoke fixture and installed assets only, including after resolving symlinks. */
|
||||
export function serveStaticAsset(root, req, res, mime = {}) {
|
||||
const notFound = () => {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('not found');
|
||||
};
|
||||
let candidate;
|
||||
try {
|
||||
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
||||
candidate = resolve(root, `.${urlPath}`);
|
||||
} catch {
|
||||
notFound();
|
||||
return;
|
||||
}
|
||||
for (const directory of ['spike', 'node_modules']) {
|
||||
const allowedRoot = resolve(root, directory);
|
||||
if (!candidate.startsWith(allowedRoot + sep)) continue;
|
||||
try {
|
||||
const realRoot = realpathSync(allowedRoot);
|
||||
const realCandidate = realpathSync(candidate);
|
||||
if (!realCandidate.startsWith(realRoot + sep)) continue;
|
||||
if (!statSync(realCandidate).isFile()) break;
|
||||
const stream = createReadStream(realCandidate);
|
||||
stream.once('error', () => {
|
||||
if (!res.headersSent) notFound();
|
||||
else res.destroy();
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': mime[extname(realCandidate)] ?? 'application/octet-stream' });
|
||||
stream.pipe(res);
|
||||
return;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
Reference in new issue
Block a user