From 46ddc1f6dc786853bea68c136eced24eab987254 Mon Sep 17 00:00:00 2001 From: nikhil-mittal-165 Date: Mon, 2 Nov 2026 17:21:38 +0530 Subject: [PATCH 1/2] fix: add scan folder to PMD classpath so repo-relative rule refs in custom rulesets resolve Co-Authored-By: Claude Sonnet 5.5 --- .../code-analyzer-pmd-engine/src/config.ts | 2 +- .../code-analyzer-pmd-engine/src/messages.ts | 2 +- .../src/pmd-wrapper.ts | 26 ++++++++++--------- .../test/pmd-engine.test.ts | 26 +++++++++++++++++++ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/code-analyzer-pmd-engine/src/config.ts b/packages/code-analyzer-pmd-engine/src/config.ts index d7a79210..cc0660b8 100644 --- a/packages/code-analyzer-pmd-engine/src/config.ts +++ b/packages/code-analyzer-pmd-engine/src/config.ts @@ -42,7 +42,7 @@ export type PmdEngineConfig = { // List of xml ruleset files containing custom PMD rules to be made available for rule selection. // Each ruleset must be an xml file that is either:: // - on disk (provided as an absolute path or a relative path to 'config_root') - // - or a relative resource found on the Java classpath. + // - or a relative resource found on the Java classpath (which implicitly includes the folder you run Code Analyzer from). // Not all custom rules can be fully defined within an xml ruleset file. For example, Java based rules may be defined in jar files. // In these cases, you will need to also add your additional files to the Java classpath using the 'java_classpath_entries' field. // See https://pmd.github.io/pmd/pmd_userdocs_making_rulesets.html diff --git a/packages/code-analyzer-pmd-engine/src/messages.ts b/packages/code-analyzer-pmd-engine/src/messages.ts index 6884c9dd..03298cee 100644 --- a/packages/code-analyzer-pmd-engine/src/messages.ts +++ b/packages/code-analyzer-pmd-engine/src/messages.ts @@ -27,7 +27,7 @@ const MESSAGE_CATALOG : { [key: string]: string } = { `List of xml ruleset files containing custom PMD rules to be made available for rule selection.\n` + `Each ruleset must be an xml file that is either:\n` + ` - on disk (provided as an absolute path or a relative path to 'config_root')\n` + - ` - or a relative resource found on the Java classpath.\n` + + ` - or a relative resource found on the Java classpath (which implicitly includes the folder you run Code Analyzer from).\n` + `Not all custom rules can be fully defined within an xml ruleset file. For example, Java based rules may be defined in jar files.\n` + `In these cases, you will need to also add your additional files to the Java classpath using the 'java_classpath_entries' field.\n` + `See https://pmd.github.io/pmd/pmd_userdocs_making_rulesets.html to learn more about PMD rulesets.`, diff --git a/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts b/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts index ed26a98f..c6815701 100644 --- a/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts +++ b/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts @@ -78,6 +78,17 @@ export class PmdWrapperInvoker { this.emitLogEvent = emitLogEvent; } + private createJavaClassPaths(): string[] { + return [ + path.join(PMD_WRAPPER_LIB_FOLDER, '*'), + ...this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry), + // The java process is pinned to the engine's install folder, but PMD resolves refs inside custom rulesets + // (like ) relative to the cwd or the classpath. So we add the user's + // cwd (last, so it has the lowest precedence) so that repo-relative refs continue to resolve. + process.cwd() + ]; + } + async invokeDescribeCommand(customRulesets: string[], pmdRuleLanguageIds: string[], workingFolder: string, emitProgress: (percComplete: number) => void): Promise { @@ -87,10 +98,7 @@ export class PmdWrapperInvoker { emitProgress(10); const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'describe', pmdRulesOutputFile, customRulesetsListFile, pmdRuleLanguageIds.join(',')]; - const javaClassPaths: string[] = [ - path.join(PMD_WRAPPER_LIB_FOLDER, '*'), - ... this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry) - ]; + const javaClassPaths: string[] = this.createJavaClassPaths(); await this.javaCommandExecutor.exec(javaCmdArgs, javaClassPaths, (stdOutMsg) => { if (stdOutMsg.startsWith(STDOUT_ERROR_MARKER)) { const errorMessage: string = stdOutMsg.slice(STDOUT_ERROR_MARKER.length).replaceAll('{NEWLINE}','\n'); @@ -138,10 +146,7 @@ export class PmdWrapperInvoker { const resultsOutputFile: string = path.join(workingFolder, 'resultsFile.json'); const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'run', inputFile, resultsOutputFile]; - const javaClassPaths: string[] = [ - path.join(PMD_WRAPPER_LIB_FOLDER, '*'), - ... this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry) - ]; + const javaClassPaths: string[] = this.createJavaClassPaths(); this.emitLogEvent(LogLevel.Fine, `Calling JAVA command with class path containing ${JSON.stringify(javaClassPaths)} and arguments: ${JSON.stringify(javaCmdArgs)}`); await this.javaCommandExecutor.exec(javaCmdArgs, javaClassPaths, (stdOutMsg: string) => { if (stdOutMsg.startsWith(STDOUT_PROGRESS_MARKER)) { @@ -189,10 +194,7 @@ export class PmdWrapperInvoker { const resultsOutputFile: string = path.join(workingFolder, 'astDumpResults.json'); const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'ast-dump', inputFile, resultsOutputFile]; - const javaClassPaths: string[] = [ - path.join(PMD_WRAPPER_LIB_FOLDER, '*'), - ...this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry) - ]; + const javaClassPaths: string[] = this.createJavaClassPaths(); this.emitLogEvent(LogLevel.Fine, `Calling AST dump for file: ${fileToDump}`); diff --git a/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts b/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts index 198de645..bdcf9ed6 100644 --- a/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts +++ b/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts @@ -16,6 +16,7 @@ import { import {PmdEngine} from "../src/pmd-engine"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import {Language, PMD_VERSION} from "../src/constants"; import {DEFAULT_PMD_ENGINE_CONFIG, PMD_AVAILABLE_LANGUAGES, PmdEngineConfig} from "../src/config"; @@ -321,6 +322,31 @@ describe('Tests for the describeRules method of PmdEngine', () => { } }); + it('When a custom ruleset references a rule using a path relative to the folder Code Analyzer is run from, then the rule is resolved', async () => { + const repoFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'pmd-cwd-ref-test-')); + const originalCwd: string = process.cwd(); + try { + await fsPromises.mkdir(path.join(repoFolder, 'scanner')); + await fsPromises.copyFile(path.join(TEST_DATA_FOLDER, 'custom rules', 'somecat3.xml'), path.join(repoFolder, 'scanner', 'full.xml')); + const wrapperRuleset: string = path.join(repoFolder, 'wrapper.xml'); + await fsPromises.writeFile(wrapperRuleset, `\n` + + `\n` + + ` Wrapper\n` + + ` \n` + + `\n`, 'utf-8'); + process.chdir(repoFolder); + const engine: PmdEngine = new PmdEngine({ + ...DEFAULT_PMD_ENGINE_CONFIG, + custom_rulesets: [wrapperRuleset] + }); + const ruleDescriptions: RuleDescription[] = await engine.describeRules(createDescribeOptions()); + expectContainsRuleWithName(ruleDescriptions, 'fakerule7'); + } finally { + process.chdir(originalCwd); + fs.rmSync(repoFolder, {recursive: true, force: true}); + } + }); + it('When specifying a custom_ruleset that cannot be found, then error with a good error message', async () => { const engine: PmdEngine = new PmdEngine({ ...DEFAULT_PMD_ENGINE_CONFIG, From 7fd77a8a3133268644e2c27b94685ef59b7ff36e Mon Sep 17 00:00:00 2001 From: nikhil-mittal-165 Date: Tue, 3 Nov 2026 11:44:29 +0530 Subject: [PATCH 2/2] fix: stage only referenced rulesets instead of putting cwd on the Java classpath Adding process.cwd() to the PMD classpath re-exposed the scanned repository to the JVM as a source of classes and resources (CWE-427, see #503). Instead, copy only the xml ruleset files that custom rulesets reference (transitively, and never from outside the base folder, including via symlinks) into a staging folder in the working folder and put just that folder last on the classpath. Repo-relative rule refs keep resolving (#2087). Co-Authored-By: Claude Sonnet 5.5 --- .../code-analyzer-pmd-engine/src/config.ts | 2 +- .../code-analyzer-pmd-engine/src/messages.ts | 2 +- .../src/pmd-wrapper.ts | 31 +++-- .../src/ruleset-staging.ts | 112 ++++++++++++++++++ .../test/ruleset-staging.test.ts | 92 ++++++++++++++ 5 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 packages/code-analyzer-pmd-engine/src/ruleset-staging.ts create mode 100644 packages/code-analyzer-pmd-engine/test/ruleset-staging.test.ts diff --git a/packages/code-analyzer-pmd-engine/src/config.ts b/packages/code-analyzer-pmd-engine/src/config.ts index cc0660b8..49955a4e 100644 --- a/packages/code-analyzer-pmd-engine/src/config.ts +++ b/packages/code-analyzer-pmd-engine/src/config.ts @@ -42,7 +42,7 @@ export type PmdEngineConfig = { // List of xml ruleset files containing custom PMD rules to be made available for rule selection. // Each ruleset must be an xml file that is either:: // - on disk (provided as an absolute path or a relative path to 'config_root') - // - or a relative resource found on the Java classpath (which implicitly includes the folder you run Code Analyzer from). + // - or a relative resource found on the Java classpath (or, for a ruleset that references another ruleset file by a path relative to the folder you run Code Analyzer from, that referenced file). // Not all custom rules can be fully defined within an xml ruleset file. For example, Java based rules may be defined in jar files. // In these cases, you will need to also add your additional files to the Java classpath using the 'java_classpath_entries' field. // See https://pmd.github.io/pmd/pmd_userdocs_making_rulesets.html diff --git a/packages/code-analyzer-pmd-engine/src/messages.ts b/packages/code-analyzer-pmd-engine/src/messages.ts index 03298cee..9339e3bb 100644 --- a/packages/code-analyzer-pmd-engine/src/messages.ts +++ b/packages/code-analyzer-pmd-engine/src/messages.ts @@ -27,7 +27,7 @@ const MESSAGE_CATALOG : { [key: string]: string } = { `List of xml ruleset files containing custom PMD rules to be made available for rule selection.\n` + `Each ruleset must be an xml file that is either:\n` + ` - on disk (provided as an absolute path or a relative path to 'config_root')\n` + - ` - or a relative resource found on the Java classpath (which implicitly includes the folder you run Code Analyzer from).\n` + + ` - or a relative resource found on the Java classpath (or, for a ruleset that references another ruleset file by a path relative to the folder you run Code Analyzer from, that referenced file).\n` + `Not all custom rules can be fully defined within an xml ruleset file. For example, Java based rules may be defined in jar files.\n` + `In these cases, you will need to also add your additional files to the Java classpath using the 'java_classpath_entries' field.\n` + `See https://pmd.github.io/pmd/pmd_userdocs_making_rulesets.html to learn more about PMD rulesets.`, diff --git a/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts b/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts index c6815701..8c4efae3 100644 --- a/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts +++ b/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts @@ -1,6 +1,7 @@ import {JavaCommandExecutor} from "@salesforce/code-analyzer-engine-api/utils"; import path from "node:path"; import fs from "node:fs"; +import {stageReferencedRulesets} from "./ruleset-staging"; import {getMessageFromCatalog, LogLevel, SHARED_MESSAGE_CATALOG} from "@salesforce/code-analyzer-engine-api"; const PMD_WRAPPER_JAVA_CLASS: string = "com.salesforce.sfca.pmdwrapper.PmdWrapper"; @@ -78,15 +79,24 @@ export class PmdWrapperInvoker { this.emitLogEvent = emitLogEvent; } - private createJavaClassPaths(): string[] { - return [ + /** + * Creates the Java classpath. Note that the Java process is pinned to the engine's install folder (instead of the + * inherited, possibly untrusted, folder being scanned) to avoid CWE-427. But PMD resolves refs inside of custom + * rulesets (like ) relative to the cwd or the classpath. To keep those refs + * resolving without exposing the entire repository to the JVM as classes/resources, we stage copies of only the + * referenced xml ruleset files into a dedicated folder that we place last on the classpath. + */ + private async createJavaClassPaths(rulesets: string[], workingFolder: string): Promise { + const classPaths: string[] = [ path.join(PMD_WRAPPER_LIB_FOLDER, '*'), - ...this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry), - // The java process is pinned to the engine's install folder, but PMD resolves refs inside custom rulesets - // (like ) relative to the cwd or the classpath. So we add the user's - // cwd (last, so it has the lowest precedence) so that repo-relative refs continue to resolve. - process.cwd() + ...this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry) ]; + const stagingFolder: string | undefined = await stageReferencedRulesets( + rulesets, process.cwd(), path.join(workingFolder, 'stagedRulesets')); + if (stagingFolder) { + classPaths.push(stagingFolder + path.sep); // Last so that it has the lowest precedence + } + return classPaths; } async invokeDescribeCommand(customRulesets: string[], pmdRuleLanguageIds: string[], @@ -98,7 +108,7 @@ export class PmdWrapperInvoker { emitProgress(10); const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'describe', pmdRulesOutputFile, customRulesetsListFile, pmdRuleLanguageIds.join(',')]; - const javaClassPaths: string[] = this.createJavaClassPaths(); + const javaClassPaths: string[] = await this.createJavaClassPaths(customRulesets, workingFolder); await this.javaCommandExecutor.exec(javaCmdArgs, javaClassPaths, (stdOutMsg) => { if (stdOutMsg.startsWith(STDOUT_ERROR_MARKER)) { const errorMessage: string = stdOutMsg.slice(STDOUT_ERROR_MARKER.length).replaceAll('{NEWLINE}','\n'); @@ -146,7 +156,8 @@ export class PmdWrapperInvoker { const resultsOutputFile: string = path.join(workingFolder, 'resultsFile.json'); const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'run', inputFile, resultsOutputFile]; - const javaClassPaths: string[] = this.createJavaClassPaths(); + const javaClassPaths: string[] = await this.createJavaClassPaths( + [...new Set(pmdRuleInfoList.map(ruleInfo => ruleInfo.ruleSetFile))], workingFolder); this.emitLogEvent(LogLevel.Fine, `Calling JAVA command with class path containing ${JSON.stringify(javaClassPaths)} and arguments: ${JSON.stringify(javaCmdArgs)}`); await this.javaCommandExecutor.exec(javaCmdArgs, javaClassPaths, (stdOutMsg: string) => { if (stdOutMsg.startsWith(STDOUT_PROGRESS_MARKER)) { @@ -194,7 +205,7 @@ export class PmdWrapperInvoker { const resultsOutputFile: string = path.join(workingFolder, 'astDumpResults.json'); const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'ast-dump', inputFile, resultsOutputFile]; - const javaClassPaths: string[] = this.createJavaClassPaths(); + const javaClassPaths: string[] = await this.createJavaClassPaths([], workingFolder); this.emitLogEvent(LogLevel.Fine, `Calling AST dump for file: ${fileToDump}`); diff --git a/packages/code-analyzer-pmd-engine/src/ruleset-staging.ts b/packages/code-analyzer-pmd-engine/src/ruleset-staging.ts new file mode 100644 index 00000000..14572b00 --- /dev/null +++ b/packages/code-analyzer-pmd-engine/src/ruleset-staging.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Matches the "ref" attribute of and style elements. +const RULE_REF_REGEX: RegExp = /]*?\bref\s*=\s*["']([^"']+)["']/gi; + +/** + * PMD resolves refs inside of a ruleset (like ) relative to the folder it was run + * from or from the Java classpath. Since we pin the Java process away from the (untrusted) folder being scanned, those + * refs would no longer resolve. Instead of putting the whole folder on the Java classpath (which would let a scanned + * repository supply arbitrary classes and resources to the JVM), we copy only the xml ruleset files that the custom + * rulesets actually reference (transitively) into a dedicated staging folder, and put just that folder on the classpath. + * + * Only regular ".xml" files that live underneath the base folder are ever staged. + * + * @param rulesetFiles the rulesets to inspect (absolute paths on disk, or paths relative to the base folder). Entries that are not xml files on disk are ignored. + * @param baseFolder the folder that relative refs are resolved against + * @param stagingFolder the folder to copy the referenced rulesets into + * @returns the staging folder if at least one file was staged, otherwise undefined + */ +export async function stageReferencedRulesets(rulesetFiles: string[], baseFolder: string, stagingFolder: string): Promise { + const realBase: string = await realpathOrUndefined(baseFolder) ?? baseFolder; + const visited: Set = new Set(); + const queue: string[] = []; + let stagedSomething: boolean = false; + + // Stages the file given by a (relative) ref and queues it up so that its own refs get processed too + const stageRef = async (ref: string): Promise => { + const relativeRulesetPath: string | undefined = extractRelativeXmlPath(ref); + if (relativeRulesetPath === undefined) { + return; + } + const source: string | undefined = await resolveStageableFile(relativeRulesetPath, realBase); + if (source === undefined) { + return; + } + const destination: string = path.join(stagingFolder, relativeRulesetPath); + await fs.promises.mkdir(path.dirname(destination), {recursive: true}); + await fs.promises.copyFile(source, destination); + stagedSomething = true; + queue.push(source); + }; + + for (const rulesetFile of rulesetFiles) { + if (path.isAbsolute(rulesetFile)) { + queue.push(rulesetFile); + } else { + await stageRef(rulesetFile); + } + } + + while (queue.length > 0) { + const currentFile: string = queue.shift()!; + if (visited.has(currentFile)) { + continue; + } + visited.add(currentFile); + + let contents: string; + try { + contents = await fs.promises.readFile(currentFile, 'utf-8'); + } catch (_err) { + continue; // Not a readable file on disk (for example, a ruleset inside of a jar file) + } + + for (const match of contents.matchAll(RULE_REF_REGEX)) { + await stageRef(match[1]); + } + } + return stagedSomething ? stagingFolder : undefined; +} + +/** + * Returns the relative path to the xml file portion of a rule ref (stripping off any trailing "/RuleName"), or + * undefined if the ref is not a relative path to an xml file (for example, "category/apex/design.xml" is fine to stage + * if it exists in the base folder, but absolute paths and non-xml refs are ignored). + */ +function extractRelativeXmlPath(ref: string): string | undefined { + const xmlMatch: RegExpMatchArray | null = ref.match(/^(.*?\.xml)(\/.*)?$/i); + if (!xmlMatch) { + return undefined; + } + const candidate: string = xmlMatch[1]; + if (path.isAbsolute(candidate) || path.win32.isAbsolute(candidate)) { + return undefined; + } + return path.normalize(candidate); +} + +async function resolveStageableFile(relativePath: string, realBase: string): Promise { + const real: string | undefined = await realpathOrUndefined(path.join(realBase, relativePath)); + if (real === undefined) { + return undefined; + } + const relativeToBase: string = path.relative(realBase, real); + if (relativeToBase === '' || relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) { + return undefined; // Never stage anything that resolves (even through symlinks) outside of the base folder + } + try { + return (await fs.promises.stat(real)).isFile() ? real : undefined; + } catch (_err) /* istanbul ignore next */ { + return undefined; + } +} + +async function realpathOrUndefined(p: string): Promise { + try { + return await fs.promises.realpath(p); + } catch (_err) { + return undefined; + } +} diff --git a/packages/code-analyzer-pmd-engine/test/ruleset-staging.test.ts b/packages/code-analyzer-pmd-engine/test/ruleset-staging.test.ts new file mode 100644 index 00000000..21a3c54b --- /dev/null +++ b/packages/code-analyzer-pmd-engine/test/ruleset-staging.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {stageReferencedRulesets} from "../src/ruleset-staging"; + +describe('Tests for stageReferencedRulesets', () => { + let baseFolder: string; + let stagingFolder: string; + + beforeEach(() => { + const tempRoot: string = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'staging-test-'))); + baseFolder = path.join(tempRoot, 'repo'); + stagingFolder = path.join(tempRoot, 'staged'); + fs.mkdirSync(path.join(baseFolder, 'scanner'), {recursive: true}); + }); + + afterEach(() => { + fs.rmSync(path.dirname(baseFolder), {recursive: true, force: true}); + }); + + const rulesetWithRefs = (...refs: string[]): string => + `${refs.map(r => ``).join('')}`; + + it('When a ruleset references a sibling xml file, then only that xml file is staged', async () => { + fs.writeFileSync(path.join(baseFolder, 'scanner', 'full.xml'), rulesetWithRefs()); + fs.writeFileSync(path.join(baseFolder, 'scanner', 'unrelated.xml'), rulesetWithRefs()); + fs.writeFileSync(path.join(baseFolder, 'Evil.class'), 'not really a class'); + const wrapper: string = path.join(baseFolder, 'wrapper.xml'); + fs.writeFileSync(wrapper, rulesetWithRefs('scanner/full.xml/MyRule')); + + const result: string | undefined = await stageReferencedRulesets([wrapper], baseFolder, stagingFolder); + + expect(result).toEqual(stagingFolder); + expect(fs.readdirSync(stagingFolder, {recursive: true}).sort()).toEqual(['scanner', path.join('scanner', 'full.xml')]); + }); + + it('When referenced rulesets reference other rulesets, then they are staged transitively (and cycles are tolerated)', async () => { + fs.writeFileSync(path.join(baseFolder, 'a.xml'), rulesetWithRefs('b.xml/R')); + fs.writeFileSync(path.join(baseFolder, 'b.xml'), rulesetWithRefs('a.xml/R', 'scanner/c.xml')); + fs.writeFileSync(path.join(baseFolder, 'scanner', 'c.xml'), rulesetWithRefs()); + const wrapper: string = path.join(baseFolder, 'wrapper.xml'); + fs.writeFileSync(wrapper, rulesetWithRefs('a.xml/R')); + + await stageReferencedRulesets([wrapper], baseFolder, stagingFolder); + + expect(fs.existsSync(path.join(stagingFolder, 'a.xml'))).toBe(true); + expect(fs.existsSync(path.join(stagingFolder, 'b.xml'))).toBe(true); + expect(fs.existsSync(path.join(stagingFolder, 'scanner', 'c.xml'))).toBe(true); + }); + + it('When a relative ruleset is given directly, then it is resolved against the base folder and staged', async () => { + fs.writeFileSync(path.join(baseFolder, 'scanner', 'full.xml'), rulesetWithRefs()); + + const result: string | undefined = await stageReferencedRulesets(['scanner/full.xml'], baseFolder, stagingFolder); + + expect(result).toEqual(stagingFolder); + expect(fs.existsSync(path.join(stagingFolder, 'scanner', 'full.xml'))).toBe(true); + }); + + it('When refs try to escape the base folder, are absolute, or are not xml, then nothing is staged', async () => { + const outsideFile: string = path.join(path.dirname(baseFolder), 'outside.xml'); + fs.writeFileSync(outsideFile, rulesetWithRefs()); + fs.writeFileSync(path.join(baseFolder, 'Evil.class'), 'x'); + const wrapper: string = path.join(baseFolder, 'wrapper.xml'); + fs.writeFileSync(wrapper, rulesetWithRefs('../outside.xml/R', outsideFile + '/R', 'Evil.class', 'does/not/exist.xml/R')); + + const result: string | undefined = await stageReferencedRulesets([wrapper], baseFolder, stagingFolder); + + expect(result).toBeUndefined(); + expect(fs.existsSync(stagingFolder)).toBe(false); + }); + + it('When a ref is a symlink pointing outside of the base folder, then it is not staged', async () => { + const outsideFile: string = path.join(path.dirname(baseFolder), 'secret.xml'); + fs.writeFileSync(outsideFile, rulesetWithRefs()); + try { + fs.symlinkSync(outsideFile, path.join(baseFolder, 'link.xml')); + } catch (_err) { + return; // Symlink creation isn't permitted in this environment (e.g. Windows without privileges) + } + const wrapper: string = path.join(baseFolder, 'wrapper.xml'); + fs.writeFileSync(wrapper, rulesetWithRefs('link.xml/R')); + + expect(await stageReferencedRulesets([wrapper], baseFolder, stagingFolder)).toBeUndefined(); + }); + + it('When rulesets are not readable files on disk (like jar based rulesets), then they are ignored', async () => { + const result: string | undefined = await stageReferencedRulesets( + [path.join(baseFolder, 'missing.xml'), 'category/apex/design.xml'], baseFolder, stagingFolder); + expect(result).toBeUndefined(); + }); +});