Skip to content
Open
4,790 changes: 2,412 additions & 2,378 deletions extractors/cds/tools/dist/cds-extractor.bundle.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions extractors/cds/tools/dist/cds-extractor.bundle.js.map

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions extractors/cds/tools/src/cds/compiler/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@ import { existsSync, readdirSync } from 'fs';
import { join, resolve } from 'path';

import type { ValidatedCdsCommand } from './types';
import { getPlatformInfo } from '../../environment';
import { fileExists } from '../../filesystem';
import { cdsExtractorLog } from '../../logging';
import type { CdsDependencyGraph } from '../parser/types';

/** Default timeout for command execution in milliseconds. **/
export const DEFAULT_COMMAND_TIMEOUT_MS = 10000;

/**
* Returns the platform-appropriate name of the local cds binary as
* created by `npm install` under `node_modules/.bin/`. On Windows the
* directly-executable file is `cds.cmd`; on Unix it is `cds` (a shell
* script). Using the wrong name causes execFileSync to fail (EINVAL on
* Windows + Node 20+ for shimless paths).
*/
function localCdsBinName(): string {
return getPlatformInfo().isWindows ? 'cds.cmd' : 'cds';
}

/**
* Cache for CDS command test results to avoid running the same CLI commands repeatedly.
*/
Expand Down Expand Up @@ -336,7 +348,7 @@ function discoverAvailableCacheDirs(sourceRoot: string): string[] {
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('cds-')) {
const cacheDir = join(cacheRootDir, entry.name);
const cdsBin = join(cacheDir, 'node_modules', '.bin', 'cds');
const cdsBin = join(cacheDir, 'node_modules', '.bin', localCdsBinName());
if (fileExists(cdsBin)) {
availableDirs.push(cacheDir);
}
Expand Down Expand Up @@ -370,7 +382,7 @@ function getBestCdsCommand(

// If a specific cache directory is provided and valid, prefer it
if (cacheDir) {
const localCdsBin = join(cacheDir, 'node_modules', '.bin', 'cds');
const localCdsBin = join(cacheDir, 'node_modules', '.bin', localCdsBinName());
const command = createCdsCommandForPath(localCdsBin);
if (command) {
const result = testCdsCommand(command, sourceRoot, true);
Expand All @@ -382,7 +394,7 @@ function getBestCdsCommand(

// Try any available cache directories
for (const availableCacheDir of cdsCommandCache.availableCacheDirs) {
const localCdsBin = join(availableCacheDir, 'node_modules', '.bin', 'cds');
const localCdsBin = join(availableCacheDir, 'node_modules', '.bin', localCdsBinName());
const command = createCdsCommandForPath(localCdsBin);
if (command) {
const result = testCdsCommand(command, sourceRoot, true);
Expand Down Expand Up @@ -508,6 +520,10 @@ function testCdsCommand(
timeout: DEFAULT_COMMAND_TIMEOUT_MS, // timeout after 10 seconds
cwd: sourceRoot,
env: cleanEnv,
// Required on Windows + Node 20+ to execute .cmd shims (e.g. cds.cmd, npx.cmd).
// All inputs here come from the extractor's own command construction, so there is
// no shell-injection surface from external input.
shell: getPlatformInfo().isWindows,
},
).toString();

Expand Down
29 changes: 24 additions & 5 deletions extractors/cds/tools/src/cds/compiler/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,11 @@ function createSpawnOptions(
const binPathPosix = 'node_modules/.bin/';
const isDirectBinary = cdsCommand.includes(binPathNative) || cdsCommand.includes(binPathPosix);

// Only enable shell on Windows for npx-style commands where .cmd resolution is needed.
// Direct binary paths (node_modules/.bin/cds) don't need shell on any platform.
const useShell = getPlatformInfo().isWindows && !isDirectBinary;
// On Windows we always need shell: true so spawnSync can execute .cmd shims —
// both the npx.cmd entry point AND the cds.cmd shim under node_modules/.bin/.
// Node 20.12.2+ (CVE-2024-27980) refuses to spawn .cmd/.bat files without a shell.
// On non-Windows, only npx-style commands need shell to resolve the executable on PATH.
const useShell = getPlatformInfo().isWindows || !isDirectBinary;

const spawnOptions: SpawnSyncOptions = {
cwd: projectBaseDir, // CRITICAL: Always use project base directory as cwd to ensure correct path generation
Expand All @@ -266,14 +268,25 @@ function createSpawnOptions(
env: { ...process.env },
};

// Always make the project's own node_modules visible to the spawned cds
// process via NODE_PATH. The cds CLI's plugin loader (`require('@cap-js/...')`)
// walks up node_modules from inside the cds-dk install location — when cds-dk
// lives in a cache directory outside the project, that walk never reaches the
// project's node_modules. Adding the project node_modules to NODE_PATH gives
// Node's resolver an explicit fallback so installed plugins are found.
const projectNodeModules = join(projectBaseDir, 'node_modules');

// Only set up Node.js environment for npx-style commands, not for direct binary execution
if (cacheDir && !isDirectBinary) {
const nodePath = join(cacheDir, 'node_modules');

// Set up environment to use the cached dependencies
// Set up environment to use the cached dependencies, with the project's
// node_modules appended so locally-installed cds plugins remain resolvable.
spawnOptions.env = {
...process.env,
NODE_PATH: `${nodePath}${delimiter}${process.env.NODE_PATH ?? ''}`,
NODE_PATH: [nodePath, projectNodeModules, process.env.NODE_PATH ?? '']
.filter(Boolean)
.join(delimiter),
PATH: `${join(nodePath, '.bin')}${delimiter}${process.env.PATH}`,
// Add NPM configuration to ensure dependencies are resolved from the cache directory
npm_config_prefix: cacheDir,
Expand All @@ -291,6 +304,12 @@ function createSpawnOptions(
delete cleanEnv.npm_config_global;
delete cleanEnv.CDS_HOME;

// Re-add the project's node_modules to NODE_PATH so the cds plugin loader
// can resolve packages the project itself installed (e.g. @cap-js/telemetry).
// Without this, the cache binary fails on any project that uses cds-plugin
// packages declared in its package.json.
cleanEnv.NODE_PATH = projectNodeModules;

spawnOptions.env = cleanEnv;
}

Expand Down
4 changes: 3 additions & 1 deletion extractors/cds/tools/src/cds/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { spawnSync } from 'child_process';
import { delimiter, join } from 'path';

import { addCdsIndexerDiagnostic } from '../diagnostics';
import { npxExecutable } from '../environment';
import { npxExecutable, getPlatformInfo } from '../environment';
import { cdsExtractorLog } from '../logging';
import { projectInstallDependencies } from '../packageManager';
import type { CdsDependencyGraph, CdsProject } from './parser/types';
Expand Down Expand Up @@ -116,6 +116,8 @@ export function runCdsIndexer(
env,
stdio: 'pipe',
timeout: CDS_INDEXER_TIMEOUT_MS,
// .cmd/.bat shims (npx.cmd) require shell: true on Windows + Node 20+ (CVE-2024-27980).
shell: getPlatformInfo().isWindows,
});

result.durationMs = Date.now() - startTime;
Expand Down
20 changes: 15 additions & 5 deletions extractors/cds/tools/src/packageManager/cacheInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'path';
import type { CdsDependencyCombination } from './types';
import { CdsDependencyGraph, CdsProject } from '../cds/parser/types';
import { DiagnosticSeverity } from '../diagnostics';
import { npmExecutable } from '../environment';
import { npmExecutable, getPlatformInfo } from '../environment';
import { cdsExtractorLog } from '../logging';
import { resolveCdsVersions } from './versionResolver';

Expand Down Expand Up @@ -495,10 +495,20 @@ function installDependenciesInCache(
}

try {
execFileSync(npmExecutable(), ['install', '--quiet', '--no-audit', '--no-fund'], {
cwd: cacheDir,
stdio: 'inherit',
});
execFileSync(
npmExecutable(),
// --engine-strict=false: transitive deps occasionally pin obsolete Node ranges
// (e.g. engines.node ^18) which would otherwise abort the install on newer Node.
// npm's default is non-strict; we make that explicit so a project-level .npmrc
// copied into the cache can't flip it on.
['install', '--engine-strict=false', '--quiet', '--no-audit', '--no-fund'],
{
cwd: cacheDir,
stdio: 'inherit',
// .cmd/.bat shims (npm.cmd) require shell: true on Windows + Node 20+ (CVE-2024-27980).
shell: getPlatformInfo().isWindows,
},
);

// Add warning diagnostic if using fallback versions
if (isFallback && warning && packageJsonPath && codeqlExePath) {
Expand Down
17 changes: 15 additions & 2 deletions extractors/cds/tools/src/packageManager/projectInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { execFileSync } from 'child_process';
import { join } from 'path';

import { npmExecutable } from '../environment';
import { npmExecutable, getPlatformInfo } from '../environment';
import type { FullDependencyInstallationResult } from './types';
import type { CdsProject } from '../cds/parser';
import { cdsExtractorLog } from '../logging';
Expand Down Expand Up @@ -74,11 +74,24 @@ export function projectInstallDependencies(
try {
execFileSync(
npmExecutable(),
['install', '--ignore-scripts', '--quiet', '--no-audit', '--no-fund'],
// --engine-strict=false: transitive deps occasionally pin obsolete Node ranges
// (e.g. engines.node ^18) which would otherwise abort the install on newer Node.
// npm's default is non-strict; we make that explicit so the project's .npmrc
// can't flip it on and break the retry path.
[
'install',
'--engine-strict=false',
'--ignore-scripts',
'--quiet',
'--no-audit',
'--no-fund',
],
{
cwd: projectPath,
stdio: 'inherit',
timeout: 120000, // 2-minute timeout
// .cmd/.bat shims (npm.cmd) require shell: true on Windows + Node 20+ (CVE-2024-27980).
shell: getPlatformInfo().isWindows,
},
);

Expand Down
2 changes: 1 addition & 1 deletion extractors/cds/tools/src/packageManager/versionResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function getAvailableVersions(packageName: CachedPackageName): string[] {
try {
const output = execSync(`npm view ${packageName} versions --json`, {
encoding: 'utf8',
timeout: 30000, // 30 second timeout
timeout: 120000, // 120 second timeout
});

const versions: unknown = JSON.parse(output);
Expand Down
4 changes: 4 additions & 0 deletions extractors/cds/tools/test/src/cds/compiler/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DEFAULT_COMMAND_TIMEOUT_MS,
determineVersionAwareCdsCommands,
} from '../../../../src/cds/compiler/command';
import { getPlatformInfo } from '../../../../src/environment';
import { fileExists } from '../../../../src/filesystem';
import { cdsExtractorLog } from '../../../../src/logging';

Expand Down Expand Up @@ -66,6 +67,7 @@ describe('cds compiler command', () => {
stdio: 'pipe',
timeout: DEFAULT_COMMAND_TIMEOUT_MS,
cwd: '/mock/source/root',
shell: getPlatformInfo().isWindows,
env: expect.objectContaining({
CODEQL_EXTRACTOR_CDS_WIP_DATABASE: undefined,
CODEQL_RUNNER: undefined,
Expand Down Expand Up @@ -98,6 +100,7 @@ describe('cds compiler command', () => {
stdio: 'pipe',
timeout: DEFAULT_COMMAND_TIMEOUT_MS,
cwd: '/mock/source/root',
shell: getPlatformInfo().isWindows,
env: expect.objectContaining({
CODEQL_EXTRACTOR_CDS_WIP_DATABASE: undefined,
CODEQL_RUNNER: undefined,
Expand All @@ -111,6 +114,7 @@ describe('cds compiler command', () => {
stdio: 'pipe',
timeout: DEFAULT_COMMAND_TIMEOUT_MS,
cwd: '/mock/source/root',
shell: getPlatformInfo().isWindows,
env: expect.objectContaining({
CODEQL_EXTRACTOR_CDS_WIP_DATABASE: undefined,
CODEQL_RUNNER: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe('installer', () => {
expect(fs.writeFileSync).toHaveBeenCalled();
expect(childProcess.execFileSync).toHaveBeenCalledWith(
'npm',
['install', '--quiet', '--no-audit', '--no-fund'],
['install', '--engine-strict=false', '--quiet', '--no-audit', '--no-fund'],
expect.objectContaining({ cwd: expect.stringContaining('cds-') }),
);
expect(result.size).toBe(1);
Expand Down Expand Up @@ -327,7 +327,7 @@ describe('installer', () => {
expect(childProcess.execFileSync).toHaveBeenCalledTimes(1);
expect(childProcess.execFileSync).toHaveBeenCalledWith(
'npm',
['install', '--quiet', '--no-audit', '--no-fund'],
['install', '--engine-strict=false', '--quiet', '--no-audit', '--no-fund'],
expect.objectContaining({ cwd: expect.stringContaining('cds-') }),
);

Expand Down Expand Up @@ -785,7 +785,7 @@ describe('installer', () => {

expect(childProcess.execFileSync).toHaveBeenCalledWith(
'npm',
['install', '--quiet', '--no-audit', '--no-fund'],
['install', '--engine-strict=false', '--quiet', '--no-audit', '--no-fund'],
expect.any(Object),
);
expect(result.size).toBe(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { execFileSync } from 'child_process';

import type { CdsProject } from '../../../src/cds/parser';
import { getPlatformInfo } from '../../../src/environment';
import {
needsFullDependencyInstallation,
projectInstallDependencies,
Expand Down Expand Up @@ -64,9 +65,17 @@ describe('CDS Compiler Installer', () => {

expect(mockExecFileSync).toHaveBeenCalledWith(
'npm',
['install', '--ignore-scripts', '--quiet', '--no-audit', '--no-fund'],
[
'install',
'--engine-strict=false',
'--ignore-scripts',
'--quiet',
'--no-audit',
'--no-fund',
],
{
cwd: '/test/source/test-project',
shell: getPlatformInfo().isWindows,
stdio: 'inherit',
timeout: 120000,
},
Expand Down
Loading