Repository: stoplightio/spectral
Pull request #925 (2020-01-19 23:20:42)

feat: expose available functions to custom functions
Needed by https://github.com/stoplightio/spectral/issues/924

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

Let's not expose this feature in docs.
Back in the day, @marbemac wanted to expose ajv, lodash, and other libraries used in Spectral, but I was a bit skeptical, since we'd lose a bit of flexibility and would have to be very careful when updating dependencies. For instance we couldn't change Ajv to Djv in future without making a breaking change, etc.
We could bundle Ajv, but honestly, that's rather something we should be avoiding at all costs on this occasion, as this vastly increases the size of the bundle function. Furthermore `schema` function is stable, so there is very little risk of any potential breakage in future.

What do you folks think?


marbemac: Don't have time to do a code read, but if we haven't already I suggest that we do a quick audit of all the custom function signatures (argument names, argument positions, response types, etc) to make sure they're consistent and well thought out. We're basically committing to a public API for these custom functions with this PR - moving forward, any changes to them will require a major release.

Patch:
diff --git a/docs/guides/custom-functions.md b/docs/guides/custom-functions.md
index 72143927..e91ea4fb 100644
--- a/docs/guides/custom-functions.md
+++ b/docs/guides/custom-functions.md
@@ -2,6 +2,22 @@
 
 If the built-in functions are not enough for your [custom ruleset](../getting-started/rulesets.md), Spectral allows you to write and use your own custom functions.
 
