diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..fb2e12d2 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report it in a private conversation with one or more of our maintainers [on Discord](https://discord.gg/yyKns29zch). + +Please encrypt your message to us using our PGP key. The key fingerprint is: + +``` +A656 0650 74D2 6C7D CF6E D0F4 0784 3C69 92BF C9FA +``` + +The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0xA656065074D26C7DCF6ED0F407843C6992BFC9FA&fingerprint=on&op=index). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +Please get in touch and give the project contributors a chance to resolve the vulnerability and issue a new release prior to any public exposure; this helps protect the project's users and provides them with a chance to upgrade and/or update in order to protect their applications. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +`cron` follows the principle of [Coordinated Vulnerability Disclosure](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html#responsible-or-coordinated-disclosure). diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..47f043f4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,67 @@ +name: 'CodeQL' + +on: + push: + branches: ['main'] + pull_request: + # The branches below must be a subset of the branches above + branches: ['main'] + schedule: + - cron: '0 0 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ['typescript'] + # CodeQL supports [ $supported-codeql-languages ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - name: Checkout repository + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # 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@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + + # ℹī¸ 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 + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/lint_pr_title.yml b/.github/workflows/lint_pr_title.yml index 08809495..a4777ea3 100644 --- a/.github/workflows/lint_pr_title.yml +++ b/.github/workflows/lint_pr_title.yml @@ -15,6 +15,11 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - uses: amannn/action-semantic-pull-request@c3cd5d1ea3580753008872425915e343e351ab54 # v5 id: lint_pr_title env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31bdfbe7..0b97fcd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,11 @@ jobs: node-version: 20.x steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - name: Checkout project uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 with: diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml new file mode 100644 index 00000000..de17fefa --- /dev/null +++ b/.github/workflows/scorecards.yml @@ -0,0 +1,76 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '20 7 * * 2' + push: + branches: ['main'] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + contents: read + actions: read + + steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - name: 'Checkout code' + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + with: + persist-credentials: false + + - name: 'Run analysis' + uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecards on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # 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@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: 'Upload to code-scanning' + uses: github/codeql-action/upload-sarif@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70d39cf7..bc99c76e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,9 @@ on: - beta - '+([0-9])?(.{+([0-9]),x}).x' +permissions: + contents: read + jobs: lint-and-test: runs-on: ${{ matrix.os }} @@ -17,6 +20,11 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - name: Checkout project uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Use Node.js ${{ matrix.node }} @@ -32,3 +40,5 @@ jobs: run: npm run lint - name: Run tests run: npm run test + - name: Run fuzz tests + run: npm run test:fuzz diff --git a/package-lock.json b/package-lock.json index e47b5dd7..8fabbf46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@commitlint/cli": "18.0.0", + "@fast-check/jest": "1.7.3", "@insurgentlab/commitlint-config": "18.1.3", "@insurgentlab/conventional-changelog-preset": "7.0.0", "@semantic-release/changelog": "6.0.3", @@ -1061,6 +1062,59 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fast-check/jest": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@fast-check/jest/-/jest-1.7.3.tgz", + "integrity": "sha512-6NcpYIIUnLwEdEfPhijYT5mnFPiQNP/isC+os+P+rV8qHRzUxRNx8WyPTOx+oVkBMm1+XSn00ZqfD3ANfciTZQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "fast-check": "^3.0.0" + }, + "peerDependencies": { + "@fast-check/worker": "~0.0.7", + "@jest/expect": ">=28.0.0", + "@jest/globals": ">=25.5.2" + }, + "peerDependenciesMeta": { + "@fast-check/worker": { + "optional": true + }, + "@jest/expect": { + "optional": true + } + } + }, + "node_modules/@fast-check/worker": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@fast-check/worker/-/worker-0.0.9.tgz", + "integrity": "sha512-Hdp+24K41OAk++Q/dnOTBljjqvnH8Jvxvq3FeqIp2kHGWwM1FL1Hpx8mn+mkaNlfEaa4oxk1fiC/xBr3pXvQ2Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "optional": true, + "peer": true, + "dependencies": { + "fast-check": "^3.4.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -4642,6 +4696,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-check": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.13.1.tgz", + "integrity": "sha512-Xp00tFuWd83i8rbG/4wU54qU+yINjQha7bXH2N4ARNTkyOimzHtUBJ5+htpdXk7RMaCOD/j2jxSjEt9u9ZPNeQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 919d8293..96b9abad 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint:fix": "npm run lint:eslint -- --fix && npm run lint:prettier -- --write", "test": "jest --coverage", "test:watch": "jest --watch --coverage", + "test:fuzz": "jest --testMatch='**/*.fuzz.ts' --coverage=false --testTimeout=120000", "prepare": "husky install", "release": "semantic-release" }, @@ -28,6 +29,7 @@ }, "devDependencies": { "@commitlint/cli": "18.0.0", + "@fast-check/jest": "1.7.3", "@insurgentlab/commitlint-config": "18.1.3", "@insurgentlab/conventional-changelog-preset": "7.0.0", "@semantic-release/changelog": "6.0.3", diff --git a/src/errors.ts b/src/errors.ts index 9f07d5bb..798e2d43 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,6 @@ -export class ExclusiveParametersError extends Error { +export class CronError extends Error {} + +export class ExclusiveParametersError extends CronError { constructor(param1: string, param2: string) { super(`You can't specify both ${param1} and ${param2}`); } diff --git a/src/job.ts b/src/job.ts index 68d7c349..1201d653 100644 --- a/src/job.ts +++ b/src/job.ts @@ -1,5 +1,5 @@ import { spawn } from 'child_process'; -import { ExclusiveParametersError } from './errors'; +import { CronError, ExclusiveParametersError } from './errors'; import { CronTime } from './time'; import { CronCallback, @@ -172,7 +172,7 @@ export class CronJob { setTime(time: CronTime) { if (!(time instanceof CronTime)) { - throw new Error('time must be an instance of CronTime.'); + throw new CronError('time must be an instance of CronTime.'); } const wasRunning = this.running; this.stop(); diff --git a/src/time.ts b/src/time.ts index 2a35b38a..9814c630 100644 --- a/src/time.ts +++ b/src/time.ts @@ -12,7 +12,7 @@ import { TIME_UNITS_LEN, TIME_UNITS_MAP } from './constants'; -import { ExclusiveParametersError } from './errors'; +import { CronError, ExclusiveParametersError } from './errors'; import { CronJobParams, DayOfMonthRange, @@ -59,7 +59,7 @@ export class CronTime { if (timeZone) { const dt = DateTime.fromObject({}, { zone: timeZone }); if (!dt.isValid) { - throw new Error('Invalid timezone.'); + throw new CronError('Invalid timezone.'); } this.timeZone = timeZone; @@ -157,13 +157,13 @@ export class CronTime { date = date.setZone(utcZone); if (!date.isValid) { - throw new Error('ERROR: You specified an invalid UTC offset.'); + throw new CronError('ERROR: You specified an invalid UTC offset.'); } } if (this.realDate) { if (DateTime.local() > date) { - throw new Error('WARNING: Date in past. Will never be fired.'); + throw new CronError('WARNING: Date in past. Will never be fired.'); } return date; @@ -240,7 +240,7 @@ export class CronTime { } if (!date.isValid) { - throw new Error('ERROR: You specified an invalid date.'); + throw new CronError('ERROR: You specified an invalid date.'); } /** @@ -258,7 +258,7 @@ export class CronTime { // hard stop if the current date is after the maximum match interval if (date > maxMatch) { - throw new Error( + throw new CronError( `Something went wrong. No execution date was found in the next 8 years. Please provide the following string if you would like to help debug: Time Zone: ${ @@ -433,7 +433,7 @@ export class CronTime { let iteration = 0; do { if (++iteration > iterationLimit) { - throw new Error( + throw new CronError( `ERROR: This DST checking related function assumes the input DateTime (${ date.toISO() ?? date.toMillis() }) is within 24 hours of a DST jump.` @@ -583,7 +583,7 @@ export class CronTime { endMinute: number ) { if (startHour >= endHour) { - throw new Error( + throw new CronError( `ERROR: This DST checking related function assumes the forward jump starting hour (${startHour}) is less than the end hour (${endHour})` ); } @@ -707,18 +707,18 @@ export class CronTime { return ALIASES[alias as keyof typeof ALIASES].toString(); } - throw new Error(`Unknown alias: ${alias}`); + throw new CronError(`Unknown alias: ${alias}`); }); const units = source.trim().split(/\s+/); // seconds are optional if (units.length < TIME_UNITS_LEN - 1) { - throw new Error('Too few fields'); + throw new CronError('Too few fields'); } if (units.length > TIME_UNITS_LEN) { - throw new Error('Too many fields'); + throw new CronError('Too many fields'); } const unitsLen = units.length; @@ -756,7 +756,9 @@ export class CronTime { fields.forEach(field => { const wildcardIndex = field.indexOf('*'); if (wildcardIndex !== -1 && wildcardIndex !== 0) { - throw new Error(`Field (${field}) has an invalid wildcard expression`); + throw new CronError( + `Field (${field}) has an invalid wildcard expression` + ); } }); @@ -776,11 +778,11 @@ export class CronTime { const wasStepDefined = mStep !== undefined; const step = parseInt(mStep ?? '1', 10); if (step === 0) { - throw new Error(`Field (${unit}) has a step of zero`); + throw new CronError(`Field (${unit}) has a step of zero`); } if (upper !== undefined && lower > upper) { - throw new Error(`Field (${unit}) has an invalid range`); + throw new CronError(`Field (${unit}) has an invalid range`); } const isOutOfRange = @@ -789,7 +791,7 @@ export class CronTime { (upper === undefined && lower > high); if (isOutOfRange) { - throw new Error(`Field value (${value}) is out of range`); + throw new CronError(`Field value (${value}) is out of range`); } // Positive integer higher than constraints[0] @@ -820,7 +822,7 @@ export class CronTime { delete typeObj[7]; } } else { - throw new Error(`Field (${unit}) cannot be parsed`); + throw new CronError(`Field (${unit}) cannot be parsed`); } } } diff --git a/tests/cron.fuzz.ts b/tests/cron.fuzz.ts new file mode 100644 index 00000000..e23f3f7a --- /dev/null +++ b/tests/cron.fuzz.ts @@ -0,0 +1,121 @@ +/* eslint-disable jest/no-standalone-expect */ +import { fc, test } from '@fast-check/jest'; +import { CronJob } from '../src'; +import { CronError } from '../src/errors'; + +/** + * fuzzing might result in an infinite loop in our code, so Jest will simply timeout. + * experimental worker implementation of ```@fast-check/jest``` could help detect that issue + * that would be better as it would also give the counter-example that causes the bug + * but since it is still experimental, simply uncomment the log line in testCronJob + * function, so you can see the input causing the infinite loop. + */ +function testCronJob( + { + cronTime, + start, + timeZone, + runOnInit, + utcOffset, + unrefTimeout, + tzOrOffset + }: { + cronTime: string; + start: boolean; + timeZone: string; + runOnInit: boolean; + utcOffset: number; + unrefTimeout: boolean; + tzOrOffset: boolean; + }, + checkError: (err: unknown) => boolean +) { + // console.debug(cronTime, '|', timeZone, '|', utcOffset); + try { + const job = new CronJob( + cronTime, + function () {}, + null, + start, + (tzOrOffset ? timeZone : null) as typeof tzOrOffset extends true + ? string + : null, + null, + runOnInit, + (tzOrOffset ? null : utcOffset) as typeof tzOrOffset extends true + ? null + : number, + unrefTimeout + ); + + expect(job.running).toBe(start); + job.stop(); + expect(job.running).toBe(false); + + expect(job.cronTime.source).toBe(cronTime); + } catch (error) { + const isOk = checkError(error); + if (!isOk) { + console.error(error); + console.error( + 'Make sure the relevant code is using an instance of CronError (or derived) when throwing.' + ); + } + expect(isOk).toBe(true); + } +} + +test.prop( + { + cronTime: fc.stringMatching(/^(((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){5,6}$/), + start: fc.boolean(), + timeZone: fc.stringMatching( + /^((?:Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])|(Africa\/Abidjan|Asia\/Singapore|Australia\/Sydney|CET|EST|Europe\/Paris|America\/New_York))$/ + ), + runOnInit: fc.boolean(), + utcOffset: fc.integer(), + unrefTimeout: fc.boolean(), + tzOrOffset: fc.boolean() + }, + { numRuns: 100_000 } +)( + 'CronJob should behave as expected and not error unexpectedly (with matching inputs)', + params => testCronJob(params, err => err instanceof CronError) +); + +test.prop( + { + cronTime: fc.string(), + start: fc.boolean(), + timeZone: fc.string(), + runOnInit: fc.boolean(), + utcOffset: fc.integer(), + unrefTimeout: fc.boolean(), + tzOrOffset: fc.boolean() + }, + { numRuns: 100_000 } +)( + 'CronJob should behave as expected and not error unexpectedly (with random inputs)', + params => testCronJob(params, err => err instanceof CronError) +); + +test.prop( + { + cronTime: fc.anything(), + start: fc.anything(), + timeZone: fc.anything(), + runOnInit: fc.anything(), + utcOffset: fc.anything(), + unrefTimeout: fc.anything(), + tzOrOffset: fc.boolean() + }, + { numRuns: 100_000 } +)( + 'CronJob should behave as expected and not error unexpectedly (with anything inputs)', + params => + testCronJob( + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + params as any, + err => err instanceof CronError || err instanceof TypeError + ) +);