diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c346668..bd7e3ff 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/init@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -63,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/autobuild@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -76,6 +76,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/analyze@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 with: category: "/language:${{matrix.language}}" \ No newline at end of file diff --git a/.github/workflows/estree-ast-utils.yml b/.github/workflows/estree-ast-utils.yml new file mode 100644 index 0000000..be460d3 --- /dev/null +++ b/.github/workflows/estree-ast-utils.yml @@ -0,0 +1,41 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Node.js CI + +on: + push: + branches: + - main + paths: + - workspaces/estree-ast-utils/** + pull_request: + paths: + - workspaces/estree-ast-utils/** + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - name: Harden Runner + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 245317a..8457575 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -31,7 +31,7 @@ jobs: - name: Run tests run: npm run test - name: Send coverage report to Codecov - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4 + uses: codecov/codecov-action@428cda1b1c731be3e8bfa389049c3f276d572ffb # v4.0.0-beta.3 nsci: runs-on: ubuntu-latest strategy: @@ -51,7 +51,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm install - - uses: NodeSecure/ci-action@e3ac9c03585752e979622279106a161e94d5717b # v1 + - uses: NodeSecure/ci-action@177c57fe32c75cafabe87f6e4515d277cc37ae6c # v1.4.1 with: warnings: warning vulnerabilities: off diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 682f0cd..2e35ffa 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -37,12 +37,12 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v3.3.0 # v3.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@08b4669551908b1024bb425080c797723083c031 # v2.2.0 + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 with: results_file: results.sarif results_format: sarif @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 with: name: SARIF file path: results.sarif @@ -72,6 +72,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/upload-sarif@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 with: sarif_file: results.sarif diff --git a/.github/workflows/sec-literal.yml b/.github/workflows/sec-literal.yml new file mode 100644 index 0000000..43ddff1 --- /dev/null +++ b/.github/workflows/sec-literal.yml @@ -0,0 +1,29 @@ +name: Node.js CI + +on: + push: + branches: + - main + paths: + - workspaces/sec-literal/** + pull_request: + paths: + - workspaces/sec-literal/** + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x] + fail-fast: false + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: npm install + - name: Run tests + run: npm run test diff --git a/README.md b/README.md index 873d51c..d107655 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,21 @@ export type ReportOnFile = { +## Workspaces + +Click on one of the links to access the documentation of the workspace: + +| name | package and link | +| --- | --- | +| estree-ast-util | [@nodesecure/estree-ast-util](./workspaces/estree-ast-util) | +| sec-literal | [@nodesecure/sec-literal ](./workspaces/sec-literal) | + +These packages are available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com). +```bash +$ npm i @nodesecure/estree-ast-util +# or +$ yarn add @nodesecure/estree-ast-util +``` ## Contributors ✨ diff --git a/package.json b/package.json index 78955f7..5f3cfa7 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "type": "git", "url": "git+https://github.com/NodeSecure/js-x-ray.git" }, + "workspaces": [ + "workspaces/estree-ast-utils", + "workspaces/sec-literal" + ], "keywords": [ "ast", "nsecure", @@ -48,10 +52,15 @@ }, "devDependencies": { "@nodesecure/eslint-config": "^1.6.0", + "@small-tech/esm-tape-runner": "^2.0.0", + "@small-tech/tap-monkey": "^1.4.0", "@types/node": "^20.6.2", "c8": "^8.0.1", + "cross-env": "^7.0.3", "eslint": "^8.31.0", "glob": "^10.3.4", - "pkg-ok": "^3.0.0" + "iterator-matcher": "^2.1.0", + "pkg-ok": "^3.0.0", + "tape": "^5.7.2" } } diff --git a/workspaces/estree-ast-utils/LICENSE b/workspaces/estree-ast-utils/LICENSE new file mode 100644 index 0000000..346097d --- /dev/null +++ b/workspaces/estree-ast-utils/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 NodeSecure + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/workspaces/estree-ast-utils/README.md b/workspaces/estree-ast-utils/README.md new file mode 100644 index 0000000..27a3b14 --- /dev/null +++ b/workspaces/estree-ast-utils/README.md @@ -0,0 +1,109 @@ +# estree-ast-utils + +[![version](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&url=https://raw.githubusercontent.com/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils/main/package.json&query=$.version&label=Version)](https://www.npmjs.com/package/@NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils) +[![maintained](https://img.shields.io/badge/Maintained%3F-yes-green.svg?style=for-the-badge)](https://github.com/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils/graphs/commit-activity) +[![OpenSSF +Scorecard](https://api.securityscorecards.dev/projects/github.com/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils/badge?style=for-the-badge)](https://api.securityscorecards.dev/projects/github.com/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils) +[![mit](https://img.shields.io/github/license/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils.svg?style=for-the-badge)](https://github.com/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils/blob/main/LICENSE) +[![build](https://img.shields.io/github/actions/workflow/status/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils/node.js.yml?style=for-the-badge)](https://github.com/NodeSecure/js-x-ray/blob/master/workspaces/estree-ast-utils/actions?query=workflow%3A%22Node.js+CI%22) + +Utilities for AST (ESTree compliant) + +## Getting Started + +This package is available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com). + +```bash +$ npm i @nodesecure/estree-ast-utils +# or +$ yarn add @nodesecure/estree-ast-utils +``` + +## Usage example + +```js +import { VariableTracer } from "@nodesecure/estree-ast-utils"; + +const tracer = new VariableTracer().enableDefaultTracing(); + +const data = tracer.getDataFromIdentifier("identifier...here"); +console.log(data); +``` + +## API + +
arrayExpressionToString(node): IterableIterator< string > + +Translate an ESTree ArrayExpression into an iterable of Literal value. + +```js +["foo", "bar"]; +``` + +will return `"foo"` then `"bar"`. + +
+ +
concatBinaryExpression(node, options): IterableIterator< string > + +Return all Literal part of a given Binary Expression. + +```js +"foo" + "bar"; +``` + +will return `"foo"` then `"bar"`. + +One of the options of the method is `stopOnUnsupportedNode`, if true it will throw an Error if the left or right side of the Expr is not a supported type. + +
+ +
getCallExpressionIdentifier(node): string | null + +Return the identifier name of the CallExpression (or null if there is none). + +```js +foobar(); +``` + +will return `"foobar"`. + +
+ +
getMemberExpressionIdentifier(node): IterableIterator< string > + +Return the identifier name of the CallExpression (or null if there is none). + +```js +foo.bar(); +``` + +will return `"foo"` then `"bar"`. + +
+ +
getVariableDeclarationIdentifiers(node): IterableIterator< string > + +Get all variables identifier name. + +```js +const [foo, bar] = [1, 2]; +``` + +will return `"foo"` then `"bar"`. + +
+ +
isLiteralRegex(node): boolean + +Return `true` if the given Node is a Literal Regex Node. + +```js +/^hello/g; +``` + +
+ +## License + +MIT diff --git a/workspaces/estree-ast-utils/package.json b/workspaces/estree-ast-utils/package.json new file mode 100644 index 0000000..8afe1d1 --- /dev/null +++ b/workspaces/estree-ast-utils/package.json @@ -0,0 +1,35 @@ +{ + "name": "@nodesecure/estree-ast-utils", + "version": "1.4.1", + "description": "Utilities for AST (ESTree compliant)", + "type": "module", + "exports": "./src/index.js", + "scripts": { + "lint": "eslint src test", + "prepublishOnly": "pkg-ok", + "test": "cross-env esm-tape-runner 'test/**/*.spec.js' | tap-monkey", + "check": "cross-env npm run lint && npm run test", + "coverage": "c8 -r html npm test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NodeSecure/estree-ast-utils.git" + }, + "keywords": [ + "estree", + "ast", + "utils" + ], + "author": "GENTILHOMME Thomas ", + "license": "MIT", + "bugs": { + "url": "https://github.com/NodeSecure/estree-ast-utils/issues" + }, + "homepage": "https://github.com/NodeSecure/estree-ast-utils#readme", + "devDependencies": { + "estree-walker": "^3.0.2" + }, + "dependencies": { + "@nodesecure/sec-literal": "^1.1.0" + } +} diff --git a/workspaces/estree-ast-utils/src/arrayExpressionToString.js b/workspaces/estree-ast-utils/src/arrayExpressionToString.js new file mode 100644 index 0000000..548154c --- /dev/null +++ b/workspaces/estree-ast-utils/src/arrayExpressionToString.js @@ -0,0 +1,37 @@ +// Import Internal Dependencies +import { VariableTracer } from "./utils/VariableTracer.js"; + +/** + * @param {*} node + * @param {object} options + * @param {VariableTracer} [options.tracer=null] + * @returns {IterableIterator} + */ + +export function* arrayExpressionToString(node, options = {}) { + const { tracer = null } = options; + + if (!node || node.type !== "ArrayExpression") { + return; + } + + for (const row of node.elements) { + switch (row.type) { + case "Literal": { + if (row.value === "") { + continue; + } + + const value = Number(row.value); + yield Number.isNaN(value) ? row.value : String.fromCharCode(value); + break; + } + case "Identifier": { + if (tracer !== null && tracer.literalIdentifiers.has(row.name)) { + yield tracer.literalIdentifiers.get(row.name); + } + break; + } + } + } +} diff --git a/workspaces/estree-ast-utils/src/concatBinaryExpression.js b/workspaces/estree-ast-utils/src/concatBinaryExpression.js new file mode 100644 index 0000000..590c96c --- /dev/null +++ b/workspaces/estree-ast-utils/src/concatBinaryExpression.js @@ -0,0 +1,57 @@ +// Import Internal Dependencies +import { arrayExpressionToString } from "./arrayExpressionToString.js"; +import { VariableTracer } from "./utils/VariableTracer.js"; + +// CONSTANTS +const kBinaryExprTypes = new Set([ + "Literal", + "BinaryExpression", + "ArrayExpression", + "Identifier" +]); + +/** + * @param {*} node + * @param {object} options + * @param {VariableTracer} [options.tracer=null] + * @param {boolean} [options.stopOnUnsupportedNode=false] + * @returns {IterableIterator} + */ +export function* concatBinaryExpression(node, options = {}) { + const { + tracer = null, + stopOnUnsupportedNode = false + } = options; + const { left, right } = node; + + if ( + stopOnUnsupportedNode && + (!kBinaryExprTypes.has(left.type) || !kBinaryExprTypes.has(right.type)) + ) { + throw new Error("concatBinaryExpression:: Unsupported node detected"); + } + + for (const childNode of [left, right]) { + switch (childNode.type) { + case "BinaryExpression": { + yield* concatBinaryExpression(childNode, { + tracer, + stopOnUnsupportedNode + }); + break; + } + case "ArrayExpression": { + yield* arrayExpressionToString(childNode, { tracer }); + break; + } + case "Literal": + yield childNode.value; + break; + case "Identifier": + if (tracer !== null && tracer.literalIdentifiers.has(childNode.name)) { + yield tracer.literalIdentifiers.get(childNode.name); + } + break; + } + } +} diff --git a/workspaces/estree-ast-utils/src/getCallExpressionArguments.js b/workspaces/estree-ast-utils/src/getCallExpressionArguments.js new file mode 100644 index 0000000..f3f8b69 --- /dev/null +++ b/workspaces/estree-ast-utils/src/getCallExpressionArguments.js @@ -0,0 +1,45 @@ +// Import Third-party Dependencies +import { Hex } from "@nodesecure/sec-literal"; + +// Import Internal Dependencies +import { concatBinaryExpression } from "./concatBinaryExpression.js"; + +export function getCallExpressionArguments(node, options = {}) { + const { tracer = null } = options; + + if (node.type !== "CallExpression" || node.arguments.length === 0) { + return null; + } + + const literalsNode = []; + for (const arg of node.arguments) { + switch (arg.type) { + case "Identifier": { + if (tracer !== null && tracer.literalIdentifiers.has(arg.name)) { + literalsNode.push(tracer.literalIdentifiers.get(arg.name)); + } + + break; + } + case "Literal": { + literalsNode.push(hexToString(arg.value)); + + break; + } + case "BinaryExpression": { + const concatenatedBinaryExpr = [...concatBinaryExpression(arg, { tracer })].join(""); + if (concatenatedBinaryExpr !== "") { + literalsNode.push(concatenatedBinaryExpr); + } + + break; + } + } + } + + return literalsNode.length === 0 ? null : literalsNode; +} + +function hexToString(value) { + return Hex.isHex(value) ? Buffer.from(value, "hex").toString() : value; +} diff --git a/workspaces/estree-ast-utils/src/getCallExpressionIdentifier.js b/workspaces/estree-ast-utils/src/getCallExpressionIdentifier.js new file mode 100644 index 0000000..1f9f80c --- /dev/null +++ b/workspaces/estree-ast-utils/src/getCallExpressionIdentifier.js @@ -0,0 +1,21 @@ +// Import Internal Dependencies +import { getMemberExpressionIdentifier } from "./getMemberExpressionIdentifier.js"; + +/** + * @param {any} node + * @returns {string | null} + */ +export function getCallExpressionIdentifier(node) { + if (node.type !== "CallExpression") { + return null; + } + + if (node.callee.type === "Identifier") { + return node.callee.name; + } + if (node.callee.type === "MemberExpression") { + return [...getMemberExpressionIdentifier(node.callee)].join("."); + } + + return getCallExpressionIdentifier(node.callee); +} diff --git a/workspaces/estree-ast-utils/src/getMemberExpressionIdentifier.js b/workspaces/estree-ast-utils/src/getMemberExpressionIdentifier.js new file mode 100644 index 0000000..799dd56 --- /dev/null +++ b/workspaces/estree-ast-utils/src/getMemberExpressionIdentifier.js @@ -0,0 +1,67 @@ +// Import Third-party Dependencies +import { Hex } from "@nodesecure/sec-literal"; + +// Import Internal Dependencies +import { concatBinaryExpression } from "./concatBinaryExpression.js"; +import { VariableTracer } from "./utils/VariableTracer.js"; + +/** + * Return the complete identifier of a MemberExpression + * + * @param {any} node + * @param {object} options + * @param {VariableTracer} [options.tracer=null] + * @returns {IterableIterator} + */ +export function* getMemberExpressionIdentifier(node, options = {}) { + const { tracer = null } = options; + + switch (node.object.type) { + // Chain with another MemberExpression + case "MemberExpression": + yield* getMemberExpressionIdentifier(node.object, options); + break; + case "Identifier": + yield node.object.name; + break; + // Literal is used when the property is computed + case "Literal": + yield node.object.value; + break; + } + + switch (node.property.type) { + case "Identifier": { + if (tracer !== null && tracer.literalIdentifiers.has(node.property.name)) { + yield tracer.literalIdentifiers.get(node.property.name); + } + else { + yield node.property.name; + } + + break; + } + // Literal is used when the property is computed + case "Literal": + yield node.property.value; + break; + + // foo.bar[callexpr()] + case "CallExpression": { + const args = node.property.arguments; + if (args.length > 0 && args[0].type === "Literal" && Hex.isHex(args[0].value)) { + yield Buffer.from(args[0].value, "hex").toString(); + } + break; + } + + // foo.bar["k" + "e" + "y"] + case "BinaryExpression": { + const literal = [...concatBinaryExpression(node.property, options)].join(""); + if (literal.trim() !== "") { + yield literal; + } + break; + } + } +} diff --git a/workspaces/estree-ast-utils/src/getVariableDeclarationIdentifiers.js b/workspaces/estree-ast-utils/src/getVariableDeclarationIdentifiers.js new file mode 100644 index 0000000..acf2319 --- /dev/null +++ b/workspaces/estree-ast-utils/src/getVariableDeclarationIdentifiers.js @@ -0,0 +1,110 @@ +// Import Internal Dependencies +import { notNullOrUndefined } from "./utils/index.js"; + +export function* getVariableDeclarationIdentifiers(node, options = {}) { + const { prefix = null } = options; + + switch (node.type) { + case "VariableDeclaration": { + for (const variableDeclarator of node.declarations) { + yield* getVariableDeclarationIdentifiers(variableDeclarator.id); + } + + break; + } + + case "VariableDeclarator": + yield* getVariableDeclarationIdentifiers(node.id); + + break; + + case "Identifier": + yield { name: autoPrefix(node.name, prefix), assignmentId: node }; + + break; + + case "Property": { + if (node.kind !== "init") { + break; + } + + if (node.value.type === "ObjectPattern" || node.value.type === "ArrayPattern") { + yield* getVariableDeclarationIdentifiers(node.value, { + prefix: autoPrefix(node.key.name, prefix) + }); + break; + } + + let assignmentId = node.key; + if (node.value.type === "Identifier") { + assignmentId = node.value; + } + else if (node.value.type === "AssignmentPattern") { + assignmentId = node.value.left; + } + + yield { name: autoPrefix(node.key.name, prefix), assignmentId }; + + break; + } + + /** + * Rest syntax (in ArrayPattern or ObjectPattern for example) + * const [...foo] = [] + * const {...foo} = {} + */ + case "RestElement": + yield { name: autoPrefix(node.argument.name, prefix), assignmentId: node.argument }; + + break; + + /** + * (foo = 5) + */ + case "AssignmentExpression": + yield* getVariableDeclarationIdentifiers(node.left); + + break; + + /** + * const [{ foo }] = [] + * const [foo = 10] = [] + * ↪ Destructuration + Assignement of a default value + */ + case "AssignmentPattern": + if (node.left.type === "Identifier") { + yield node.left.name; + } + else { + yield* getVariableDeclarationIdentifiers(node.left); + } + + break; + + /** + * const [foo] = []; + * ↪ Destructuration of foo is an ArrayPattern + */ + case "ArrayPattern": + yield* node.elements + .filter(notNullOrUndefined) + .map((id) => [...getVariableDeclarationIdentifiers(id)]).flat(); + + break; + + /** + * const {foo} = {}; + * ↪ Destructuration of foo is an ObjectPattern + */ + case "ObjectPattern": + yield* node.properties + .filter(notNullOrUndefined) + .map((property) => [...getVariableDeclarationIdentifiers(property)]).flat(); + + break; + } +} + +function autoPrefix(name, prefix = null) { + return typeof prefix === "string" ? `${prefix}.${name}` : name; +} diff --git a/workspaces/estree-ast-utils/src/index.js b/workspaces/estree-ast-utils/src/index.js new file mode 100644 index 0000000..f747004 --- /dev/null +++ b/workspaces/estree-ast-utils/src/index.js @@ -0,0 +1,9 @@ +export * from "./getMemberExpressionIdentifier.js"; +export * from "./getCallExpressionIdentifier.js"; +export * from "./getVariableDeclarationIdentifiers.js"; +export * from "./getCallExpressionArguments.js"; +export * from "./concatBinaryExpression.js"; +export * from "./arrayExpressionToString.js"; +export * from "./isLiteralRegex.js"; + +export * from "./utils/VariableTracer.js"; diff --git a/workspaces/estree-ast-utils/src/isLiteralRegex.js b/workspaces/estree-ast-utils/src/isLiteralRegex.js new file mode 100644 index 0000000..b73beff --- /dev/null +++ b/workspaces/estree-ast-utils/src/isLiteralRegex.js @@ -0,0 +1,3 @@ +export function isLiteralRegex(node) { + return node.type === "Literal" && "regex" in node; +} diff --git a/workspaces/estree-ast-utils/src/utils/VariableTracer.js b/workspaces/estree-ast-utils/src/utils/VariableTracer.js new file mode 100644 index 0000000..965a48f --- /dev/null +++ b/workspaces/estree-ast-utils/src/utils/VariableTracer.js @@ -0,0 +1,411 @@ +// Import Node.js Dependencies +import { EventEmitter } from "node:events"; + +// Import Internal Dependencies +import { notNullOrUndefined } from "./notNullOrUndefined.js"; +import { isEvilIdentifierPath, isNeutralCallable } from "./isEvilIdentifierPath.js"; +import { getSubMemberExpressionSegments } from "./getSubMemberExpressionSegments.js"; +import { getMemberExpressionIdentifier } from "../getMemberExpressionIdentifier.js"; +import { getCallExpressionIdentifier } from "../getCallExpressionIdentifier.js"; +import { getVariableDeclarationIdentifiers } from "../getVariableDeclarationIdentifiers.js"; +import { getCallExpressionArguments } from "../getCallExpressionArguments.js"; + +// CONSTANTS +const kGlobalIdentifiersToTrace = new Set([ + "global", "globalThis", "root", "GLOBAL", "window" +]); +const kRequirePatterns = new Set([ + "require", "require.resolve", "require.main", "process.mainModule.require" +]); +const kUnsafeGlobalCallExpression = new Set(["eval", "Function"]); + +export class VariableTracer extends EventEmitter { + static AssignmentEvent = Symbol("AssignmentEvent"); + + // PUBLIC PROPERTIES + /** @type {Map} */ + literalIdentifiers = new Map(); + + /** @type {Set} */ + importedModules = new Set(); + + // PRIVATE PROPERTIES + #traced = new Map(); + #variablesRefToGlobal = new Set(); + + /** @type {Set} */ + #neutralCallable = new Set(); + + debug() { + console.log(this.#traced); + } + + enableDefaultTracing() { + [...kRequirePatterns] + .forEach((pattern) => this.trace(pattern, { followConsecutiveAssignment: true, name: "require" })); + + return this + .trace("eval") + .trace("Function") + .trace("atob", { followConsecutiveAssignment: true }); + } + + /** + * + * @param {!string} identifierOrMemberExpr + * @param {object} [options] + * @param {string} [options.name] + * @param {string} [options.moduleName=null] + * @param {boolean} [options.followConsecutiveAssignment=false] + * + * @example + * new VariableTracer() + * .trace("require", { followConsecutiveAssignment: true }) + * .trace("process.mainModule") + */ + trace(identifierOrMemberExpr, options = {}) { + const { + followConsecutiveAssignment = false, + moduleName = null, + name = identifierOrMemberExpr + } = options; + + this.#traced.set(identifierOrMemberExpr, { + name, + identifierOrMemberExpr, + followConsecutiveAssignment, + assignmentMemory: [], + moduleName + }); + + if (identifierOrMemberExpr.includes(".")) { + const exprs = [...getSubMemberExpressionSegments(identifierOrMemberExpr)] + .filter((expr) => !this.#traced.has(expr)); + + for (const expr of exprs) { + this.trace(expr, { + followConsecutiveAssignment: true, name, moduleName + }); + } + } + + return this; + } + + /** + * @param {!string} identifierOrMemberExpr An identifier like "foo" or "foo.bar" + */ + getDataFromIdentifier(identifierOrMemberExpr) { + const isMemberExpr = identifierOrMemberExpr.includes("."); + const isTracingIdentifier = this.#traced.has(identifierOrMemberExpr); + + let finalIdentifier = identifierOrMemberExpr; + if (isMemberExpr && !isTracingIdentifier) { + const [segment] = identifierOrMemberExpr.split("."); + if (this.#traced.has(segment)) { + const tracedIdentifier = this.#traced.get(segment); + finalIdentifier = `${tracedIdentifier.identifierOrMemberExpr}${identifierOrMemberExpr.slice(segment.length)}`; + } + + if (!this.#traced.has(finalIdentifier)) { + return null; + } + } + else if (!isTracingIdentifier) { + return null; + } + + const tracedIdentifier = this.#traced.get(finalIdentifier); + if (!this.#isTracedIdentifierImportedAsModule(tracedIdentifier)) { + return null; + } + + const assignmentMemory = this.#traced.get(tracedIdentifier.name)?.assignmentMemory ?? []; + + return { + name: tracedIdentifier.name, + identifierOrMemberExpr: tracedIdentifier.identifierOrMemberExpr, + assignmentMemory + }; + } + + #getTracedName(identifierOrMemberExpr) { + return this.#traced.has(identifierOrMemberExpr) ? + this.#traced.get(identifierOrMemberExpr).name : null; + } + + #isTracedIdentifierImportedAsModule(id) { + return id.moduleName === null || this.importedModules.has(id.moduleName); + } + + #declareNewAssignment(identifierOrMemberExpr, id) { + const tracedVariant = this.#traced.get(identifierOrMemberExpr); + + // We return if required module has not been imported + // It mean the assigment has no relation with the required tracing + if (!this.#isTracedIdentifierImportedAsModule(tracedVariant)) { + return; + } + + const newIdentiferName = id.name; + + const assignmentEventPayload = { + name: tracedVariant.name, + identifierOrMemberExpr: tracedVariant.identifierOrMemberExpr, + id: newIdentiferName, + location: id.loc + }; + this.emit(VariableTracer.AssignmentEvent, assignmentEventPayload); + this.emit(tracedVariant.identifierOrMemberExpr, assignmentEventPayload); + + if (tracedVariant.followConsecutiveAssignment && !this.#traced.has(newIdentiferName)) { + this.#traced.get(tracedVariant.name).assignmentMemory.push(newIdentiferName); + this.#traced.set(newIdentiferName, tracedVariant); + } + } + + #isGlobalVariableIdentifier(identifierName) { + return kGlobalIdentifiersToTrace.has(identifierName) || + this.#variablesRefToGlobal.has(identifierName); + } + + /** + * Search alternative for the given MemberExpression parts + * + * @example + * const { process: aName } = globalThis; + * const boo = aName.mainModule.require; // alternative: process.mainModule.require + */ + #searchForMemberExprAlternative(parts = []) { + return parts.flatMap((identifierName) => { + if (this.#traced.has(identifierName)) { + return this.#traced.get(identifierName).identifierOrMemberExpr; + } + + /** + * If identifier is global then we can eliminate the value from MemberExpr + * + * globalThis.process === process; + */ + if (this.#isGlobalVariableIdentifier(identifierName)) { + return []; + } + + return identifierName; + }); + } + + #autoTraceId(id, prefix = null) { + for (const { name, assignmentId } of getVariableDeclarationIdentifiers(id)) { + const identifierOrMemberExpr = typeof prefix === "string" ? `${prefix}.${name}` : name; + + if (this.#traced.has(identifierOrMemberExpr)) { + this.#declareNewAssignment(identifierOrMemberExpr, assignmentId); + } + } + } + + #walkImportDeclaration(node) { + const moduleName = node.source.value; + if (!this.#traced.has(moduleName)) { + return; + } + + this.importedModules.add(moduleName); + + // import * as boo from "crypto"; + if (node.specifiers[0].type === "ImportNamespaceSpecifier") { + const importNamespaceNode = node.specifiers[0]; + this.#declareNewAssignment(moduleName, importNamespaceNode.local); + + return; + } + + // import { createHash } from "crypto"; + const importSpecifiers = node.specifiers + .filter((specifierNode) => specifierNode.type === "ImportSpecifier"); + for (const specifier of importSpecifiers) { + const fullImportedName = `${moduleName}.${specifier.imported.name}`; + + if (this.#traced.has(fullImportedName)) { + this.#declareNewAssignment(fullImportedName, specifier.imported); + } + } + } + + #walkRequireCallExpression(variableDeclaratorNode) { + const { init, id } = variableDeclaratorNode; + + const moduleNameLiteral = init.arguments + .find((argumentNode) => argumentNode.type === "Literal" && this.#traced.has(argumentNode.value)); + if (!moduleNameLiteral) { + return; + } + this.importedModules.add(moduleNameLiteral.value); + + switch (id.type) { + case "Identifier": + this.#declareNewAssignment(moduleNameLiteral.value, id); + break; + case "ObjectPattern": { + this.#autoTraceId(id, moduleNameLiteral.value); + + break; + } + } + } + + #walkVariableDeclarationWithIdentifier(variableDeclaratorNode) { + const { init, id } = variableDeclaratorNode; + + switch (init.type) { + // let foo = "10"; <-- "foo" is the key and "10" the value + case "Literal": + this.literalIdentifiers.set(id.name, init.value); + break; + + // const g = eval("this"); + case "CallExpression": { + const fullIdentifierPath = getCallExpressionIdentifier(init); + if (fullIdentifierPath === null) { + break; + } + + const tracedFullIdentifierName = this.#getTracedName(fullIdentifierPath) ?? fullIdentifierPath; + const [identifierName] = fullIdentifierPath.split("."); + + // const id = Function.prototype.call.call(require, require, "http"); + if (this.#neutralCallable.has(identifierName) || isEvilIdentifierPath(fullIdentifierPath)) { + // TODO: make sure we are walking on a require CallExpr here ? + this.#walkRequireCallExpression(variableDeclaratorNode); + } + else if (kUnsafeGlobalCallExpression.has(identifierName)) { + this.#variablesRefToGlobal.add(id.name); + } + // const foo = require("crypto"); + // const bar = require.call(null, "crypto"); + else if (kRequirePatterns.has(identifierName)) { + this.#walkRequireCallExpression(variableDeclaratorNode); + } + else if (tracedFullIdentifierName === "atob") { + const callExprArguments = getCallExpressionArguments(init, { tracer: this }); + if (callExprArguments === null) { + break; + } + + const callExprArgumentNode = callExprArguments.at(0); + if (typeof callExprArgumentNode === "string") { + this.literalIdentifiers.set( + id.name, + Buffer.from(callExprArgumentNode, "base64").toString() + ); + } + } + + break; + } + + // const r = require + case "Identifier": { + const identifierName = init.name; + if (this.#traced.has(identifierName)) { + this.#declareNewAssignment(identifierName, variableDeclaratorNode.id); + } + else if (this.#isGlobalVariableIdentifier(identifierName)) { + this.#variablesRefToGlobal.add(id.name); + } + + break; + } + + // process.mainModule and require.resolve + case "MemberExpression": { + // Example: ["process", "mainModule"] + const memberExprParts = [...getMemberExpressionIdentifier(init, { tracer: this })]; + const memberExprFullname = memberExprParts.join("."); + + // Function.prototype.call + if (isNeutralCallable(memberExprFullname)) { + this.#neutralCallable.add(variableDeclaratorNode.id.name); + } + else if (this.#traced.has(memberExprFullname)) { + this.#declareNewAssignment(memberExprFullname, variableDeclaratorNode.id); + } + else { + const alternativeMemberExprParts = this.#searchForMemberExprAlternative(memberExprParts); + const alternativeMemberExprFullname = alternativeMemberExprParts.join("."); + + if (this.#traced.has(alternativeMemberExprFullname)) { + this.#declareNewAssignment(alternativeMemberExprFullname, variableDeclaratorNode.id); + } + } + + break; + } + } + } + + #walkVariableDeclarationWithAnythingElse(variableDeclaratorNode) { + const { init, id } = variableDeclaratorNode; + + switch (init.type) { + // const { process } = eval("this"); + case "CallExpression": { + const fullIdentifierPath = getCallExpressionIdentifier(init); + if (fullIdentifierPath === null) { + break; + } + const [identifierName] = fullIdentifierPath.split("."); + + // const {} = Function.prototype.call.call(require, require, "http"); + if (isEvilIdentifierPath(fullIdentifierPath)) { + this.#walkRequireCallExpression(variableDeclaratorNode); + } + else if (kUnsafeGlobalCallExpression.has(identifierName)) { + this.#autoTraceId(id); + } + // const { createHash } = require("crypto"); + else if (kRequirePatterns.has(identifierName)) { + this.#walkRequireCallExpression(variableDeclaratorNode); + } + + break; + } + + // const { process } = globalThis; + case "Identifier": { + const identifierName = init.name; + if (this.#isGlobalVariableIdentifier(identifierName)) { + this.#autoTraceId(id); + } + + break; + } + } + } + + walk(node) { + switch (node.type) { + case "ImportDeclaration": { + this.#walkImportDeclaration(node); + break; + } + case "VariableDeclaration": { + for (const variableDeclaratorNode of node.declarations) { + // var foo; <-- no initialization here. + if (!notNullOrUndefined(variableDeclaratorNode.init)) { + continue; + } + + if (variableDeclaratorNode.id.type === "Identifier") { + this.#walkVariableDeclarationWithIdentifier(variableDeclaratorNode); + } + else { + this.#walkVariableDeclarationWithAnythingElse(variableDeclaratorNode); + } + } + break; + } + } + } +} diff --git a/workspaces/estree-ast-utils/src/utils/getSubMemberExpressionSegments.js b/workspaces/estree-ast-utils/src/utils/getSubMemberExpressionSegments.js new file mode 100644 index 0000000..e4ccc3f --- /dev/null +++ b/workspaces/estree-ast-utils/src/utils/getSubMemberExpressionSegments.js @@ -0,0 +1,13 @@ +/** + * @param {!string} str + * @returns {IterableIterator} + */ +export function* getSubMemberExpressionSegments(memberExpressionFullpath) { + const identifiers = memberExpressionFullpath.split("."); + const segments = []; + + for (let i = 0; i < identifiers.length - 1; i++) { + segments.push(identifiers[i]); + yield segments.join("."); + } +} diff --git a/workspaces/estree-ast-utils/src/utils/index.js b/workspaces/estree-ast-utils/src/utils/index.js new file mode 100644 index 0000000..d4b343b --- /dev/null +++ b/workspaces/estree-ast-utils/src/utils/index.js @@ -0,0 +1,4 @@ +export * from "./getSubMemberExpressionSegments.js"; +export * from "./notNullOrUndefined.js"; +export * from "./VariableTracer.js"; +export * from "./isEvilIdentifierPath.js"; diff --git a/workspaces/estree-ast-utils/src/utils/isEvilIdentifierPath.js b/workspaces/estree-ast-utils/src/utils/isEvilIdentifierPath.js new file mode 100644 index 0000000..e9671c2 --- /dev/null +++ b/workspaces/estree-ast-utils/src/utils/isEvilIdentifierPath.js @@ -0,0 +1,18 @@ +/** + * @param {!string} identifier + */ +export function isEvilIdentifierPath(identifier) { + return isFunctionPrototype(identifier); +} + +export function isNeutralCallable(identifier) { + return identifier === "Function.prototype.call"; +} + +/** + * @param {!string} identifier + */ +function isFunctionPrototype(identifier) { + return identifier.startsWith("Function.prototype") + && /call|apply|bind/i.test(identifier); +} diff --git a/workspaces/estree-ast-utils/src/utils/notNullOrUndefined.js b/workspaces/estree-ast-utils/src/utils/notNullOrUndefined.js new file mode 100644 index 0000000..9eee585 --- /dev/null +++ b/workspaces/estree-ast-utils/src/utils/notNullOrUndefined.js @@ -0,0 +1,3 @@ +export function notNullOrUndefined(value) { + return value !== null && value !== void 0; +} diff --git a/workspaces/estree-ast-utils/test/VariableTracer/VariableTracer.spec.js b/workspaces/estree-ast-utils/test/VariableTracer/VariableTracer.spec.js new file mode 100644 index 0000000..41f9ce5 --- /dev/null +++ b/workspaces/estree-ast-utils/test/VariableTracer/VariableTracer.spec.js @@ -0,0 +1,145 @@ +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { createTracer } from "../utils.js"; + +test("getDataFromIdentifier must return primitive null is there is no kwown traced identifier", (tape) => { + const helpers = createTracer(true); + + const result = helpers.tracer.getDataFromIdentifier("foobar"); + + tape.strictEqual(result, null); + tape.end(); +}); + +test("it should be able to Trace a malicious code with Global, BinaryExpr, Assignments and Hexadecimal", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + var foo; + const g = eval("this"); + const p = g["pro" + "cess"]; + + const evil = p["mainMod" + "ule"][unhex("72657175697265")]; + const work = evil(unhex("2e2f746573742f64617461")) + `); + + const evil = helpers.tracer.getDataFromIdentifier("evil"); + tape.deepEqual(evil, { + name: "require", + identifierOrMemberExpr: "process.mainModule.require", + assignmentMemory: ["p", "evil"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "process"); + tape.strictEqual(eventOne.id, "p"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "process.mainModule.require"); + tape.strictEqual(eventTwo.id, "evil"); + + tape.end(); +}); + +test("it should be able to Trace a malicious CallExpression by recombining segments of the MemberExpression", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const g = global.process; + const r = g.mainModule; + const c = r.require; + c("http"); + r.require("fs"); + `); + + const evil = helpers.tracer.getDataFromIdentifier("r.require"); + tape.deepEqual(evil, { + name: "require", + identifierOrMemberExpr: "process.mainModule.require", + assignmentMemory: ["g", "r", "c"] + }); + tape.strictEqual(assignments.length, 3); + + const [eventOne, eventTwo, eventThree] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "process"); + tape.strictEqual(eventOne.id, "g"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "process.mainModule"); + tape.strictEqual(eventTwo.id, "r"); + + tape.strictEqual(eventThree.identifierOrMemberExpr, "process.mainModule.require"); + tape.strictEqual(eventThree.id, "c"); + + tape.end(); +}); + +test("given a MemberExpression segment that doesn't match anything then it should return null", (tape) => { + const helpers = createTracer(true); + + const result = helpers.tracer.getDataFromIdentifier("foo.bar"); + tape.strictEqual(result, null); + + tape.end(); +}); + +test("it should be able to Trace a require using Function.prototype.call", (tape) => { + const helpers = createTracer(); + helpers.tracer.trace("http"); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const proto = Function.prototype.call.call(require, require, "http"); + `); + + const proto = helpers.tracer.getDataFromIdentifier("proto"); + + tape.strictEqual(proto, null); + tape.strictEqual(assignments.length, 1); + + const [eventOne] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "http"); + tape.strictEqual(eventOne.id, "proto"); + + tape.end(); +}); + +test("it should be able to Trace an unsafe crypto.createHash using Function.prototype.call reassignment", (tape) => { + const helpers = createTracer(true); + helpers.tracer.trace("crypto.createHash", { followConsecutiveAssignment: true }); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const aA = Function.prototype.call; + const bB = require; + + const crr = aA.call(bB, bB, "crypto"); + const createHashBis = crr.createHash; + createHashBis("md5"); + `); + + const createHashBis = helpers.tracer.getDataFromIdentifier("createHashBis"); + tape.deepEqual(createHashBis, { + name: "crypto.createHash", + identifierOrMemberExpr: "crypto.createHash", + assignmentMemory: ["crr", "createHashBis"] + }); + + tape.strictEqual(helpers.tracer.importedModules.has("crypto"), true); + tape.strictEqual(assignments.length, 3); + + const [eventOne, eventTwo, eventThree] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "require"); + tape.strictEqual(eventOne.id, "bB"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "crypto"); + tape.strictEqual(eventTwo.id, "crr"); + + tape.strictEqual(eventThree.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventThree.id, "createHashBis"); + + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/VariableTracer/assignments.spec.js b/workspaces/estree-ast-utils/test/VariableTracer/assignments.spec.js new file mode 100644 index 0000000..e5f6833 --- /dev/null +++ b/workspaces/estree-ast-utils/test/VariableTracer/assignments.spec.js @@ -0,0 +1,134 @@ + +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { createTracer } from "../utils.js"; + +test("it should be able to Trace a require Assignment (using a global variable)", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const test = globalThis; + const foo = test.require; + foo("http"); + `); + + const foo = helpers.tracer.getDataFromIdentifier("foo"); + tape.deepEqual(foo, { + name: "require", + identifierOrMemberExpr: "require", + assignmentMemory: ["foo"] + }); + tape.strictEqual(assignments.length, 1); + + const [eventOne] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "require"); + tape.strictEqual(eventOne.id, "foo"); + + tape.end(); +}); + +test("it should be able to Trace a require Assignment (using a MemberExpression)", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const foo = require.resolve; + foo("http"); + `); + + const foo = helpers.tracer.getDataFromIdentifier("foo"); + tape.deepEqual(foo, { + name: "require", + identifierOrMemberExpr: "require.resolve", + assignmentMemory: ["foo"] + }); + tape.strictEqual(assignments.length, 1); + + const [eventOne] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "require.resolve"); + tape.strictEqual(eventOne.id, "foo"); + + tape.end(); +}); + +test("it should be able to Trace a global Assignment using an ESTree ObjectPattern", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const { process: yoo } = globalThis; + + const boo = yoo.mainModule.require; + `); + + const boo = helpers.tracer.getDataFromIdentifier("boo"); + + tape.deepEqual(boo, { + name: "require", + identifierOrMemberExpr: "process.mainModule.require", + assignmentMemory: ["yoo", "boo"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "process"); + tape.strictEqual(eventOne.id, "yoo"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "process.mainModule.require"); + tape.strictEqual(eventTwo.id, "boo"); + + tape.end(); +}); + +test("it should be able to Trace an Unsafe Function() Assignment using an ESTree ObjectPattern", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const { process: yoo } = Function("return this")(); + + const boo = yoo.mainModule.require; + `); + + const boo = helpers.tracer.getDataFromIdentifier("boo"); + + tape.deepEqual(boo, { + name: "require", + identifierOrMemberExpr: "process.mainModule.require", + assignmentMemory: ["yoo", "boo"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "process"); + tape.strictEqual(eventOne.id, "yoo"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "process.mainModule.require"); + tape.strictEqual(eventTwo.id, "boo"); + + tape.end(); +}); + +test("it should be able to Trace a require Assignment with atob", (tape) => { + const helpers = createTracer(true); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const xo = atob; + const yo = 'b3M='; + const ff = xo(yo); + `); + tape.strictEqual(assignments.length, 1); + + const [eventOne] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "atob"); + tape.strictEqual(eventOne.id, "xo"); + + tape.true(helpers.tracer.literalIdentifiers.has("ff")); + tape.strictEqual(helpers.tracer.literalIdentifiers.get("ff"), "os"); + + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/VariableTracer/cryptoCreateHash.spec.js b/workspaces/estree-ast-utils/test/VariableTracer/cryptoCreateHash.spec.js new file mode 100644 index 0000000..55f01bf --- /dev/null +++ b/workspaces/estree-ast-utils/test/VariableTracer/cryptoCreateHash.spec.js @@ -0,0 +1,194 @@ +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { createTracer } from "../utils.js"; + +test("it should be able to Trace crypto.createHash when imported with an ESTree ImportNamespaceSpecifier (ESM)", (tape) => { + const helpers = createTracer(); + helpers.tracer.trace("crypto.createHash", { + followConsecutiveAssignment: true, + moduleName: "crypto" + }); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + import fs from "fs"; + import * as cryptoBis from "crypto"; + + const createHashBis = cryptoBis.createHash; + createHashBis("md5"); + `); + + const createHashBis = helpers.tracer.getDataFromIdentifier("createHashBis"); + + tape.deepEqual(createHashBis, { + name: "crypto.createHash", + identifierOrMemberExpr: "crypto.createHash", + assignmentMemory: ["cryptoBis", "createHashBis"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "crypto"); + tape.strictEqual(eventOne.id, "cryptoBis"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventTwo.id, "createHashBis"); + + tape.end(); +}); + +test("it should be able to Trace createHash when required (CommonJS) and destructured with an ESTree ObjectPattern", (tape) => { + const helpers = createTracer(); + helpers.tracer.trace("crypto.createHash", { + followConsecutiveAssignment: true, + moduleName: "crypto" + }); + const assignments = helpers.getAssignmentArray(); + + /** + * This is an ObjectPattern: + * const { createHash } = ... + */ + helpers.walkOnCode(` + const { createHash } = require("crypto"); + + const createHashBis = createHash; + createHashBis("md5"); + `); + + const createHashBis = helpers.tracer.getDataFromIdentifier("createHashBis"); + + tape.deepEqual(createHashBis, { + name: "crypto.createHash", + identifierOrMemberExpr: "crypto.createHash", + assignmentMemory: ["createHash", "createHashBis"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventOne.id, "createHash"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventTwo.id, "createHashBis"); + + tape.end(); +}); + +test("it should be able to Trace crypto.createHash when imported with an ESTree ImportSpecifier (ESM)", (tape) => { + const helpers = createTracer(); + helpers.tracer.trace("crypto.createHash", { + followConsecutiveAssignment: true, + moduleName: "crypto" + }); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + import { createHash } from "crypto"; + + const createHashBis = createHash; + createHashBis("md5"); + `); + + const createHashBis = helpers.tracer.getDataFromIdentifier("createHashBis"); + + tape.deepEqual(createHashBis, { + name: "crypto.createHash", + identifierOrMemberExpr: "crypto.createHash", + assignmentMemory: ["createHash", "createHashBis"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventOne.id, "createHash"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventTwo.id, "createHashBis"); + + tape.end(); +}); + +test("it should be able to Trace crypto.createHash with CommonJS require and with a computed method with a Literal", (tape) => { + const helpers = createTracer(); + helpers.tracer.trace("crypto.createHash", { + followConsecutiveAssignment: true, + moduleName: "crypto" + }); + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const fs = require("fs"); + const crypto = require("crypto"); + + const id = "createHash"; + const createHashBis = crypto[id]; + createHashBis("md5"); + `); + + tape.strictEqual(helpers.tracer.importedModules.has("crypto"), true); + + const createHashBis = helpers.tracer.getDataFromIdentifier("createHashBis"); + + tape.deepEqual(createHashBis, { + name: "crypto.createHash", + identifierOrMemberExpr: "crypto.createHash", + assignmentMemory: ["createHashBis"] + }); + tape.strictEqual(assignments.length, 2); + + const [eventOne, eventTwo] = assignments; + tape.strictEqual(eventOne.identifierOrMemberExpr, "crypto"); + tape.strictEqual(eventOne.id, "crypto"); + + tape.strictEqual(eventTwo.identifierOrMemberExpr, "crypto.createHash"); + tape.strictEqual(eventTwo.id, "createHashBis"); + + tape.end(); +}); + +test("it should not detect variable assignment since the crypto module is not imported", (tape) => { + const helpers = createTracer(); + helpers.tracer.trace("crypto.createHash", { + followConsecutiveAssignment: true, + moduleName: "crypto" + }); + + const assignments = helpers.getAssignmentArray(); + + helpers.walkOnCode(` + const crypto = { + createHash() {} + } + const _t = crypto.createHash; + _t("md5"); + `); + + tape.strictEqual(helpers.tracer.importedModules.has("crypto"), false); + tape.strictEqual(assignments.length, 0); + + tape.end(); +}); + +test("it should return null because crypto.createHash is not imported from a module", (tape) => { + const helpers = createTracer(true); + helpers.tracer.trace("crypto.createHash", { + followConsecutiveAssignment: true, + moduleName: "crypto" + }); + + helpers.walkOnCode(` + const crypto = { + createHash() {} + } + const evil = crypto.createHash; + evil('md5'); + `); + + const result = helpers.tracer.getDataFromIdentifier("crypto.createHash"); + tape.strictEqual(result, null); + + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/arrayExpressionToString.spec.js b/workspaces/estree-ast-utils/test/arrayExpressionToString.spec.js new file mode 100644 index 0000000..7de5156 --- /dev/null +++ b/workspaces/estree-ast-utils/test/arrayExpressionToString.spec.js @@ -0,0 +1,75 @@ +// Import Third-party Dependencies +import test from "tape"; +import { IteratorMatcher } from "iterator-matcher"; + +// Import Internal Dependencies +import { arrayExpressionToString } from "../src/index.js"; +import { codeToAst, getExpressionFromStatement, createTracer } from "./utils.js"; + +test("given an ArrayExpression with two Literals then the iterable must return them one by one", (tape) => { + const [astNode] = codeToAst("['foo', 'bar']"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("bar") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 2); + tape.end(); +}); + +test("given an ArrayExpression with two Identifiers then the iterable must return value from the Tracer", (tape) => { + const { tracer } = createTracer(); + tracer.literalIdentifiers.set("foo", "1"); + tracer.literalIdentifiers.set("bar", "2"); + + const [astNode] = codeToAst("[foo, bar]"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode), { tracer }); + + const iterResult = new IteratorMatcher() + .expect("1") + .expect("2") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 2); + tape.end(); +}); + +test(`given an ArrayExpression with two numbers + then the function must convert them as char code + and return them in the iterable`, (tape) => { + const [astNode] = codeToAst("[65, 66]"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); + + const iterResult = new IteratorMatcher() + .expect("A") + .expect("B") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 2); + tape.end(); +}); + +test("given an ArrayExpression with empty Literals then the iterable must return no values", (tape) => { + const [astNode] = codeToAst("['', '']"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); + + const iterResult = [...iter]; + + tape.strictEqual(iterResult.length, 0); + tape.end(); +}); + +test("given an AST that is not an ArrayExpression then it must return immediately", (tape) => { + const [astNode] = codeToAst("const foo = 5;"); + const iter = arrayExpressionToString(astNode); + + const iterResult = [...iter]; + + tape.strictEqual(iterResult.length, 0); + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/concatBinaryExpression.spec.js b/workspaces/estree-ast-utils/test/concatBinaryExpression.spec.js new file mode 100644 index 0000000..630d5a9 --- /dev/null +++ b/workspaces/estree-ast-utils/test/concatBinaryExpression.spec.js @@ -0,0 +1,92 @@ +// Import Third-party Dependencies +import test from "tape"; +import { IteratorMatcher } from "iterator-matcher"; + +// Import Internal Dependencies +import { concatBinaryExpression } from "../src/index.js"; +import { codeToAst, getExpressionFromStatement, createTracer } from "./utils.js"; + +test("given a BinaryExpression of two literals then the iterable must return Literal values", (tape) => { + const [astNode] = codeToAst("'foo' + 'bar' + 'xd'"); + const iter = concatBinaryExpression(getExpressionFromStatement(astNode)); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("bar") + .expect("xd") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 3); + tape.end(); +}); + +test("given a BinaryExpression of two ArrayExpression then the iterable must return Array values as string", (tape) => { + const [astNode] = codeToAst("['A'] + ['B']"); + const iter = concatBinaryExpression(getExpressionFromStatement(astNode)); + + const iterResult = new IteratorMatcher() + .expect("A") + .expect("B") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 2); + tape.end(); +}); + +test("given a BinaryExpression of two Identifiers then the iterable must the tracer values", (tape) => { + const { tracer } = createTracer(); + tracer.literalIdentifiers.set("foo", "A"); + tracer.literalIdentifiers.set("bar", "B"); + + const [astNode] = codeToAst("foo + bar"); + const iter = concatBinaryExpression(getExpressionFromStatement(astNode), { tracer }); + + const iterResult = new IteratorMatcher() + .expect("A") + .expect("B") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 2); + tape.end(); +}); + +test("given a one level BinaryExpression with an unsupported node it should throw an Error", (tape) => { + tape.plan(1); + const { tracer } = createTracer(); + + const [astNode] = codeToAst("evil() + 's'"); + try { + const iter = concatBinaryExpression(getExpressionFromStatement(astNode), { + tracer, + stopOnUnsupportedNode: true + }); + iter.next(); + } + catch (error) { + tape.strictEqual(error.message, "concatBinaryExpression:: Unsupported node detected"); + } + + tape.end(); +}); + +test("given a Deep BinaryExpression with an unsupported node it should throw an Error", (tape) => { + tape.plan(1); + const { tracer } = createTracer(); + + const [astNode] = codeToAst("'a' + evil() + 's'"); + try { + const iter = concatBinaryExpression(getExpressionFromStatement(astNode), { + tracer, + stopOnUnsupportedNode: true + }); + iter.next(); + } + catch (error) { + tape.strictEqual(error.message, "concatBinaryExpression:: Unsupported node detected"); + } + + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/getCallExpressionIdentifier.spec.js b/workspaces/estree-ast-utils/test/getCallExpressionIdentifier.spec.js new file mode 100644 index 0000000..e0d29ec --- /dev/null +++ b/workspaces/estree-ast-utils/test/getCallExpressionIdentifier.spec.js @@ -0,0 +1,30 @@ +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { getCallExpressionIdentifier } from "../src/index.js"; +import { codeToAst, getExpressionFromStatement } from "./utils.js"; + +test("given a JavaScript eval CallExpression then it must return eval", (tape) => { + const [astNode] = codeToAst("eval(\"this\");"); + const nodeIdentifier = getCallExpressionIdentifier(getExpressionFromStatement(astNode)); + + tape.strictEqual(nodeIdentifier, "eval"); + tape.end(); +}); + +test("given a JavaScript Function() CallExpression then it must return Function", (tape) => { + const [astNode] = codeToAst("Function(\"return this\")();"); + const nodeIdentifier = getCallExpressionIdentifier(getExpressionFromStatement(astNode)); + + tape.strictEqual(nodeIdentifier, "Function"); + tape.end(); +}); + +test("given a JavaScript AssignmentExpression then it must return null", (tape) => { + const [astNode] = codeToAst("foo = 10;"); + const nodeIdentifier = getCallExpressionIdentifier(getExpressionFromStatement(astNode)); + + tape.strictEqual(nodeIdentifier, null); + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/getMemberExpressionIdentifier.spec.js b/workspaces/estree-ast-utils/test/getMemberExpressionIdentifier.spec.js new file mode 100644 index 0000000..8761e78 --- /dev/null +++ b/workspaces/estree-ast-utils/test/getMemberExpressionIdentifier.spec.js @@ -0,0 +1,84 @@ +// Import Third-party Dependencies +import test from "tape"; +import { IteratorMatcher } from "iterator-matcher"; + +// Import Internal Dependencies +import { getMemberExpressionIdentifier } from "../src/index.js"; +import { codeToAst, createTracer, getExpressionFromStatement } from "./utils.js"; + +test("it must return all literals part of the given MemberExpression", (tape) => { + const [astNode] = codeToAst("foo.bar.xd"); + const iter = getMemberExpressionIdentifier( + getExpressionFromStatement(astNode) + ); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("bar") + .expect("xd") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 3); + tape.end(); +}); + +test("it must return all computed properties of the given MemberExpression", (tape) => { + const [astNode] = codeToAst("foo['bar']['xd']"); + const iter = getMemberExpressionIdentifier( + getExpressionFromStatement(astNode) + ); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("bar") + .expect("xd") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 3); + + tape.end(); +}); + +test(`given a MemberExpression with a computed property containing a deep tree of BinaryExpression + then it must return all literals parts even the last one which is the concatenation of the BinaryExpr`, (tape) => { + const [astNode] = codeToAst("foo.bar[\"k\" + \"e\" + \"y\"]"); + const iter = getMemberExpressionIdentifier( + getExpressionFromStatement(astNode) + ); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("bar") + .expect("key") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 3); + + tape.end(); +}); + +test(`given a MemberExpression with computed properties containing identifiers + then it must return all literals values from the tracer`, (tape) => { + const { tracer } = createTracer(); + tracer.literalIdentifiers.set("foo", "hello"); + tracer.literalIdentifiers.set("yo", "bar"); + + const [astNode] = codeToAst("hey[foo][yo]"); + const iter = getMemberExpressionIdentifier( + getExpressionFromStatement(astNode), { tracer } + ); + + const iterResult = new IteratorMatcher() + .expect("hey") + .expect("hello") + .expect("bar") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 3); + + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/isLiteralRegex.spec.js b/workspaces/estree-ast-utils/test/isLiteralRegex.spec.js new file mode 100644 index 0000000..97dd7be --- /dev/null +++ b/workspaces/estree-ast-utils/test/isLiteralRegex.spec.js @@ -0,0 +1,22 @@ +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { isLiteralRegex } from "../src/index.js"; +import { codeToAst, getExpressionFromStatement } from "./utils.js"; + +test("given a Literal Regex Node it should return true", (tape) => { + const [astNode] = codeToAst("/^a/g"); + const isLRegex = isLiteralRegex(getExpressionFromStatement(astNode)); + + tape.strictEqual(isLRegex, true); + tape.end(); +}); + +test("given a RegexObject Node it should return false", (tape) => { + const [astNode] = codeToAst("new RegExp('^hello')"); + const isLRegex = isLiteralRegex(getExpressionFromStatement(astNode)); + + tape.strictEqual(isLRegex, false); + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/utils.js b/workspaces/estree-ast-utils/test/utils.js new file mode 100644 index 0000000..4d654f5 --- /dev/null +++ b/workspaces/estree-ast-utils/test/utils.js @@ -0,0 +1,62 @@ +// Import Third-party Dependencies +import * as meriyah from "meriyah"; +import { walk } from "estree-walker"; + +// Import Internal Dependencies +import { VariableTracer } from "../src/index.js"; + +export function codeToAst(code) { + const estreeRootNode = meriyah.parseScript(code, { + next: true, + loc: true, + raw: true, + module: true, + globalReturn: false + }); + + return estreeRootNode.body; +} + +export function getExpressionFromStatement(node) { + return node.type === "ExpressionStatement" ? node.expression : null; +} + +export function createTracer(enableDefaultTracing = false) { + const tracer = new VariableTracer(); + if (enableDefaultTracing) { + tracer.enableDefaultTracing(); + } + + return { + tracer, + walkOnAst(astNode) { + walk(astNode, { + enter(node) { + tracer.walk(node); + } + }); + }, + /** + * @param {!string} codeStr + * @param {object} [options] + * @param {boolean} [options.debugAst=false] + * @returns {void} + */ + walkOnCode(codeStr, options = {}) { + const { debugAst = false } = options; + + const astNode = codeToAst(codeStr); + if (debugAst) { + console.log(JSON.stringify(astNode, null, 2)); + } + + this.walkOnAst(astNode); + }, + getAssignmentArray(event = VariableTracer.AssignmentEvent) { + const assignmentEvents = []; + tracer.on(event, (value) => assignmentEvents.push(value)); + + return assignmentEvents; + } + }; +} diff --git a/workspaces/estree-ast-utils/test/utils/getSubMemberExpressionSegments.spec.js b/workspaces/estree-ast-utils/test/utils/getSubMemberExpressionSegments.spec.js new file mode 100644 index 0000000..bab8c93 --- /dev/null +++ b/workspaces/estree-ast-utils/test/utils/getSubMemberExpressionSegments.spec.js @@ -0,0 +1,19 @@ +// Import Third-party Dependencies +import test from "tape"; +import { IteratorMatcher } from "iterator-matcher"; + +// Import Internal Dependencies +import { getSubMemberExpressionSegments } from "../../src/utils/index.js"; + +test("given a MemberExpression then it should return each segments (except the last one)", (tape) => { + const iter = getSubMemberExpressionSegments("foo.bar.xd"); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("foo.bar") + .execute(iter, { allowNoMatchingValues: false }); + + tape.strictEqual(iterResult.isMatching, true); + tape.strictEqual(iterResult.elapsedSteps, 2); + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/utils/isEvilIdentifierPath.spec.js b/workspaces/estree-ast-utils/test/utils/isEvilIdentifierPath.spec.js new file mode 100644 index 0000000..9affdf9 --- /dev/null +++ b/workspaces/estree-ast-utils/test/utils/isEvilIdentifierPath.spec.js @@ -0,0 +1,28 @@ +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { isEvilIdentifierPath } from "../../src/utils/index.js"; + +test("given a random prototype method name then it should return false", (tape) => { + const result = isEvilIdentifierPath( + "Function.prototype.foo" + ); + + tape.strictEqual(result, false); + + tape.end(); +}); + +test("given a list of evil identifiers it should always return true", (tape) => { + const evilIdentifiers = [ + "Function.prototype.bind", + "Function.prototype.call", + "Function.prototype.apply" + ]; + for (const identifier of evilIdentifiers) { + tape.strictEqual(isEvilIdentifierPath(identifier), true); + } + + tape.end(); +}); diff --git a/workspaces/estree-ast-utils/test/utils/notNullOrUndefined.spec.js b/workspaces/estree-ast-utils/test/utils/notNullOrUndefined.spec.js new file mode 100644 index 0000000..4f7c031 --- /dev/null +++ b/workspaces/estree-ast-utils/test/utils/notNullOrUndefined.spec.js @@ -0,0 +1,21 @@ +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { notNullOrUndefined } from "../../src/utils/index.js"; + +test("given a null or undefined primitive value then it must always return false", (tape) => { + tape.strictEqual(notNullOrUndefined(null), false, "null primitive value should return false"); + tape.strictEqual(notNullOrUndefined(void 0), false, "undefined primitive value should return false"); + + tape.end(); +}); + +test("given values (primitive or objects) that are not null or undefined then it must always return true", (tape) => { + const valuesToAssert = ["", 1, true, Symbol("foo"), {}, [], /^xd/g]; + for (const value of valuesToAssert) { + tape.strictEqual(notNullOrUndefined(value), true); + } + + tape.end(); +}); diff --git a/workspaces/sec-literal/LICENSE b/workspaces/sec-literal/LICENSE new file mode 100644 index 0000000..346097d --- /dev/null +++ b/workspaces/sec-literal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 NodeSecure + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/workspaces/sec-literal/README.md b/workspaces/sec-literal/README.md new file mode 100644 index 0000000..d2e7552 --- /dev/null +++ b/workspaces/sec-literal/README.md @@ -0,0 +1,69 @@ +# Sec-literal +![version](https://img.shields.io/badge/dynamic/json.svg?url=https://raw.githubusercontent.com/NodeSecure/js-x-ray/blob/master/workspaces/sec-literal/master/package.json&query=$.version&label=Version) +[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/NodeSecure/js-x-ray/blob/master/workspaces/sec-literal/commit-activity) +[![OpenSSF +Scorecard](https://api.securityscorecards.dev/projects/github.com/NodeSecure/js-x-ray/blob/master/workspaces/sec-literal/badge)](https://api.securityscorecards.dev/projects/github.com/NodeSecure/js-x-ray/blob/master/workspaces/sec-literal) +[![mit](https://img.shields.io/github/license/Naereen/StrapDown.js.svg)](https://github.com/NodeSecure/js-x-ray/blob/master/workspaces/sec-literal/blob/master/LICENSE) +![build](https://img.shields.io/github/actions/workflow/status/NodeSecure/js-x-ray/blob/master/workspaces/sec-literal/node.js.yml) + +This package is a security utilities library created to analyze [ESTree Literal](https://github.com/estree/estree/blob/master/es5.md#literal) and JavaScript string primitive. This project was originally created to simplify and better test the functionalities required for the SAST Scanner [JS-X-Ray](https://github.com/fraxken/js-x-ray). + +## Features + +- Detect Hexadecimal, Base64, Hexa and Unicode sequences. +- Detect patterns (prefix, suffix) on groups of identifiers. +- Detect suspicious string and return advanced metrics on it (char diversity etc). + +## Getting Started + +This package is available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com). + +```bash +$ npm i @nodesecure/sec-literal +# or +$ yarn add @nodesecure/sec-literal +``` + +## API + +## Hex + +### isHex(anyValue): boolean +Detect if the given string is an Hexadecimal value + +### isSafe(anyValue): boolean +Detect if the given string is a safe Hexadecimal value. The goal of this method is to eliminate false-positive. + +```js +Hex.isSafe("1234"); // true +Hex.isSafe("abcdef"); // true +``` + +## Literal + +### isLiteral(anyValue): boolean +### toValue(anyValue): string +### toRaw(anyValue): string +### defaultAnalysis(literalValue) + +## Utils + +### isSvg(strValue): boolean + +### isSvgPath(strValue): boolean +Detect if a given string is a svg path or not. + +### stringCharDiversity(str): number +Get the number of unique chars in a given string + +### stringSuspicionScore(str): number +Analyze a given string an give it a suspicion score (higher than 1 or 2 mean that the string is highly suspect). + +## Patterns + +### commonStringPrefix(leftStr, rightStr): string | null +### commonStringSuffix(leftStr, rightStr): string | null +### commonHexadecimalPrefix(identifiersArray: string[]) + +## License +MIT diff --git a/workspaces/sec-literal/package.json b/workspaces/sec-literal/package.json new file mode 100644 index 0000000..53b0995 --- /dev/null +++ b/workspaces/sec-literal/package.json @@ -0,0 +1,39 @@ +{ + "name": "@nodesecure/sec-literal", + "version": "1.2.0", + "description": "Package created to analyze JavaScript literals", + "exports": "./src/index.js", + "private": false, + "type": "module", + "scripts": { + "lint": "eslint --ext .js", + "test-only": "cross-env esm-tape-runner 'test/*.spec.js' | tap-monkey", + "test": "cross-env npm run lint && npm run test-only" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NodeSecure/sec-literal.git" + }, + "keywords": [ + "security", + "literal", + "estree", + "analysis", + "scanner" + ], + "files": [ + "src" + ], + "author": "GENTILHOMME Thomas ", + "license": "MIT", + "bugs": { + "url": "https://github.com/NodeSecure/sec-literal/issues" + }, + "homepage": "https://github.com/NodeSecure/sec-literal#readme", + "dependencies": { + "frequency-set": "^1.0.2", + "is-base64": "^1.1.0", + "is-svg": "^4.3.2", + "string-width": "^5.1.2" + } +} diff --git a/workspaces/sec-literal/src/hex.js b/workspaces/sec-literal/src/hex.js new file mode 100644 index 0000000..cb0988c --- /dev/null +++ b/workspaces/sec-literal/src/hex.js @@ -0,0 +1,53 @@ +// Import Internal Dependencies +import * as Literal from "./literal.js"; +import * as Utils from "./utils.js"; + +const kUnsafeHexValues = new Set([ + "require", + "length" +].map((value) => Buffer.from(value).toString("hex"))); + +// CONSTANTS +const kSafeHexValues = new Set([ + "0123456789", + "123456789", + "abcdef", + "abc123456789", + "0123456789abcdef", + "abcdef0123456789abcdef" +]); + +export const CONSTANTS = Object.freeze({ + SAFE_HEXA_VALUES: [...kSafeHexValues], + UNSAFE_HEXA_VALUES: [...kUnsafeHexValues] +}); + +/** + * @description detect if the given string is an Hexadecimal value + * @param {SecLiteral.Literal | string} anyValue + * @returns {boolean} + */ +export function isHex(anyValue) { + const value = Literal.toValue(anyValue); + + return typeof value === "string" && /^[0-9A-Fa-f]{4,}$/g.test(value); +} + +/** + * @description detect if the given string is a safe Hexadecimal value + * @param {SecLiteral.Literal | string} anyValue + * @returns {boolean} + */ +export function isSafe(anyValue) { + const rawValue = Literal.toRaw(anyValue); + if (kUnsafeHexValues.has(rawValue)) { + return false; + } + + const charCount = Utils.stringCharDiversity(rawValue); + if (/^([0-9]+|[a-z]+|[A-Z]+)$/g.test(rawValue) || rawValue.length <= 5 || charCount <= 2) { + return true; + } + + return [...kSafeHexValues].some((value) => rawValue.toLowerCase().startsWith(value)); +} diff --git a/workspaces/sec-literal/src/index.js b/workspaces/sec-literal/src/index.js new file mode 100644 index 0000000..8eaea83 --- /dev/null +++ b/workspaces/sec-literal/src/index.js @@ -0,0 +1,4 @@ +export * as Hex from "./hex.js"; +export * as Literal from "./literal.js"; +export * as Utils from "./utils.js"; +export * as Patterns from "./patterns.js"; diff --git a/workspaces/sec-literal/src/literal.js b/workspaces/sec-literal/src/literal.js new file mode 100644 index 0000000..1937c07 --- /dev/null +++ b/workspaces/sec-literal/src/literal.js @@ -0,0 +1,43 @@ +// Import Third-party Dependencies +import isStringBase64 from "is-base64"; + +/** + * @param {SecLiteral.Literal | string} anyValue + * @returns {string} + */ +export function isLiteral(anyValue) { + return typeof anyValue === "object" && "type" in anyValue && anyValue.type === "Literal"; +} + +/** + * @param {SecLiteral.Literal | string} strOrLiteral + * @returns {string} + */ +export function toValue(strOrLiteral) { + return isLiteral(strOrLiteral) ? strOrLiteral.value : strOrLiteral; +} + +/** + * @param {SecLiteral.Literal | string} strOrLiteral + * @returns {string} + */ +export function toRaw(strOrLiteral) { + return isLiteral(strOrLiteral) ? strOrLiteral.raw : strOrLiteral; +} + +/** + * @param {!SecLiteral.Literal} literalValue + * @returns {SecLiteral.LiteralDefaultAnalysis} + */ +export function defaultAnalysis(literalValue) { + if (!isLiteral(literalValue)) { + return null; + } + + const hasRawValue = "raw" in literalValue; + const hasHexadecimalSequence = hasRawValue ? /\\x[a-fA-F0-9]{2}/g.exec(literalValue.raw) !== null : null; + const hasUnicodeSequence = hasRawValue ? /\\u[a-fA-F0-9]{4}/g.exec(literalValue.raw) !== null : null; + const isBase64 = isStringBase64(literalValue.value, { allowEmpty: false }); + + return { hasHexadecimalSequence, hasUnicodeSequence, isBase64 }; +} diff --git a/workspaces/sec-literal/src/patterns.js b/workspaces/sec-literal/src/patterns.js new file mode 100644 index 0000000..034654c --- /dev/null +++ b/workspaces/sec-literal/src/patterns.js @@ -0,0 +1,98 @@ +// Import Third-party Dependencies +import FrequencySet from "frequency-set"; + +// Import Internal Dependencies +import * as Literal from "./literal.js"; + +/** + * @description get the common string prefix (at the start) pattern + * @param {!string | SecLiteral} leftAnyValue + * @param {!string | SecLiteral} rightAnyValue + * @returns {string | null} + * + * @example + * commonStringPrefix("boo", "foo"); // null + * commonStringPrefix("bromance", "brother"); // "bro" + */ +export function commonStringPrefix(leftAnyValue, rightAnyValue) { + const leftStr = Literal.toValue(leftAnyValue); + const rightStr = Literal.toValue(rightAnyValue); + + // The length of leftStr cannot be greater than that rightStr + const minLen = leftStr.length > rightStr.length ? rightStr.length : leftStr.length; + let commonStr = ""; + + for (let id = 0; id < minLen; id++) { + if (leftStr.charAt(id) !== rightStr.charAt(id)) { + break; + } + + commonStr += leftStr.charAt(id); + } + + return commonStr === "" ? null : commonStr; +} + +function reverseString(string) { + return string.split("").reverse().join(""); +} + +/** + * @description get the common string suffixes (at the end) pattern + * @param {!string} leftStr + * @param {!string} rightStr + * @returns {string | null} + * + * @example + * commonStringSuffix("boo", "foo"); // oo + * commonStringSuffix("bromance", "brother"); // null + */ +export function commonStringSuffix(leftStr, rightStr) { + const commonPrefix = commonStringPrefix( + reverseString(leftStr), + reverseString(rightStr) + ); + + return commonPrefix === null ? null : reverseString(commonPrefix); +} + +export function commonHexadecimalPrefix(identifiersArray) { + if (!Array.isArray(identifiersArray)) { + throw new TypeError("identifiersArray must be an Array"); + } + const prefix = new FrequencySet(); + + mainLoop: for (const value of identifiersArray.slice().sort()) { + for (const [cp, count] of prefix) { + const commonStr = commonStringPrefix(value, cp); + if (commonStr === null) { + continue; + } + + if (commonStr === cp || commonStr.startsWith(cp)) { + prefix.add(cp); + } + else if (cp.startsWith(commonStr)) { + prefix.delete(cp); + prefix.add(commonStr, count + 1); + } + continue mainLoop; + } + + prefix.add(value); + } + + // We remove one-time occurences (because they are normal variables) + let oneTimeOccurence = 0; + for (const [key, value] of prefix.entries()) { + if (value === 1) { + prefix.delete(key); + oneTimeOccurence++; + } + } + + return { + oneTimeOccurence, + prefix: Object.fromEntries(prefix) + }; +} diff --git a/workspaces/sec-literal/src/utils.js b/workspaces/sec-literal/src/utils.js new file mode 100644 index 0000000..e77be2d --- /dev/null +++ b/workspaces/sec-literal/src/utils.js @@ -0,0 +1,93 @@ +// Import Third-party Dependencies +import isStringSvg from "is-svg"; +import stringWidth from "string-width"; + +// Import Internal Dependencies +import { toValue } from "./literal.js"; + +/** + * @param {SecLiteral.Literal | string} strOrLiteral + * @returns {boolean} + */ +export function isSvg(strOrLiteral) { + try { + const value = toValue(strOrLiteral); + + return isStringSvg(value) || isSvgPath(value); + } + catch { + return false; + } +} + +/** + * @description detect if a given string is a svg path or not. + * @param {!string} str svg path literal + * @returns {boolean} + */ +export function isSvgPath(str) { + if (typeof str !== "string") { + return false; + } + const trimStr = str.trim(); + + return trimStr.length > 4 && /^[mzlhvcsqta]\s*[-+.0-9][^mlhvzcsqta]+/i.test(trimStr) && /[\dz]$/i.test(trimStr); +} + +/** + * @description detect if a given string is a morse value. + * @param {!string} str any string value + * @returns {boolean} + */ +export function isMorse(str) { + return /^[.-]{1,5}(?:[\s\t]+[.-]{1,5})*(?:[\s\t]+[.-]{1,5}(?:[\s\t]+[.-]{1,5})*)*$/g.test(str); +} + +/** + * @param {!string} str any string value + * @returns {string} + */ +export function escapeRegExp(str) { + return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +} + +/** + * @description Get the number of unique chars in a given string + * @param {!string} str string + * @param {string[]} [charsToExclude=[]] + * @returns {number} + */ +export function stringCharDiversity(str, charsToExclude = []) { + const data = new Set(str); + charsToExclude.forEach((char) => data.delete(char)); + + return data.size; +} + +// --- +const kMaxSafeStringLen = 45; +const kMaxSafeStringCharDiversity = 70; +const kMinUnsafeStringLenThreshold = 200; +const kScoreStringLengthThreshold = 750; + +/** + * @description Analyze a given string an give it a suspicion score (higher than 1 or 2 mean that the string is highly suspect). + * @param {!string} str string to analyze + * @returns {number} + */ +export function stringSuspicionScore(str) { + const strLen = stringWidth(str); + if (strLen < kMaxSafeStringLen) { + return 0; + } + + const includeSpace = str.includes(" "); + const includeSpaceAtStart = includeSpace ? str.slice(0, kMaxSafeStringLen).includes(" ") : false; + + let suspectScore = includeSpaceAtStart ? 0 : 1; + if (strLen > kMinUnsafeStringLenThreshold) { + suspectScore += Math.ceil(strLen / kScoreStringLengthThreshold); + } + + return stringCharDiversity(str) >= kMaxSafeStringCharDiversity ? suspectScore + 2 : suspectScore; +} diff --git a/workspaces/sec-literal/test/hex.spec.js b/workspaces/sec-literal/test/hex.spec.js new file mode 100644 index 0000000..fb1b4e9 --- /dev/null +++ b/workspaces/sec-literal/test/hex.spec.js @@ -0,0 +1,77 @@ +// Import Node.js Dependencies +import { randomBytes } from "node:crypto"; + +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { isHex, isSafe, CONSTANTS } from "../src/hex.js"; +import { createLiteral } from "./utils/index.js"; + +test("isHex() of a random Hexadecimal value must return true", (tape) => { + const hexValue = randomBytes(4).toString("hex"); + + tape.strictEqual(isHex(hexValue), true, `Hexadecimal value '${hexValue}' must return true`); + tape.end(); +}); + +test("isHex() of an ESTree Literal containing a random Hexadecimal value must return true", (tape) => { + const hexValue = createLiteral(randomBytes(4).toString("hex")); + + tape.strictEqual(isHex(hexValue), true, `Hexadecimal value '${hexValue.value}' must return true`); + tape.end(); +}); + +test("An hexadecimal value must be at least 4 chars long", (tape) => { + const hexValue = randomBytes(1).toString("hex"); + + tape.strictEqual(isHex(hexValue), false, `Hexadecimal value '${hexValue}' must return false`); + tape.end(); +}); + +test("isHex() of a value that is not a string or an ESTree Literal must return false", (tape) => { + const hexValue = 100; + + tape.strictEqual(isHex(hexValue), false, "100 is typeof number so it must always return false"); + tape.end(); +}); + +test("isSafe must return true for a value with a length lower or equal five characters", (tape) => { + tape.ok(isSafe("h2l5x")); + tape.end(); +}); + +test("isSafe must return true if the string diversity is only two characters or lower", (tape) => { + tape.ok(isSafe("aaaaaaaaaaaaaabbbbbbbbbbbbb")); + tape.end(); +}); + +test("isSafe must always return true if argument is only number, lower or upper letters", (tape) => { + const values = ["00000000", "aaaaaaaa", "AAAAAAAAA"]; + + for (const hexValue of values) { + tape.ok(isSafe(hexValue)); + } + tape.end(); +}); + +test("isSafe() must always return true if the value start with one of the 'safe' values", (tape) => { + for (const safeValue of CONSTANTS.SAFE_HEXA_VALUES) { + const hexValue = safeValue + randomBytes(4).toString("hex"); + + tape.ok(isSafe(hexValue)); + } + tape.end(); +}); + +test("isSafe must return true because it start with a safe pattern (and it must lowerCase the string)", (tape) => { + tape.ok(isSafe("ABCDEF1234567890")); + tape.end(); +}); + +test("isSafe() must always return false if the value start with one of the 'unsafe' values", (tape) => { + for (const unsafeValue of CONSTANTS.UNSAFE_HEXA_VALUES) { + tape.strictEqual(isSafe(unsafeValue), false); + } + tape.end(); +}); diff --git a/workspaces/sec-literal/test/literal.spec.js b/workspaces/sec-literal/test/literal.spec.js new file mode 100644 index 0000000..b47df12 --- /dev/null +++ b/workspaces/sec-literal/test/literal.spec.js @@ -0,0 +1,99 @@ +// Import Node.js Dependencies +import { randomBytes } from "node:crypto"; + +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { isLiteral, toValue, toRaw, defaultAnalysis } from "../src/literal.js"; +import { createLiteral } from "./utils/index.js"; + +test("isLiteral must return true for a valid ESTree Literal Node", (tape) => { + const literalSample = createLiteral("boo"); + + tape.strictEqual(isLiteral(literalSample), true); + tape.strictEqual(isLiteral("hey"), false); + tape.strictEqual(isLiteral({ type: "fake", value: "boo" }), false); + tape.end(); +}); + +test("toValue must return a string when we give a valid EStree Literal", (tape) => { + const literalSample = createLiteral("boo"); + + tape.strictEqual(toValue(literalSample), "boo"); + tape.strictEqual(toValue("hey"), "hey"); + tape.end(); +}); + +test("toRaw must return a string when we give a valid EStree Literal", (tape) => { + const literalSample = createLiteral("boo", true); + + tape.strictEqual(toRaw(literalSample), "boo"); + tape.strictEqual(toRaw("hey"), "hey"); + tape.end(); +}); + +test("defaultAnalysis() of something else than a Literal must always return null", (tape) => { + tape.strictEqual(defaultAnalysis(10), null); + tape.end(); +}); + +test("defaultAnalysis() of an Hexadecimal value", (tape) => { + const hexValue = randomBytes(10).toString("hex"); + + const result = defaultAnalysis(createLiteral(hexValue, true)); + const expected = { + isBase64: true, hasHexadecimalSequence: false, hasUnicodeSequence: false + }; + + tape.deepEqual(result, expected); + tape.end(); +}); + +test("defaultAnalysis() of an Base64 value", (tape) => { + const hexValue = randomBytes(10).toString("base64"); + + const result = defaultAnalysis(createLiteral(hexValue, true)); + const expected = { + isBase64: true, hasHexadecimalSequence: false, hasUnicodeSequence: false + }; + + tape.deepEqual(result, expected); + tape.end(); +}); + +test("defaultAnalysis() of an Unicode Sequence", (tape) => { + const unicodeSequence = createLiteral("'\\u0024\\u0024'", true); + + const result = defaultAnalysis(unicodeSequence); + const expected = { + isBase64: false, hasHexadecimalSequence: false, hasUnicodeSequence: true + }; + + tape.deepEqual(result, expected); + tape.end(); +}); + +test("defaultAnalysis() of an Unicode Sequence", (tape) => { + const hexSequence = createLiteral("'\\x64\\x61\\x74\\x61'", true); + + const result = defaultAnalysis(hexSequence); + const expected = { + isBase64: false, hasHexadecimalSequence: true, hasUnicodeSequence: false + }; + + tape.deepEqual(result, expected); + tape.end(); +}); + +test("defaultAnalysis() with a Literal with no 'raw' property must return two null values", (tape) => { + const hexValue = randomBytes(10).toString("base64"); + + const result = defaultAnalysis(createLiteral(hexValue)); + const expected = { + isBase64: true, hasHexadecimalSequence: null, hasUnicodeSequence: null + }; + + tape.deepEqual(result, expected); + tape.end(); +}); diff --git a/workspaces/sec-literal/test/patterns.spec.js b/workspaces/sec-literal/test/patterns.spec.js new file mode 100644 index 0000000..89db85b --- /dev/null +++ b/workspaces/sec-literal/test/patterns.spec.js @@ -0,0 +1,64 @@ +// Import Internal Dependencies +import { + commonStringPrefix, + commonStringSuffix, + commonHexadecimalPrefix +} from "../src/patterns.js"; + +// Import Third-party Dependencies +import test from "tape"; + +test("commonStringPrefix of two strings that does not start with the same set of characters must return null", (tape) => { + tape.strictEqual(commonStringPrefix("boo", "foo"), null, + "there is no common prefix between 'boo' and 'foo' so the result must be null"); + tape.end(); +}); + +test("commonStringPrefix of two strings that start with the same set of characters must return it as result", (tape) => { + tape.strictEqual(commonStringPrefix("bromance", "brother"), "bro", + "the common prefix between bromance and brother must be 'bro'."); + tape.end(); +}); + +test("commonStringSuffix of two strings that end with the same set of characters must return it as result", (tape) => { + tape.strictEqual(commonStringSuffix("boo", "foo"), "oo", + "the common suffix between boo and foo must be 'oo'"); + tape.end(); +}); + +test("commonStringSuffix of two strings that does not end with the same set of characters must return null", (tape) => { + tape.strictEqual(commonStringSuffix("bromance", "brother"), null, + "there is no common suffix between 'bromance' and 'brother' so the result must be null"); + tape.end(); +}); + +test("commonHexadecimalPrefix - throw a TypeError if identifiersArray is not an Array", (tape) => { + tape.throws(() => commonHexadecimalPrefix(10), "identifiersArray must be an Array"); + tape.end(); +}); + +test("commonHexadecimalPrefix - only hexadecimal identifiers", (tape) => { + const data = [ + "_0x3c0c55", "_0x1185d5", "_0x160fc8", "_0x18a66f", "_0x18a835", "_0x1a8356", + "_0x1adf3b", "_0x1e4510", "_0x1e9a2a", "_0x215558", "_0x2b0194", "_0x2fffe5", + "_0x32c822", "_0x33bb79" + ]; + const result = commonHexadecimalPrefix(data); + + tape.strictEqual(result.oneTimeOccurence, 0); + tape.strictEqual(result.prefix._0x, data.length); + tape.end(); +}); + +test("commonHexadecimalPrefix - add one non-hexadecimal identifier", (tape) => { + const data = [ + "_0x3c0c55", "_0x1185d5", "_0x160fc8", "_0x18a66f", "_0x18a835", "_0x1a8356", + "_0x1adf3b", "_0x1e4510", "_0x1e9a2a", "_0x215558", "_0x2b0194", "_0x2fffe5", + "_0x32c822", "_0x33bb79", "foo" + ]; + const result = commonHexadecimalPrefix(data); + + tape.strictEqual(result.oneTimeOccurence, 1); + tape.strictEqual(result.prefix._0x, data.length - 1); + tape.end(); +}); diff --git a/workspaces/sec-literal/test/utils.spec.js b/workspaces/sec-literal/test/utils.spec.js new file mode 100644 index 0000000..830dfe4 --- /dev/null +++ b/workspaces/sec-literal/test/utils.spec.js @@ -0,0 +1,89 @@ +/* eslint-disable max-len */ + +// Import Node.js Dependencies +import { randomBytes } from "node:crypto"; + +// Import Third-party Dependencies +import test from "tape"; + +// Import Internal Dependencies +import { stringCharDiversity, isSvg, isSvgPath, stringSuspicionScore } from "../src/utils.js"; + +test("stringCharDiversity must return the number of unique chars in a given string", (tape) => { + tape.strictEqual(stringCharDiversity("helloo!"), 5, + "the following string 'helloo!' contains five unique chars: h, e, l, o and !"); + tape.end(); +}); + +test("stringCharDiversity must return the number of unique chars in a given string (but with exclusions of given chars)", (tape) => { + tape.strictEqual(stringCharDiversity("- - -\n", ["\n"]), 2); + tape.end(); +}); + +test("isSvg must return true for an HTML svg balise", (tape) => { + const SVGHTML = ` + + + + + `; + tape.strictEqual(isSvg(SVGHTML), true); + tape.end(); +}); + +test("isSvg of a SVG Path must return true", (tape) => { + tape.strictEqual(isSvg("M150 0 L75 200 L225 200 Z"), true); + tape.end(); +}); + +test("isSvg must return false for invalid XML string", (tape) => { + tape.strictEqual(isSvg(""), false); + tape.end(); +}); + +test("isSvgPath must return true when we give a valid svg path and false when the string is not valid", (tape) => { + tape.strictEqual(isSvgPath("M150 0 L75 200 L225 200 Z"), true); + tape.strictEqual(isSvgPath("M150"), false, "the length of an svg path must be always higher than four characters"); + tape.strictEqual(isSvgPath("hello world!"), false); + tape.strictEqual(isSvgPath(10), false, "isSvgPath argument must always return false for anything that is not a string primitive"); + tape.end(); +}); + +test("stringSuspicionScore must always return 0 if the string length if below 45", (tape) => { + for (let strSize = 1; strSize < 45; strSize++) { + // We generate a random String (with slice it in two because a size of 20 for hex is 40 bytes). + const randomStr = randomBytes(strSize).toString("hex").slice(strSize); + + tape.strictEqual(stringSuspicionScore(randomStr), 0); + } + tape.end(); +}); + +test("stringSuspicionScore must return one if the str is between 45 and 200 chars and had no space in the first 45 chars", (tape) => { + const randomStrWithNoSpaces = randomBytes(25).toString("hex"); + + tape.strictEqual(stringSuspicionScore(randomStrWithNoSpaces), 1); + tape.end(); +}); + +test("stringSuspicionScore must return zero if the str is between 45 and 200 chars and has at least one space in the first 45 chars", (tape) => { + const randomStrWithSpaces = randomBytes(10).toString("hex") + " -_- " + randomBytes(30).toString("hex"); + + tape.strictEqual(stringSuspicionScore(randomStrWithSpaces), 0); + tape.end(); +}); + +test("stringSuspicionScore must return a score of two for a string with more than 200 chars and no spaces", (tape) => { + const randomStr = randomBytes(200).toString("hex"); + + tape.strictEqual(stringSuspicionScore(randomStr), 2); + tape.end(); +}); + +test("stringSuspicionScore must add two to the final score when the string has more than 70 uniques chars", (tape) => { + const randomStr = "૱꠸┯┰┱┲❗►◄Ăă0123456789ᶀᶁᶂᶃᶄᶆᶇᶈᶉᶊᶋᶌᶍᶎᶏᶐᶑᶒᶓᶔᶕᶖᶗᶘᶙᶚᶸᵯᵰᵴᵶᵹᵼᵽᵾᵿ⤢⤣⤤⤥⥆⥇™°×π±√ "; + + tape.strictEqual(stringSuspicionScore(randomStr), 3); + tape.end(); +}); diff --git a/workspaces/sec-literal/test/utils/index.js b/workspaces/sec-literal/test/utils/index.js new file mode 100644 index 0000000..afc0950 --- /dev/null +++ b/workspaces/sec-literal/test/utils/index.js @@ -0,0 +1,10 @@ +// @see https://github.com/estree/estree/blob/master/es5.md#literal +export function createLiteral(value, includeRaw = false) { + const node = { type: "Literal", value }; + if (includeRaw) { + node.raw = value; + } + + return node; +} +