Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ jobs:
- name: Type check
run: npm run typecheck

audit:
runs-on: ubuntu-latest
name: Security audit
steps:
- uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22

- name: Install dependencies
run: npm ci

- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high

verify-imports:
runs-on: ubuntu-latest
name: Verify dynamic imports
Expand Down Expand Up @@ -137,7 +154,7 @@ jobs:

ci-pipeline:
if: always()
needs: [lint, test, typecheck, verify-imports, rust-check]
needs: [lint, test, typecheck, audit, verify-imports, rust-check]
runs-on: ubuntu-latest
name: CI Testing Pipeline
steps:
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 93 additions & 5 deletions scripts/build-wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { execFileSync } from 'child_process';
import { mkdirSync, existsSync } from 'fs';
import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
Expand All @@ -22,6 +22,70 @@ const grammarsDir = resolve(root, 'grammars');

if (!existsSync(grammarsDir)) mkdirSync(grammarsDir);

// Allowed WASM imports — pure C runtime / memory primitives only (no I/O, no syscalls)
const ALLOWED_WASM_IMPORTS = new Set([
'env.memory',
'env.__indirect_function_table',
'env.__memory_base',
'env.__stack_pointer',
'env.__table_base',
'env.abort',
'env.__assert_fail',
'env.calloc',
'env.malloc',
'env.realloc',
'env.free',
'env.memchr',
'env.memcmp',
'env.strcmp',
'env.strlen',
'env.iswalnum',
'env.iswalpha',
'env.iswlower',
'env.iswspace',
'env.iswupper',
'env.iswxdigit',
'env.towupper',
]);

const WASM_MAGIC = '0061736d';

async function validateGrammar(wasmPath: string, expectedName: string): Promise<string[]> {
const errors: string[] = [];
const buf = readFileSync(wasmPath);

if (buf.slice(0, 4).toString('hex') !== WASM_MAGIC) {
errors.push('not a valid WASM binary (bad magic bytes)');
return errors;
}

const mod = await WebAssembly.compile(buf);
const exports = WebAssembly.Module.exports(mod).map((e) => e.name);
const imports = WebAssembly.Module.imports(mod).map((e) => `${e.module}.${e.name}`);

const tsExports = exports.filter((e) => e.startsWith('tree_sitter_'));
if (tsExports.length !== 1) {
errors.push(`expected 1 tree_sitter_ export, found ${tsExports.length}: [${tsExports.join(', ')}]`);
}

// Verify the export name matches the expected grammar (prevents substitution attacks)
const expectedExport = expectedName.replace(/-/g, '_');
if (tsExports.length === 1 && tsExports[0] !== expectedExport) {
errors.push(`expected export '${expectedExport}', found '${tsExports[0]}'`);
}

if (exports.length < 2) {
errors.push(`expected at least 2 exports, found ${exports.length}: [${exports.join(', ')}]`);
}

const disallowed = imports.filter((i) => !ALLOWED_WASM_IMPORTS.has(i));
if (disallowed.length > 0) {
errors.push(`disallowed WASM imports: [${disallowed.join(', ')}]`);
}

return errors;
}

const grammars = [
{ name: 'tree-sitter-javascript', pkg: 'tree-sitter-javascript', sub: null },
{ name: 'tree-sitter-typescript', pkg: 'tree-sitter-typescript', sub: 'typescript' },
Expand Down Expand Up @@ -61,6 +125,8 @@ const grammars = [
];

let failed = 0;
let rejected = 0;

for (const g of grammars) {
const pkgDir = dirname(require.resolve(`${g.pkg}/package.json`));
const grammarDir = g.sub ? resolve(pkgDir, g.sub) : pkgDir;
Expand All @@ -72,15 +138,37 @@ for (const g of grammars) {
stdio: 'inherit',
shell: true,
});
console.log(` Done: ${g.name}.wasm`);
} catch (err: any) {
failed++;
console.warn(` WARN: Failed to build ${g.name}.wasm — ${err.message ?? 'unknown error'}`);
continue;
}

// Validate the built grammar is a legitimate tree-sitter WASM module
const wasmFile = resolve(grammarsDir, `${g.name}.wasm`);
if (!existsSync(wasmFile)) {
failed++;
console.warn(` WARN: ${g.name}.wasm not found after build`);
continue;
}

const errors = await validateGrammar(wasmFile, g.name);
if (errors.length > 0) {
rejected++;
unlinkSync(wasmFile);
console.error(` REJECTED: ${g.name}.wasm failed validation and was deleted:`);
for (const e of errors) console.error(` - ${e}`);
} else {
console.log(` OK: ${g.name}.wasm (validated)`);
}
}

if (failed > 0) {
console.warn(`\n${failed}/${grammars.length} grammars failed to build (non-fatal — native engine available)`);
const total = failed + rejected;
if (total > 0) {
console.warn(`\n${failed} build failures, ${rejected} validation rejections out of ${grammars.length} grammars (non-fatal — native engine available)`);
if (rejected > 0) {
console.error('SECURITY: Some grammars were rejected — inspect the source packages before retrying.');
}
} else {
console.log('\nAll grammars built successfully into grammars/');
console.log('\nAll grammars built and validated successfully into grammars/');
}
Loading