+Please, do keep in mind that for the time being, the code is **not** executed in a sandboxed environment, so be very careful when including external rulesets.
+This indicates that almost any arbitrary code can be executed.
+Potential risks include:
+- data / credentials infiltration,
+- data tampering,
+- running cpu-intensive tasks, i.e. crypto-mining.
+
+While the risk is relatively low, you should be careful about including **external rulesets** you are not in charge of, in particular these that leverage custom functions.
+You are strongly encouraged to review the custom functions a given ruleset provides.
+What you should hunt for is:
+- obfuscated code,
+- calls untrusted external library,
+- places where remote code is executed.
+
+If you notice any weirdness, consider forking the ruleset and removal of any evil-looking code.
+
 A custom function might be any JavaScript function compliant with [IFunction](https://github.com/stoplightio/spectral/blob/90a0864863fa232bf367a26dace61fd9f93198db/src/types/function.ts#L3#L8) type.
 
 ```ts
diff --git a/src/functions/index.ts b/src/functions/index.ts
index 79c4e72a..8ae274d7 100644
--- a/src/functions/index.ts
+++ b/src/functions/index.ts
@@ -1,15 +1,31 @@
+import { alphabetical } from './alphabetical';
+import { casing } from './casing';
+import { enumeration } from './enumeration';
+import { falsy } from './falsy';
+import { length } from './length';
+import { pattern } from './pattern';
+import { schema } from './schema';
+import { schemaPath } from './schema-path';
+import { truthy } from './truthy';
+import { typedEnum } from './typedEnum';
+import { undefined } from './undefined';
+import { unreferencedReusableObject } from './unreferencedReusableObject';
+import { xor } from './xor';
+
 export const functions = {
-  alphabetical: require('./alphabetical').alphabetical,
-  casing: require('./casing').casing,
-  enumeration: require('./enumeration').enumeration,
-  length: require('./length').length,
-  pattern: require('./pattern').pattern,
-  falsy: require('./falsy').falsy,
-  schema: require('./schema').schema,
-  schemaPath: require('./schema-path').schemaPath,
-  truthy: require('./truthy').truthy,
-  undefined: require('./undefined').undefined,
-  xor: require('./xor').xor,
-  unreferencedReusableObject: require('./unreferencedReusableObject').unreferencedReusableObject,
-  typedEnum: require('./typedEnum').typedEnum,
+  alphabetical,
+  casing,
+  enumeration,
+  length,
+  pattern,
+  falsy,
+  schema,
+  schemaPath,
+  truthy,
+  undefined,
+  xor,
+  unreferencedReusableObject,
+  typedEnum,
 };
+
+export type CoreFunctions = typeof functions;
diff --git a/src/rulesets/evaluators.ts b/src/rulesets/evaluators.ts
index 37fb183a..c31ddbc0 100644
--- a/src/rulesets/evaluators.ts
+++ b/src/rulesets/evaluators.ts
@@ -147,5 +147,13 @@ export const compileExportedFunction = (
     value: name,
   });
 
+  Object.freeze(fn);
   return fn;
 };
+
+export function setFunctionContext(context: unknown, fn: Function) {
+  return Function.prototype.bind.call(
+    fn,
+    Object.freeze(Object.defineProperties({}, Object.getOwnPropertyDescriptors(context))),
+  );
+}
diff --git a/src/spectral.ts b/src/spectral.ts
index 2bd8b7e9..4fb6f681 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -7,10 +7,10 @@ import { memoize, merge } from 'lodash';
 import { STATIC_ASSETS } from './assets';
 import { Document, IDocument, IParsedResult, isParsedResult, ParsedDocument } from './document';
 import { DocumentInventory } from './documentInventory';
-import { functions as defaultFunctions } from './functions';
+import { CoreFunctions, functions as coreFunctions } from './functions';
 import * as Parsers from './parsers';
 import { readRuleset } from './rulesets';
-import { compileExportedFunction } from './rulesets/evaluators';
+import { compileExportedFunction, setFunctionContext } from './rulesets/evaluators';
 import { mergeExceptions } from './rulesets/mergers/exceptions';
 import { IRulesetReadOptions } from './rulesets/reader';
 import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity } from './rulesets/severity';
@@ -19,6 +19,7 @@ import {
   FormatLookup,
   FunctionCollection,
   IConstructorOpts,
+  IFunctionContext,
   IResolver,
   IRuleResult,
   IRunOpts,
@@ -39,7 +40,7 @@ export * from './types';
 export class Spectral {
   private readonly _resolver: IResolver;
 
-  public functions: FunctionCollection = { ...defaultFunctions };
+  public functions: FunctionCollection & CoreFunctions = { ...coreFunctions };
   public rules: RunRuleCollection = {};
   public exceptions: RulesetExceptionCollection = {};
   public formats: RegisteredFormats;
@@ -110,7 +111,7 @@ export class Spectral {
   public setFunctions(functions: FunctionCollection) {
     empty(this.functions);
 
-    Object.assign(this.functions, { ...defaultFunctions, ...functions });
+    Object.assign(this.functions, { ...coreFunctions, ...functions });
   }
 
   public setRules(rules: RuleCollection) {
@@ -168,11 +169,15 @@ export class Spectral {
             return fns;
           }
 
-          fns[key] = compileExportedFunction(code, name, source, schema);
+          const context: IFunctionContext = {
+            functions: this.functions,
+          };
+
+          fns[key] = setFunctionContext(context, compileExportedFunction(code, name, source, schema));
           return fns;
         },
         {
-          ...defaultFunctions,
+          ...coreFunctions,
         },
       ),
     );
diff --git a/src/types/function.ts b/src/types/function.ts
index 9d4e3a8c..356167df 100644
--- a/src/types/function.ts
+++ b/src/types/function.ts
@@ -1,5 +1,10 @@
 import { JsonPath } from '@stoplight/types';
 import { DocumentInventory } from '../documentInventory';
+import { CoreFunctions } from '../functions';
+
+export interface IFunctionContext {
+  functions: CoreFunctions;
+}
 
 export type IFunction<O = any> = (
   targetValue: any,


Test patch:
diff --git a/src/__tests__/__fixtures__/custom-functions-oas-ruleset.json b/src/__tests__/__fixtures__/custom-functions-oas-ruleset.json
index 6aa60090..b1ccf693 100644
--- a/src/__tests__/__fixtures__/custom-functions-oas-ruleset.json
+++ b/src/__tests__/__fixtures__/custom-functions-oas-ruleset.json
@@ -2,6 +2,7 @@
   "extends": ["spectral:oas"],
   "functions": [
     "truthy",
+    "validateBar",
     "deepHasIn",
     [
       "hasIn",
@@ -60,6 +61,18 @@
           "path": "/bar.get"
         }
       }
+    },
+    "validate-bar": {
+      "message": "`bar` property should be a string",
+      "given": "$",
+      "then": {
+        "function": "validateBar",
+        "functionOptions": {
+          "schema": {
+            "type": "string"
+          }
+        }
+      }
     }
   }
 }
diff --git a/src/__tests__/__fixtures__/functions/validateBar.js b/src/__tests__/__fixtures__/functions/validateBar.js
new file mode 100644
index 00000000..32a97580
--- /dev/null
+++ b/src/__tests__/__fixtures__/functions/validateBar.js
@@ -0,0 +1,3 @@
+module.exports = function(targetVal, opts, paths, otherValues) {
+  return 'bar' in targetVal ? this.functions.schema(targetVal.bar, opts, paths, otherValues) : void 0;
+};
diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts
index 2c506f03..232c4eff 100644
--- a/src/__tests__/linter.jest.test.ts
+++ b/src/__tests__/linter.jest.test.ts
@@ -96,6 +96,18 @@ describe('Linter', () => {
     ]);
   });
 
+  it('should expose all available functions to custom functions', async () => {
+    await spectral.loadRuleset(customDirectoryFunctionsRuleset);
+    expect(await spectral.run({ bar: 2 })).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          code: 'validate-bar',
+          message: '`bar` property should be a string',
+        }),
+      ]),
+    );
+  });
+
   it('should report resolving errors for correct files', async () => {
     spectral = new Spectral({ resolver: httpAndFileResolver });
 
diff --git a/src/rulesets/__tests__/evaluators.test.ts b/src/rulesets/__tests__/evaluators.test.ts
index 92551967..32bde66d 100644
--- a/src/rulesets/__tests__/evaluators.test.ts
+++ b/src/rulesets/__tests__/evaluators.test.ts
@@ -1,4 +1,4 @@
-import { evaluateExport } from '../evaluators';
+import { evaluateExport, setFunctionContext } from '../evaluators';
 
 describe('Code evaluators', () => {
   describe('Export evaluator', () => {
@@ -60,4 +60,19 @@ describe('Code evaluators', () => {
       expect(() => evaluateExport(`this.returnExports = {}`, null)).toThrow();
     });
   });
+
+  describe('setFunctionContext', () => {
+    it('binds context to given function', () => {
+      const context = { a: true };
+      const fn = setFunctionContext(context, jest.fn().mockReturnThis());
+      expect(fn()).toStrictEqual({ a: true });
+    });
+
+    it('deep-copies provided context', () => {
+      const context = { a: true };
+      const fn = setFunctionContext(context, jest.fn().mockReturnThis());
+      const fn2 = setFunctionContext(context, jest.fn().mockReturnThis());
+      expect(fn()).not.toBe(fn2());
+    });
+  });
 });