dev.specpilot/specpilot
version 1.0.0
- scanned 2026-10-16
- source github.com/girishr/SpecPilot
- commit 34cd658c849d
- transport streamable-http
- files read 85
- methodology 0.2.0
- ruleset 0.1.1
No tag matching version 1.0.0 was found in this repository, so the scan read the default branch instead. The findings below describe that code, not necessarily the released version.
80
32 findings in test code are listed but not deducted
92measured on 65% of this axis
0
93measured on 75% of this axis
91measured on 80% of this axis
The evidence
Every deduction below names the artefact that produced it. Nothing on this page was executed: the source was read, never run, and no running server was contacted.
Code Safety
Showing the 50 most severe of 51 findings on this axis. The score was computed over all of them — findings beyond the fourth of a severity still deduct at a tenth of their weight — so the remainder is omitted from this page, not from the arithmetic.
- src/core/chatFlow.ts:1196mcp-js-prototype-pollution-merge-loop (medium/medium)
Keys from a caller-supplied object are copied onto a target without excluding __proto__ / constructor / prototype. A tool argument carrying __proto__ then mutates Object.prototype for the whole process, which can turn an unrelated later property read into attacker-controlled data.
if (key === 'integrations') { const m = Object.fromEntries(Object.entries(map(a, key)).map(([k, v]) => [k, v.filter(x => x !== 'None')]).filter(([, v]) => v.length)); if (Object.keys(m).length) body[key] = m; } else if (q.type === 'text') { if (typeof value === 'string' && value.trim()) body[key] = value.trim(); - src/core/chatFlow.ts:1198mcp-js-prototype-pollution-merge-loop (medium/medium)
Keys from a caller-supplied object are copied onto a target without excluding __proto__ / constructor / prototype. A tool argument carrying __proto__ then mutates Object.prototype for the whole process, which can turn an unrelated later property read into attacker-controlled data.
if (Object.keys(m).length) body[key] = m; } else if (q.type === 'text') { if (typeof value === 'string' && value.trim()) body[key] = value.trim(); } else if (q.type === 'choice' || q.type === 'cards') { const v = typeof value === 'string' ? labelsOf(q, [value]) : []; - src/core/chatFlow.ts:1201mcp-js-prototype-pollution-merge-loop (medium/medium)
Keys from a caller-supplied object are copied onto a target without excluding __proto__ / constructor / prototype. A tool argument carrying __proto__ then mutates Object.prototype for the whole process, which can turn an unrelated later property read into attacker-controlled data.
} else if (q.type === 'choice' || q.type === 'cards') { const v = typeof value === 'string' ? labelsOf(q, [value]) : []; if (v.length) body[key] = v[0]; } else { const v = labelsOf(q, asArray(value)); - src/core/chatFlow.ts:1204mcp-js-prototype-pollution-merge-loop (medium/medium)
Keys from a caller-supplied object are copied onto a target without excluding __proto__ / constructor / prototype. A tool argument carrying __proto__ then mutates Object.prototype for the whole process, which can turn an unrelated later property read into attacker-controlled data.
} else { const v = labelsOf(q, asArray(value)); if (v.length) body[key] = v; } } - src/core/render.ts:96mcp-js-prototype-pollution-merge-loop (medium/medium)
Keys from a caller-supplied object are copied onto a target without excluding __proto__ / constructor / prototype. A tool argument carrying __proto__ then mutates Object.prototype for the whole process, which can turn an unrelated later property read into attacker-controlled data.
); } if (present(value)) out[key] = value; } return out as OptionalFields; - src/utils/specSetup.ts:124mcp-js-prototype-pollution-merge-loop (medium/medium)
Keys from a caller-supplied object are copied onto a target without excluding __proto__ / constructor / prototype. A tool argument carrying __proto__ then mutates Object.prototype for the whole process, which can turn an unrelated later property read into attacker-controlled data.
for (const language of SUPPORTED_LANGUAGES) { const list = getFrameworksForLanguage(language); if (list.length) frameworks[language] = ['none', ...list]; } return frameworks; - src/commands/serve.ts:55mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
function loopback(port: number, method: string, path: string, headers: Record<string, string> = {}, body?: string): Promise<{ status: number; body: string }> { return new Promise((resolveRequest, reject) => { const req = request({ host: '127.0.0.1', port, method, path, headers, timeout: 5000, agent: false }, res => { let text = ''; res.setEncoding('utf8'); - src/utils/codeAnalyzer.ts:106mcp-js-path-join-unvalidated (medium/low)
A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
testFiles.forEach(file => { try { const content = readFileSync(join(dir, file), 'utf-8'); // Count test cases - src/utils/specServer.ts:1002mcp-js-path-join-unvalidated (medium/low)
A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
} const ui = UI_ROUTES[url.pathname]; if (ui) return send(res, 200, ui[1], readFileSync(join(UI_DIR, ui[0]))); if (url.pathname !== '/api/specs' && url.pathname !== '/api/file' && url.pathname !== '/api/events') return send(res, 404, TEXT, 'Not Found\n'); const i = projectIndex(url, roots.length); - src/utils/specSetup.ts:374mcp-js-path-join-unvalidated (medium/low)
A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
createdFiles.push(target); try { writeSync(fd, readFileSync(join(staging, ...rel.split('/')))); fsyncSync(fd); } finally { - ui/app.js:47mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
if(p in fileCache)return fileCache[p]; const pj=PROJECT; const r=await fetch('/api/file?project='+pj+'&p='+encodeURIComponent(p),{cache:'no-store'}); if(!r.ok)throw Object.assign(new Error(p+': HTTP '+r.status),{status:r.status}); const text=await r.text(); - ui/app.js:293mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
let r,body={}; try{ r=await fetch('/api/commands/regenerate?'+pq(),{method:'POST',cache:'no-store',headers:{'Content-Type':'application/json','X-SpecPilot-Token':TOKEN},body:'{}'}); body=await r.json().catch(()=>({})); }catch(e){r=null;} - ui/app.js:429mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
let r,body={}; try{ r=await fetch('/api/tasks/move?'+pq(),{method:'POST',cache:'no-store',headers:{'Content-Type':'application/json','X-SpecPilot-Token':TOKEN,'If-Match':DATA.tasks.sha256},body:JSON.stringify({id:t.id,toSection,toIndex})}); body=await r.json().catch(()=>({})); }catch(e){moving=false;toast('The server did not answer. Nothing was moved.');return;} - ui/app.js:463mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
let r,body={}; try{ r=await fetch('/api/tasks/new?'+pq(),{method:'POST',cache:'no-store',headers:{'Content-Type':'application/json','X-SpecPilot-Token':TOKEN,'If-Match':DATA.tasks.sha256},body:JSON.stringify({description:taskIn.value,section})}); body=await r.json().catch(()=>({})); }catch(e){r=null;} - ui/app.js:617mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
async function redraw(paths){ let d;const p=PROJECT; try{const r=await fetch('/api/specs?'+pq(),{cache:'no-store'});if(!r.ok)return;d=await r.json();}catch(e){return;} // server gone: keep what is shown if(p!==PROJECT)return; // switched projects meanwhile // The poller's echo of a move this page just made: same tasks.md hash, nothing to redraw. - ui/app.js:664mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
const was=PROJECT;PROJECT=n; let d; try{const r=await fetch('/api/specs?'+pq(),{cache:'no-store'});if(!r.ok)throw new Error('/api/specs: HTTP '+r.status);d=await r.json();} catch(e){if(PROJECT===n)PROJECT=was;toast('Could not load the project: '+e.message);return;} if(PROJECT!==n)return; - ui/app.js:729mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
} async function postPath(path,url){ const r=await fetch(url,{method:'POST',cache:'no-store',headers:{'Content-Type':'application/json','X-SpecPilot-Token':TOKEN},body:JSON.stringify({path})}); return {r,res:await r.json().catch(()=>({}))}; } - ui/app.js:853mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
$('#gDone').classList.add('closed'); PROJECT=resolveRoute(curHash(),{},Infinity).project; fetch('/api/specs?'+pq(),{cache:'no-store'}) .then(r=>{if(!r.ok&&PROJECT){PROJECT=0;return fetch('/api/specs?'+pq(),{cache:'no-store'});}return r;}) .then(r=>{if(!r.ok)throw new Error('/api/specs: HTTP '+r.status);return r.json();}) - ui/app.js:854mcp-js-ssrf-nonliteral-url (medium/low)
An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
PROJECT=resolveRoute(curHash(),{},Infinity).project; fetch('/api/specs?'+pq(),{cache:'no-store'}) .then(r=>{if(!r.ok&&PROJECT){PROJECT=0;return fetch('/api/specs?'+pq(),{cache:'no-store'});}return r;}) .then(r=>{if(!r.ok)throw new Error('/api/specs: HTTP '+r.status);return r.json();}) .then(d=>{DATA=d;renderProject();renderTasks();renderIde();renderAgentFiles();route();listen();}) - src/__tests__/slashCommandGenerator.test.ts:432mcp-js-dynamic-code-execution (critical/medium)
Test code — listed, not deducted: `03` §3 grades the code a user runs. Caller-influenced input reaches an evaluator. eval, the Function constructor and vm.runInNewContext all execute their argument; none of them sandbox anything by default, vm least of all despite the name.
const start = src.indexOf('const skillContent = `') + 'const skillContent = `'.length; const literal = src.slice(start, src.indexOf('`;\n', start)); const template = new Function(`return \`${literal}\`;`)() as string; return (c: { projectName: string; language: string; framework?: string }) => template - src/__browser__/home.browser.test.ts:128mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
let bin: string; let realPath: string | undefined; const go = (step: string) => writeFileSync(join(bin, step), ''); beforeEach(() => { bin = join(env.base, 'bin'); - src/__browser__/setupview.browser.test.ts:28mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
new Promise<number>((resolve, reject) => { const body = JSON.stringify(msg); const req = request(`${srv.url}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-SpecPilot-Token': srv.handle.mcpToken!, 'Content-Length': Buffer.byteLength(body) } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode!)); - src/__browser__/setupview.browser.test.ts:39mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
new Promise<{ structuredContent: { project: number; written: string[]; validate: { errors: string[]; warnings: string[] } }; isError?: boolean; content: { text: string }[] }>((resolve, reject) => { const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); const req = request(`${srv.url}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-SpecPilot-Token': srv.handle.mcpToken!, 'Content-Length': Buffer.byteLength(body) } }, res => { let text = ''; res.on('data', c => (text += c)); - src/__browser__/views.browser.test.ts:106mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const cursorOn = (root: string) => { mkdirSync(join(root, '.cursor', 'rules'), { recursive: true }); writeFileSync(join(root, ...RULES), '---\nalwaysApply: true\n---\n'); }; const onboarding = (prompt: string) => `---\ntitle: Onboarding Prompt\n---\n\n> **One-time file — delete after use.**\n\n---\n\n${prompt}\n`; - src/__browser__/views.browser.test.ts:135mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const prompt = 'You are the specification co-pilot for "spec-project".\nFill every file & keep #IDs, 100% of them. Étape 日本.'; let r = await rev(page); writeFileSync(join(env.project, ...ONB), onboarding(prompt)); await redrawn(page, r); const items = await ideItems(page); - src/__browser__/views.browser.test.ts:170mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
expect(routeJs.cursorPromptUrl(long)).toBeNull(); r = await rev(page); writeFileSync(join(env.project, ...ONB), onboarding(long)); await redrawn(page, r); expect((await ideItems(page))[1]).toEqual(['Send onboarding prompt to Cursor', null]); - src/__tests__/folderPicker.test.ts:21mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
/** A stub dialog: logs its arguments one per line, its working directory and pid, then acts on STUB_MODE. */ function installStub(name: string): void { writeFileSync( join(bin, name), `#!/bin/sh - src/__tests__/folderPicker.test.ts:39mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
); } const logged = (what: string) => readFileSync(`${log}.${what}`, 'utf-8'); const args = () => logged('args').replace(/\n$/, '').split('\n'); const alive = (pid: number) => { - src/__tests__/folderPicker.test.ts:210mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
const h: Record<string, string> = {}; for (const [k, v] of Object.entries(headers)) if (v !== undefined) h[k] = v; req = request({ host: '127.0.0.1', port, path, method, headers: h }, res => { let text = ''; res.setEncoding('utf8'); - src/__tests__/gitClone.test.ts:479mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
let log: string; let target: string; const logged = (what: string) => readFileSync(`${log}.${what}`, 'utf-8').trim(); const envOf = () => Object.fromEntries(logged('env').split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])); beforeEach(() => { - src/__tests__/gitClone.test.ts:637mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
return new Promise((resolve, reject) => { let text = ''; const req = request({ host: '127.0.0.1', port, path, headers: { Host: `127.0.0.1:${port}` } }, res => { res.setEncoding('utf8'); res.on('data', c => (text += c)); - src/__tests__/gitClone.test.ts:654mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
const h: Record<string, string> = {}; for (const [k, v] of Object.entries(headers)) if (v !== undefined) h[k] = v; req = request({ host: '127.0.0.1', port, path, method, headers: h }, res => { let text = ''; res.setEncoding('utf8'); - src/__tests__/gitClone.test.ts:694mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const clone = (over: Record<string, unknown> = {}, headers = good()) => post('/api/projects/clone', { url: URL, parent, name: '', ...over }, headers).answer; const projects = async () => (await get('/api/specs')).json.projects as unknown[]; const logged = (what: string) => readFileSync(`${log}.${what}`, 'utf-8').trim(); const stubRan = () => existsSync(`${log}.args`); const stubGone = () => until(() => !alive(Number(logged('pid'))) && !alive(Number(logged('child')))); - src/__tests__/projectDetector.test.ts:419mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
])('a Node project with %j uses %s', async (locks, expected) => { pkg(); for (const f of locks) writeFileSync(join(testDir, f), ''); expect((await more())!.packageManager).toBe(expected); }); - src/__tests__/projectDetector.test.ts:429mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
])('a Python project with %j uses %s', async (locks, expected) => { writeFileSync(join(testDir, 'requirements.txt'), 'flask\n'); for (const f of locks) writeFileSync(join(testDir, f), ''); expect((await more())!.packageManager).toBe(expected); }); - src/__tests__/slashCommandGenerator.test.ts:183mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
for (const f of readdirSync(join(dir, sub))) { const rel = `${sub}/${f}`; if (!(KNOWN_COMMAND_HASHES[rel] ?? []).includes(sha(readFileSync(join(dir, sub, f))))) missing.push(rel); } } - src/__tests__/slashCommandGenerator.test.ts:530mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
for (const f of render(initOptions('', 'app', { projectType: 'greenfield', language: 'typescript', framework: 'react', apiParadigm, handle: 'jsmith', ide: 'claude-code', whatItDoes: '', targetUsers: '', expectedScale: '', constraints: '' } as never)).files) { mkdirSync(dirname(join(dir, f.path)), { recursive: true }); writeFileSync(join(dir, f.path), f.content); } }; - src/__tests__/specCore.test.ts:53mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const p = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) Object.assign(out, walk(root, p)); else if (e.isFile()) out[p] = readFileSync(join(root, p), 'utf-8'); } return out; - src/__tests__/specCore.test.ts:130mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
test.each(files)('%s imports only handlebars and files beside it, and uses no Node global', file => { const source = readFileSync(join(core, file), 'utf-8'); const specifiers = [...source.matchAll(/^(?:import|export)\s[^'"]*from\s+'([^']+)'/gm)].map(m => m[1]); for (const s of specifiers) expect(s === 'handlebars' || /^\.\/[A-Za-z]+$/.test(s)).toBe(true); - src/__tests__/specPoller.test.ts:31mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
function write(root: string, rel: string, content: string): void { mkdirSync(join(root, rel, '..'), { recursive: true }); writeFileSync(join(root, rel), content); } - src/__tests__/specServer.test.ts:51mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
function write(root: string, rel: string, content: string): void { mkdirSync(join(root, rel, '..'), { recursive: true }); writeFileSync(join(root, rel), content); } - src/__tests__/specServer.test.ts:305mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
function hit(port: number, path: string, opts: { method?: string; host?: string } = {}) { return new Promise<{ status: number; headers: Record<string, unknown>; body: string }>((resolve, reject) => { const req = request( { host: '127.0.0.1', port, path, method: opts.method ?? 'GET', headers: { Host: opts.host ?? `127.0.0.1:${port}` } }, res => { - src/__tests__/specServer.test.ts:414mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
for (const f of readdirSync(ui)) { expect(f).not.toMatch(/\.(woff2?|ttf|otf|eot)$/i); expect(readFileSync(join(ui, f), 'utf-8')).not.toMatch(/@font-face|fonts\.googleapis|fonts\.gstatic/); } const ofl = readFileSync(join(ui, 'OFL-Sixtyfour.txt'), 'utf-8'); - src/__tests__/specServer.test.ts:640mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
(resolve, reject) => { let text = ''; const req = request({ host: '127.0.0.1', port, path, headers: { Host: host } }, res => { res.setEncoding('utf8'); res.on('data', c => (text += c)); - src/__tests__/specServer.test.ts:1200mcp-js-ssrf-nonliteral-url (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. An outbound request is made to a URL that is not a literal. If any part is caller-supplied the server can be aimed at internal hosts it can reach and the caller cannot.
const h: Record<string, string | string[]> = {}; for (const [k, v] of Object.entries(headers)) if (v !== undefined) h[k] = v; const req = request({ host: '127.0.0.1', port, path, method: 'POST', headers: h }, res => { let text = ''; res.setEncoding('utf8'); - src/__tests__/specServer.test.ts:2050mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const p = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) Object.assign(out, snapshot(root, p)); else if (e.isFile()) out[p] = readFileSync(join(root, p), 'utf-8'); } return out; - src/__tests__/specServer.test.ts:2504mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const marked = (root: string, name: string) => { mkdirSync(join(root, name)); writeFileSync(join(root, name, STAGING_MARKER), 'm\n'); writeFileSync(join(root, name, 'CLAUDE.md'), 'staged\n'); }; - src/__tests__/specServer.test.ts:2532mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
if (process.getuid && process.getuid() === 0) return; // root ignores folder permissions mkdirSync(join(e.root, '.specpilot-setup-0123456789ab')); writeFileSync(join(e.root, '.specpilot-setup-0123456789ab', STAGING_MARKER), 'm\n'); chmodSync(join(e.root, '.specpilot-setup-0123456789ab'), 0o500); // its contents cannot be unlinked try { - src/__tests__/specSetup.test.ts:36mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
const p = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) Object.assign(out, tree(root, p)); else if (e.isFile()) out[p] = readFileSync(join(root, p)); } return out; - src/__tests__/specSetup.test.ts:48mcp-js-path-join-unvalidated (medium/low)
Test code — listed, not deducted: `03` §3 grades the code a user runs. A filesystem path is assembled from a non-literal and read or written without confinement. path.join does not resolve away `..`, and path.resolve discards the base entirely on an absolute second component.
for (const [rel, content] of Object.entries(files)) { mkdirSync(join(root, rel, '..'), { recursive: true }); writeFileSync(join(root, rel), content); } return { base, root };
Auth Posture
- authentication_model
auth enforcement present at a call site in the source (auth middleware) and documented in the README; "refused without credentials" (`03` §4) established by reading the code rather than by connecting
- transport_security
declared endpoint(s) are HTTPS: https://init.specpilot.dev/mcp; scored 80, not 100: `03` §4 reserves 100 for HTTPS *with HSTS*, and an HSTS header can only be seen by connecting, which `04` §9 does not do. This is an unverifiable band, not an observed absence.
- secret_handling
detect-secrets matched 184 credential-shaped string(s), every one from a keyword or entropy heuristic (Basic Auth Credentials, Hex High Entropy String). Those flag placeholders, variable names and content hashes as readily as credentials, so they do not establish the committed credential `03` §4's 0 asserts, and this sub-check is not scored; Basic Auth Credentials at .specs/quality/tests.md:65; Basic Auth Credentials at .specs/quality/tests.md:103; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:5; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:6; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:7; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:8; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:9; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:10; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:11; Hex High Entropy String at src/__tests__/__snapshots__/specCore.test.ts.snap:12; …and 174 more matches
- authorization_granularity
no tool registration was recognised anywhere in the source. An MCP server registers tools by definition, so this is a gap in our detection rather than a server with none, and `03` §4's threshold cannot be applied to it.
Dependency Health
- package-lock.jsonhandlebars 4.7.9 — GHSA-8r5x-fm3f-whwj
direct, CVSS 9.8, fixed in 4.7.10
https://osv.dev/vulnerability/GHSA-8r5x-fm3f-whwj - package-lock.jsonhandlebars 4.7.9 — GHSA-p8wg-vrv2-v86f
direct, CVSS 9.2, fixed in 4.7.10
https://osv.dev/vulnerability/GHSA-p8wg-vrv2-v86f - package-lock.jsonjs-yaml 3.14.2 — GHSA-2883-xcg3-v3hh
direct, CVSS 7.5, fixed in 3.15.2
https://osv.dev/vulnerability/GHSA-2883-xcg3-v3hh - package-lock.jsonjs-yaml 4.1.1 — GHSA-2883-xcg3-v3hh
direct, CVSS 7.5, fixed in 4.3.2
https://osv.dev/vulnerability/GHSA-2883-xcg3-v3hh - package-lock.jsonjs-yaml 3.14.2 — GHSA-52cp-r559-cp3m
direct, CVSS 7.5, fixed in 3.15.0
https://osv.dev/vulnerability/GHSA-52cp-r559-cp3m - package-lock.jsonjs-yaml 4.1.1 — GHSA-52cp-r559-cp3m
direct, CVSS 7.5, fixed in 4.3.0
https://osv.dev/vulnerability/GHSA-52cp-r559-cp3m - package-lock.jsonjs-yaml 3.14.2 — GHSA-5p4m-2wfm-xmqj
direct, CVSS 7.5, fixed in 3.15.1
https://osv.dev/vulnerability/GHSA-5p4m-2wfm-xmqj - package-lock.jsonjs-yaml 4.1.1 — GHSA-5p4m-2wfm-xmqj
direct, CVSS 7.5, fixed in 4.3.1
https://osv.dev/vulnerability/GHSA-5p4m-2wfm-xmqj - package-lock.jsonbraces 3.0.3 — GHSA-vfj7-8cjw-p6xm
transitive, CVSS 8.7
https://osv.dev/vulnerability/GHSA-vfj7-8cjw-p6xm - package-lock.jsonbrace-expansion 1.1.13 — GHSA-3jxr-9vmj-r5cp
transitive, CVSS 7.7, fixed in 1.1.16
https://osv.dev/vulnerability/GHSA-3jxr-9vmj-r5cp - package-lock.jsonbrace-expansion 2.0.3 — GHSA-3jxr-9vmj-r5cp
transitive, CVSS 7.7, fixed in 2.1.2
https://osv.dev/vulnerability/GHSA-3jxr-9vmj-r5cp - package-lock.jsonbrace-expansion 5.0.6 — GHSA-3jxr-9vmj-r5cp
transitive, CVSS 7.7, fixed in 5.0.7
https://osv.dev/vulnerability/GHSA-3jxr-9vmj-r5cp - package-lock.jsonbrace-expansion 1.1.13 — GHSA-6j4f-fj2g-mc7p
transitive, CVSS 7.5, fixed in 1.1.19
https://osv.dev/vulnerability/GHSA-6j4f-fj2g-mc7p - package-lock.jsonbrace-expansion 2.0.3 — GHSA-6j4f-fj2g-mc7p
transitive, CVSS 7.5, fixed in 2.1.5
https://osv.dev/vulnerability/GHSA-6j4f-fj2g-mc7p - package-lock.jsonbrace-expansion 5.0.6 — GHSA-6j4f-fj2g-mc7p
transitive, CVSS 7.5, fixed in 5.0.10
https://osv.dev/vulnerability/GHSA-6j4f-fj2g-mc7p - package-lock.jsonbrace-expansion 1.1.13 — GHSA-mh99-v99m-4gvg
transitive, CVSS 7.5, fixed in 1.1.17
https://osv.dev/vulnerability/GHSA-mh99-v99m-4gvg - package-lock.jsonbrace-expansion 2.0.3 — GHSA-mh99-v99m-4gvg
transitive, CVSS 7.5, fixed in 2.1.3
https://osv.dev/vulnerability/GHSA-mh99-v99m-4gvg - package-lock.jsonbrace-expansion 5.0.6 — GHSA-mh99-v99m-4gvg
transitive, CVSS 7.5, fixed in 5.0.8
https://osv.dev/vulnerability/GHSA-mh99-v99m-4gvg - package-lock.jsonbrace-expansion 1.1.13 — GHSA-qhr7-859c-m2p7
transitive, CVSS 7.5, fixed in 1.1.20
https://osv.dev/vulnerability/GHSA-qhr7-859c-m2p7 - package-lock.jsonbrace-expansion 2.0.3 — GHSA-qhr7-859c-m2p7
transitive, CVSS 7.5, fixed in 2.1.6
https://osv.dev/vulnerability/GHSA-qhr7-859c-m2p7 - package-lock.jsonbrace-expansion 5.0.6 — GHSA-qhr7-859c-m2p7
transitive, CVSS 7.5, fixed in 5.0.11
https://osv.dev/vulnerability/GHSA-qhr7-859c-m2p7 - package-lock.jsonbrace-expansion 1.1.13 — GHSA-rgw5-rvv9-x895
transitive, CVSS 7.5, fixed in 1.1.18
https://osv.dev/vulnerability/GHSA-rgw5-rvv9-x895 - package-lock.jsonbrace-expansion 2.0.3 — GHSA-rgw5-rvv9-x895
transitive, CVSS 7.5, fixed in 2.1.4
https://osv.dev/vulnerability/GHSA-rgw5-rvv9-x895 - package-lock.jsonbrace-expansion 5.0.6 — GHSA-rgw5-rvv9-x895
transitive, CVSS 7.5, fixed in 5.0.9
https://osv.dev/vulnerability/GHSA-rgw5-rvv9-x895 - package-lock.jsonbrowserslist 4.26.2 — GHSA-73wf-gq98-2v4g
transitive, CVSS 7.5, fixed in 4.28.7
https://osv.dev/vulnerability/GHSA-73wf-gq98-2v4g - package-lock.jsonbrowserslist 4.26.2 — GHSA-c83g-rgw3-j3cx
transitive, CVSS 7.5, fixed in 4.28.7
https://osv.dev/vulnerability/GHSA-c83g-rgw3-j3cx - package-lock.jsonjs-yaml 3.14.2 — GHSA-h67p-54hq-rp68
direct, CVSS 5.3, fixed in 3.15.0
https://osv.dev/vulnerability/GHSA-h67p-54hq-rp68 - package-lock.jsonjs-yaml 4.1.1 — GHSA-h67p-54hq-rp68
direct, CVSS 5.3, fixed in 4.2.0
https://osv.dev/vulnerability/GHSA-h67p-54hq-rp68 - package-lock.jsonhandlebars 4.7.9 — GHSA-xw65-4hp5-5hc7
direct, CVSS 4.7, fixed in 4.7.10
https://osv.dev/vulnerability/GHSA-xw65-4hp5-5hc7 - package-lock.jsonsprintf-js 1.0.3 — GHSA-hp3w-g68c-fv3c
transitive, CVSS 6.9
https://osv.dev/vulnerability/GHSA-hp3w-g68c-fv3c - package-lock.jsonbaseline-browser-mapping 2.8.6 — GHSA-w5vr-8v7q-w6rv
transitive, CVSS 6.6, fixed in 2.11.0
https://osv.dev/vulnerability/GHSA-w5vr-8v7q-w6rv - package-lock.json@humanfs/node 0.16.7 — GHSA-p498-v437-472g
transitive, CVSS 5.7, fixed in 0.16.8
https://osv.dev/vulnerability/GHSA-p498-v437-472g - package-lock.jsonbrace-expansion 1.1.13 — GHSA-q2hr-2g5m-vwhr
transitive, CVSS 5.3, fixed in 1.1.21
https://osv.dev/vulnerability/GHSA-q2hr-2g5m-vwhr - package-lock.jsonbrace-expansion 2.0.3 — GHSA-q2hr-2g5m-vwhr
transitive, CVSS 5.3, fixed in 2.1.7
https://osv.dev/vulnerability/GHSA-q2hr-2g5m-vwhr - package-lock.jsonbrace-expansion 5.0.6 — GHSA-q2hr-2g5m-vwhr
transitive, CVSS 5.3, fixed in 5.0.12
https://osv.dev/vulnerability/GHSA-q2hr-2g5m-vwhr - package-lock.json@babel/core 7.28.4 — GHSA-4x5r-pxfx-6jf8
transitive, CVSS 3.2, fixed in 7.29.6
https://osv.dev/vulnerability/GHSA-4x5r-pxfx-6jf8
Maintenance
- recency
last commit 2 day(s) before 2026-10-16
- issue_responsiveness
lifetime median (1 issue(s) in the last 6 months, under `03` §5's floor of 3): 3.4 day(s) to first maintainer comment
- release_cadence
released 2 day(s) ago (by releases), inside `03` §5's 30-day window
- bus_factor
this repository's 12-month history is longer than the bounded walk `04` §7 performs, and the authors found so far span more than one of `03` §5's bands. A limit of ours, not a finding about the server.
- repository_signals
`03` §5 scores this by quartile WITHIN the server's category, which needs a distribution across every scanned server and so cannot be computed while scanning one. It is a post-pass over the crawl and that pass does not exist yet.
Transparency
- license
LICENSE present with no SPDX-License-Identifier tag; identified as 'MIT' from its text
- readme_quality
README.md: installation instructions (+25), configuration documented (+25), exposed tools or resources enumerated (+30), states what the server does in the first 200 words (+20)
- declared_scopes
specific scope or access documentation is present, but `03` §7's 100 and 60 bands both compare it against scopes INFERRED from static analysis, which is not built yet. Scoring 100 on an empty inference would be a vacuous truth — 'all ∅ inferred scopes are documented'.
- changelog
CHANGELOG.md present
- security_contact
.specs/security/security-decisions.md present
Contest a finding
Every number here comes from a rule reading published source, and rules get things wrong. If something on this page is incorrect — or correct but mitigated by a check the rule cannot see — open an issue and we answer it in writing. A finding we got wrong is removed and the rule is fixed; one that stands is answered on the record.
Anyone can file: the maintainer, a researcher who disagrees with the classification, or a user who tested the server and could not reproduce it. Name this server and the rule or advisory ID shown beside the finding above. We aim to reply within five business days and to resolve within ten.