diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34bfca4..476febc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,16 +5,19 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 14 groups: everything: patterns: - "*" - package-ecosystem: npm + target-branch: develop directory: "/" schedule: interval: "weekly" cooldown: - default-days: 15 + default-days: 14 groups: dev-dependencies: dependency-type: "development" diff --git a/.github/package.json b/.github/package.json index fc3ef4f..d7f8051 100644 --- a/.github/package.json +++ b/.github/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "engines": { - "node": ">=22.0" + "node": ">=24" }, "devDependencies": { "lockfile-lint": "5.0.0" diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index 97d847d..43290ba 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -17,6 +17,11 @@ jobs: security-events: write id-token: write steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: 'Checkout code' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2650cb4..887f5d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,10 @@ on: paths: - 'package.json' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: NODE_VERSION: 24.x @@ -23,6 +27,11 @@ jobs: id-token: write attestations: write steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -36,11 +45,15 @@ jobs: - name: Set env run: | tag=$(npm pkg get version | xargs) - echo "tag=${tag}" >> "$GITHUB_ENV" - echo "prerelease=$([ ${tag##*-*} ] && echo false || echo true)" >> "$GITHUB_ENV" + printf 'tag=%s\n' "$tag" >> "$GITHUB_ENV" + if [ "${tag##*-*}" ]; then prerelease=false; else prerelease=true; fi + printf 'prerelease=%s\n' "$prerelease" >> "$GITHUB_ENV" - name: Install dependencies run: | npm ci --ignore-scripts + - name: Verify dependency signatures + run: | + npm audit signatures - name: Build run: | npm run build --if-present @@ -56,14 +69,15 @@ jobs: uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | - **/*.tgz + *.tgz + # upload-artifact is independently versioned from download-artifact; v7.0.1 is the latest major (no v8.x exists) - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ env.tag }} path: | - **/package.json - **/*.tgz + package.json + *.tgz outputs: tag: ${{ env.tag }} prerelease: ${{ env.prerelease }} @@ -75,17 +89,22 @@ jobs: permissions: contents: write steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # zizmor: ignore[cache-poisoning] no cache configured (no `cache:` input); reconsider if caching is added with: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org - name: Download artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.build.outputs.tag }} - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 # zizmor: ignore[superfluous-actions] prefer maintained action over inline `gh release` script with: draft: true prerelease: ${{ needs.build.outputs.prerelease }} @@ -103,17 +122,28 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - id-token: write + id-token: write # npm publish + attestations: read # gh attestation verify steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org - name: Download artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.release.outputs.tag }} + - name: Verify build provenance attestation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + find . -name '*.tgz' -print -exec gh attestation verify {} --repo ${{ github.repository }} \; - name: npm publish (next) if: ${{ needs.release.outputs.prerelease == 'true' }} run: | diff --git a/.github/workflows/test-dast.yml b/.github/workflows/test-dast.yml index 335b91c..352ddd7 100644 --- a/.github/workflows/test-dast.yml +++ b/.github/workflows/test-dast.yml @@ -3,6 +3,10 @@ name: Tests (dast) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -15,6 +19,11 @@ jobs: runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/test-dco.yml b/.github/workflows/test-dco.yml index 024218f..9e67c8e 100644 --- a/.github/workflows/test-dco.yml +++ b/.github/workflows/test-dco.yml @@ -3,6 +3,10 @@ name: Tests (dco) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -11,5 +15,10 @@ jobs: name: Tests (dco) runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Check for Developer Certificate of Origin (DCO) compliance uses: KineticCafe/actions-dco@1da04282bbf757dab7d92a5c8535dbfb8113da5c # v3.1.0 diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml index 77594a0..de5c598 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -2,6 +2,12 @@ name: Tests (lint) on: pull_request: + push: + branches: [main, develop] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: NODE_VERSION: 24.x @@ -15,6 +21,11 @@ jobs: runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/test-mutation.yml b/.github/workflows/test-mutation.yml new file mode 100644 index 0000000..93df38b --- /dev/null +++ b/.github/workflows/test-mutation.yml @@ -0,0 +1,43 @@ +name: Tests (mutation) + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 24.x + +permissions: + contents: read + +jobs: + mutation: + name: Tests (mutation) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + cache: npm + - name: Install dependencies + run: | + npm ci --ignore-scripts + - name: Mutation tests + run: | + npm run test:mutation diff --git a/.github/workflows/test-perf.yml b/.github/workflows/test-perf.yml index 6c5ec1f..20c4800 100644 --- a/.github/workflows/test-perf.yml +++ b/.github/workflows/test-perf.yml @@ -3,6 +3,10 @@ name: Tests (perf) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -15,6 +19,11 @@ jobs: runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/test-sast.yml b/.github/workflows/test-sast.yml index 1908d55..2a787ed 100644 --- a/.github/workflows/test-sast.yml +++ b/.github/workflows/test-sast.yml @@ -6,6 +6,10 @@ on: - cron: '43 3 * * 5' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -18,6 +22,11 @@ jobs: runs-on: ubuntu-latest if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -38,6 +47,11 @@ jobs: runs-on: ubuntu-latest if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -66,6 +80,11 @@ jobs: runs-on: ubuntu-latest if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -93,8 +112,13 @@ jobs: strategy: fail-fast: false matrix: - language: [javascript] + language: [javascript, actions] steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -116,6 +140,11 @@ jobs: runs-on: ubuntu-latest if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -137,12 +166,93 @@ jobs: name: 'semgrep: SAST' runs-on: ubuntu-latest container: - image: semgrep/semgrep:1.111.0@sha256:3e6e5065d9e68abffddffdb536a8db2d79a8fa92dc424daa48d3a4b7d9bc65d0 + image: semgrep/semgrep@sha256:3e6e5065d9e68abffddffdb536a8db2d79a8fa92dc424daa48d3a4b7d9bc65d0 # v1.111.0 if: (github.actor != 'dependabot[bot]') steps: + # harden-runner skipped: this job runs in a container; harden-runner patches the host runner. - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: semgrep run: semgrep ci + + # https://github.com/raven-actions/actionlint + actionlint: + name: "actionlint: GitHub Actions lint" + runs-on: ubuntu-latest + steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: actionlint + uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 + + # https://github.com/zizmorcore/zizmor-action + zizmor: + name: "zizmor: GitHub Actions SAST" + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + security-events: write + steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + + # https://github.com/trufflesecurity/trufflehog + trufflehog: + name: 'TruffleHog: secrets' + runs-on: ubuntu-latest + if: (github.actor != 'dependabot[bot]') + steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: TruffleHog + uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2 + with: + extra_args: --only-verified --results=verified,unknown + + # https://github.com/gitleaks/gitleaks-action + gitleaks: + name: 'gitleaks: secrets' + runs-on: ubuntu-latest + if: (github.actor != 'dependabot[bot]') + steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: gitleaks + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 2f927d1..0faf1e1 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -2,6 +2,12 @@ name: Tests (unit) on: pull_request: + push: + branches: [main, develop] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: NODE_VERSION: 24.x @@ -15,6 +21,11 @@ jobs: runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -30,7 +41,7 @@ jobs: npm ci --ignore-scripts - name: Build run: | - npm run build --if-present + npm run build - name: Unit tests run: | npm run test:unit diff --git a/.license.config.json b/.license.config.json index 0437987..07ad316 100644 --- a/.license.config.json +++ b/.license.config.json @@ -12,9 +12,11 @@ ".husky/**/*", "tests/fixtures/*", "commitlint.config.cjs", + "stryker.config.*", "LICENSE", ".license.template", "**/.gitignore", + "**/.npmignore", "**/*.fuzz.js", "**/*.perf.js", "**/*.test.js", diff --git a/README.md b/README.md index 01b3096..aa738d1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,13 @@ if (!isSchemaSecure(schema)) { } ``` -Per-draft entry points are also exported: `sast-json-schema/2020-12`, `/2019-09`, `/draft-07`, `/draft-06`, `/draft-04`. Each meta-schema is identified by a `urn:willfarrell:sast-json-schema:` URN. Shared primitives (`safePattern`, `safeUrl`, etc.) are available via `sast-json-schema/$defs`. +Per-draft entry points are also exported: `sast-json-schema/2020-12`, `/2019-09`, `/draft-07`, `/draft-06`, `/draft-04`. These are JSON exports, so they require an import attribute: + +```javascript +import schema2020 from "sast-json-schema/2020-12" with { type: "json" } +``` + +Each meta-schema is identified by a `urn:willfarrell:sast-json-schema:` URN. Shared primitives (`safePattern`, `safeUrl`, etc.) are available via `sast-json-schema/$defs`. ### CLI @@ -78,10 +84,15 @@ Options: - `--override-max-depth `: Override max depth limit (default: 32) - `--override-max-items `: Override max items limit (default: 1024) - `--override-max-properties `: Override max properties limit (default: 1024) -- `--ignore `: Suppress errors by instancePath or instancePath:keyword (repeatable). Paths use [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON Pointer encoding (`~` to `~0`, `/` to `~1`) +- `--ignore `: Suppress errors by instancePath or instancePath:keyword (repeatable). Paths use [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON Pointer encoding (`~` to `~0`, `/` to `~1`). Depth-exceeded and timeout findings cannot be suppressed, since they indicate the schema was not fully analyzed - `--offline`: Skip SSRF DNS resolution for remote `$ref` URLs (useful in airgapped CI) +- `-r, --ref-schema-files `: Load a reference schema; its `$id` hostname is treated as safe and skipped during SSRF DNS checks (repeatable) - `--lang `: Downstream language whose deserialization-vector names to deny in property keys. Default is `default` (union of every named language). See [language coverage](#language-coverage) below - `--format `: Output format. `json` emits a JSON array of error objects on stdout; `sarif` emits a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for GitHub code-scanning, SonarQube, Semgrep and other security pipelines; `human` is the default +- `--max-schema-size `: Maximum serialized schema size in bytes. Default 67108864 (64 MiB). Larger schemas are rejected as a tool error (exit 2) +- `--analysis-timeout-ms `: Wall-clock budget for the schema crawl. Default 60000. Exceeding it produces a `timeout` finding (exit 1) +- `--max-ssrf-hostnames `: Maximum distinct remote `$ref` hostnames resolved during SSRF checks. Default 256. Above this, DNS resolution is refused and reported as a finding +- `--dns-total-timeout-ms `: Total wall-clock budget for all SSRF DNS lookups. Default 30000. Hosts not checked within the budget are reported (fail-closed) - `-v, --version`: Show version - `-h, --help`: Show this help @@ -90,8 +101,12 @@ Options: | Code | Meaning | |------|---------| | `0` | No issues found | -| `1` | Schema has security issues | -| `2` | Usage/tool error (bad args, unreadable file, invalid JSON, unsupported `$schema`) | +| `1` | Schema has security findings, including depth-exceeded, analysis timeout, and SSRF hostname-cap / DNS-budget conditions (a schema too expensive or unsafe to fully analyze is itself a finding) | +| `2` | Usage/tool error: bad args, unreadable file, invalid JSON, unsupported `$schema`, an oversized schema (over `--max-schema-size`), or a non-JSON-serializable (circular) schema | + +Exit 1 means a problem was found in the schema, including the resource-limit conditions above; exit 2 means the tool could not analyze the input at all. + +The `--max-schema-size`, `--analysis-timeout-ms`, `--max-ssrf-hostnames`, and `--dns-total-timeout-ms` flags, together with the existing `--override-max-*` limits, are the tool's resource budgets. Also available via [`ajv-cmd`](https://github.com/willfarrell/ajv-cmd): @@ -111,8 +126,9 @@ ajv sast --fail path/to/schema.json - **Prototype-pollution denylist does not cover `patternProperties` keys.** The meta-schema rejects `__proto__`, `constructor`, and `prototype` as literal keys in `properties`, `$defs`, `definitions`, `dependentSchemas`, `dependentRequired`, and `required`. It does NOT reject these names when introduced via a `patternProperties` regex key, because any literal denylist (`^__proto__$`) is trivially bypassed by equivalent regexes (`^_{2}proto_{2}$`, `^[_][_]proto__$`, `^.{9}$`). Enforced by the CLI: `crawlSchema` compiles each `patternProperties` key and tests it against the denylisted names. Consumers using the meta-schema standalone (without `cli.js` / `analyze()`) get property-key protection but not `patternProperties` protection. - **Language-specific deserialization-vector names are not in the meta-schema.** Only `__proto__`, `constructor`, `prototype` are rejected at the meta-schema layer (the universal baseline). Names like `@type` (Java), `$type` (.NET), `__class__` (Python), `isa` (Objective-C), `__struct__` (Elixir), or PHP magic methods are enforced only at the CLI / `analyze()` layer via `--lang`. See [Language coverage](#language-coverage). - **Depth limits are a runtime concern.** Deeply nested schemas could cause stack overflow during recursive validation. Configure your validator's depth limits (e.g. AJV does not limit recursion depth by default). Enforced by the CLI, see `--override-max-depth`. +- **`const` / `default` / `enum` / `examples` value sizes are bounded recursively, but value nesting depth is a runtime concern.** The meta-schema bounds nested string length (`maxLength`), array length (`maxItems`), and object property counts (`maxProperties`) at every level of a literal value, so an oversized nested string, array, or object is rejected (ASVS 1.3.3). It does NOT cap the nesting *depth* of a literal value: a value nested thousands of levels deep is not given a clean rejection by the meta-schema rule itself (it fails closed via the validator's own recursion limit). Schema-nesting depth is enforced by the CLI, see `--override-max-depth`. - **Min/max logical consistency not enforced.** A schema with `minimum: 100, maximum: 1` (impossible range) will pass validation. This cannot be reliably enforced in JSON Schema alone and would require a wrapper function. Having unit tests for your schema is recommended, this would catch this type of error. Enforced by the CLI. -- **`pattern` regex validation has known gaps.** The check rejects negated character classes `[^...]` as broad denylist matchers (use allowlist patterns like `[\p{L}\p{N}]` instead), blocks nested quantifiers like `(a+)+`, backreferences, identical overlapping quantifiers like `[a-z]+[a-z]+`, semantically identical overlapping quantifiers like `\d+[0-9]+`, and superset overlaps like `\w+\d+` (where `\w` ⊃ `\d`). Bare alternation at the top level (`^a|b$`) is rejected, but alternation across sibling groups (`^(a)|(b)$`) is not detected at the meta-schema level (it is enforced by the CLI). The check cannot detect non-identical overlapping quantifiers (e.g. `[a-z]+\\w+` where `\\w` ⊃ `[a-z]`). Use runtime ReDoS checking for full protection. +- **`pattern` regex validation has known gaps.** The check rejects negated character classes `[^...]` as broad denylist matchers (use allowlist patterns like `[\p{L}\p{N}]` instead), blocks nested quantifiers like `(a+)+`, a bounded quantifier wrapped around a group with an unbounded inner quantifier like `(a+){1,5}`, absurd quantifier upper bounds (5+ digits, e.g. `a{1,1000000}` or `[a-z]{10000}`), backreferences, identical overlapping quantifiers like `[a-z]+[a-z]+`, semantically identical overlapping quantifiers like `\d+[0-9]+`, and superset overlaps like `\w+\d+` (where `\w` ⊃ `\d`). Bare alternation at the top level (`^a|b$`) is rejected, but alternation across sibling groups (`^(a)|(b)$`) is not detected at the meta-schema level (it is enforced by the CLI). The check cannot detect non-identical overlapping quantifiers (e.g. `[a-z]+\\w+` where `\\w` ⊃ `[a-z]`). It also does NOT reject a bounded quantifier wrapped around a group whose inner quantifier is itself bounded with a large product (e.g. `(a{1,1000}){1,1000}`); this nested-bounded-quantifier shape is caught by the CLI runtime (`redos-detector`) but not by the standalone meta-schema. Use runtime ReDoS checking for full protection. - **Remote `$ref` URLs can be SSRF vectors.** The meta-schema restricts `$ref` to `#` (local) or `https://` URLs and blocks private IP ranges (dotted-decimal, hex `0x`, and decimal representations), but DNS-based bypasses (domains resolving to internal IPs) cannot be detected at the schema level. Ensure your validator is configured to disallow or restrict remote schema loading (e.g., use `ajv.addSchema()` instead of allowing external fetches). Dereferencing before running SAST is recommended. Enforced by the CLI. ## Language coverage @@ -172,7 +188,7 @@ All meta-schemas reject keywords not listed in their respective JSON Schema spec | `$defs` | n/a | n/a | n/a | ✓ | ✓ | | | `title`, `description`, `default` | ✓ | ✓ | ✓ | ✓ | ✓ | | | `const` | n/a | ✓ | ✓ | ✓ | ✓ | Type-locked to declared `type` | -| `contains` | n/a | ✓ | ✓ | ✓ | ✓ | Requires `maxContains` + `uniqueItems` | +| `contains` | n/a | ✓ | ✓ | ✓ | ✓ | draft-06/07 require `maxItems` + `uniqueItems`; 2019-09/2020-12 additionally require `maxContains` + `unevaluatedItems` | | `propertyNames` | n/a | ✓ | ✓ | ✓ | ✓ | | | `if`/`then`/`else` | n/a | n/a | ✓ | ✓ | ✓ | | | `contentMediaType`, `contentEncoding` | n/a | n/a | ✓ | ✓ | ✓ | Allow-listed per RFC 6838 / RFC-standard | diff --git a/SECURITY.md b/SECURITY.md index 581de56..2465ffb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ This document outlines security procedures and general policies for the sast-jso * [Disclosure Policy](#disclosure-policy) ## Security Goals -Our goal is to ensure OSS follows secure design principles and meets security best practices as outlined by the following [OWASP ASVS v5.0 Level 3](https://github.com/OWASP/ASVS/tree/master/5.0/en). +Our goal is to ensure OSS follows secure design principles and meets security best practices as outlined by the following [OWASP ASVS 5.0.0 Level 3](https://github.com/OWASP/ASVS/tree/master/5.0/en). Standards are evaluated using automated scans (Linting, Unit tests, SAST, SCA, DAST, Perf) and manual self-audits. 3rd party audits are welcome. diff --git a/bin/build.js b/bin/build.js index bee0fa3..2c77781 100644 --- a/bin/build.js +++ b/bin/build.js @@ -7,17 +7,14 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -let defsLib; -try { - defsLib = JSON.parse(readFileSync(`${root}/src/$defs.json`, "utf8")); -} catch (err) { - console.error(`Error loading src/$defs.json: ${err.message}`); - process.exit(1); -} const drafts = ["draft-04", "draft-06", "draft-07", "2019-09", "2020-12"]; -const outputs = []; const REF_PREFIX = "#/$defs/"; +const LOCAL_REF_CONTAINERS = { + "#/$defs/": "$defs", + "#/definitions/": "definitions", +}; + const collectRefs = (node, acc) => { if (Array.isArray(node)) { for (const child of node) collectRefs(child, acc); @@ -33,54 +30,111 @@ const collectRefs = (node, acc) => { } }; -for (const draft of drafts) { - let src; - let manifest; +export const verifyRefs = (schema, file = "schema") => { + const walk = (node) => { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (node === null || typeof node !== "object") return; + for (const [k, v] of Object.entries(node)) { + if (k === "$ref" && typeof v === "string") { + for (const [prefix, container] of Object.entries( + LOCAL_REF_CONTAINERS, + )) { + if (!v.startsWith(prefix)) continue; + const name = v.slice(prefix.length); + const bag = schema[container]; + const resolves = + bag && + typeof bag === "object" && + !Array.isArray(bag) && + Object.hasOwn(bag, name); + if (!resolves) { + throw new Error( + `${file}: dangling $ref "${v}" does not resolve to an entry in "${container}"`, + ); + } + } + } else { + walk(v); + } + } + }; + walk(schema); +}; + +const build = () => { + let defsLib; try { - src = readFileSync(`${root}/src/${draft}.json`, "utf8"); - manifest = JSON.parse(src); + defsLib = JSON.parse(readFileSync(`${root}/src/$defs.json`, "utf8")); } catch (err) { - console.error(`Error loading src/${draft}.json: ${err.message}`); + console.error(`Error loading src/$defs.json: ${err.message}`); process.exit(1); } - const authored = - manifest.$defs && - typeof manifest.$defs === "object" && - !Array.isArray(manifest.$defs) - ? { ...manifest.$defs } - : {}; + const outputs = []; + for (const draft of drafts) { + let src; + let manifest; + try { + src = readFileSync(`${root}/src/${draft}.json`, "utf8"); + manifest = JSON.parse(src); + } catch (err) { + console.error(`Error loading src/${draft}.json: ${err.message}`); + process.exit(1); + } + const authored = + manifest.$defs && + typeof manifest.$defs === "object" && + !Array.isArray(manifest.$defs) + ? { ...manifest.$defs } + : {}; - const needed = new Set(); - collectRefs(manifest, needed); - const seen = new Set(); - let grew = true; - while (grew) { - grew = false; - for (const name of [...needed]) { - if (seen.has(name)) continue; - seen.add(name); - const entry = authored[name] ?? defsLib[name]; - if (entry === undefined) - throw new Error(`${draft}: unknown $defs entry "${name}"`); - const before = needed.size; - collectRefs(entry, needed); - if (needed.size > before) grew = true; + const needed = new Set(); + collectRefs(manifest, needed); + const seen = new Set(); + let grew = true; + while (grew) { + grew = false; + for (const name of [...needed]) { + if (seen.has(name)) continue; + seen.add(name); + const entry = authored[name] ?? defsLib[name]; + if (entry === undefined) + throw new Error(`${draft}: unknown $defs entry "${name}"`); + const before = needed.size; + collectRefs(entry, needed); + if (needed.size > before) grew = true; + } } + const finalDefs = {}; + for (const name of [...needed].sort()) { + finalDefs[name] = authored[name] ?? defsLib[name]; + } + manifest.$defs = finalDefs; + const outPath = `${root}/${draft}.json`; + verifyRefs(manifest, `${draft}.json`); + writeFileSync(outPath, `${JSON.stringify(manifest, null, "\t")}\n`); + outputs.push(outPath); + console.log( + `built ${draft}.json (${Object.keys(finalDefs).length} defs, ${Object.keys(authored).length} local)`, + ); } - const finalDefs = {}; - for (const name of [...needed].sort()) { - finalDefs[name] = authored[name] ?? defsLib[name]; + + try { + execFileSync("npx", ["biome", "format", "--write", ...outputs], { + cwd: root, + stdio: "inherit", + }); + } catch (err) { + console.error(`biome format step failed: ${err.message}`); + throw err; } - manifest.$defs = finalDefs; - const outPath = `${root}/${draft}.json`; - writeFileSync(outPath, `${JSON.stringify(manifest, null, "\t")}\n`); - outputs.push(outPath); - console.log( - `built ${draft}.json (${Object.keys(finalDefs).length} defs, ${Object.keys(authored).length} local)`, - ); -} +}; -execFileSync("npx", ["biome", "format", "--write", ...outputs], { - cwd: root, - stdio: "inherit", -}); +const isEntryPoint = + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isEntryPoint) { + build(); +} diff --git a/biome.json b/biome.json index 388e00c..78967f6 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -16,7 +16,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "preset": "recommended" } }, "javascript": { diff --git a/cli.js b/cli.js index 251afb8..d90737c 100755 --- a/cli.js +++ b/cli.js @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { lookup as dnsLookup } from "node:dns/promises"; import { readFile, stat } from "node:fs/promises"; -import { resolve } from "node:path"; +import { relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import Ajv from "ajv/dist/2020.js"; @@ -24,6 +24,8 @@ const DEFAULT_VERSION = "2020-12"; export const DNS_TIMEOUT_MS = 5_000; export const DNS_CONCURRENCY = 10; +export const MAX_SSRF_HOSTNAMES = 256; +export const DNS_TOTAL_TIMEOUT_MS = 30_000; // Pre-compiled SAST meta-schema validators, keyed by draft version. Compiled // once at module load so every sast() / analyze() call reuses the same @@ -37,6 +39,10 @@ const builtSchemas = new Map( ["draft-04", schemaDraft04], ].map(([version, metaSchema]) => [ version, + // allErrors:true is required so a single pass surfaces EVERY meta-schema + // violation for the security report; the schemas are trusted, not attacker + // input, so unbounded error allocation is not a DoS vector here. + // nosemgrep: javascript.ajv.security.audit.ajv-allerrors-true.ajv-allerrors-true new Ajv(defaultOptions).compile(metaSchema), ]), ); @@ -55,16 +61,49 @@ const knownSchemaUrls = new Map([ // Maps a user schema's $schema URL to the matching draft version. const schemaVersion = (url) => { if (!url) return DEFAULT_VERSION; + // Stryker disable next-line Regex: dropping the ^/$ anchors only changes the + // result for URLs with an interior "//" or "#", none of which are in the known + // set, so they resolve to the same (unsupported) lookup either way. const normalized = url.replace(/^(?:https?:)?\/\//, "").replace(/#$/, ""); return knownSchemaUrls.get(normalized); }; export const MAX_DEPTH = 32; export const MAX_SCHEMA_SIZE = 64 * 1024 * 1024; // 64 MiB +// Hard cap on the total number of remote $ref/$dynamicRef entries crawlSchema +// will collect into result.refs. The distinct-hostname cap (MAX_SSRF_HOSTNAMES) +// only applies later, after every ref has already been buffered, so a schema +// with a huge number of refs to the same few hosts would still accumulate an +// unbounded array. This is a backstop (overall bounded by MAX_SCHEMA_SIZE); set +// well above what realistic schemas need (multiple refs per distinct host). +export const MAX_COLLECTED_REFS = 4 * MAX_SSRF_HOSTNAMES; // Per-pattern budget for ReDoS analysis. Patterns that exceed this are // fail-closed (reported as unsafe with reason "timedOut") to keep total // scan time bounded on adversarial input. export const REDOS_TIMEOUT_MS = 1_000; +// HEAP CIRCUIT BREAKER: the PRIMARY memory bound for ReDoS analysis. The +// `timeout` option bounds time but NOT memory, and redos-detector's `maxSteps` +// cannot serve as the memory control: a value low enough to bound a catastrophic +// pattern (which retains ~7MB post-GC and ~270MB pre-GC each, so a handful OOM a +// 600MB heap) also wrongly fail-closes legitimate complex-but-safe patterns +// (e.g. semver is reported hitMaxSteps at maxSteps<=250 but is SAFE at the +// library default). So we drop maxSteps and instead read the live heap before +// each pattern: once it has grown beyond this budget above the phase baseline, +// analysis STOPS and one fail-closed (incomplete) finding is emitted. Chosen at +// 128MB: a single catastrophic pattern grows the heap by ~270MB at the default, +// so the breaker fires after the FIRST evil pattern (delta ~270MB > 128MB) and +// before the second, keeping peak well under --max-old-space-size=600; meanwhile +// realistic schemas with many SIMPLE patterns retain almost nothing and never +// approach 128MB. Injectable via crawlSchema options for deterministic testing. +export const REDOS_HEAP_BUDGET_BYTES = 128 * 1024 * 1024; +// Defense in depth: a hard cap on the TOTAL number of regex patterns crawlSchema +// will ReDoS-analyze in a single crawl. The heap circuit breaker above is the +// primary memory control; this is an independent backstop against an adversary +// supplying a huge number of patterns. Set well above what realistic schemas +// need (they can legitimately carry hundreds of simple patterns, which are +// cheap). +export const MAX_REDOS_PATTERNS = 256; +export const ANALYSIS_TIMEOUT_MS = 60_000; // Property names that act as deserialization / type-confusion vectors in // each downstream language ecosystem. Selected at the analyze() / CLI layer @@ -273,6 +312,9 @@ export const sast = (schema) => { const version = schemaVersion(schema?.$schema); const validate = builtSchemas.get(version); if (!validate) { + // Stryker disable next-line OptionalChaining: reaching this throw requires a + // non-null schema object (a null schema resolves to the default and validates), + // so schema?.$schema and schema.$schema are equivalent here. throw new Error(`Unsupported $schema: ${schema?.$schema}`); } return validate; @@ -283,21 +325,17 @@ export default sast; // Checks whether a numeric schema's min/max bounds describe an impossible // range. Returns an AJV-style error object when they do, or null otherwise. const checkNumericRange = (current, path) => { + // Number.isFinite is true only for an actual finite number, so it already + // implies `typeof === "number"`; no separate type check is needed. const hasMin = - Object.hasOwn(current, "minimum") && - typeof current.minimum === "number" && - Number.isFinite(current.minimum); + Object.hasOwn(current, "minimum") && Number.isFinite(current.minimum); const hasExMin = Object.hasOwn(current, "exclusiveMinimum") && - typeof current.exclusiveMinimum === "number" && Number.isFinite(current.exclusiveMinimum); const hasMax = - Object.hasOwn(current, "maximum") && - typeof current.maximum === "number" && - Number.isFinite(current.maximum); + Object.hasOwn(current, "maximum") && Number.isFinite(current.maximum); const hasExMax = Object.hasOwn(current, "exclusiveMaximum") && - typeof current.exclusiveMaximum === "number" && Number.isFinite(current.exclusiveMaximum); if (!(hasMin || hasExMin) || !(hasMax || hasExMax)) return null; @@ -361,6 +399,8 @@ const checkNumericRange = (current, path) => { }; }; +const INSTANCE_DATA_KEYS = new Set(["const", "enum", "default", "examples"]); + // RFC 6901 JSON Pointer token escaping: ~ → ~0, / → ~1. // https://datatracker.ietf.org/doc/html/rfc6901#section-3 const escapeJsonPointer = (token) => @@ -373,9 +413,21 @@ const escapeJsonPointer = (token) => // properties, a, properties, b). With MAX_DEPTH=32 this corresponds to roughly // 16 levels of real schema nesting. export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { - const result = { depth: 0, depthExceeded: false, errors: [], refs: [] }; + const result = { + depth: 0, + depthExceeded: false, + timedOut: false, + errors: [], + refs: [], + }; if (typeof obj !== "object" || obj === null) return result; + const deadline = options.deadline; + // Injectable monotonic clock (defaults to the real wall clock). Reading the + // clock through this indirection lets tests drive the deadline branches + // deterministically (the same pattern as options.memoryUsage below). + const now = typeof options.now === "function" ? options.now : Date.now; + const denylist = resolveDangerousNames(options.lang); const denySet = new Set(denylist); @@ -384,7 +436,104 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { result.depth = 1; const stack = [[obj, "", 1]]; + // Defense in depth: total number of regex patterns ReDoS-analyzed so far. + // Shared across top-level `pattern` and patternProperties keys. Once it + // exceeds MAX_REDOS_PATTERNS, no further pattern is analyzed and a single + // fail-closed budget finding is emitted (see redosBudgetExceeded). + let redosPatternCount = 0; + let redosBudgetReported = false; + // One-time flag: have we already recorded the collected-refs truncation + // finding? (See MAX_COLLECTED_REFS in the $ref/$dynamicRef collection below.) + let refsTruncated = false; + // Pushes the timeout finding and flags the result as timed out. Used both at + // the top of the stack loop and before each individual ReDoS analysis (the + // per-pattern loops can run many isSafePattern() calls in one stack frame, so + // the once-per-pop check is not enough on adversarial input). + const timeoutBail = () => { + result.errors.push({ + instancePath: "", + schemaPath: "#/timeout", + keyword: "timeout", + params: {}, + message: "schema analysis exceeded time budget", + }); + result.timedOut = true; + }; + // True when a deadline is configured and has passed. The `> deadline` boundary + // is exclusive (a clock reading EXACTLY at the deadline does NOT bail), pinned by + // the injected-clock deadline tests in cli.crawl.test.js, which also kill the + // whole-condition ConditionalExpression mutant (expired clock bails, future clock + // does not). The `deadline != null` guard sits on its own line so ONLY its + // genuinely-equivalent mutant is disabled. + const deadlineConfigured = () => + // Stryker disable next-line ConditionalExpression: forcing this `!= null` guard + // true is equivalent; when deadline is absent, now() > undefined is false anyway + // (deadlinePassed short-circuits the same way), so no input distinguishes it. + deadline != null; + const deadlinePassed = () => deadlineConfigured() && now() > deadline; + + // Returns true (and emits one #/redos-budget finding the first time) when the + // total-pattern cap has been exceeded, so callers can skip further analysis. + // Marked incomplete:true so --ignore cannot suppress it (analysis stopped). + const redosBudgetExceeded = (path) => { + if (redosPatternCount <= MAX_REDOS_PATTERNS) return false; + if (!redosBudgetReported) { + redosBudgetReported = true; + result.errors.push({ + instancePath: path, + schemaPath: "#/redos-budget", + keyword: "pattern", + params: { limit: MAX_REDOS_PATTERNS, incomplete: true }, + message: `refusing to ReDoS-analyze more than ${MAX_REDOS_PATTERNS} patterns; remaining patterns not analyzed`, + }); + } + return true; + }; + + // HEAP CIRCUIT BREAKER (primary memory bound). Reads live heap usage before + // each pattern; once it has grown beyond redosHeapBudgetBytes above the + // baseline captured at the first pattern, analysis stops and one fail-closed + // finding is emitted. Marked incomplete:true so --ignore cannot suppress it. + // Injectable: options.memoryUsage / options.redosHeapBudgetBytes for tests. + const memoryUsage = + typeof options.memoryUsage === "function" + ? options.memoryUsage + : () => process.memoryUsage().heapUsed; + const redosHeapBudgetBytes = + // Stryker disable next-line ConditionalExpression: when absent the default + // const is used; any test exercising the override passes it explicitly. + options.redosHeapBudgetBytes != null + ? options.redosHeapBudgetBytes + : REDOS_HEAP_BUDGET_BYTES; + let redosHeapBaseline = null; + let redosHeapReported = false; + // Returns true (and emits one #/redos-budget heap finding the first time) when + // the heap has grown more than the budget above the baseline. The first call + // captures the baseline (delta 0, never trips), so the breaker only fires once + // a real allocation has crossed the budget. + const redosHeapExceeded = (path) => { + const current = memoryUsage(); + if (redosHeapBaseline === null) redosHeapBaseline = current; + if (current - redosHeapBaseline <= redosHeapBudgetBytes) return false; + if (!redosHeapReported) { + redosHeapReported = true; + result.errors.push({ + instancePath: path, + schemaPath: "#/redos-budget", + keyword: "heap", + params: { budget: redosHeapBudgetBytes, incomplete: true }, + message: `ReDoS analysis heap budget of ${redosHeapBudgetBytes} bytes exceeded; remaining patterns not analyzed`, + }); + } + return true; + }; + while (stack.length > 0) { + if (deadlinePassed()) { + timeoutBail(); + return result; + } + const [current, path, currentDepth] = stack.pop(); const currentType = current.type; @@ -473,37 +622,70 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { Object.hasOwn(current, "pattern") && typeof current.pattern === "string" ) { - try { - const patternResult = isSafePattern(current.pattern, { - timeout: REDOS_TIMEOUT_MS, - }); - if (!patternResult.safe) { - const reason = patternResult.error ?? "hitMaxScore"; - const message = - reason === "timedOut" - ? `pattern analysis timed out after ${REDOS_TIMEOUT_MS}ms (fail-closed as ReDoS)` - : reason === "hitMaxSteps" - ? "pattern analysis exceeded step limit (fail-closed as ReDoS)" - : "pattern is vulnerable to ReDoS"; + // Check the deadline BEFORE the (potentially expensive) analysis: the + // once-per-pop check above is not enough when one stack frame holds many + // patterns. Bail to the timeout path on an expired deadline. Exercised + // deterministically by an injected clock that is under-deadline at the + // once-per-pop check and over it here (see cli.crawl.test.js). + if (deadlinePassed()) { + timeoutBail(); + return result; + } + redosPatternCount++; + // Skip analysis (of this and every later pattern) once a backstop trips: + // the total-pattern cap or the heap circuit breaker (primary memory bound). + if ( + !redosBudgetExceeded(`${path}/pattern`) && + !redosHeapExceeded(`${path}/pattern`) + ) { + try { + // The timeout bounds analysis TIME; the heap breaker above bounds + // MEMORY. No maxSteps: it would fail-close legitimate safe patterns. + // Stryker disable next-line ObjectLiteral: the timeout option bounds + // analysis TIME only; for any pattern fast enough for a test the + // safe/unsafe verdict is identical with or without it, so dropping it + // (-> {}) is an equivalent (timing-only) mutant. + const patternResult = isSafePattern(current.pattern, { + timeout: REDOS_TIMEOUT_MS, + }); + if (!patternResult.safe) { + // Stryker disable next-line LogicalOperator,StringLiteral: redos-detector + // always reports error:"hitMaxScore" for these, so the ?? fallback is + // defensive dead-weight here. + const reason = patternResult.error ?? "hitMaxScore"; + // timedOut/hitMaxSteps reasons only arise on a real library timeout, + // which cannot be triggered deterministically in a fast test. + // Stryker disable ConditionalExpression,StringLiteral + const message = + reason === "timedOut" + ? `pattern analysis timed out after ${REDOS_TIMEOUT_MS}ms (fail-closed as ReDoS)` + : reason === "hitMaxSteps" + ? "pattern analysis exceeded step limit (fail-closed as ReDoS)" + : "pattern is vulnerable to ReDoS"; + // Stryker restore ConditionalExpression,StringLiteral + result.errors.push({ + instancePath: `${path}/pattern`, + schemaPath: "#/redos", + keyword: "pattern", + params: { pattern: current.pattern, reason }, + message, + }); + } + } catch { result.errors.push({ instancePath: `${path}/pattern`, schemaPath: "#/redos", keyword: "pattern", - params: { pattern: current.pattern, reason }, - message, + params: { pattern: current.pattern, reason: "parseError" }, + message: "pattern could not be parsed for ReDoS analysis", }); } - } catch { - result.errors.push({ - instancePath: `${path}/pattern`, - schemaPath: "#/redos", - keyword: "pattern", - params: { pattern: current.pattern, reason: "parseError" }, - message: "pattern could not be parsed for ReDoS analysis", - }); } } + // Stryker disable next-line ConditionalExpression,EqualityOperator: this only + // skips the dangerous-name loops when the denylist is empty; entering with an + // empty denySet matches nothing, so it is a pure (equivalent) optimization. if (denylist.length > 0) { for (const siteKey of [ "properties", @@ -529,8 +711,9 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { } if (Array.isArray(current.required)) { - for (let i = 0; i < current.required.length; i++) { - const name = current.required[i]; + for (const [i, name] of current.required.entries()) { + // Stryker disable next-line ConditionalExpression: denySet only holds + // strings, so denySet.has(non-string) is already false. if (typeof name === "string" && denySet.has(name)) { result.errors.push({ instancePath: `${path}/required/${i}`, @@ -551,9 +734,12 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { for (const [trigger, deps] of Object.entries( current.dependentRequired, )) { + // Stryker disable next-line ConditionalExpression: a non-array deps has + // no length, so the loop below simply never runs. if (Array.isArray(deps)) { - for (let i = 0; i < deps.length; i++) { - const name = deps[i]; + for (const [i, name] of deps.entries()) { + // Stryker disable next-line ConditionalExpression,LogicalOperator: denySet + // only holds strings, so has(non-string) is already false. if (typeof name === "string" && denySet.has(name)) { result.errors.push({ instancePath: `${path}/dependentRequired/${escapeJsonPointer(trigger)}/${i}`, @@ -567,57 +753,126 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { } } } + } - if ( - typeof current.patternProperties === "object" && - current.patternProperties !== null && - !Array.isArray(current.patternProperties) - ) { - for (const patternKey of Object.keys(current.patternProperties)) { - try { - const re = new RegExp(patternKey); - const matches = denylist.filter((n) => re.test(n)); - if (matches.length > 0) { - result.errors.push({ - instancePath: `${path}/patternProperties/${escapeJsonPointer(patternKey)}`, - schemaPath: "#/dangerous-name", - keyword: "patternProperties", - params: { - pattern: patternKey, - matches, - lang: options.lang ?? DEFAULT_LANG, - }, - message: `patternProperties key "${patternKey}" matches deserialization vector(s) for lang="${options.lang ?? DEFAULT_LANG}": ${matches.join(", ")}`, - }); - } - } catch { - // unparseable regex; meta-schema safePattern rejects it + // ReDoS scanning of patternProperties keys is independent of the + // dangerous-name denylist, so it runs unconditionally; the dangerous-name + // match below self-gates (filtering an empty denylist yields no matches). + if ( + typeof current.patternProperties === "object" && + current.patternProperties !== null && + !Array.isArray(current.patternProperties) + ) { + for (const patternKey of Object.keys(current.patternProperties)) { + const keyPath = `${path}/patternProperties/${escapeJsonPointer(patternKey)}`; + // Check the deadline before each key: a single object can carry many + // patternProperties keys, all analyzed in ONE stack frame, so the + // once-per-pop check above never fires between them. Exercised with an + // injected clock that crosses the deadline before a later key (see + // cli.crawl.test.js). + if (deadlinePassed()) { + timeoutBail(); + return result; + } + redosPatternCount++; + // Stop analyzing further patterns once a backstop trips: the + // total-pattern cap or the heap circuit breaker (primary memory bound). + if (redosBudgetExceeded(keyPath) || redosHeapExceeded(keyPath)) break; + let patternSafe = true; + try { + // The timeout bounds analysis TIME; the heap breaker above bounds + // MEMORY. No maxSteps: it would fail-close legitimate safe patterns. + // Stryker disable next-line ObjectLiteral: the timeout option bounds + // analysis TIME only; for any key fast enough for a test the verdict + // is identical with or without it, so dropping it is timing-equivalent. + const patternResult = isSafePattern(patternKey, { + timeout: REDOS_TIMEOUT_MS, + }); + if (!patternResult.safe) { + patternSafe = false; + result.errors.push({ + instancePath: keyPath, + schemaPath: "#/redos", + keyword: "patternProperties", + params: { + pattern: patternKey, + // Stryker disable next-line LogicalOperator,StringLiteral: redos-detector + // always reports error:"hitMaxScore"; the ?? fallback is dead-weight. + reason: patternResult.error ?? "hitMaxScore", + }, + message: `patternProperties key "${patternKey}" is vulnerable to ReDoS`, + }); } + } catch {} + if (!patternSafe) continue; + try { + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + const re = new RegExp(patternKey); + const matches = denylist.filter((n) => re.test(n)); + if (matches.length > 0) { + result.errors.push({ + instancePath: keyPath, + schemaPath: "#/dangerous-name", + keyword: "patternProperties", + params: { + pattern: patternKey, + matches, + lang: options.lang ?? DEFAULT_LANG, + }, + message: `patternProperties key "${patternKey}" matches deserialization vector(s) for lang="${options.lang ?? DEFAULT_LANG}": ${matches.join(", ")}`, + }); + } + } catch { + // unparseable regex; meta-schema safePattern rejects it } } } - if ( - Object.hasOwn(current, "$ref") && - typeof current.$ref === "string" && - !current.$ref.startsWith("#") - ) { - try { - const url = new URL(current.$ref); - if (url.hostname) { - result.refs.push({ - hostname: url.hostname, - ref: current.$ref, - path: `${path}/$ref`, - }); + // Collect remote $ref and $dynamicRef (2020-12) URLs as SSRF fetch targets. + // $id is deliberately NOT collected: it declares a base URI identifier, not + // a fetch target, and the -r/--ref-schema-files flag uses $id hostnames as + // the SAFE list, so flagging $id would self-flag the user's own schema. + for (const refKey of ["$ref", "$dynamicRef"]) { + const refValue = current[refKey]; + if ( + Object.hasOwn(current, refKey) && + typeof refValue === "string" && + !refValue.startsWith("#") + ) { + try { + const url = new URL(refValue); + if (url.hostname) { + // Backstop: stop buffering once the collected-refs cap is reached, + // recording one truncation finding. Without this, a schema with a + // huge number of refs could accumulate an unbounded array before + // the later distinct-hostname cap ever applies. + if (result.refs.length >= MAX_COLLECTED_REFS) { + if (!refsTruncated) { + refsTruncated = true; + result.errors.push({ + instancePath: `${path}/${refKey}`, + schemaPath: "#/refs-truncated", + keyword: "$ref", + params: { limit: MAX_COLLECTED_REFS, incomplete: true }, + message: `more than ${MAX_COLLECTED_REFS} remote $ref(s); remaining refs not collected for SSRF analysis`, + }); + } + } else { + result.refs.push({ + hostname: url.hostname, + ref: refValue, + path: `${path}/${refKey}`, + }); + } + } + } catch { + // not a valid URL, skip } - } catch { - // not a valid URL, skip } } for (const key in current) { - if (Object.hasOwn(current, key)) { + if (Object.hasOwn(current, key) && !INSTANCE_DATA_KEYS.has(key)) { const value = current[key]; if ( typeof value === "object" && @@ -626,6 +881,8 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { ) { visited.add(value); const newDepth = currentDepth + 1; + // Stryker disable next-line ConditionalExpression,EqualityOperator: this only + // tracks the max depth seen; > vs >= and always-assign reach the same maximum. if (newDepth > result.depth) result.depth = newDepth; if (result.depth > maxDepth) { result.depthExceeded = true; @@ -641,11 +898,15 @@ export const crawlSchema = (obj, maxDepth = MAX_DEPTH, options = {}) => { }; // RFC 1918 + loopback + link-local + CGN + TEST-NETs + multicast + reserved. +// IPv6 covered: :: and ::1, unique-local fc00::/7, link-local fe80::/10 and +// site-local fec0::/10 (combined fe80-feff), multicast ff00::/8, IPv4-mapped +// ::ffff:0:0/96, NAT64 64:ff9b::/96, 6to4 2002::/16, and documentation +// 2001:db8::/32. NAT64/6to4/IPv4-mapped recurse on their embedded IPv4. // Used to block $ref URLs whose hostname resolves to an internal/private IP. -// Fail-closed: malformed IPv6 (e.g. invalid hex groups, wrong group count) -// returns false, but the upstream DNS lookup will already have rejected such -// addresses; we only see well-formed IPs here. Tests in cli.ip.test.js pin -// the boundary cases. +// Fail-closed: malformed IPv6 (e.g. invalid hex groups, wrong group count) is +// treated as private (returns true) so it is blocked rather than allowed +// through as a forged public address. Tests in cli.ip.test.js pin the +// boundary cases. export const isPrivateIP = (ip) => { const parts = ip.split(".").map(Number); if ( @@ -665,6 +926,9 @@ export const isPrivateIP = (ip) => { if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmark if (a === 198 && b === 51 && parts[2] === 100) return true; // 198.51.100.0/24 TEST-NET-2 if (a === 203 && b === 0 && parts[2] === 113) return true; // 203.0.113.0/24 TEST-NET-3 + // Stryker disable next-line ConditionalExpression: octets are validated to + // 0-255 and 240-255 is already private via the next line, so the `a <= 239` + // bound is redundant; dropping it is an equivalent mutant (no input changes). if (a >= 224 && a <= 239) return true; // 224.0.0.0/4 multicast if (a >= 240) return true; // 240.0.0.0/4 reserved + 255.255.255.255 broadcast } @@ -702,15 +966,44 @@ export const isPrivateIP = (ip) => { const normalized = groups.join(":"); if (normalized === "0:0:0:0:0:0:0:0" || normalized === "0:0:0:0:0:0:0:1") return true; - if (groups[0].startsWith("fc") || groups[0].startsWith("fd")) return true; // unique local - if (groups[0].startsWith("fe80")) return true; // link-local - if (groups[0].startsWith("ff")) return true; // multicast + // Decode two normalized hex groups into a dotted IPv4 and recurse. + // Fail-closed: invalid hex parses as NaN, and NaN bit-math would forge a + // public-looking IPv4. Block (return true) instead. + const embeddedIPv4Private = (hiGroup, loGroup) => { + const hi = Number.parseInt(hiGroup, 16); + const lo = Number.parseInt(loGroup, 16); + if (Number.isNaN(hi) || Number.isNaN(lo)) return true; + return isPrivateIP(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`); + }; + if (groups[0].startsWith("fc") || groups[0].startsWith("fd")) return true; // unique local fc00::/7 + if (groups[0].startsWith("ff")) return true; // multicast ff00::/8 + // First-group numeric ranges. Fail-closed on NaN (malformed hex) and on + // values > 0xffff (an over-long group such as "fe800" is malformed IPv6). + const g0 = Number.parseInt(groups[0], 16); + // Stryker disable next-line EqualityOperator: 0xffff (and above) starts with + // "ff" and is already returned by the multicast check, so the boundary value + // is unreachable here; `>` vs `>=` is an equivalent mutant. + if (Number.isNaN(g0) || g0 > 0xffff) return true; + // fe80-feff covers link-local fe80::/10 (fe80-febf) and site-local + // fec0::/10 (fec0-feff); the old check only matched the literal "fe80". + // Stryker disable next-line ConditionalExpression,EqualityOperator: the "ff" + // startsWith and `g0 > 0xffff` returns above already exclude every g0 above + // 0xfeff, so the upper-bound conjunct is always true here; dropping or + // loosening it is an equivalent mutant (no reachable input changes). + if (g0 >= 0xfe80 && g0 <= 0xfeff) return true; + // 2002::/16 6to4: embedded IPv4 sits in groups 1 and 2. + if (groups[0] === "2002") + return embeddedIPv4Private(groups[1], groups[2]); + // 2001:db8::/32 documentation (the IPv6 analog of the IPv4 TEST-NETs). + if (groups[0] === "2001" && groups[1] === "db8") return true; + // 64:ff9b::/96 NAT64 well-known prefix (RFC 6052): normalized form is + // 64:ff9b:0:0:0:0:X:Y with the IPv4 embedded in the last two groups. + if (normalized.startsWith("64:ff9b:0:0:0:0:")) { + return embeddedIPv4Private(groups[6], groups[7]); + } // IPv4-mapped with hex groups (e.g. 0:0:0:0:0:ffff:7f00:1) if (normalized.startsWith("0:0:0:0:0:ffff:")) { - const hi = Number.parseInt(groups[6], 16); - const lo = Number.parseInt(groups[7], 16); - const mappedIP = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; - return isPrivateIP(mappedIP); + return embeddedIPv4Private(groups[6], groups[7]); } } } @@ -744,6 +1037,8 @@ const lookupHostname = async (hostname, entries, timeoutMs) => { }; export const resolveSSRFRefs = async (refs, options = {}) => { + // Stryker disable next-line LogicalOperator: ?? vs && only changes the DNS + // timeout magnitude, never the resolve/abort outcome; equivalent mutant. const timeoutMs = options.dnsTimeoutMs ?? DNS_TIMEOUT_MS; const concurrency = options.dnsConcurrency ?? DNS_CONCURRENCY; const safeHostnames = options.safeHostnames ?? new Set(); @@ -756,9 +1051,68 @@ export const resolveSSRFRefs = async (refs, options = {}) => { hostnameMap.get(entry.hostname).push(entry); } + const maxHostnames = options.maxHostnames ?? MAX_SSRF_HOSTNAMES; + if (hostnameMap.size > maxHostnames) { + return [ + { + instancePath: "", + schemaPath: "#/ssrf", + keyword: "ssrf", + // `incomplete: true` marks this as an INCOMPLETE-analysis finding: DNS + // was entirely skipped, so analyze() must never let --ignore drop it + // (same protection depth/timeout get). The normal per-host findings + // below deliberately omit this marker and stay ignorable. + params: { + hostnames: hostnameMap.size, + limit: maxHostnames, + incomplete: true, + }, + message: `too many distinct remote $ref hostnames (${hostnameMap.size}); refusing SSRF DNS resolution above ${maxHostnames}`, + }, + ]; + } + + const totalMs = + // Stryker disable next-line ConditionalExpression: with the option absent the + // default branch and Number(undefined)=NaN both yield "never time out". + options.dnsTotalTimeoutMs != null + ? Number(options.dnsTotalTimeoutMs) + : DNS_TOTAL_TIMEOUT_MS; + // Injectable monotonic clock (defaults to the real wall clock), mirroring + // crawlSchema's options.now so the total-budget deadline can be crossed + // deterministically in tests, including at a batch index > 0. + const now = typeof options.now === "function" ? options.now : Date.now; + const overallDeadline = totalMs <= 0 ? 0 : now() + totalMs; + const results = []; const batches = [...hostnameMap.entries()]; + // Stryker disable next-line EqualityOperator: < vs <= only adds one empty + // trailing batch (slice past the end), so it is an equivalent mutant. for (let i = 0; i < batches.length; i += concurrency) { + // The budget boundary is exclusive; pinned by an injected-clock test that + // puts now() exactly at overallDeadline (no bail). + if (now() > overallDeadline) { + // slice(i) skips the batches already resolved; an injected clock that + // expires the budget at a batch index > 0 makes this a proper subset, + // pinned by a test in cli.analyze.test.js. + for (const [hostname, entries] of batches.slice(i)) { + for (const { ref, path } of entries) { + results.push([ + { + instancePath: path, + schemaPath: "#/ssrf", + keyword: "ssrf", + // Incomplete-analysis marker: this host's DNS was skipped because + // the total budget was exhausted, so analyze() must not let + // --ignore drop it (see applyIgnore). + params: { ref, hostname, incomplete: true }, + message: `$ref hostname "${hostname}" not checked: SSRF DNS budget exceeded`, + }, + ]); + } + } + break; + } const batch = batches.slice(i, i + concurrency); const batchResults = await Promise.all( batch.map(([hostname, entries]) => @@ -770,8 +1124,10 @@ export const resolveSSRFRefs = async (refs, options = {}) => { return results.flat(); }; -const resolveInstancePath = (obj, pointer) => { +export const resolveInstancePath = (obj, pointer) => { if (typeof obj !== "object" || obj === null) return undefined; + // Stryker disable next-line ConditionalExpression: an empty pointer also yields + // zero parts below, so returning obj early vs falling through is equivalent. if (!pointer) return obj; const parts = pointer .split("/") @@ -781,6 +1137,12 @@ const resolveInstancePath = (obj, pointer) => { for (const part of parts) { if (typeof current !== "object" || current === null) return undefined; if (!Object.hasOwn(current, part)) return undefined; + // Read-only walk: this never assigns INTO current, and the Object.hasOwn + // guard above keeps it on own properties, so inherited prototype keys are + // never traversed. Prototype pollution requires a write; there is none. + // Resolution of own keys named constructor/__proto__ is covered by the + // "own-property read" tests in tests/cli.analyze.test.js. + // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop current = current[part]; } return current; @@ -791,24 +1153,96 @@ const resolveInstancePath = (obj, pointer) => { export const analyze = async (schema, options = {}) => { if (options.overrideMaxDepth != null) { const n = Number(options.overrideMaxDepth); - if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { + if (n < 0 || !Number.isInteger(n)) { throw new TypeError("overrideMaxDepth must be a non-negative integer"); } } if (options.overrideMaxItems != null) { const n = Number(options.overrideMaxItems); - if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { + if (n < 0 || !Number.isInteger(n)) { throw new TypeError("overrideMaxItems must be a non-negative integer"); } } if (options.overrideMaxProperties != null) { const n = Number(options.overrideMaxProperties); - if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { + if (n < 0 || !Number.isInteger(n)) { throw new TypeError( "overrideMaxProperties must be a non-negative integer", ); } } + if (options.maxSchemaSize != null) { + const n = Number(options.maxSchemaSize); + if (n < 0 || !Number.isInteger(n)) { + throw new TypeError("maxSchemaSize must be a non-negative integer"); + } + } + if (options.analysisTimeoutMs != null) { + const n = Number(options.analysisTimeoutMs); + if (n < 0 || !Number.isInteger(n)) { + throw new TypeError("analysisTimeoutMs must be a non-negative integer"); + } + } + if (options.maxHostnames != null) { + const n = Number(options.maxHostnames); + if (n < 0 || !Number.isInteger(n)) { + throw new TypeError("maxHostnames must be a non-negative integer"); + } + } + if (options.dnsTotalTimeoutMs != null) { + const n = Number(options.dnsTotalTimeoutMs); + if (n < 0 || !Number.isInteger(n)) { + throw new TypeError("dnsTotalTimeoutMs must be a non-negative integer"); + } + } + + const applyIgnore = (errs) => { + // Stryker disable next-line ConditionalExpression,LogicalOperator: the + // length checks are short-circuit guards; filtering with an empty/no ignore + // set is a no-op, so dropping them yields the same returned array. + if (Array.isArray(options.ignore) && options.ignore.length && errs.length) { + const ignore = new Set(options.ignore); + return errs.filter( + (err) => + // Findings marked incomplete (SSRF hostname-cap / DNS-budget) mean + // analysis was NOT completed, so they are never suppressible by + // --ignore, exactly like the depth/timeout findings. + // Stryker disable next-line OptionalChaining: every finding that reaches + // applyIgnore (AJV errors, crawl findings, SSRF findings) carries a + // `params` object, so `?.` and a plain access are equivalent here; the + // guard is defensive against a hypothetical param-less finding only. + err.params?.incomplete === true || + (!ignore.has(err.instancePath) && + !ignore.has(`${err.instancePath}:${err.keyword}`)), + ); + } + return errs; + }; + + const sizeLimit = + // Stryker disable next-line ConditionalExpression: when absent, Number(undefined) + // = NaN, and `bytes > NaN` is false, matching the MAX_SCHEMA_SIZE default for any + // schema small enough to test. + options.maxSchemaSize != null + ? Number(options.maxSchemaSize) + : MAX_SCHEMA_SIZE; + let serialized; + try { + serialized = JSON.stringify(schema); + } catch (err) { + throw new TypeError( + `schema must be JSON-serializable (circular reference?): ${err.message}`, + ); + } + if ( + // Stryker disable next-line ConditionalExpression: JSON.stringify only returns a + // non-string (undefined) for undefined/function input, which a parsed schema + // never is, so this defensive check is always true here. + typeof serialized === "string" && + Buffer.byteLength(serialized) > sizeLimit + ) { + throw new RangeError(`schema exceeds ${sizeLimit} byte size limit`); + } const maxDepth = options.overrideMaxDepth != null @@ -817,8 +1251,27 @@ export const analyze = async (schema, options = {}) => { resolveDangerousNames(options.lang); // throws on unknown lang - const crawl = crawlSchema(schema, maxDepth, { lang: options.lang }); + // Default budget first, then narrow it if the caller set one (avoids an else + // branch whose only job is the default). + let deadline = Date.now() + ANALYSIS_TIMEOUT_MS; + // Stryker disable next-line ConditionalExpression: without the option, + // Number(undefined)=NaN gives a NaN deadline that never fires, the same + // observable result (no timeout) as the default budget within a fast test. + if (options.analysisTimeoutMs != null) { + const ms = Number(options.analysisTimeoutMs); + deadline = ms <= 0 ? 0 : Date.now() + ms; + } + + const crawl = crawlSchema(schema, maxDepth, { + lang: options.lang, + deadline, + now: options.now, + }); + // Depth and timeout signal INCOMPLETE analysis: the crawl bailed early and + // AJV validation plus SSRF checks were skipped. They are deliberately NOT + // passed through applyIgnore, because suppressing them would falsely report + // a partially-analyzed schema as clean (empty errors, exit 0). if (crawl.depthExceeded) { return [ { @@ -831,6 +1284,10 @@ export const analyze = async (schema, options = {}) => { ]; } + if (crawl.timedOut) { + return crawl.errors; + } + let errors = []; const validate = sast(schema); validate(schema); @@ -838,10 +1295,19 @@ export const analyze = async (schema, options = {}) => { errors.push(...crawl.errors); if (!options.offline) { + // Notify the caller (e.g. run(), to print a STDERR notice) of the remote + // refs about to be DNS-resolved, BEFORE any lookup happens. Opt-in: absent + // callback means no-op, keeping analyze() pure for library consumers. + // Stryker disable next-line OptionalChaining: with no callback the ?. and a + // plain call are equivalent (no observable effect) for library callers. + options.onRemoteRefs?.(crawl.refs); const ssrfErrors = await resolveSSRFRefs(crawl.refs, { dnsTimeoutMs: options.dnsTimeoutMs, dnsConcurrency: options.dnsConcurrency, safeHostnames: options.safeHostnames, + maxHostnames: options.maxHostnames, + dnsTotalTimeoutMs: options.dnsTotalTimeoutMs, + now: options.now, }); errors.push(...ssrfErrors); } @@ -849,6 +1315,9 @@ export const analyze = async (schema, options = {}) => { if (options.overrideMaxItems != null && errors.length) { const limit = Number(options.overrideMaxItems); errors = errors.filter((err) => { + // Stryker disable next-line ConditionalExpression: treating a non-maxItems + // error as maxItems still resolves a non-array instance, so !Array.isArray + // keeps it — the same outcome as the `return true` fall-through. if (err.schemaPath === SCHEMA_PATH_MAX_ITEMS) { const arr = resolveInstancePath(schema, err.instancePath); return !Array.isArray(arr) || arr.length > limit; @@ -859,32 +1328,43 @@ export const analyze = async (schema, options = {}) => { if (options.overrideMaxProperties != null && errors.length) { const limit = Number(options.overrideMaxProperties); errors = errors.filter((err) => { + // Stryker disable next-line ConditionalExpression: as above, a non-target + // error resolves to a non-object instance and is kept either way. if (err.schemaPath === SCHEMA_PATH_MAX_PROPERTIES) { const obj = resolveInstancePath(schema, err.instancePath); - if (typeof obj !== "object" || obj === null) return true; - return Object.keys(obj).length > limit; + // a real maxProperties finding always resolves to a non-null object, so + // these two defensive guards are false and the length check decides. + return ( + // Stryker disable next-line ConditionalExpression,LogicalOperator + typeof obj !== "object" || + // Stryker disable next-line ConditionalExpression + obj === null || + Object.keys(obj).length > limit + ); } return true; }); } - if (Array.isArray(options.ignore) && options.ignore.length && errors.length) { - const ignore = new Set(options.ignore); - errors = errors.filter((err) => { - const pathKey = err.instancePath; - const keywordKey = `${err.instancePath}:${err.keyword}`; - return !ignore.has(pathKey) && !ignore.has(keywordKey); - }); - } - - return errors; + return applyIgnore(errors); }; // Maps the analyze() error array to SARIF 2.1.0. Designed for GitHub // code-scanning, SonarQube, and other security pipelines that consume SARIF. // instancePath is encoded as logicalLocations.fullyQualifiedName (JSON Pointer) // since SARIF doesn't natively model JSON-pointer regions. -export const formatSarif = (errors, inputPath) => { - const inputUri = pathToFileURL(resolve(inputPath)).href; +export const formatSarif = (errors, inputPath, cwd = process.cwd()) => { + // GitHub code scanning / SonarQube match results to repo files via a path + // RELATIVE to the repo root, so prefer a relative uri + SRCROOT uriBaseId + // (resolvable via originalUriBaseIds). Only fall back to an absolute file:// + // uri when the input lives OUTSIDE cwd (relative would escape with ".."). + // Stryker disable next-line StringLiteral: relative() only yields backslash + // separators on Windows; on the POSIX test platform the input never contains a + // "\\", so this replacement is a no-op and the mutant is equivalent there. + const relPath = relative(cwd, resolve(inputPath)).replaceAll("\\", "/"); + const insideCwd = relPath !== "" && !relPath.startsWith("../"); + const artifactLocation = insideCwd + ? { uri: relPath, uriBaseId: "SRCROOT" } + : { uri: pathToFileURL(resolve(inputPath)).href }; const ruleMap = new Map(); for (const err of errors) { const ruleId = err.schemaPath @@ -914,6 +1394,13 @@ export const formatSarif = (errors, inputPath) => { rules: [...ruleMap.values()], }, }, + ...(insideCwd + ? { + originalUriBaseIds: { + SRCROOT: { uri: pathToFileURL(`${resolve(cwd)}/`).href }, + }, + } + : {}), results: errors.map((err) => { const ruleId = err.schemaPath ? err.schemaPath.replace(/^#\//, "").split("/")[0] || err.keyword @@ -925,7 +1412,7 @@ export const formatSarif = (errors, inputPath) => { locations: [ { physicalLocation: { - artifactLocation: { uri: inputUri }, + artifactLocation, }, logicalLocations: [ { @@ -949,16 +1436,40 @@ export const formatSarif = (errors, inputPath) => { }; // --- CLI entrypoint --- -if (process.argv[1] && resolve(process.argv[1]) === import.meta.filename) { + +// Thrown by `die` to unwind to run()'s handler with an exit code, instead of +// calling process.exit (which would make the entrypoint untestable in-process). +class CliExit extends Error { + constructor(code) { + // Stryker disable next-line StringLiteral: this sentinel's message is never + // read (only .code is), so its exact text is unobservable. + super(`cli exit ${code}`); + this.code = code; + } +} + +// Parses argv, reads the schema file, runs analyze(), and writes the report. +// Returns the exit code (0 = no issues, 1 = issues, 2 = usage/tool error). All +// I/O is injectable via `io` ({ log, error, write, readFile, stat }) so the whole +// entrypoint is unit-testable without spawning a subprocess. +export const run = async (argv, io = {}) => { + const log = io.log ?? ((m) => console.log(m)); + const errorLog = io.error ?? ((m) => console.error(m)); + const write = io.write ?? ((s) => process.stdout.write(s)); + const readFileFn = io.readFile ?? readFile; + const statFn = io.stat ?? stat; + const die = (msg) => { - console.error(`Error: ${msg}`); - process.exit(2); + errorLog(`Error: ${msg}`); + throw new CliExit(2); }; const readJsonFile = async (filePath, label) => { let content; try { - content = await readFile(filePath, "utf8"); + // Stryker disable next-line StringLiteral: JSON.parse coerces a Buffer the + // same as a string, so the "utf8" encoding hint is not observable here. + content = await readFileFn(filePath, "utf8"); } catch (err) { die(`cannot read ${label}: ${err.message}`); } @@ -969,36 +1480,47 @@ if (process.argv[1] && resolve(process.argv[1]) === import.meta.filename) { } }; - let values; - let positionals; try { - ({ values, positionals } = parseArgs({ - allowPositionals: true, - options: { - "override-max-items": { type: "string" }, - "override-max-depth": { type: "string" }, - "override-max-properties": { type: "string" }, - ignore: { type: "string", multiple: true }, - offline: { type: "boolean", default: false }, - lang: { type: "string", default: DEFAULT_LANG }, - format: { type: "string", default: "human" }, - "ref-schema-files": { type: "string", multiple: true, short: "r" }, - version: { type: "boolean", short: "v", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - })); - } catch (err) { - die(err.message); - } + let values; + let positionals; + try { + ({ values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + "override-max-items": { type: "string" }, + "override-max-depth": { type: "string" }, + "override-max-properties": { type: "string" }, + "max-schema-size": { type: "string" }, + "analysis-timeout-ms": { type: "string" }, + "max-ssrf-hostnames": { type: "string" }, + "dns-total-timeout-ms": { type: "string" }, + ignore: { type: "string", multiple: true }, + offline: { type: "boolean", default: false }, + lang: { type: "string", default: DEFAULT_LANG }, + format: { type: "string", default: "human" }, + "ref-schema-files": { type: "string", multiple: true, short: "r" }, + version: { type: "boolean", short: "v", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + })); + } catch (err) { + die(err.message); + } - if (values.help) { - console.log(`Usage: sast-json-schema [options] + if (values.help) { + log(`Usage: sast-json-schema [options] Options: --override-max-items Override max items limit (default: 1024) --override-max-depth Override max depth limit (default: 32) --override-max-properties Override max properties limit (default: 1024) - --ignore Suppress errors by instancePath or instancePath:keyword (repeatable) + --max-schema-size Max serialized schema size in bytes (default: 67108864 = 64 MiB) + --analysis-timeout-ms Wall-clock budget for the schema crawl (default: 60000) + --max-ssrf-hostnames Max distinct remote $ref hostnames resolved for SSRF (default: 256) + --dns-total-timeout-ms Total budget for all SSRF DNS lookups (default: 30000) + --ignore Suppress errors by instancePath or instancePath:keyword (repeatable). + Depth and timeout findings cannot be suppressed (they mean analysis was incomplete) --offline Skip SSRF DNS resolution for remote $ref URLs -r, --ref-schema-files Load a reference schema; its $id hostname is treated as safe and skipped during SSRF DNS checks (repeatable) @@ -1013,95 +1535,158 @@ Options: Exit codes: 0 no issues found - 1 schema has issues - 2 usage / tool error`); - process.exit(0); - } + 1 schema has issues, including depth-exceeded, analysis timeout, and SSRF hostname-cap / DNS-budget findings + 2 usage / tool error: bad args, unreadable file, invalid JSON, unsupported $schema, oversized schema, or non-JSON-serializable (circular) schema`); + return 0; + } - if (values.version) { - console.log(pkg.version); - process.exit(0); - } + if (values.version) { + log(pkg.version); + return 0; + } - if ( - values.format !== "human" && - values.format !== "json" && - values.format !== "sarif" - ) { - die(`--format must be "human", "json", or "sarif", got "${values.format}"`); - } + if ( + values.format !== "human" && + values.format !== "json" && + values.format !== "sarif" + ) { + die( + `--format must be "human", "json", or "sarif", got "${values.format}"`, + ); + } - if (!Object.hasOwn(DANGEROUS_NAMES_BY_LANG, values.lang)) { - die( - `--lang must be one of: ${Object.keys(DANGEROUS_NAMES_BY_LANG).join(", ")}, got "${values.lang}"`, - ); - } + if (!Object.hasOwn(DANGEROUS_NAMES_BY_LANG, values.lang)) { + die( + `--lang must be one of: ${Object.keys(DANGEROUS_NAMES_BY_LANG).join(", ")}, got "${values.lang}"`, + ); + } - const input = positionals[0]; - if (!input) die("missing required argument "); + const input = positionals[0]; + if (!input) die("missing required argument "); - const filePath = resolve(input); - let fileStat; - try { - fileStat = await stat(filePath); - } catch (err) { - die(`cannot read file "${input}": ${err.message}`); - } - if (fileStat.size > MAX_SCHEMA_SIZE) { - die(`schema file exceeds ${MAX_SCHEMA_SIZE} byte limit: "${input}"`); - } - const schema = await readJsonFile(filePath, `file "${input}"`); - - const safeHostnames = new Set(); - if (values["ref-schema-files"]) { - for (const refFile of values["ref-schema-files"]) { - const refSchema = await readJsonFile( - resolve(refFile), - `--ref-schema-files file "${refFile}"`, - ); - if (typeof refSchema.$id === "string") { - try { - const url = new URL(refSchema.$id); - if (url.hostname) safeHostnames.add(url.hostname); - } catch {} + const filePath = resolve(input); + let fileStat; + try { + fileStat = await statFn(filePath); + } catch (err) { + die(`cannot read file "${input}": ${err.message}`); + } + // Only enforce --max-schema-size at the file gate when it parses to a valid + // non-negative integer. For invalid values (e.g. 3.5 or a negative), fall + // back to the default here and let analyze() raise the proper validation + // error instead of a misleading "file exceeds N byte" message. + const parsedMaxSchemaSize = + // Stryker disable next-line ConditionalExpression: when absent, Number(undefined) + // = NaN, which the !Number.isInteger guard below rejects to MAX just like null does. + values["max-schema-size"] != null + ? Number(values["max-schema-size"]) + : null; + const fileSizeLimit = + // Stryker disable next-line ConditionalExpression: the != null head is + // redundant with Number.isInteger(null) === false on the next line. + parsedMaxSchemaSize != null && + Number.isInteger(parsedMaxSchemaSize) && + parsedMaxSchemaSize >= 0 + ? parsedMaxSchemaSize + : MAX_SCHEMA_SIZE; + if (fileStat.size > fileSizeLimit) { + die(`schema file exceeds ${fileSizeLimit} byte size limit: "${input}"`); + } + const schema = await readJsonFile(filePath, `file "${input}"`); + + const safeHostnames = new Set(); + if (values["ref-schema-files"]) { + for (const refFile of values["ref-schema-files"]) { + const refSchema = await readJsonFile( + resolve(refFile), + `--ref-schema-files file "${refFile}"`, + ); + // Stryker disable next-line ConditionalExpression: a non-string $id makes + // `new URL` throw into the catch below, so skipping vs trying is the same. + if (typeof refSchema.$id === "string") { + try { + const url = new URL(refSchema.$id); + // Stryker disable next-line ConditionalExpression: an empty hostname + // is never a real $ref host, so adding "" to the safe set is a no-op. + if (url.hostname) safeHostnames.add(url.hostname); + } catch {} + } } } - } - const options = { offline: values.offline, lang: values.lang }; - if (values["override-max-items"] != null) - options.overrideMaxItems = values["override-max-items"]; - if (values["override-max-depth"] != null) - options.overrideMaxDepth = values["override-max-depth"]; - if (values["override-max-properties"] != null) - options.overrideMaxProperties = values["override-max-properties"]; - if (values.ignore) options.ignore = values.ignore; - options.safeHostnames = safeHostnames; - - let errors; - try { - errors = await analyze(schema, options); - } catch (err) { - die(`analyzing schema "${input}": ${err.message}`); - } + // Pass options straight through; analyze() already treats undefined (an + // absent flag) as "use the default" via its own `!= null` guards, so a + // conditional copy here would only add equivalent-mutant noise. + const options = { + offline: values.offline, + lang: values.lang, + overrideMaxItems: values["override-max-items"], + overrideMaxDepth: values["override-max-depth"], + overrideMaxProperties: values["override-max-properties"], + maxSchemaSize: values["max-schema-size"], + analysisTimeoutMs: values["analysis-timeout-ms"], + maxHostnames: values["max-ssrf-hostnames"], + dnsTotalTimeoutMs: values["dns-total-timeout-ms"], + ignore: values.ignore, + safeHostnames, + // Resolving attacker-controlled hostnames from an untrusted schema is a + // blind-SSRF / DNS-exfil amplifier. Warn on STDERR (never STDOUT, to keep + // json/sarif output clean) right before DNS runs, but only when there is + // at least one non-safe remote hostname to resolve. + onRemoteRefs: (refs) => { + const hostnames = new Set(); + for (const { hostname } of refs) { + if (!safeHostnames.has(hostname)) hostnames.add(hostname); + } + if (hostnames.size > 0) { + errorLog( + `note: resolving ${hostnames.size} remote $ref hostname(s) via DNS; pass --offline to skip`, + ); + } + }, + }; - if (values.format === "json") { - process.stdout.write(`${JSON.stringify(errors)}\n`); - if (errors.length) { - console.error(`${input} has ${errors.length} issue(s)`); - process.exit(1); + let errors; + try { + errors = await analyze(schema, options); + } catch (err) { + die(`analyzing schema "${input}": ${err.message}`); + } + + if (values.format === "json") { + write(`${JSON.stringify(errors)}\n`); + if (errors.length) { + errorLog(`${input} has ${errors.length} issue(s)`); + return 1; + } + return 0; + } + if (values.format === "sarif") { + write(`${JSON.stringify(formatSarif(errors, input))}\n`); + if (errors.length) { + errorLog(`${input} has ${errors.length} issue(s)`); + return 1; + } + return 0; } - } else if (values.format === "sarif") { - process.stdout.write(`${JSON.stringify(formatSarif(errors, input))}\n`); if (errors.length) { - console.error(`${input} has ${errors.length} issue(s)`); - process.exit(1); + log(`${input} has issues`); + log(JSON.stringify(errors, null, 2)); + return 1; } - } else if (errors.length) { - console.log(`${input} has issues`); - console.log(JSON.stringify(errors, null, 2)); - process.exit(1); - } else { - console.log(`${input} has no issues`); + log(`${input} has no issues`); + return 0; + } catch (err) { + if (err instanceof CliExit) return err.code; + throw err; } +}; + +// Stryker disable ConditionalExpression,LogicalOperator,BlockStatement,MethodExpression: the +// main-module guard and its body only run when cli.js IS the process entry, which the +// in-process tests (which import run() directly) never are; the spawned subprocess in +// cli.test.js exercises it but perTest cannot attribute that coverage back here. +if (process.argv[1] && resolve(process.argv[1]) === import.meta.filename) { + process.exitCode = await run(process.argv.slice(2)); } +// Stryker restore all diff --git a/package-lock.json b/package-lock.json index 8094161..1d05be7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sast-json-schema", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sast-json-schema", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "workspaces": [ ".github" @@ -21,7 +21,8 @@ "devDependencies": { "@biomejs/biome": "^2.0.0", "@commitlint/cli": "^21.0.0", - "@commitlint/config-conventional": "^21.0.1", + "@commitlint/config-conventional": "^21.0.0", + "@stryker-mutator/core": "^9.0.0", "ajv-cmd": "^0.13.0", "fast-check": "^4.0.0", "husky": "^9.0.0", @@ -44,7 +45,7 @@ "lockfile-lint": "5.0.0" }, "engines": { - "node": ">=22.0" + "node": ">=24" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -64,13 +65,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -78,20 +79,526 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@biomejs/biome": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", - "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.0.tgz", + "integrity": "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -105,20 +612,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.15", - "@biomejs/cli-darwin-x64": "2.4.15", - "@biomejs/cli-linux-arm64": "2.4.15", - "@biomejs/cli-linux-arm64-musl": "2.4.15", - "@biomejs/cli-linux-x64": "2.4.15", - "@biomejs/cli-linux-x64-musl": "2.4.15", - "@biomejs/cli-win32-arm64": "2.4.15", - "@biomejs/cli-win32-x64": "2.4.15" + "@biomejs/cli-darwin-arm64": "2.5.0", + "@biomejs/cli-darwin-x64": "2.5.0", + "@biomejs/cli-linux-arm64": "2.5.0", + "@biomejs/cli-linux-arm64-musl": "2.5.0", + "@biomejs/cli-linux-x64": "2.5.0", + "@biomejs/cli-linux-x64-musl": "2.5.0", + "@biomejs/cli-win32-arm64": "2.5.0", + "@biomejs/cli-win32-x64": "2.5.0" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", - "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==", "cpu": [ "arm64" ], @@ -133,9 +640,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", - "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz", + "integrity": "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==", "cpu": [ "x64" ], @@ -150,13 +657,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", - "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.0.tgz", + "integrity": "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -167,13 +677,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", - "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -184,13 +697,16 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", - "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.0.tgz", + "integrity": "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -201,13 +717,16 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", - "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -218,9 +737,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", - "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.0.tgz", + "integrity": "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==", "cpu": [ "arm64" ], @@ -235,9 +754,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", - "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.0.tgz", + "integrity": "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==", "cpu": [ "x64" ], @@ -252,16 +771,16 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.1.tgz", - "integrity": "sha512-8vq10krmbJwBkvzXKhbs4o4JQEVscd3pqOlWuDUaDBwbeL694/P33UC29tZQFTAgPU9fVJ2+f2m3zw16yKWxHg==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz", + "integrity": "sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/format": "^21.0.1", - "@commitlint/lint": "^21.0.1", - "@commitlint/load": "^21.0.1", - "@commitlint/read": "^21.0.1", + "@commitlint/lint": "^21.0.2", + "@commitlint/load": "^21.0.2", + "@commitlint/read": "^21.0.2", "@commitlint/types": "^21.0.1", "tinyexec": "^1.0.0", "yargs": "^18.0.0" @@ -270,141 +789,13 @@ "commitlint": "cli.js" }, "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@commitlint/cli/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@commitlint/cli/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@commitlint/cli/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@commitlint/cli/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@commitlint/cli/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/cli/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@commitlint/cli/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@commitlint/cli/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/@commitlint/cli/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=22.12.0" } }, "node_modules/@commitlint/config-conventional": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.1.tgz", - "integrity": "sha512-gRorrkfWOh/+V5X8GYWWbQvrzPczopGMS4CCNrQdHkK4xWElv82BDvIsDhJZWTlI7TazOlYea6VATufCsFs+sw==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", + "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", "dev": true, "license": "MIT", "dependencies": { @@ -468,9 +859,9 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.1.tgz", - "integrity": "sha512-iNDP8SFdw8JEkM0CHZ2XFnhTN4Zg5jKUY2d8kBOSFrI2aA+3YJI7fcqVpfgbpJ9xtxFVYpi+DBATU5AvhoTq8g==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.2.tgz", + "integrity": "sha512-H5z4t8PC9tUsmZ/o+EptM3Nq8sTFtskAShdcqxCoyzklW5eaVT5xbrDAET2uypzir9Vsj4ZZmBtyKjYe2XqgeQ==", "dev": true, "license": "MIT", "dependencies": { @@ -482,15 +873,15 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.1.tgz", - "integrity": "sha512-gF+iYtUw1gBG3HUH9z3VxwUjGg2R2G5j+nmvPs8aIeYkiB7TtneBu3wO85I0bUl93bYNsvsCNI9Nte2fmDUMww==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.2.tgz", + "integrity": "sha512-PnUmLYGeGLfW8oVatR9KpNxSHYAnJOEWlMZzfdeFOUq6WUrFx1fGQaWCWJqMoIll/xPM+GdfJV+tKHZVHhl0Fg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.0.1", - "@commitlint/parse": "^21.0.1", - "@commitlint/rules": "^21.0.1", + "@commitlint/is-ignored": "^21.0.2", + "@commitlint/parse": "^21.0.2", + "@commitlint/rules": "^21.0.2", "@commitlint/types": "^21.0.1" }, "engines": { @@ -498,9 +889,9 @@ } }, "node_modules/@commitlint/load": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.1.tgz", - "integrity": "sha512-Btg1q1mKmiihN4W3x0EsPDrJMOQfMa9NIqlzlJyXAfxvsOGdGXOW5p3R3RcSxDCaY7JabY9flIl+Om1af3PSrw==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.2.tgz", + "integrity": "sha512-lwUE70hN0/qE/ZRROhbaX65ly/FF12DrqfReLCESo37M0OQCFAf2jRS+2tSCSORq+bm4Kdju7qNDj46uc1QzTA==", "dev": true, "license": "MIT", "dependencies": { @@ -519,9 +910,9 @@ } }, "node_modules/@commitlint/message": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.1.tgz", - "integrity": "sha512-R3dVQeJQ0B6yqrZEjkUHD4r7UJYLV9Lvk2xs3PTOmtWk2G3mI6Xgc+YdRxL1PwcDfBiUjv2SkIkW4AUc976w1w==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.2.tgz", + "integrity": "sha512-5n4aqHGD/FNnom/D5L8i7cYtV+xjuXcBL832C3w9VglEsZzIsoHpJsvxzJ7cgiOsOdc/2jU4t5+7qMHh7GBX3g==", "dev": true, "license": "MIT", "engines": { @@ -529,9 +920,9 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.1.tgz", - "integrity": "sha512-oh/nCSOqdoeQNA1tO8aAmxkq5EBo8/NzcFQRvv66AWc9HpED28sL2iSicCKU6hPintWuscL6BJEWi77Wq1LPMQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.2.tgz", + "integrity": "sha512-QVZJhGHTm+oiuWyEKOCTQ0ZM3mfJ0eGWFeHuj7WzSKEth+UukcCHac9GD8pgdFlg/qGkFWOtyaNd1T8REgagaw==", "dev": true, "license": "MIT", "dependencies": { @@ -544,13 +935,13 @@ } }, "node_modules/@commitlint/read": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.1.tgz", - "integrity": "sha512-pMEu4lbpC8W0ZgKJj2U6WaobXIZWdFlULpIEewYhkPXx+WZcnoO53YrVPc7QErQuNolq2Me8dP58Wu7YAVXVOA==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.2.tgz", + "integrity": "sha512-BtsrnLVycSSKf4Q0gMch4giCj5NNlmcbhc8ra5vONgGtP2IjRDo33bEFtr5Pm+2N+5fXGWb2MksWPrspPfdhdw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^21.0.1", + "@commitlint/top-level": "^21.0.2", "@commitlint/types": "^21.0.1", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" @@ -577,14 +968,14 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.1.tgz", - "integrity": "sha512-VMooYpz4nJg7xlaUso6CCOWEz8D/ChkvsvZUMARcoJ1ZpfKPyFCGrHNha2tbsETNAb6ErgiRuCr2DvghrvPDYQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.2.tgz", + "integrity": "sha512-k6tQ69Td7t2qUSIbik8D3TL1q3ZJpkEbV+yLogDzCRAdOxJm4ndhtBNREsLA1/puRfWvzS9eioF2w43WT+hHgQ==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/ensure": "^21.0.1", - "@commitlint/message": "^21.0.1", + "@commitlint/message": "^21.0.2", "@commitlint/to-lines": "^21.0.1", "@commitlint/types": "^21.0.1" }, @@ -603,9 +994,9 @@ } }, "node_modules/@commitlint/top-level": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.1.tgz", - "integrity": "sha512-4esUYqzY7K0FCgcJ/1xWEZekV7Ch4yZT1+xjEb7KzqbJ05XEkxHVsTfC8ADKNNtlCE2pj98KEbPGZWw9WwEnVw==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.2.tgz", + "integrity": "sha512-s9KKM+e+mXgFeIh4n7KmOGAVT3mkJ3Fp1bBYHIK5pjeUwlEMzp/tZfb5u0Poa680AsQTXMEMRxZi1vQ9m2X5ug==", "dev": true, "license": "MIT", "dependencies": { @@ -657,9 +1048,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -674,9 +1065,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -691,9 +1082,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -708,9 +1099,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -725,9 +1116,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -742,9 +1133,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -759,9 +1150,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -776,9 +1167,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -793,9 +1184,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -810,9 +1201,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -827,9 +1218,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -844,9 +1235,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -861,9 +1252,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -878,9 +1269,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -895,9 +1286,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -912,9 +1303,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -929,9 +1320,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -946,9 +1337,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -963,9 +1354,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -980,9 +1371,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -997,9 +1388,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1010,103 +1401,497 @@ "openbsd" ], "engines": { - "node": ">=18" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@fluent/syntax": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", - "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14.0.0", - "npm": ">=7.0.0" + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@nodelib/fs.scandir": { @@ -1147,6 +1932,13 @@ "node": ">= 8" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@silverbucket/ajv-formats-draft2019": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/@silverbucket/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.5.tgz", @@ -1198,6 +1990,137 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/api": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-9.6.1.tgz", + "integrity": "sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-metrics": "3.7.3", + "mutation-testing-report-schema": "3.7.3", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-9.6.1.tgz", + "integrity": "sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inquirer/prompts": "^8.0.0", + "@stryker-mutator/api": "9.6.1", + "@stryker-mutator/instrumenter": "9.6.1", + "@stryker-mutator/util": "9.6.1", + "ajv": "~8.18.0", + "chalk": "~5.6.0", + "commander": "~14.0.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.6.0", + "execa": "~9.6.0", + "json-rpc-2.0": "^1.7.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~10.2.4", + "mutation-server-protocol": "~0.4.0", + "mutation-testing-elements": "3.7.3", + "mutation-testing-metrics": "3.7.3", + "mutation-testing-report-schema": "3.7.3", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.3.0" + }, + "bin": { + "stryker": "bin/stryker.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@stryker-mutator/instrumenter": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-9.6.1.tgz", + "integrity": "sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~7.29.0", + "@babel/generator": "~7.29.0", + "@babel/parser": "~7.29.0", + "@babel/plugin-proposal-decorators": "~7.29.0", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/preset-typescript": "~7.28.0", + "@stryker-mutator/api": "9.6.1", + "@stryker-mutator/util": "9.6.1", + "angular-html-parser": "~10.4.0", + "semver": "~7.7.0", + "tslib": "2.8.1", + "weapon-regex": "~1.3.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@stryker-mutator/util": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-9.6.1.tgz", + "integrity": "sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -1225,13 +2148,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@willfarrell/sast-json-schema-github-workflows": { @@ -1400,27 +2323,37 @@ "ajv": "^8.8.2" } }, + "node_modules/angular-html-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.4.0.tgz", + "integrity": "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -1451,21 +2384,39 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -1481,6 +2432,71 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1501,6 +2517,40 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -1508,38 +2558,52 @@ "dev": true, "license": "MIT" }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "color-name": "1.1.3" } }, "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, "license": "MIT" }, @@ -1614,10 +2678,17 @@ "node": ">=18" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -1659,6 +2730,21 @@ "typescript": ">=5" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1687,6 +2773,24 @@ "node": ">=0.10.0" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1720,10 +2824,32 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -1747,10 +2873,43 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", "dev": true, "license": "MIT", "workspaces": [ @@ -1759,9 +2918,9 @@ ] }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1772,32 +2931,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -1824,6 +2983,33 @@ "node": ">=4" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-check": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", @@ -1870,6 +3056,23 @@ "node": ">=8.6.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -1886,6 +3089,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -1896,6 +3109,22 @@ "reusify": "^1.0.4" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1969,6 +3198,26 @@ "dev": true, "license": "ISC" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1992,6 +3241,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/git-raw-commits": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", @@ -2050,6 +3355,37 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/global-directory": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", @@ -2086,6 +3422,24 @@ "node": ">=8" } }, + "node_modules/globby/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/globby/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/globby/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2108,6 +3462,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/globby/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2115,6 +3495,42 @@ "dev": true, "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2128,7 +3544,24 @@ "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/typicode" + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -2215,13 +3648,13 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/is-glob": { @@ -2270,6 +3703,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2280,6 +3746,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2288,10 +3761,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2319,6 +3802,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-rpc-2.0": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", + "integrity": "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-test-esm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-test-esm/-/json-schema-test-esm-3.0.0.tgz", @@ -2335,6 +3825,19 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -2397,174 +3900,327 @@ "wrap-ansi": "^5.1.0" } }, - "node_modules/license-check-and-add/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/license-check-and-add/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/license-check-and-add/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" } }, - "node_modules/license-check-and-add/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "node_modules/license-check-and-add/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/license-check-and-add/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/license-check-and-add/node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lockfile-lint": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", + "integrity": "sha512-QcVIVITLZAhWYHU2wbNSOMgwc6EN4Y2sy6mjgS5aikYyRzgDIfotXUsCrm38En+3fZpc58Yu7DF9dNeT/goi1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cosmiconfig": "^9.0.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "lockfile-lint-api": "^5.9.2", + "yargs": "^17.7.2" + }, + "bin": { + "lockfile-lint": "bin/lockfile-lint.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lockfile-lint-api": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/lockfile-lint-api/-/lockfile-lint-api-5.9.2.tgz", + "integrity": "sha512-3QhxWxl3jT9GcMxuCnTsU8Tz5U6U1lKBlKBu2zOYOz/x3ONUoojEtky3uzoaaDgExcLqIX0Aqv2I7TZXE383CQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@yarnpkg/parsers": "^3.0.0-rc.48.1", + "debug": "^4.3.4", + "object-hash": "^3.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lockfile-lint/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lockfile-lint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/lockfile-lint/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/lockfile-lint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/lockfile-lint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, - "node_modules/license-check-and-add/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "node_modules/lockfile-lint/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, - "node_modules/license-check-and-add/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "node_modules/lockfile-lint/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/license-check-and-add/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "node_modules/lockfile-lint/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/license-check-and-add/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "node_modules/lockfile-lint/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^4.1.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/license-check-and-add/node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "node_modules/lockfile-lint/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/license-check-and-add/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/license-check-and-add/node_modules/yargs": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", - "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "node_modules/lockfile-lint/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.2" + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" } }, - "node_modules/license-check-and-add/node_modules/yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "node_modules/lockfile-lint/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "engines": { + "node": ">=12" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", "dev": true, "license": "MIT" }, - "node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lockfile-lint": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", - "integrity": "sha512-QcVIVITLZAhWYHU2wbNSOMgwc6EN4Y2sy6mjgS5aikYyRzgDIfotXUsCrm38En+3fZpc58Yu7DF9dNeT/goi1A==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", "dependencies": { - "cosmiconfig": "^9.0.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "lockfile-lint-api": "^5.9.2", - "yargs": "^17.7.2" - }, - "bin": { - "lockfile-lint": "bin/lockfile-lint.js" - }, - "engines": { - "node": ">=16.0.0" + "yallist": "^3.0.2" } }, - "node_modules/lockfile-lint-api": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/lockfile-lint-api/-/lockfile-lint-api-5.9.2.tgz", - "integrity": "sha512-3QhxWxl3jT9GcMxuCnTsU8Tz5U6U1lKBlKBu2zOYOz/x3ONUoojEtky3uzoaaDgExcLqIX0Aqv2I7TZXE383CQ==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@yarnpkg/parsers": "^3.0.0-rc.48.1", - "debug": "^4.3.4", - "object-hash": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">= 0.4" } }, "node_modules/meow": { @@ -2604,17 +4260,27 @@ "node": ">=8.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/moo": { @@ -2631,6 +4297,53 @@ "dev": true, "license": "MIT" }, + "node_modules/mutation-server-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz", + "integrity": "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.1.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mutation-testing-elements": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.7.3.tgz", + "integrity": "sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.7.3.tgz", + "integrity": "sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.7.3" + } + }, + "node_modules/mutation-testing-report-schema": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.7.3.tgz", + "integrity": "sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nearley": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", @@ -2661,6 +4374,46 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -2671,6 +4424,19 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2752,6 +4518,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -2772,6 +4551,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -2802,6 +4591,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/pure-rand": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", @@ -2819,6 +4634,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2969,6 +4800,23 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sast-json-schema": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/sast-json-schema/-/sast-json-schema-0.4.1.tgz", @@ -2994,9 +4842,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -3013,6 +4861,118 @@ "dev": true, "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3036,6 +4996,16 @@ "node": ">=0.10" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3044,31 +5014,50 @@ "license": "BSD-3-Clause" }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tinybench": { @@ -3082,9 +5071,9 @@ } }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -3104,6 +5093,16 @@ "node": ">=8.0" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3111,6 +5110,43 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-rest-client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz", + "integrity": "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "6.15.1", + "tunnel": "0.0.6", + "underscore": "^1.13.8" + }, + "engines": { + "node": ">= 16.0.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3126,13 +5162,33 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -3143,6 +5199,37 @@ "node": ">= 4.0.0" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", @@ -3150,6 +5237,29 @@ "dev": true, "license": "MIT" }, + "node_modules/weapon-regex": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", + "integrity": "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -3158,18 +5268,18 @@ "license": "ISC" }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -3192,33 +5302,62 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index 7250df3..edd010f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sast-json-schema", - "version": "0.4.1", + "version": "0.5.0", "description": "Meta-schema for the Static Application Security Testing (SAST) of JSON Schemas", "type": "module", "engines": { @@ -27,6 +27,7 @@ "2020-12.json", "src/$defs.json", "cli.js", + "package.json", "SECURITY.md" ], "publishConfig": { @@ -43,15 +44,29 @@ "prepublishOnly": "npm run build && npm test", "lint": "biome check --write --no-errors-on-unmatched", "pretest": "npm run build", - "test": "npm run test:lint && npm run test:unit && npm run test:sast && npm run test:perf && npm run test:dast", + "test": "npm run test:lint && npm run test:unit && npm run test:mutation && npm run test:sast && npm run test:perf && npm run test:dast", "test:lint": "biome ci --no-errors-on-unmatched", "test:lint:staged": "biome check --staged --no-errors-on-unmatched", "test:unit": "node --test --experimental-test-coverage --test-coverage-include=cli.js --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 ./tests/*.test.js", + "test:mutation": "stryker run", "test:perf": "node --test --test-concurrency=1 ./tests/*.perf.js", "test:dast": "npm run test:dast:fuzz", "test:dast:fuzz": "node --test ./tests/*.fuzz.js", - "test:sast": "npm run test:sast:license", - "test:sast:license": "license-check-and-add check -f .license.config.json" + "rm": "npm run rm:macos && npm run rm:node_modules && npm run rm:lock", + "rm:macos": "find . -name '.DS_Store' -type f -delete", + "rm:lock": "find . -name 'package-lock.json' -type f -delete", + "rm:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", + "test:sast": "npm run test:sast:license && npm run test:sast:lockfile && npm run test:sast:semgrep && npm run test:sast:trufflehog && npm run test:sast:gitleaks && npm run test:sast:actionlint && npm run test:sast:zizmor && npm run test:sast:trivy", + "test:sast:actionlint": "actionlint", + "test:sast:gitleaks": "npm run test:sast:gitleaks:dir && npm run test:sast:gitleaks:git", + "test:sast:gitleaks:dir": "gitleaks dir . --redact --no-banner", + "test:sast:gitleaks:git": "gitleaks git . --redact --no-banner", + "test:sast:license": "license-check-and-add check -f .license.config.json", + "test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https", + "test:sast:semgrep": "semgrep scan --config auto --error", + "test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,Python-2.0,LGPL-3.0-or-later,MPL-2.0,BlueOak-1.0.0,Unlicense --exit-code 1 --skip-files '**/bun.lock' --disable-telemetry .", + "test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./", + "test:sast:zizmor": "zizmor .github" }, "keywords": [ "JSON", @@ -87,12 +102,16 @@ "devDependencies": { "@biomejs/biome": "^2.0.0", "@commitlint/cli": "^21.0.0", - "@commitlint/config-conventional": "^21.0.1", + "@commitlint/config-conventional": "^21.0.0", + "@stryker-mutator/core": "^9.0.0", "ajv-cmd": "^0.13.0", "fast-check": "^4.0.0", "husky": "^9.0.0", "json-schema-test-esm": "^3.0.0", "license-check-and-add": "^4.0.0", "tinybench": "^6.0.0" + }, + "overrides": { + "qs": "^6.15.2" } } diff --git a/src/$defs.json b/src/$defs.json index 9582e83..56c26ef 100644 --- a/src/$defs.json +++ b/src/$defs.json @@ -23,6 +23,7 @@ "maxProperties": 1024 }, "safeValueSizeLimits": { + "$comment": "Recursively bounds the size of a const/default/enum/examples value. Top-level strings/arrays/objects are bounded by length/items/property counts, and nested array items and object property values are bounded by self-$ref, so a nested oversized string, array, or object is rejected at every level (ASVS 1.3.3). Recursion terminates on the value's own structure; AJV resolves the recursive $ref without infinite compilation.", "allOf": [ { "if": { @@ -37,7 +38,7 @@ "type": "array" }, "then": { - "$ref": "#/$defs/safeArrayItemsLimits" + "$ref": "#/$defs/safeValueSizeLimits.array" } }, { @@ -45,11 +46,25 @@ "type": "object" }, "then": { - "$ref": "#/$defs/safeObjectPropertiesLimits" + "$ref": "#/$defs/safeValueSizeLimits.object" } } ] }, + "safeValueSizeLimits.array": { + "$comment": "Array branch of safeValueSizeLimits: bounds the item count and recurses into each item so nested strings/arrays/objects are also bounded.", + "$ref": "#/$defs/safeArrayItemsLimits", + "items": { + "$ref": "#/$defs/safeValueSizeLimits" + } + }, + "safeValueSizeLimits.object": { + "$comment": "Object branch of safeValueSizeLimits: bounds the property count and recurses into each property value so nested strings/arrays/objects are also bounded.", + "$ref": "#/$defs/safeObjectPropertiesLimits", + "additionalProperties": { + "$ref": "#/$defs/safeValueSizeLimits" + } + }, "safeStringLengthLimits": { "type": "string", "minLength": 0, @@ -247,6 +262,18 @@ "not": { "pattern": "\\|([^()]|\\([^()]*\\))*\\$$" } + }, + { + "$comment": "Reject absurd quantifier upper bounds: a {n,m} (or {n}) whose count has 5+ digits (>= 10000). Catches huge single repetitions like a{1,1000000} and a{100000} that the runtime redos-detector would otherwise have to catch. Four-digit bounds (<= 9999) remain allowed.", + "not": { + "pattern": "\\{[0-9]*,?[0-9]{5,}\\}" + } + }, + { + "$comment": "Reject a bounded quantifier {n,m} applied to a group (...) that itself contains an unbounded quantifier (+ or *), e.g. (a+){1,5}, (a*){1,5}, or (?:a+){1,5}. This nested-quantifier-around-a-repeating-group shape is a classic exponential-backtracking ReDoS. Groups whose only inner quantifiers are fixed/bounded {n}, e.g. (?:[0-9a-f]{4}-){3}, and bounded groups with no inner quantifier, e.g. (abc){1,5}, remain allowed. Known limitation: a group whose inner quantifier is itself bounded but with a large product, e.g. (a{1,1000}){1,1000}, is NOT rejected here; the CLI runtime (redos-detector) still catches it. See README Known Limitations.", + "not": { + "pattern": "\\([^()]*[+*][^()]*\\)\\{[0-9]" + } } ] }, @@ -893,6 +920,36 @@ "required": ["maxProperties", "propertyNames"] } }, + "dependentSchemas.additionalProperties.draft-04": { + "$comment": "draft-04 variant of dependentSchemas.additionalProperties. draft-04 has no `propertyNames` keyword (it is banned via `propertyNames: false`), so a typed additionalProperties map can only be bounded by `maxProperties`. Requiring `propertyNames` here would make every draft-04 dictionary schema unsatisfiable.", + "properties": { + "type": { + "$ref": "#/$defs/typeObject" + }, + "additionalProperties": { + "oneOf": [ + { + "const": false + }, + { + "type": "object", + "required": ["type"] + } + ] + } + }, + "if": { + "properties": { + "additionalProperties": { + "type": "object" + } + }, + "required": ["additionalProperties"] + }, + "then": { + "required": ["maxProperties"] + } + }, "dependentSchemas.unevaluatedProperties": { "properties": { "type": { @@ -1159,25 +1216,6 @@ } } }, - "dependentSchemas.$id": { - "properties": { - "$id": { - "oneOf": [ - { - "$ref": "#/$defs/safeIdUrl" - }, - { - "$ref": "#/$defs/safeUrn" - }, - { - "type": "string", - "maxLength": 1024, - "pattern": "^[a-zA-Z0-9_-]+$" - } - ] - } - } - }, "dependentSchemas.$anchor": { "properties": { "$anchor": { diff --git a/src/.npmignore b/src/.npmignore new file mode 100644 index 0000000..1902eec --- /dev/null +++ b/src/.npmignore @@ -0,0 +1,6 @@ +draft-04.json +draft-06.json +draft-07.json +2019-09.json +2020-12.json +!$defs.json diff --git a/src/2019-09.json b/src/2019-09.json index 1d4232f..75107da 100644 --- a/src/2019-09.json +++ b/src/2019-09.json @@ -91,9 +91,6 @@ "items": { "$ref": "#/$defs/dependentSchemas.items" }, - "prefixItems": { - "$ref": "#/$defs/dependentSchemas.prefixItems" - }, "properties": { "$ref": "#/$defs/dependentSchemas.properties" }, diff --git a/src/draft-04.json b/src/draft-04.json index 9bbaed9..1dc4808 100644 --- a/src/draft-04.json +++ b/src/draft-04.json @@ -143,26 +143,6 @@ } } }, - "dependentSchemas.id": { - "$comment": "Draft-04 used `id` as the identifier keyword; draft-06+ renamed it to $id. Same URL/local-ref rules apply.", - "properties": { - "id": { - "oneOf": [ - { - "$ref": "#/$defs/safeIdUrl" - }, - { - "$ref": "#/$defs/safeUrn" - }, - { - "type": "string", - "maxLength": 1024, - "pattern": "^[a-zA-Z0-9_-]+$" - } - ] - } - } - }, "schemaBase": { "properties": { "additionalItems": { @@ -240,7 +220,7 @@ "$ref": "#/$defs/dependentSchemas.properties" }, "additionalProperties": { - "$ref": "#/$defs/dependentSchemas.additionalProperties" + "$ref": "#/$defs/dependentSchemas.additionalProperties.draft-04" }, "items": { "$ref": "#/$defs/dependentSchemas.items" diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 0000000..08d2214 --- /dev/null +++ b/stryker.config.json @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "packageManager": "npm", + "testRunner": "command", + "commandRunner": { + "command": "node bin/build.js && node --test ./tests/*.test.js" + }, + "coverageAnalysis": "off", + "tempDirName": "/tmp/stryker/sast-json-schema", + "mutate": ["cli.js"], + "reporters": ["progress", "clear-text"], + "thresholds": { + "high": 100, + "low": 100, + "break": 100 + } +} diff --git a/tests/bfixes.test.js b/tests/bfixes.test.js new file mode 100644 index 0000000..4a16a9a --- /dev/null +++ b/tests/bfixes.test.js @@ -0,0 +1,254 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import Ajv from "ajv/dist/2020.js"; +import schema202012 from "../2020-12.json" with { type: "json" }; +import schemaDraft04 from "../draft-04.json" with { type: "json" }; + +const compile = (schema) => + new Ajv({ strictTypes: false, allowUnionTypes: true }).compile(schema); + +// Wrap a keyword value (const/default/examples) in a minimal-but-valid 2020-12 +// object schema so only the value-size rule under test decides accept/reject. +const wrap2020 = (keyword, value) => ({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/x", + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: { + v: { + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: {}, + [keyword]: value, + }, + }, +}); + +const buildDeep = (depth) => { + const root = {}; + let cur = root; + for (let i = 0; i < depth; i++) { + cur.a = {}; + cur = cur.a; + } + cur.leaf = "x"; + return root; +}; + +// === B3: safePattern static rule catches bounded-quantifier ReDoS === +describe("B3: safePattern bounded-quantifier ReDoS (2020-12)", () => { + const validate = compile(schema202012); + const withPattern = (pattern) => ({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/x", + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: { + v: { type: "string", pattern, maxLength: 100 }, + }, + }); + const accepts = (pattern) => validate(withPattern(pattern)); + + const rejectCases = [ + ["bounded quantifier around a + group", "^(a+){1,5}$"], + ["bounded quantifier around a * group", "^(a*){1,5}$"], + ["bounded quantifier around a + non-capturing group", "^(?:a+){1,5}$"], + ["huge single repetition (5+ digit upper bound)", "^a{1,1000000}$"], + ["huge exact repetition (5+ digits)", "^a{100000}$"], + ["huge char-class repetition (5+ digits)", "^[a-z]{10000}$"], + ]; + for (const [label, pattern] of rejectCases) { + it(`rejects ${label}: ${pattern}`, () => { + assert.strictEqual(accepts(pattern), false); + }); + } + + const acceptCases = [ + ["simple bounded char class", "^[a-z]{1,10}$"], + ["bounded group with no inner quantifier", "^(abc){1,5}$"], + ["non-capturing bounded group, no inner quantifier", "^(?:abc){1,5}$"], + [ + "group with only fixed inner {n} quantifier (uuid)", + "^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$", + ], + ["exact small repetition", "^a{2,8}$"], + ["date pattern", "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"], + ["four-digit upper bound (below the 5-digit cap)", "^[a-z]{1,9999}$"], + ]; + for (const [label, pattern] of acceptCases) { + it(`accepts ${label}: ${pattern}`, () => { + assert.strictEqual( + accepts(pattern), + true, + `expected ACCEPT, got errors: ${JSON.stringify(validate.errors, null, 2)}`, + ); + }); + } + + it("KNOWN LIMITATION: nested bounded-quantifier large product is accepted by meta-schema (CLI runtime catches it)", () => { + // (a{1,1000}){1,1000} has a large product of bounded quantifiers but no + // unbounded + / * inside the group, so the static meta-schema rule does + // not reject it. Documented in README Known Limitations; the CLI's + // redos-detector rejects it at runtime. + assert.strictEqual(accepts("^(a{1,1000}){1,1000}$"), true); + }); +}); + +// === B1: nested const/default/examples values are size-bounded recursively === +describe("B1: recursive value size limits (2020-12)", () => { + const validate = compile(schema202012); + + for (const keyword of ["const", "default"]) { + it(`rejects a nested 1MB string inside a ${keyword} object`, () => { + const value = { a: "X".repeat(1000000) }; + assert.strictEqual(validate(wrap2020(keyword, value)), false); + }); + + it(`does not accept a deeply-nested (2000-level) ${keyword}`, () => { + // The recursive self-$ref bounds size at every level. Pure depth is + // bounded by AJV's own runtime recursion limit: a 2000-level value is + // never accepted, it fails closed (validate returns false or throws a + // RangeError during validation). Either way it is not treated as safe. + let accepted; + try { + accepted = validate(wrap2020(keyword, buildDeep(2000))); + } catch { + accepted = false; + } + assert.strictEqual(accepted, false); + }); + + it(`rejects a 1MB string nested deep inside a ${keyword} (size, not depth)`, () => { + const deep = buildDeep(10); + let cur = deep; + while (cur.a) cur = cur.a; + cur.leaf = "X".repeat(1000000); + assert.strictEqual(validate(wrap2020(keyword, deep)), false); + }); + + it(`accepts a small nested ${keyword} (a few short strings)`, () => { + const value = { a: "hello", b: "world", c: { d: "ok" } }; + assert.strictEqual( + validate(wrap2020(keyword, value)), + true, + `expected ACCEPT, got errors: ${JSON.stringify(validate.errors, null, 2)}`, + ); + }); + } + + it("rejects a nested 1MB string inside an examples value", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/x", + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: { + v: { + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: {}, + examples: [{ a: "X".repeat(1000000) }], + }, + }, + }; + assert.strictEqual(validate(schema), false); + }); + + it("accepts a small nested examples value", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/x", + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: { + v: { + type: "object", + additionalProperties: false, + required: [], + unevaluatedProperties: false, + properties: {}, + examples: [{ a: "hi", b: ["ok", "fine"] }], + }, + }, + }; + assert.strictEqual( + validate(schema), + true, + `expected ACCEPT, got errors: ${JSON.stringify(validate.errors, null, 2)}`, + ); + }); +}); + +// === B2: draft-04 typed additionalProperties must be satisfiable === +describe("B2: draft-04 typed additionalProperties (dictionary)", () => { + const validate = compile(schemaDraft04); + const dictionary = (overrides = {}) => ({ + $schema: "https://json-schema.org/draft-04/schema", + id: "https://example.com/dict", + type: "object", + additionalProperties: { + type: "string", + pattern: "^[a-z]+$", + maxLength: 10, + }, + maxProperties: 10, + required: [], + ...overrides, + }); + + it("accepts a valid draft-04 dictionary schema with maxProperties", () => { + const valid = validate(dictionary()); + assert.strictEqual( + valid, + true, + `expected ACCEPT, got errors: ${JSON.stringify(validate.errors, null, 2)}`, + ); + }); + + it("rejects the same dictionary schema without maxProperties", () => { + const noMax = dictionary(); + delete noMax.maxProperties; + assert.strictEqual(validate(noMax), false); + }); + + it("2020-12 still requires propertyNames for a typed additionalProperties map (unchanged)", () => { + const validate2020 = compile(schema202012); + const base = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/dict", + type: "object", + additionalProperties: { + type: "string", + pattern: "^[a-z]+$", + maxLength: 10, + }, + maxProperties: 10, + required: [], + unevaluatedProperties: false, + }; + // Without propertyNames: rejected. + assert.strictEqual(validate2020(base), false); + // With propertyNames: accepted. + const withPN = { + ...base, + propertyNames: { type: "string", pattern: "^[a-z]+$", maxLength: 20 }, + }; + assert.strictEqual( + validate2020(withPN), + true, + `expected ACCEPT, got errors: ${JSON.stringify(validate2020.errors, null, 2)}`, + ); + }); +}); diff --git a/tests/build.test.js b/tests/build.test.js new file mode 100644 index 0000000..ae531da --- /dev/null +++ b/tests/build.test.js @@ -0,0 +1,66 @@ +import { doesNotThrow, strictEqual, throws } from "node:assert"; +import { describe, test } from "node:test"; +import { verifyRefs } from "../bin/build.js"; + +describe("verifyRefs", () => { + test("passes when every $defs ref resolves", () => { + const schema = { + $defs: { + a: { type: "string" }, + b: { $ref: "#/$defs/a" }, + }, + properties: { + x: { $ref: "#/$defs/b" }, + }, + }; + doesNotThrow(() => verifyRefs(schema, "ok.json")); + }); + + test("throws naming the dangling pointer", () => { + const schema = { + $defs: { a: { type: "string" } }, + properties: { x: { $ref: "#/$defs/nope" } }, + }; + throws( + () => verifyRefs(schema, "bad.json"), + (err) => { + strictEqual(err instanceof Error, true); + strictEqual(err.message.includes("#/$defs/nope"), true); + strictEqual(err.message.includes("bad.json"), true); + return true; + }, + ); + }); + + test("finds refs nested in properties, allOf arrays, and items", () => { + const schema = { + $defs: { present: { type: "string" } }, + allOf: [{ properties: { y: { items: { $ref: "#/$defs/missing" } } } }], + }; + throws(() => verifyRefs(schema, "nested.json"), /#\/\$defs\/missing/); + }); + + test("ignores non-local refs (http and #/properties)", () => { + const schema = { + $defs: { a: { type: "string" } }, + properties: { + x: { $ref: "http://example.com/other.json#/$defs/whatever" }, + y: { $ref: "#/properties/x" }, + }, + }; + doesNotThrow(() => verifyRefs(schema, "ignore.json")); + }); + + test("resolves #/definitions/ pointers against definitions", () => { + const ok = { + definitions: { a: { type: "string" } }, + properties: { x: { $ref: "#/definitions/a" } }, + }; + doesNotThrow(() => verifyRefs(ok, "defs-ok.json")); + const bad = { + definitions: { a: { type: "string" } }, + properties: { x: { $ref: "#/definitions/missing" } }, + }; + throws(() => verifyRefs(bad, "defs-bad.json"), /#\/definitions\/missing/); + }); +}); diff --git a/tests/cli.analyze.test.js b/tests/cli.analyze.test.js index 3bae1d1..420c250 100644 --- a/tests/cli.analyze.test.js +++ b/tests/cli.analyze.test.js @@ -1,7 +1,14 @@ -import { ok, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, strictEqual } from "node:assert"; import { describe, test } from "node:test"; +import { pathToFileURL } from "node:url"; import schema202012 from "../2020-12.json" with { type: "json" }; -import { analyze, formatSarif, resolveSSRFRefs } from "../cli.js"; +import { + analyze, + formatSarif, + resolveInstancePath, + resolveSSRFRefs, + run, +} from "../cli.js"; test("analyze should filter errors matching options.ignore by instancePath", async () => { const schema = { @@ -230,6 +237,143 @@ describe("analyze options validation", () => { ok(err.message.includes("non-negative integer")); } }); + + test("should throw TypeError for non-numeric maxHostnames", async () => { + try { + await analyze( + { type: "string", maxLength: 10 }, + { offline: true, maxHostnames: "abc" }, + ); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes("maxHostnames")); + ok(err.message.includes("non-negative integer")); + } + }); + + test("should throw TypeError for negative dnsTotalTimeoutMs", async () => { + try { + await analyze( + { type: "string", maxLength: 10 }, + { offline: true, dnsTotalTimeoutMs: -1 }, + ); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes("dnsTotalTimeoutMs")); + ok(err.message.includes("non-negative integer")); + } + }); + + test("should throw TypeError for non-integer maxSchemaSize", async () => { + try { + await analyze( + { type: "string", maxLength: 10 }, + { offline: true, maxSchemaSize: 3.5 }, + ); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes("maxSchemaSize")); + ok(err.message.includes("non-negative integer")); + } + }); + + test("should throw TypeError for negative analysisTimeoutMs", async () => { + try { + await analyze( + { type: "string", maxLength: 10 }, + { offline: true, analysisTimeoutMs: -1 }, + ); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes("analysisTimeoutMs")); + ok(err.message.includes("non-negative integer")); + } + }); +}); + +// --- analyze size guard --- + +describe("analyze size guard", () => { + test("should reject schema exceeding maxSchemaSize with a RangeError", async () => { + try { + await analyze( + { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + { offline: true, maxSchemaSize: 5 }, + ); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof RangeError); + ok(err.message.includes("size")); + } + }); + + test("should resolve to an array when within maxSchemaSize", async () => { + const errors = await analyze( + { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + { offline: true, maxSchemaSize: 1_000_000 }, + ); + ok(Array.isArray(errors)); + }); + + test("should throw TypeError for a circular schema", async () => { + const o = { type: "object" }; + o.self = o; + try { + await analyze(o, { offline: true }); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes("JSON-serializable")); + } + }); +}); + +// --- analyze time budget --- + +describe("analyze time budget", () => { + test("analysisTimeoutMs=0 should emit a timeout error", async () => { + const errors = await analyze( + { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + { offline: true, analysisTimeoutMs: 0 }, + ); + const timeout = errors.find((e) => e.keyword === "timeout"); + ok(timeout, "expected a timeout error"); + strictEqual(timeout.schemaPath, "#/timeout"); + strictEqual(timeout.instancePath, ""); + strictEqual(timeout.message, "schema analysis exceeded time budget"); + deepStrictEqual(timeout.params, {}); + }); + + test("ignore must NOT suppress the timeout finding (incomplete analysis stays visible)", async () => { + const errors = await analyze( + { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + { offline: true, analysisTimeoutMs: 0, ignore: [":timeout", ""] }, + ); + ok( + errors.some((e) => e.keyword === "timeout"), + "timeout finding must remain even when explicitly ignored", + ); + }); + + test("ignore must NOT suppress the depth finding", async () => { + const errors = await analyze( + { + type: "object", + properties: { + a: { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + }, + }, + { offline: true, overrideMaxDepth: 0, ignore: [":depth", ""] }, + ); + ok( + errors.some((e) => e.keyword === "depth"), + "depth finding must remain even when explicitly ignored", + ); + }); }); // --- regression: filter schemaPaths must match what AJV actually emits --- @@ -305,6 +449,37 @@ describe("analyze DNS options", () => { ); }); + test("$dynamicRef to a private-resolving hostname yields ssrf error online", async () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $dynamicRef: "https://localhost/schema.json", + }; + const errors = await analyze(schema, { dnsTimeoutMs: 5_000 }); + const ssrf = errors.filter((e) => e.keyword === "ssrf"); + strictEqual( + ssrf.length, + 1, + "localhost $dynamicRef must be flagged as ssrf", + ); + strictEqual(ssrf[0].instancePath, "/$dynamicRef"); + ok(ssrf[0].params.resolvedIP, "should include the resolved private IP"); + }); + + test("offline mode should not emit ssrf errors for remote $dynamicRef", async () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $dynamicRef: "https://localhost/schema.json", + }; + const offline = await analyze(schema, { offline: true }); + strictEqual( + offline.filter((e) => e.keyword === "ssrf").length, + 0, + "offline mode must skip DNS lookup entirely", + ); + }); + test("dnsTimeoutMs=1 should fail fast when offline is unset", async () => { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -426,6 +601,32 @@ describe("resolveSSRFRefs", () => { }); strictEqual(errors.length, 0); }); + + test("should refuse DNS above maxHostnames cap (no DNS performed)", async () => { + const refs = Array.from({ length: 60 }, (_, i) => ({ + hostname: `h${i}.invalid`, + ref: `https://h${i}.invalid/schema.json`, + path: `/$ref/${i}`, + })); + const errors = await resolveSSRFRefs(refs, { maxHostnames: 50 }); + strictEqual(errors.length, 1); + strictEqual(errors[0].keyword, "ssrf"); + ok(errors[0].message.includes("too many")); + }); + + test("should fail closed when dnsTotalTimeoutMs budget is exceeded (no DNS performed)", async () => { + const refs = [ + { + hostname: "h.invalid", + ref: "https://h.invalid/schema.json", + path: "/$ref", + }, + ]; + const errors = await resolveSSRFRefs(refs, { dnsTotalTimeoutMs: 0 }); + strictEqual(errors.length, 1); + strictEqual(errors[0].keyword, "ssrf"); + ok(errors[0].message.includes("budget")); + }); }); // --- resolveInstancePath (via analyze overrides) --- @@ -485,6 +686,124 @@ describe("resolveInstancePath via overrides", () => { }); ok(errors.some((e) => e.keyword === "maxItems")); }); + + test("resolves an instancePath segment named 'constructor' (own-property read)", async () => { + // resolveInstancePath walks /properties/constructor/enum. The "constructor" + // segment must resolve via a guarded own-property read; if the walk were + // blocked or diverted onto the prototype, the maxItems error could not be + // suppressed and the first assertion would fail. Backs the + // prototype-pollution-loop nosemgrep in cli.js (the walk is a read, never + // a write, and Object.hasOwn keeps it on own properties). + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "object", + properties: { + constructor: { + type: "string", + maxLength: 50, + enum: Array.from({ length: 2000 }, (_, i) => `s${i}`), + }, + }, + required: ["constructor"], + unevaluatedProperties: false, + maxProperties: 5, + }; + const within = await analyze(schema, { + overrideMaxItems: 2000, + offline: true, + }); + ok(!within.some((e) => e.keyword === "maxItems")); + const below = await analyze(schema, { + overrideMaxItems: 1500, + offline: true, + }); + ok(below.some((e) => e.keyword === "maxItems")); + }); + + test("resolves an instancePath segment named '__proto__' (own-property read)", async () => { + // JSON.parse makes "__proto__" a real own data property (not the prototype + // setter), mirroring how a hostile schema reaches the tool. The walk must + // read it as own data and never traverse the real prototype chain. + const enumJson = JSON.stringify( + Array.from({ length: 2000 }, (_, i) => `s${i}`), + ); + const schema = JSON.parse( + `{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"test","type":"object","properties":{"__proto__":{"type":"string","maxLength":50,"enum":${enumJson}}},"required":["__proto__"],"unevaluatedProperties":false,"maxProperties":5}`, + ); + const within = await analyze(schema, { + overrideMaxItems: 2000, + offline: true, + }); + ok(!within.some((e) => e.keyword === "maxItems")); + const below = await analyze(schema, { + overrideMaxItems: 1500, + offline: true, + }); + ok(below.some((e) => e.keyword === "maxItems")); + }); +}); + +// --- resolveInstancePath (direct) --- +// The override filters only ever hand resolveInstancePath a JSON pointer that +// AJV emitted for a real array/object location, so they never exercise its +// defensive guards. These cover the helper directly: a non-walkable root, an +// empty pointer, a mid-walk descent into a non-object, and a missing segment. +describe("resolveInstancePath direct", () => { + test("returns undefined when the root is not a walkable object", () => { + strictEqual(resolveInstancePath(null, "/a"), undefined); + strictEqual(resolveInstancePath(42, "/a"), undefined); + strictEqual(resolveInstancePath("string", "/a"), undefined); + }); + + test("returns the root object for an empty pointer", () => { + const root = { a: 1 }; + strictEqual(resolveInstancePath(root, ""), root); + }); + + test("returns undefined when a mid-walk segment is not an object", () => { + strictEqual(resolveInstancePath({ a: 5 }, "/a/b"), undefined); + }); + + test("returns undefined when a segment is not an own property", () => { + strictEqual(resolveInstancePath({ a: {} }, "/a/missing"), undefined); + }); + + test("walks own properties, unescaping ~1 and ~0 segments", () => { + const root = { "a/b": { "c~d": 7 } }; + strictEqual(resolveInstancePath(root, "/a~1b/c~0d"), 7); + }); +}); + +// --- allErrors completeness --- +// The meta-schema validators compile with allErrors:true so a single pass +// surfaces EVERY violation, not just the first. This locks in that behavior, +// which is the justification for the ajv-allerrors-true nosemgrep in cli.js: +// dropping allErrors would silently hide findings from a security report. +describe("analyze allErrors completeness", () => { + test("reports violations from multiple sibling properties in one pass", async () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "object", + properties: { + a: { type: "string" }, + b: { type: "string" }, + }, + required: ["a", "b"], + unevaluatedProperties: false, + maxProperties: 5, + }; + const errors = await analyze(schema, { offline: true }); + ok( + errors.some((e) => e.instancePath === "/properties/a"), + "expected a violation for property a", + ); + ok( + errors.some((e) => e.instancePath === "/properties/b"), + "expected a violation for property b", + ); + }); }); // --- resolveSSRFRefs private IP detection --- @@ -665,4 +984,947 @@ describe("formatSarif", () => { "", ); }); + + test("rule entry carries id, descriptions and driver metadata", () => { + const errors = [ + { + instancePath: "/properties/name", + schemaPath: "#/maxLength", + keyword: "maxLength", + params: { limit: 100 }, + message: "must have maxLength", + }, + ]; + const driver = formatSarif(errors, "/tmp/schema.json").runs[0].tool.driver; + strictEqual( + driver.informationUri, + "https://github.com/willfarrell/sast-json-schema", + ); + const rule = driver.rules[0]; + strictEqual(rule.id, "maxLength"); + strictEqual(rule.name, "maxLength"); + strictEqual(rule.shortDescription.text, "maxLength"); + strictEqual(rule.fullDescription.text, "must have maxLength"); + strictEqual(rule.defaultConfiguration.level, "error"); + }); + + test("rule fullDescription falls back to the ruleId when message is absent", () => { + const errors = [{ schemaPath: "#/maxLength", keyword: "maxLength" }]; + const rule = formatSarif(errors, "/tmp/schema.json").runs[0].tool.driver + .rules[0]; + strictEqual(rule.fullDescription.text, "maxLength"); + }); + + test("rule id is 'unknown' in the rule table when schemaPath and keyword are absent", () => { + const errors = [{ instancePath: "/", message: "no rule" }]; + const rule = formatSarif(errors, "/tmp/schema.json").runs[0].tool.driver + .rules[0]; + strictEqual(rule.id, "unknown"); + }); + + test("repeated ruleId is de-duplicated, keeping the first occurrence", () => { + const errors = [ + { schemaPath: "#/maxLength", keyword: "maxLength", message: "first" }, + { schemaPath: "#/maxLength", keyword: "maxLength", message: "second" }, + ]; + const driver = formatSarif(errors, "/tmp/schema.json").runs[0].tool.driver; + strictEqual(driver.rules.length, 1); + strictEqual(driver.rules[0].id, "maxLength"); + // First-wins: re-adding would overwrite fullDescription with "second". + strictEqual(driver.rules[0].fullDescription.text, "first"); + }); + + test("ruleId is the first schemaPath segment, not the keyword, when both exist", () => { + const errors = [ + { schemaPath: "#/required/0", keyword: "required", message: "m" }, + ]; + const sarif = formatSarif(errors, "/tmp/schema.json"); + // `... || err.keyword` only fires when the segment is empty; here it is + // "required" from the path, which happens to differ from a keyword test. + strictEqual(sarif.runs[0].results[0].ruleId, "required"); + const errors2 = [ + { schemaPath: "#/properties/x", keyword: "kw", message: "m" }, + ]; + const sarif2 = formatSarif(errors2, "/tmp/schema.json"); + strictEqual(sarif2.runs[0].results[0].ruleId, "properties"); + // Same derivation in the rule table: must be the path segment, not keyword. + strictEqual(sarif2.runs[0].tool.driver.rules[0].id, "properties"); + }); + + test("ruleId strips only a leading '#/' from schemaPath", () => { + // A non-leading "#/" must be preserved: the strip is anchored to the start, + // so "foo#/bar" keeps its first segment "foo#" rather than collapsing to + // "foobar". Exercises both the rule-table and the per-result derivations. + const errors = [{ schemaPath: "foo#/bar", keyword: "kw", message: "m" }]; + const sarif = formatSarif(errors, "/tmp/schema.json"); + strictEqual(sarif.runs[0].results[0].ruleId, "foo#"); + strictEqual(sarif.runs[0].tool.driver.rules[0].id, "foo#"); + }); + + test("top-level $schema is the SARIF 2.1.0 schema URL", () => { + const sarif = formatSarif([{ keyword: "x" }], "/tmp/schema.json"); + strictEqual( + sarif.$schema, + "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/Schemata/sarif-schema-2.1.0.json", + ); + }); + + test("result location carries the input file URI and value kind", () => { + const errors = [ + { instancePath: "/a", schemaPath: "#/maxLength", keyword: "maxLength" }, + ]; + const loc = formatSarif(errors, "/tmp/schema.json").runs[0].results[0] + .locations[0]; + ok(loc.physicalLocation.artifactLocation.uri.startsWith("file://")); + ok(loc.physicalLocation.artifactLocation.uri.endsWith("/tmp/schema.json")); + strictEqual(loc.logicalLocations[0].kind, "value"); + }); + + test("artifactLocation uri is repo-relative with a SRCROOT uriBaseId when the input is under cwd", () => { + const errors = [ + { instancePath: "/a", schemaPath: "#/maxLength", keyword: "maxLength" }, + ]; + const sarif = formatSarif(errors, "/repo/schemas/api.json", "/repo"); + const loc = sarif.runs[0].results[0].locations[0]; + strictEqual(loc.physicalLocation.artifactLocation.uri, "schemas/api.json"); + strictEqual(loc.physicalLocation.artifactLocation.uriBaseId, "SRCROOT"); + strictEqual( + sarif.runs[0].originalUriBaseIds.SRCROOT.uri, + pathToFileURL("/repo/").href, + ); + }); + + test("relative artifactLocation uri has no leading './' for a file directly in cwd", () => { + const errors = [ + { instancePath: "/a", schemaPath: "#/maxLength", keyword: "maxLength" }, + ]; + const sarif = formatSarif(errors, "/repo/api.json", "/repo"); + strictEqual( + sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation + .uri, + "api.json", + ); + }); + + test("originalUriBaseIds SRCROOT uri is the cwd as a file:// directory URI", () => { + const errors = [{ instancePath: "/a", keyword: "x" }]; + const sarif = formatSarif(errors, "/repo/sub/x.json", "/repo"); + strictEqual( + sarif.runs[0].originalUriBaseIds.SRCROOT.uri, + pathToFileURL("/repo/").href, + ); + ok(sarif.runs[0].originalUriBaseIds.SRCROOT.uri.endsWith("/repo/")); + }); + + test("input path equal to cwd (empty relative path) falls back to the absolute file:// uri", () => { + // resolve(inputPath) === cwd makes relative() return "", which must NOT be + // treated as inside-cwd (an empty uri is not a usable artifact location); + // it falls back to the absolute file:// uri with no uriBaseId. + const errors = [{ instancePath: "/a", keyword: "x" }]; + const sarif = formatSarif(errors, "/repo", "/repo"); + const loc = sarif.runs[0].results[0].locations[0]; + strictEqual( + loc.physicalLocation.artifactLocation.uri, + pathToFileURL("/repo").href, + ); + strictEqual(loc.physicalLocation.artifactLocation.uriBaseId, undefined); + strictEqual(sarif.runs[0].originalUriBaseIds, undefined); + }); + + test("input outside cwd falls back to the absolute file:// uri with no uriBaseId or originalUriBaseIds", () => { + const errors = [ + { instancePath: "/a", schemaPath: "#/maxLength", keyword: "maxLength" }, + ]; + const sarif = formatSarif(errors, "/elsewhere/api.json", "/repo"); + const loc = sarif.runs[0].results[0].locations[0]; + strictEqual( + loc.physicalLocation.artifactLocation.uri, + pathToFileURL("/elsewhere/api.json").href, + ); + ok(loc.physicalLocation.artifactLocation.uri.startsWith("file://")); + strictEqual(loc.physicalLocation.artifactLocation.uriBaseId, undefined); + strictEqual(sarif.runs[0].originalUriBaseIds, undefined); + }); + + test("result properties carry schemaPath, keyword and spread params", () => { + const errors = [ + { + instancePath: "/properties/name", + schemaPath: "#/maxLength", + keyword: "maxLength", + params: { limit: 100 }, + message: "must have maxLength", + }, + ]; + const props = formatSarif(errors, "/tmp/schema.json").runs[0].results[0] + .properties; + strictEqual(props.schemaPath, "#/maxLength"); + strictEqual(props.keyword, "maxLength"); + strictEqual(props.instancePath, "/properties/name"); + strictEqual(props.limit, 100); + }); + + test("result properties default schemaPath and keyword to empty strings", () => { + const errors = [{ instancePath: "/", message: "bare" }]; + const props = formatSarif(errors, "/tmp/schema.json").runs[0].results[0] + .properties; + strictEqual(props.schemaPath, ""); + strictEqual(props.keyword, ""); + }); +}); + +// Boundary and valid-value coverage for the integer option validators +// (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)). Existing tests cover +// non-numeric/non-integer; these add the valid, zero and negative-integer cases. +describe("analyze integer option validators", () => { + const tiny = { type: "string" }; + + test("maxHostnames: a valid positive integer is accepted", async () => { + ok( + Array.isArray(await analyze(tiny, { offline: true, maxHostnames: 256 })), + ); + }); + test("maxHostnames: zero is a valid non-negative integer", async () => { + ok(Array.isArray(await analyze(tiny, { offline: true, maxHostnames: 0 }))); + }); + test("maxHostnames: a negative integer throws TypeError", async () => { + try { + await analyze(tiny, { offline: true, maxHostnames: -5 }); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes("maxHostnames")); + ok(err.message.includes("non-negative integer")); + } + }); + + test("dnsTotalTimeoutMs: a valid positive integer is accepted", async () => { + ok( + Array.isArray( + await analyze(tiny, { offline: true, dnsTotalTimeoutMs: 30000 }), + ), + ); + }); + test("dnsTotalTimeoutMs: zero is a valid non-negative integer", async () => { + ok( + Array.isArray( + await analyze(tiny, { offline: true, dnsTotalTimeoutMs: 0 }), + ), + ); + }); + + test("maxSchemaSize: a large valid integer is accepted", async () => { + ok( + Array.isArray( + await analyze(tiny, { offline: true, maxSchemaSize: 1_000_000 }), + ), + ); + }); + test("maxSchemaSize: zero is valid (no TypeError) and hits the size guard instead", async () => { + // 0 passes the n>=0 validator, so the failure must be the RangeError size + // guard, not the TypeError validator (kills n<0 -> n<=0). + try { + await analyze(tiny, { offline: true, maxSchemaSize: 0 }); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof RangeError, `expected RangeError, got ${err.name}`); + ok(err.message.includes("size")); + } + }); + test("maxSchemaSize: a negative integer throws TypeError (not the size guard)", async () => { + try { + await analyze(tiny, { offline: true, maxSchemaSize: -5 }); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError, `expected TypeError, got ${err.name}`); + ok(err.message.includes("maxSchemaSize")); + } + }); +}); + +// The size guard uses a strict `>`; a schema serialized to exactly the limit must +// be accepted (kills the `> sizeLimit` -> `>= sizeLimit` mutation). +describe("analyze size guard boundary", () => { + test("schema serialized to exactly maxSchemaSize is accepted", async () => { + const schema = { type: "string" }; + const exact = Buffer.byteLength(JSON.stringify(schema)); + ok( + Array.isArray( + await analyze(schema, { offline: true, maxSchemaSize: exact }), + ), + ); + }); + test("schema one byte over the limit is rejected", async () => { + const schema = { type: "string" }; + const exact = Buffer.byteLength(JSON.stringify(schema)); + try { + await analyze(schema, { offline: true, maxSchemaSize: exact - 1 }); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof RangeError); + } + }); +}); + +// The depth-exceeded short-circuit returns one synthetic finding; pin its full +// shape so blanking any field is caught. +describe("analyze depth-exceeded payload", () => { + test("depth-exceeded returns the depth finding with full shape", async () => { + const schema = { + type: "object", + properties: { + a: { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + }, + }; + const errors = await analyze(schema, { + offline: true, + overrideMaxDepth: 0, + }); + strictEqual(errors.length, 1); + const e = errors[0]; + strictEqual(e.instancePath, ""); + strictEqual(e.schemaPath, "#/depth"); + strictEqual(e.keyword, "depth"); + strictEqual(e.params.limit, 0); + ok(typeof e.params.depth === "number"); + strictEqual(e.message, "must NOT have depth greater than 0"); + }); +}); + +// Full error-shape and boundary locks for resolveSSRFRefs. Existing tests assert +// keyword + a message substring; these pin instancePath, schemaPath and params so +// blanking a field (a mutation) is caught, plus the no-DNS cap/budget boundaries. +describe("resolveSSRFRefs error shapes and boundaries", () => { + const ref = (hostname, path = "/$ref") => ({ + hostname, + ref: `https://${hostname}/schema.json`, + path, + }); + + test("unresolvable hostname error has full shape", async () => { + const errors = await resolveSSRFRefs([ref("nx-host-abc987.invalid")], { + dnsTimeoutMs: 100, + }); + strictEqual(errors.length, 1); + const e = errors[0]; + strictEqual(e.instancePath, "/$ref"); + strictEqual(e.schemaPath, "#/ssrf"); + strictEqual(e.params.ref, "https://nx-host-abc987.invalid/schema.json"); + strictEqual(e.params.hostname, "nx-host-abc987.invalid"); + strictEqual( + e.message, + '$ref hostname "nx-host-abc987.invalid" does not resolve', + ); + }); + + test("private-resolving hostname (localhost) error has full shape", async () => { + const errors = await resolveSSRFRefs([ref("localhost")], { + dnsTimeoutMs: 5_000, + }); + strictEqual(errors.length, 1); + const e = errors[0]; + strictEqual(e.instancePath, "/$ref"); + strictEqual(e.schemaPath, "#/ssrf"); + strictEqual(e.params.hostname, "localhost"); + ok(e.params.resolvedIP, "must include the resolved private IP"); + ok(e.message.includes("resolves to private IP")); + }); + + test("a public hostname produces no findings at all", async () => { + // Not just zero resolvedIP findings: dropping the `if (!privateAddr) return []` + // guard would synthesize spurious findings, so assert a fully empty result. + const errors = await resolveSSRFRefs([ref("dns.google")], { + dnsTimeoutMs: 5_000, + }); + strictEqual(errors.length, 0); + }); + + test("hostname-cap finding has full shape", async () => { + const refs = Array.from({ length: 3 }, (_, i) => ref(`cap${i}.invalid`)); + const errors = await resolveSSRFRefs(refs, { maxHostnames: 2 }); + strictEqual(errors.length, 1); + const e = errors[0]; + strictEqual(e.instancePath, ""); + strictEqual(e.schemaPath, "#/ssrf"); + strictEqual(e.params.hostnames, 3); + strictEqual(e.params.limit, 2); + ok(e.message.includes("too many distinct remote $ref hostnames (3)")); + ok(e.message.includes("above 2")); + }); + + test("exactly maxHostnames distinct hosts is under the cap (strict >)", async () => { + // 2 distinct hosts with maxHostnames 2: must NOT trip the cap. Use + // dnsTotalTimeoutMs:0 so it fails closed on the budget rather than doing DNS. + const refs = [ref("a.invalid"), ref("b.invalid")]; + const errors = await resolveSSRFRefs(refs, { + maxHostnames: 2, + dnsTotalTimeoutMs: 0, + }); + ok( + !errors.some((e) => e.message.includes("too many distinct")), + "exactly-at-cap must not be reported as over the cap", + ); + ok( + errors.every((e) => e.message.includes("budget")), + "should fall through to the budget-exceeded path", + ); + }); + + test("budget-exceeded finding has full shape", async () => { + const errors = await resolveSSRFRefs([ref("budget.invalid")], { + dnsTotalTimeoutMs: 0, + }); + strictEqual(errors.length, 1); + const e = errors[0]; + strictEqual(e.instancePath, "/$ref"); + strictEqual(e.schemaPath, "#/ssrf"); + strictEqual(e.params.hostname, "budget.invalid"); + strictEqual(e.params.ref, "https://budget.invalid/schema.json"); + ok(e.message.includes("SSRF DNS budget exceeded")); + }); +}); + +// --- injectable monotonic clock: total-budget deadline at a batch index > 0 --- +// resolveSSRFRefs reads the clock through options.now (defaulting to Date.now), +// mirroring crawlSchema. With dnsConcurrency:1 each distinct host is its own batch, +// so an injected clock can let the FIRST batch resolve and then expire the budget +// before a LATER batch. That makes batches.slice(i) a proper subset (i > 0) and +// lets us pin the deadline boundary exactly. The hosts are RFC 6761 `.invalid` +// names that never resolve, so the few lookups that do run fail fast and offline. +describe("resolveSSRFRefs injected clock (budget at batch index > 0)", () => { + const ref = (hostname, path = "/$ref") => ({ + hostname, + ref: `https://${hostname}/schema.json`, + path, + }); + const stepClock = (...values) => { + let i = 0; + return () => values[Math.min(i++, values.length - 1)]; + }; + + // D-(i): the budget expires at batch index 1, not 0. now() reads: #1 sets the + // deadline (0 + 100 = 100); #2 (i=0) = 0 -> under, so host0 is resolved via DNS; + // #3 (i=1) = 200 -> over, so the loop bails with batches.slice(1). Only host1 + // (the second batch) is reported budget-exceeded; host0 got a real DNS finding. + // Kills the MethodExpression: slice() / slice(0) would mark BOTH hosts as + // budget-exceeded (and never DNS-resolve host0). + test("budget expiring at batch index 1 only skips the LATER batch (slice(i) subset)", async () => { + const errors = await resolveSSRFRefs( + [ + ref("first-host-aaa.invalid", "/a/$ref"), + ref("second-host-bbb.invalid", "/b/$ref"), + ], + { + dnsConcurrency: 1, + dnsTimeoutMs: 100, + dnsTotalTimeoutMs: 100, + now: stepClock(0, 0, 200), + }, + ); + strictEqual(errors.length, 2, "one finding per host"); + const first = errors.find( + (e) => e.params.hostname === "first-host-aaa.invalid", + ); + const second = errors.find( + (e) => e.params.hostname === "second-host-bbb.invalid", + ); + ok(first, "the first host must have a finding"); + ok(second, "the second host must have a finding"); + // host0 was resolved (DNS ran): a `.invalid` host does not resolve, so it is a + // "does not resolve" finding WITHOUT the incomplete marker. + ok( + first.message.includes("does not resolve"), + "the first batch must have been DNS-resolved, not skipped", + ); + strictEqual( + first.params.incomplete, + undefined, + "resolved host is not incomplete", + ); + // host1 was skipped because the budget expired: budget-exceeded + incomplete. + ok( + second.message.includes("SSRF DNS budget exceeded"), + "the later batch must be skipped as budget-exceeded", + ); + strictEqual( + second.params.incomplete, + true, + "skipped host is marked incomplete", + ); + }); + + // D-(ii): the budget boundary is EXCLUSIVE. now() reads: #1 sets the deadline + // (0 + 100 = 100); every later read returns EXACTLY 100, so `100 > 100` is false + // and the loop NEVER bails. Both `.invalid` hosts are therefore DNS-resolved (no + // budget-exceeded findings). Kills the EqualityOperator `>`->`>=` (which would + // bail at the boundary and mark both hosts budget-exceeded). + test("a clock exactly at the overall deadline does not bail (> is exclusive)", async () => { + const errors = await resolveSSRFRefs( + [ref("boundary-aaa.invalid"), ref("boundary-bbb.invalid")], + { + dnsConcurrency: 1, + dnsTimeoutMs: 100, + dnsTotalTimeoutMs: 100, + now: stepClock(0, 100, 100, 100), + }, + ); + strictEqual(errors.length, 2, "both hosts produce a finding"); + ok( + errors.every((e) => e.message.includes("does not resolve")), + "at the exclusive boundary both hosts must be DNS-resolved, none skipped", + ); + ok( + !errors.some((e) => e.params.incomplete === true), + "no host may be marked budget-exceeded at the boundary", + ); + }); + + // analyze() threads options.now through to BOTH crawlSchema and resolveSSRFRefs. + // A monotonic counter clock is injected: crawlSchema reads it during its crawl + // (its deadline is the real Date.now()+60s, so these tiny readings never trip the + // crawl timeout), and resolveSSRFRefs then reads the SAME injected clock for the + // SSRF budget. With dnsTotalTimeoutMs:0 the overall deadline is 0, and by the time + // the SSRF loop runs the counter has already advanced past 0, so `now() > 0` is + // true and both remote hosts are skipped as budget-exceeded. A forced-constant + // (equivalent) clock could not produce this, proving options.now flows end to end. + test("analyze threads options.now into the SSRF budget end to end", async () => { + let tick = 0; + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $defs: { + a: { $ref: "https://e2e-first-aaa.invalid/s.json" }, + b: { $ref: "https://e2e-second-bbb.invalid/s.json" }, + }, + }; + const errors = await analyze(schema, { + dnsConcurrency: 1, + dnsTimeoutMs: 100, + dnsTotalTimeoutMs: 0, + now: () => ++tick, + }); + ok(tick > 0, "the injected clock must have been read"); + const remote = errors.filter((e) => e.params?.incomplete === true); + strictEqual( + remote.length, + 2, + "both remote hosts are skipped as budget-exceeded", + ); + ok( + remote.every((e) => e.message.includes("SSRF DNS budget exceeded")), + "both must be budget-exceeded via the injected clock", + ); + }); +}); + +// Additional resolveInstancePath guards the existing direct tests miss: an empty +// pointer with a non-object root (the root guard must win over the !pointer +// shortcut), and a null encountered mid-walk (the guard must return before +// Object.hasOwn(null) throws). +describe("resolveInstancePath extra guards", () => { + test("non-object root with an empty pointer is still undefined", () => { + strictEqual(resolveInstancePath(null, ""), undefined); + strictEqual(resolveInstancePath("string", ""), undefined); + strictEqual(resolveInstancePath(42, ""), undefined); + }); + + test("a null value mid-walk returns undefined (does not throw)", () => { + strictEqual(resolveInstancePath({ a: null }, "/a/b"), undefined); + }); + + test("does not walk into a string value by index", () => { + // {a:"str"} then "/a/0": the typeof guard must stop the walk, not read s[0]. + strictEqual(resolveInstancePath({ a: "str" }, "/a/0"), undefined); + }); + + test("does not resolve inherited prototype keys", () => { + // Only own keys are walked; a pointer to an inherited member must be undefined. + strictEqual(resolveInstancePath({}, "/toString"), undefined); + strictEqual(resolveInstancePath({ a: {} }, "/a/constructor"), undefined); + }); +}); + +// The SSRF check is gated on `!options.offline`. Use dnsTotalTimeoutMs:0 so the +// SSRF path fails closed on the budget (no real DNS): a remote $ref then yields +// an ssrf finding only when SSRF actually runs. +describe("analyze offline gate", () => { + const remoteRefSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $ref: "https://ssrf-gate-test.invalid/s.json", + }; + + test("a remote $ref is SSRF-checked when offline is not set", async () => { + const errors = await analyze(remoteRefSchema, { dnsTotalTimeoutMs: 0 }); + ok( + errors.some((e) => e.keyword === "ssrf"), + "non-offline analysis must run the SSRF check", + ); + }); + + test("offline:true skips the SSRF check entirely", async () => { + const errors = await analyze(remoteRefSchema, { + offline: true, + dnsTotalTimeoutMs: 0, + }); + strictEqual( + errors.filter((e) => e.keyword === "ssrf").length, + 0, + "offline must skip SSRF even when a remote $ref is present", + ); + }); +}); + +// A2: the two SSRF "incomplete-analysis" findings (the hostname-cap finding and +// the DNS-total-budget finding) mean DNS resolution was skipped, so suppressing +// them would falsely report a partially-analyzed schema as clean. They must NOT +// be droppable by --ignore, exactly like the depth/timeout findings. Normal +// per-host ssrf findings (resolves-to-private / does-not-resolve) SHOULD remain +// ignorable. +describe("analyze SSRF incomplete-analysis findings are not suppressible", () => { + // Builds a schema with `n` distinct remote $ref hostnames so the hostname cap + // can be tripped with a small maxHostnames. + const manyHostSchema = (n) => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $defs: {}, + }; + for (let i = 0; i < n; i++) { + schema.$defs[`d${i}`] = { $ref: `https://cap-host-${i}.invalid/s.json` }; + } + return schema; + }; + + test('hostname-cap finding survives --ignore "" (cap finding marked incomplete)', async () => { + const schema = manyHostSchema(5); + const errors = await analyze(schema, { + maxHostnames: 2, + // "" matches the cap finding's empty instancePath; it must NOT suppress it. + ignore: [""], + }); + const cap = errors.find((e) => e.message.includes("too many distinct")); + ok(cap, 'hostname-cap finding must survive --ignore ""'); + strictEqual(cap.params.incomplete, true); + }); + + test("DNS-budget finding survives --ignore ", async () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $ref: "https://budget-ignore-test.invalid/s.json", + }; + const errors = await analyze(schema, { + dnsTotalTimeoutMs: 0, + // the budget finding's instancePath is the ref path "/$ref". + ignore: ["/$ref"], + }); + const budget = errors.find((e) => + e.message.includes("SSRF DNS budget exceeded"), + ); + ok(budget, "DNS-budget finding must survive --ignore on its ref path"); + strictEqual(budget.params.incomplete, true); + }); + + test("a normal per-host ssrf finding IS still suppressible by its instancePath", async () => { + // localhost resolves to a private IP -> a normal (suppressible) ssrf finding + // at instancePath "/$ref". Ignoring that path must drop it. + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + $ref: "https://localhost/s.json", + }; + const without = await analyze(schema, { dnsTimeoutMs: 5_000 }); + ok( + without.some((e) => e.keyword === "ssrf"), + "baseline: localhost ssrf finding is present", + ); + const ignored = await analyze(schema, { + dnsTimeoutMs: 5_000, + ignore: ["/$ref"], + }); + strictEqual( + ignored.filter((e) => e.keyword === "ssrf").length, + 0, + "a normal per-host ssrf finding must remain suppressible", + ); + }); +}); + +// The override filters must only suppress their OWN finding type and keep every +// other finding (the `return true` for non-target errors), and must use a strict +// `>` so an instance exactly at the override limit is suppressed. +describe("analyze override filters keep unrelated findings", () => { + const dangerous = JSON.parse( + '{"properties":{"__proto__":{"type":"string","maxLength":10,"pattern":"^[a-z]+$"}},"required":["__proto__"],"maxProperties":5,"unevaluatedProperties":false}', + ); + + test("overrideMaxItems does not drop a dangerous-name finding", async () => { + const errors = await analyze(dangerous, { + offline: true, + overrideMaxItems: 100, + }); + ok( + errors.some((e) => e.schemaPath === "#/dangerous-name"), + "a non-maxItems finding must survive the overrideMaxItems filter", + ); + }); + + test("overrideMaxProperties does not drop a dangerous-name finding", async () => { + const errors = await analyze(dangerous, { + offline: true, + overrideMaxProperties: 100, + }); + ok( + errors.some((e) => e.schemaPath === "#/dangerous-name"), + "a non-maxProperties finding must survive the overrideMaxProperties filter", + ); + }); + + test("overrideMaxProperties suppresses at exactly the property count (strict >)", async () => { + const props = {}; + for (let i = 0; i < 1100; i++) + props[`p${i}`] = { type: "string", maxLength: 10, pattern: "^[a-z]+$" }; + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "object", + properties: props, + required: ["p0"], + unevaluatedProperties: false, + maxProperties: 2000, + }; + // exactly 1100 properties; override of 1100 means 1100 > 1100 is false, so + // the finding is suppressed. `>=` would keep it. + const errors = await analyze(schema, { + offline: true, + overrideMaxProperties: 1100, + }); + ok(!errors.some((e) => e.keyword === "maxProperties")); + }); + + test("overrideMaxItems suppresses at exactly the array length (strict >)", async () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 100, + enum: Array.from({ length: 2000 }, (_, i) => `v${i}`), + }; + const errors = await analyze(schema, { + offline: true, + overrideMaxItems: 2000, + }); + ok(!errors.some((e) => e.keyword === "maxItems")); + }); +}); + +// Valid/zero/negative cases for the override integer validators (existing tests +// cover only non-numeric/non-integer). +describe("analyze override validator boundaries", () => { + const tiny = { type: "string" }; + for (const opt of ["overrideMaxItems", "overrideMaxProperties"]) { + test(`${opt}: a valid positive integer is accepted`, async () => { + ok(Array.isArray(await analyze(tiny, { offline: true, [opt]: 100 }))); + }); + test(`${opt}: zero is a valid non-negative integer`, async () => { + ok(Array.isArray(await analyze(tiny, { offline: true, [opt]: 0 }))); + }); + test(`${opt}: a negative integer throws TypeError`, async () => { + try { + await analyze(tiny, { offline: true, [opt]: -5 }); + ok(false, "should have thrown"); + } catch (err) { + ok(err instanceof TypeError); + ok(err.message.includes(opt)); + ok(err.message.includes("non-negative integer")); + } + }); + } +}); + +// Cover the deadline/maxDepth defaults and the keep-side of the override filter. +describe("analyze depth/timeout defaults and override keep-side", () => { + test("a schema deeper than the default MAX_DEPTH is flagged (default maxDepth used)", async () => { + let deep = { type: "string", maxLength: 10, pattern: "^[a-z]+$" }; + for (let i = 0; i < 40; i++) { + deep = { + type: "object", + properties: { a: deep }, + required: ["a"], + unevaluatedProperties: false, + maxProperties: 5, + }; + } + const errors = await analyze(deep, { offline: true }); + ok( + errors.some((e) => e.keyword === "depth"), + "deep schema must trip the default depth limit", + ); + }); + + test("a large analysisTimeoutMs does not immediately time out", async () => { + // ms > 0 means the deadline is in the future; the ms<=0 short-circuit to 0 + // (immediate timeout) must not fire. + const errors = await analyze( + { type: "string", maxLength: 10, pattern: "^[a-z]+$" }, + { offline: true, analysisTimeoutMs: 600000 }, + ); + ok(!errors.some((e) => e.keyword === "timeout")); + }); + + test("overrideMaxProperties below the property count keeps the maxProperties finding", async () => { + const props = {}; + for (let i = 0; i < 1100; i++) + props[`p${i}`] = { type: "string", maxLength: 10, pattern: "^[a-z]+$" }; + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "object", + properties: props, + required: ["p0"], + unevaluatedProperties: false, + maxProperties: 2000, + }; + const errors = await analyze(schema, { + offline: true, + overrideMaxProperties: 500, + }); + ok( + errors.some((e) => e.keyword === "maxProperties"), + "1100 properties still exceeds an override of 500", + ); + }); +}); + +// A3: resolving attacker-controlled hostnames from an untrusted schema is a +// blind-SSRF / DNS-exfil amplifier. run() must emit a one-line notice to STDERR +// (never STDOUT, to keep json/sarif output clean) whenever it is about to do DNS +// resolution, i.e. NOT --offline AND the schema actually has remote $ref(s). +describe("run() SSRF DNS notice (A3)", () => { + const runCli = async (argv, files = {}) => { + const out = { log: [], error: [], write: [] }; + const io = { + log: (m) => out.log.push(String(m)), + error: (m) => out.error.push(String(m)), + write: (s) => out.write.push(String(s)), + readFile: async (p) => { + const key = Object.keys(files).find((k) => p.endsWith(k)); + if (key === undefined) throw new Error(`ENOENT ${p}`); + return files[key]; + }, + stat: async (p) => { + const key = Object.keys(files).find((k) => p.endsWith(k)); + if (key === undefined) throw new Error(`ENOENT ${p}`); + return { size: files[key].length }; + }, + }; + const code = await run(argv, io); + return { code, ...out }; + }; + + const REMOTE = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/remote.json", + $ref: "https://a3-notice-host.invalid/x.json", + }); + const TWO_REMOTE = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/remote.json", + $defs: { + a: { $ref: "https://a3-h1.invalid/x.json" }, + b: { $ref: "https://a3-h2.invalid/x.json" }, + }, + }); + const CLEAN = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/clean.json", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }); + + test("emits the DNS notice to STDERR (not STDOUT) when remote refs exist and not offline", async () => { + const r = await runCli( + ["s.json", "--dns-total-timeout-ms", "0", "--format", "json"], + { "s.json": REMOTE }, + ); + ok( + r.error.some((m) => m.includes("resolving") && m.includes("DNS")), + "a DNS notice must be written to error (stderr)", + ); + ok( + r.error.some((m) => m.includes("--offline")), + "the notice should mention --offline as the opt-out", + ); + // Must NOT pollute stdout (json/sarif consumers parse it). + ok( + !r.write.some((s) => s.includes("resolving")), + "the notice must never go to stdout/write", + ); + ok( + !r.log.some((m) => m.includes("resolving")), + "the notice must never go to the stdout logger", + ); + }); + + test("the notice reports the count of remote ref hostnames", async () => { + const r = await runCli( + ["s.json", "--dns-total-timeout-ms", "0", "--format", "json"], + { "s.json": TWO_REMOTE }, + ); + const notice = r.error.find((m) => m.includes("resolving")); + ok(notice, "expected a DNS notice"); + ok(notice.includes("2"), `expected count 2 in the notice: ${notice}`); + }); + + test("a safe-listed hostname is NOT counted in the DNS notice", async () => { + // Two remote ref hosts; one (a3-h1) is marked safe via -r (its $id hostname). + // The notice must count only the UNSAFE host, so it reports 1, not 2. This + // pins the `if (!safeHostnames.has(hostname))` guard: forcing it true would + // count the safe host too and report 2. + const refSchema = JSON.stringify({ + $id: "https://a3-h1.invalid/ref.json", + }); + const r = await runCli( + [ + "s.json", + "--dns-total-timeout-ms", + "0", + "--format", + "json", + "-r", + "ref.json", + ], + { "s.json": TWO_REMOTE, "ref.json": refSchema }, + ); + const notice = r.error.find((m) => m.includes("resolving")); + ok(notice, "expected a DNS notice for the remaining unsafe host"); + ok( + notice.includes("1"), + `safe host must be excluded from the count: ${notice}`, + ); + ok( + !notice.includes("2"), + `the safe-listed host must not be counted: ${notice}`, + ); + }); + + test("is absent under --offline", async () => { + const r = await runCli(["s.json", "--offline", "--format", "json"], { + "s.json": REMOTE, + }); + ok( + !r.error.some((m) => m.includes("resolving")), + "no DNS notice when --offline is set", + ); + }); + + test("is absent when the schema has no remote refs", async () => { + const r = await runCli(["s.json", "--format", "json"], { "s.json": CLEAN }); + ok( + !r.error.some((m) => m.includes("resolving")), + "a clean schema with no remote refs stays quiet", + ); + }); }); diff --git a/tests/cli.crawl.test.js b/tests/cli.crawl.test.js index 1ae2643..dfa339f 100644 --- a/tests/cli.crawl.test.js +++ b/tests/cli.crawl.test.js @@ -1,6 +1,11 @@ -import { ok, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, strictEqual } from "node:assert"; import { describe, test } from "node:test"; -import { crawlSchema } from "../cli.js"; +import { + crawlSchema, + MAX_COLLECTED_REFS, + MAX_REDOS_PATTERNS, + REDOS_HEAP_BUDGET_BYTES, +} from "../cli.js"; describe("crawlSchema", () => { test("should return empty result for null input", () => { @@ -231,7 +236,7 @@ describe("crawlSchema", () => { // --- range consistency: NaN/Infinity edge cases --- test("should ignore NaN minimum", () => { const r = crawlSchema({ type: "integer", minimum: NaN, maximum: 5 }); - ok(!r.errors.some((e) => e.keyword === "minimum")); + strictEqual(r.errors.length, 0); }); test("should ignore Infinity exclusiveMinimum", () => { @@ -240,7 +245,10 @@ describe("crawlSchema", () => { exclusiveMinimum: Infinity, maximum: 100, }); - ok(!r.errors.some((e) => e.keyword === "minimum")); + // A non-finite bound must be ignored entirely: dropping the Number.isFinite + // guard would treat exclusiveMinimum:Infinity as the effective minimum and + // emit a spurious (exclusiveMinimum) range error, so assert no errors at all. + strictEqual(r.errors.length, 0); }); test("should ignore NaN exclusiveMaximum", () => { @@ -249,7 +257,9 @@ describe("crawlSchema", () => { minimum: 0, exclusiveMaximum: NaN, }); - ok(!r.errors.some((e) => e.keyword === "minimum")); + // Dropping the finiteness guard would make exclusiveMaximum:NaN the + // effective (exclusive) maximum and emit a bogus range error. + strictEqual(r.errors.length, 0); }); test("should ignore -Infinity maximum", () => { @@ -258,7 +268,92 @@ describe("crawlSchema", () => { minimum: 0, maximum: -Infinity, }); - ok(!r.errors.some((e) => e.keyword === "minimum")); + strictEqual(r.errors.length, 0); + }); + + // --- range consistency: exclusive/inclusive boundary tie-breaks --- + // When exclusiveMinimum === minimum, the exclusive bound is the effective one + // (>= picks exclusive). With maximum also at that value the range is empty. + test("should treat exclusiveMinimum === minimum as the exclusive bound", () => { + const r = crawlSchema({ + type: "number", + minimum: 5, + exclusiveMinimum: 5, + maximum: 5, + }); + ok( + r.errors.some((e) => e.keyword === "exclusiveMinimum"), + "exclusiveMinimum tie must win and make [5,5) empty", + ); + }); + + // When exclusiveMaximum === maximum, the exclusive bound wins (<= picks it), + // so (5,5] collapses to an empty range. + test("should treat exclusiveMaximum === maximum as the exclusive bound", () => { + const r = crawlSchema({ + type: "number", + minimum: 5, + maximum: 5, + exclusiveMaximum: 5, + }); + ok( + r.errors.some((e) => e.keyword === "exclusiveMaximum"), + "exclusiveMaximum tie must win and make (5,5] empty", + ); + }); + + // --- range error payload (params + message) --- + test("inclusive impossible range reports both bounds and the message", () => { + const r = crawlSchema({ type: "number", minimum: 10, maximum: 5 }); + const e = r.errors.find((err) => err.keyword === "minimum"); + ok(e, "expected a minimum range error"); + strictEqual(e.params.minimum, 10); + strictEqual(e.params.maximum, 5); + strictEqual(e.message, "numeric range is unsatisfiable"); + }); + + test("exclusive impossible range reports both exclusive bounds", () => { + const r = crawlSchema({ + type: "number", + exclusiveMinimum: 10, + exclusiveMaximum: 5, + }); + const e = r.errors.find((err) => err.keyword === "exclusiveMaximum"); + ok(e, "expected an exclusiveMaximum range error"); + strictEqual(e.params.exclusiveMinimum, 10); + strictEqual(e.params.exclusiveMaximum, 5); + }); + + // --- bound type guards: non-number bounds must be ignored entirely --- + // A string bound must not be coerced into a comparison. Each case is impossible + // only if the typeof guard is dropped (&&→|| would let the string through and + // numeric coercion would then fabricate a range error), so assert no errors. + test("should ignore a string minimum", () => { + const r = crawlSchema({ type: "number", minimum: "10", maximum: 5 }); + strictEqual(r.errors.length, 0); + }); + + test("should ignore a string maximum", () => { + const r = crawlSchema({ type: "number", minimum: 10, maximum: "5" }); + strictEqual(r.errors.length, 0); + }); + + test("should ignore a string exclusiveMinimum", () => { + const r = crawlSchema({ + type: "number", + exclusiveMinimum: "10", + maximum: 5, + }); + strictEqual(r.errors.length, 0); + }); + + test("should ignore a string exclusiveMaximum", () => { + const r = crawlSchema({ + type: "number", + minimum: 10, + exclusiveMaximum: "5", + }); + strictEqual(r.errors.length, 0); }); // --- circular reference protection --- @@ -443,6 +538,30 @@ describe("crawlSchema", () => { strictEqual(r.refs.length, 0); }); + // --- $dynamicRef collection (mirrors $ref) --- + test("should collect remote $dynamicRef URLs", () => { + const r = crawlSchema({ $dynamicRef: "https://internal.host/schema.json" }); + strictEqual(r.refs.length, 1); + strictEqual(r.refs[0].hostname, "internal.host"); + strictEqual(r.refs[0].ref, "https://internal.host/schema.json"); + strictEqual(r.refs[0].path, "/$dynamicRef"); + }); + + test("should skip fragment-only $dynamicRef", () => { + const r = crawlSchema({ $dynamicRef: "#meta" }); + strictEqual(r.refs.length, 0); + }); + + test("should skip $dynamicRef that is not a valid URL and does not start with #", () => { + const r = crawlSchema({ $dynamicRef: "relative/path/schema.json" }); + strictEqual(r.refs.length, 0); + }); + + test("should not collect $id as a fetch target", () => { + const r = crawlSchema({ $id: "https://internal.host/schema.json" }); + strictEqual(r.refs.length, 0); + }); + // --- $ref URL edge cases --- test("should collect $ref with IPv6 literal host", () => { const r = crawlSchema({ $ref: "https://[::1]/schema.json" }); @@ -582,6 +701,96 @@ describe("crawlSchema", () => { ok(Array.isArray(r.errors)); }); + test("should flag ReDoS-vulnerable patternProperties key before denylist matching", () => { + const r = crawlSchema({ + patternProperties: { "^(a+)+$": { type: "string" } }, + }); + const err = r.errors.find( + (e) => e.keyword === "patternProperties" && e.schemaPath === "#/redos", + ); + ok(err, "expected a patternProperties ReDoS error"); + strictEqual(err.params.reason, "hitMaxScore"); + strictEqual(err.params.pattern, "^(a+)+$"); + ok(err.message.includes("^(a+)+$")); + ok(err.instancePath.includes("/patternProperties/")); + }); + + test("unsafe patternProperties key is never matched via RegExp (short-circuits first)", () => { + // new RegExp(patternKey) in cli.js is reached only AFTER isSafePattern + // clears the key (the `if (!patternSafe) continue;` guard). An unsafe key + // must therefore yield a ReDoS finding and NO denylist-match finding, + // proving the dynamic RegExp never runs on a catastrophic pattern. This + // is the justification for the detect-non-literal-regexp nosemgrep. + const r = crawlSchema({ + patternProperties: { + "^(a+)+$": { type: "string" }, + "^(\\w+)*$": { type: "string" }, + }, + }); + const redos = r.errors.filter( + (e) => e.keyword === "patternProperties" && e.schemaPath === "#/redos", + ); + const matched = r.errors.filter( + (e) => e.keyword === "patternProperties" && e.params?.matches, + ); + strictEqual(redos.length, 2, "both unsafe keys must be flagged as ReDoS"); + strictEqual(matched.length, 0, "no denylist match may run on unsafe keys"); + }); + + // --- instance-data keywords are not analyzed as schemas --- + // const/enum/default/examples hold literal instance values, never + // subschemas. The crawler must not descend into them, or it reports + // false positives on data that merely looks like a schema. + test("should not run ReDoS analysis on a pattern inside const", () => { + const r = crawlSchema({ const: { pattern: "^(a+)+$" } }); + ok(!r.errors.some((e) => e.keyword === "pattern")); + }); + + test("should not flag a numeric range inside default", () => { + const r = crawlSchema({ + type: "object", + default: { type: "integer", minimum: 100, maximum: 1 }, + }); + ok(!r.errors.some((e) => e.keyword === "minimum")); + }); + + test("should not flag dangerous property names inside enum values", () => { + const r = crawlSchema( + JSON.parse('{"enum":[{"properties":{"__proto__":{"type":"string"}}}]}'), + ); + ok(!r.errors.some((e) => e.schemaPath === "#/dangerous-name")); + }); + + test("should not flag dangerous property names inside examples values", () => { + const r = crawlSchema( + JSON.parse( + '{"examples":[{"properties":{"__proto__":{"type":"string"}}}]}', + ), + ); + ok(!r.errors.some((e) => e.schemaPath === "#/dangerous-name")); + }); + + test("should still analyze real subschemas alongside instance-data keywords", () => { + // A genuine sibling subschema (in properties) must still be crawled even + // when const/default/examples are present and skipped. + const r = crawlSchema( + JSON.parse( + '{"properties":{"bad":{"type":"string","minLength":10,"maxLength":5}},"default":{"pattern":"^(a+)+$"}}', + ), + ); + ok(r.errors.some((e) => e.keyword === "minLength")); + ok(!r.errors.some((e) => e.keyword === "pattern")); + }); + + // --- analysis time budget (deadline) --- + test("should fail closed with a timeout error when deadline already passed", () => { + const r = crawlSchema({ type: "string", pattern: "^[a-z]+$" }, 32, { + deadline: 0, + }); + ok(r.errors.some((e) => e.keyword === "timeout")); + strictEqual(r.timedOut, true); + }); + // --- dangerous-name detection across all property-key sites (default lang=js) --- // Schemas come from JSON.parse in real usage. Object literals like // `{ __proto__: x }` set the prototype rather than adding a key, so we @@ -883,3 +1092,1154 @@ describe("crawlSchema", () => { ok(!r.errors.some((e) => e.params.name === "safe")); }); }); + +// Regression lock for the DANGEROUS_NAMES_BY_LANG denylist. These names are a +// security contract, so they are duplicated here as literals on purpose: the +// test must fail if any entry is dropped or altered in cli.js. Deriving the +// expectations from the exported table instead would let a mutation hide +// behind itself (the test would read the same mutated value it asserts on). +const EXPECTED_DANGEROUS_NAMES_BY_LANG = { + js: ["__proto__", "constructor", "prototype"], + py: [ + "__proto__", + "constructor", + "prototype", + "__class__", + "__init__", + "__globals__", + "__builtins__", + "__import__", + "__reduce__", + "__subclasses__", + "__dict__", + "__mro__", + ], + rb: [ + "__proto__", + "constructor", + "prototype", + "__send__", + "json_class", + "instance_eval", + "instance_variable_set", + "singleton_class", + ], + rs: ["__proto__", "constructor", "prototype"], + java: ["__proto__", "constructor", "prototype", "@type", "@class"], + kotlin: ["__proto__", "constructor", "prototype", "@type", "@class"], + clojure: ["__proto__", "constructor", "prototype", "@type", "@class"], + cs: [ + "__proto__", + "constructor", + "prototype", + "$type", + "__type", + "@odata.type", + ], + vb: [ + "__proto__", + "constructor", + "prototype", + "$type", + "__type", + "@odata.type", + ], + fsharp: [ + "__proto__", + "constructor", + "prototype", + "$type", + "__type", + "@odata.type", + ], + php: [ + "__proto__", + "constructor", + "prototype", + "__construct", + "__destruct", + "__wakeup", + "__sleep", + "__serialize", + "__unserialize", + "__call", + "__callStatic", + "__get", + "__set", + "__isset", + "__unset", + "__toString", + "__invoke", + "__set_state", + "__clone", + "__debugInfo", + ], + objc: [ + "__proto__", + "constructor", + "prototype", + "isa", + "class", + "superclass", + "description", + "init", + "_cmd", + ], + swift: [ + "__proto__", + "constructor", + "prototype", + "isa", + "class", + "superclass", + "description", + "init", + "_cmd", + ], + ex: [ + "__proto__", + "constructor", + "prototype", + "__struct__", + "__exception__", + "__protocol__", + ], + lua: [ + "__proto__", + "constructor", + "prototype", + "__index", + "__newindex", + "__call", + "__metatable", + "__tostring", + "__name", + "__pairs", + "__eq", + "__lt", + "__le", + "__add", + "__sub", + "__mul", + "__div", + "__mod", + "__pow", + "__concat", + "__len", + "__unm", + "__band", + "__bor", + "__bxor", + "__bnot", + "__shl", + "__shr", + "__idiv", + "__close", + "__gc", + ], +}; + +describe("crawlSchema dangerous-name denylist", () => { + for (const [lang, names] of Object.entries( + EXPECTED_DANGEROUS_NAMES_BY_LANG, + )) { + for (const name of names) { + test(`lang="${lang}" flags property key ${JSON.stringify(name)}`, () => { + // Build via JSON.parse so a "__proto__" key is a real own property + // rather than the object prototype (an object literal would skip it). + const r = crawlSchema( + JSON.parse( + `{"properties":{${JSON.stringify(name)}:{"type":"string"}}}`, + ), + 32, + { lang }, + ); + ok( + r.errors.some( + (e) => e.keyword === "properties" && e.params.name === name, + ), + `expected lang="${lang}" to flag dangerous property name ${JSON.stringify(name)}`, + ); + }); + } + } + + // The "default" lang is the de-duplicated union of every language's list, so + // it must flag both a shared name and a name contributed by a single language + // (guards the spread/Set construction of DANGEROUS_NAMES_BY_LANG.default). + for (const name of ["__proto__", "constructor", "__bor", "@odata.type"]) { + test(`lang="default" flags property key ${JSON.stringify(name)}`, () => { + const r = crawlSchema( + JSON.parse( + `{"properties":{${JSON.stringify(name)}:{"type":"string"}}}`, + ), + 32, + { lang: "default" }, + ); + ok( + r.errors.some( + (e) => e.keyword === "properties" && e.params.name === name, + ), + `expected lang="default" to flag ${JSON.stringify(name)}`, + ); + }); + } +}); + +// Full error-shape locks for crawlSchema. Existing tests mostly assert `keyword`; +// these pin instancePath, schemaPath, params and message so that blanking any of +// them (a mutation) is caught. +describe("crawlSchema error payloads", () => { + const findByKeyword = (schema, keyword) => + crawlSchema(schema).errors.find((e) => e.keyword === keyword); + + test("minLength > maxLength error shape", () => { + const e = findByKeyword( + { type: "string", minLength: 10, maxLength: 5 }, + "minLength", + ); + strictEqual(e.instancePath, ""); + strictEqual(e.schemaPath, "#/minLength"); + strictEqual(e.params.minLength, 10); + strictEqual(e.params.maxLength, 5); + strictEqual(e.message, "minLength must be less than or equal to maxLength"); + }); + + test("minItems > maxItems error shape", () => { + const e = findByKeyword( + { type: "array", minItems: 10, maxItems: 3 }, + "minItems", + ); + strictEqual(e.schemaPath, "#/minItems"); + strictEqual(e.params.minItems, 10); + strictEqual(e.params.maxItems, 3); + strictEqual(e.message, "minItems must be less than or equal to maxItems"); + }); + + test("minContains > maxContains error shape", () => { + const e = findByKeyword( + { type: "array", minContains: 10, maxContains: 3 }, + "minContains", + ); + strictEqual(e.schemaPath, "#/minContains"); + strictEqual(e.params.minContains, 10); + strictEqual(e.params.maxContains, 3); + strictEqual( + e.message, + "minContains must be less than or equal to maxContains", + ); + }); + + test("minProperties > maxProperties error shape", () => { + const e = findByKeyword( + { type: "object", minProperties: 10, maxProperties: 5 }, + "minProperties", + ); + strictEqual(e.schemaPath, "#/minProperties"); + strictEqual(e.params.minProperties, 10); + strictEqual(e.params.maxProperties, 5); + strictEqual( + e.message, + "minProperties must be less than or equal to maxProperties", + ); + }); + + test("ReDoS pattern error shape", () => { + const e = findByKeyword({ type: "string", pattern: "(a+)+$" }, "pattern"); + strictEqual(e.instancePath, "/pattern"); + strictEqual(e.schemaPath, "#/redos"); + strictEqual(e.params.pattern, "(a+)+$"); + ok(typeof e.params.reason === "string" && e.params.reason.length > 0); + ok(typeof e.message === "string" && e.message.length > 0); + }); + + test("dangerous-name in properties error shape", () => { + const r = crawlSchema( + JSON.parse('{"properties":{"__proto__":{"type":"string"}}}'), + ); + const e = r.errors.find((x) => x.keyword === "properties"); + strictEqual(e.instancePath, "/properties/__proto__"); + strictEqual(e.schemaPath, "#/dangerous-name"); + strictEqual(e.params.name, "__proto__"); + strictEqual(e.params.lang, "default"); + strictEqual( + e.message, + 'properties key "__proto__" is a deserialization vector for lang="default"', + ); + }); + + test("dangerous-name in required error shape", () => { + const r = crawlSchema(JSON.parse('{"required":["constructor"]}')); + const e = r.errors.find((x) => x.keyword === "required"); + strictEqual(e.instancePath, "/required/0"); + strictEqual(e.schemaPath, "#/dangerous-name"); + strictEqual(e.params.name, "constructor"); + strictEqual( + e.message, + 'required entry "constructor" is a deserialization vector for lang="default"', + ); + }); + + test("dangerous-name in dependentRequired error shape", () => { + const r = crawlSchema( + JSON.parse('{"dependentRequired":{"a":["prototype"]}}'), + ); + const e = r.errors.find((x) => x.keyword === "dependentRequired"); + strictEqual(e.instancePath, "/dependentRequired/a/0"); + strictEqual(e.schemaPath, "#/dangerous-name"); + strictEqual(e.params.name, "prototype"); + strictEqual( + e.message, + 'dependentRequired entry "prototype" is a deserialization vector for lang="default"', + ); + }); + + test("ReDoS patternProperties key error shape", () => { + const r = crawlSchema( + JSON.parse('{"patternProperties":{"(a+)+$":{"type":"string"}}}'), + ); + const e = r.errors.find( + (x) => x.keyword === "patternProperties" && x.schemaPath === "#/redos", + ); + strictEqual(e.instancePath, "/patternProperties/(a+)+$"); + strictEqual(e.params.pattern, "(a+)+$"); + strictEqual( + e.message, + 'patternProperties key "(a+)+$" is vulnerable to ReDoS', + ); + }); + + test("dangerous-name patternProperties match error shape", () => { + const r = crawlSchema( + JSON.parse('{"patternProperties":{"^__proto__$":{"type":"string"}}}'), + ); + const e = r.errors.find( + (x) => + x.keyword === "patternProperties" && + x.schemaPath === "#/dangerous-name", + ); + strictEqual(e.instancePath, "/patternProperties/^__proto__$"); + strictEqual(e.params.pattern, "^__proto__$"); + ok(e.params.matches.includes("__proto__")); + strictEqual(e.params.lang, "default"); + strictEqual( + e.message, + 'patternProperties key "^__proto__$" matches deserialization vector(s) for lang="default": __proto__', + ); + }); +}); + +// Equal bounds are valid: the range checks use a strict `>` so tightening it to +// `>=` (a mutation) would flag a satisfiable range as impossible. +describe("crawlSchema equal-bound ranges are valid", () => { + const noError = (schema, keyword) => + ok(!crawlSchema(schema).errors.some((e) => e.keyword === keyword)); + + test("minLength === maxLength", () => + noError({ type: "string", minLength: 5, maxLength: 5 }, "minLength")); + test("minItems === maxItems", () => + noError({ type: "array", minItems: 5, maxItems: 5 }, "minItems")); + test("minContains === maxContains", () => + noError({ type: "array", minContains: 5, maxContains: 5 }, "minContains")); + test("minProperties === maxProperties", () => + noError( + { type: "object", minProperties: 5, maxProperties: 5 }, + "minProperties", + )); +}); + +// Type/own-property guards on the range checks: a non-array (or a schema missing +// one bound) must not trigger array/object range errors. +describe("crawlSchema range guards", () => { + test("minContains > maxContains ignored on non-array", () => { + ok( + !crawlSchema({ + type: "object", + minContains: 10, + maxContains: 3, + }).errors.some((e) => e.keyword === "minContains"), + ); + }); + test("minContains without maxContains does not error", () => { + ok( + !crawlSchema({ type: "array", minContains: 10 }).errors.some( + (e) => e.keyword === "minContains", + ), + ); + }); + test("dangerous name as the last required entry is still flagged", () => { + const r = crawlSchema(JSON.parse('{"required":["safe","__proto__"]}')); + const e = r.errors.find((x) => x.keyword === "required"); + strictEqual(e.params.name, "__proto__"); + strictEqual(e.instancePath, "/required/1"); + }); +}); + +// Targeted guards and boundaries in crawlSchema that blanket error-shape tests +// don't reach. +describe("crawlSchema guards and boundaries", () => { + test("non-object, non-null input returns the empty result", () => { + const r = crawlSchema("not-a-schema"); + strictEqual(r.depth, 0); + strictEqual(r.errors.length, 0); + strictEqual(r.refs.length, 0); + }); + + test("a non-string pattern is ignored (no ReDoS analysis)", () => { + const r = crawlSchema({ type: "string", pattern: 123 }); + ok(!r.errors.some((e) => e.keyword === "pattern")); + }); + + test("an unparseable pattern is reported as a parse-error ReDoS finding", () => { + const e = crawlSchema({ type: "string", pattern: "(" }).errors.find( + (x) => x.keyword === "pattern", + ); + ok(e, "expected a pattern finding for an unparseable regex"); + strictEqual(e.instancePath, "/pattern"); + strictEqual(e.schemaPath, "#/redos"); + strictEqual(e.params.reason, "parseError"); + strictEqual(e.message, "pattern could not be parsed for ReDoS analysis"); + }); + + test("required dangerous-name carries the requested lang, not the default", () => { + const e = crawlSchema(JSON.parse('{"required":["__send__"]}'), 32, { + lang: "rb", + }).errors.find((x) => x.keyword === "required"); + ok(e, "expected __send__ flagged for ruby"); + strictEqual(e.params.lang, "rb"); + ok(e.message.includes('lang="rb"')); + }); + + test("dependentRequired dangerous-name carries the requested lang", () => { + const e = crawlSchema( + JSON.parse('{"dependentRequired":{"a":["__send__"]}}'), + 32, + { lang: "rb" }, + ).errors.find((x) => x.keyword === "dependentRequired"); + ok(e, "expected __send__ flagged for ruby"); + strictEqual(e.params.lang, "rb"); + }); + + test("external $ref whose URL ends in '#' is still collected", () => { + const r = crawlSchema(JSON.parse('{"$ref":"http://evil.example/#"}')); + const ref = r.refs.find((x) => x.hostname === "evil.example"); + ok(ref, "expected the external $ref to be collected"); + strictEqual(ref.ref, "http://evil.example/#"); + strictEqual(ref.path, "/$ref"); + }); + + test("internal '#'-prefixed $ref is not collected as remote", () => { + const r = crawlSchema(JSON.parse('{"$ref":"#/$defs/foo"}')); + strictEqual(r.refs.length, 0); + }); + + test("a primitive property value is not descended into", () => { + // { foo: "bar" } — "bar" is a string, so the crawl must not recurse into it + // (recursing would bump the reported depth). + const r = crawlSchema({ type: "object", foo: "bar" }); + strictEqual(r.depth, 1); + }); + + test("depth exactly at the limit is not flagged as exceeded", () => { + // root(1) -> a(2) -> b(3): exactly maxDepth 3, must not be flagged. + const r = crawlSchema({ a: { b: { type: "string" } } }, 3); + strictEqual(r.depthExceeded, false); + }); + + test("depth one past the limit is flagged as exceeded", () => { + const r = crawlSchema({ a: { b: { c: { type: "string" } } } }, 3); + strictEqual(r.depthExceeded, true); + }); +}); + +describe("crawlSchema null-value and multi-match guards", () => { + test("a null property value is not descended into (typeof null is 'object')", () => { + // Must not throw: dropping the `value !== null` guard would push null onto + // the stack and then read null.type on the next iteration. + const r = crawlSchema({ type: "object", foo: null }); + strictEqual(r.depth, 1); + strictEqual(r.errors.length, 0); + }); + + test("patternProperties matching multiple dangerous names lists them comma-separated", () => { + // "^(__proto__|constructor)$" matches two denylist entries, exposing the + // ", " join separator in the message. + const r = crawlSchema( + JSON.parse( + '{"patternProperties":{"^(__proto__|constructor)$":{"type":"string"}}}', + ), + ); + const e = r.errors.find( + (x) => + x.keyword === "patternProperties" && + x.schemaPath === "#/dangerous-name", + ); + ok(e, "expected a dangerous-name patternProperties finding"); + ok(e.params.matches.includes("__proto__")); + ok(e.params.matches.includes("constructor")); + ok(e.message.includes("__proto__, constructor")); + }); +}); + +// Adversarial / malformed-input guards. These exercise exactly the edge cases +// crawlSchema's defensive type-guards exist for, so each both kills a mutant and +// documents a real robustness/security property (e.g. never analyze inherited or +// non-own keys, never crash on a null sub-schema). +describe("crawlSchema defensive guards", () => { + test("ReDoS analysis is independent of the denylist (lang:[] still scans)", () => { + const r = crawlSchema( + JSON.parse('{"patternProperties":{"(a+)+$":{}}}'), + 32, + { lang: [] }, + ); + ok(r.errors.some((e) => e.schemaPath === "#/redos")); + }); + + test("an inherited `pattern` is not analyzed (own-property only)", () => { + const proto = Object.create({ pattern: "(a+)+$" }); + ok(!crawlSchema(proto).errors.some((e) => e.schemaPath === "#/redos")); + }); + + test("a non-string `pattern` is ignored, not coerced to a regex", () => { + // String(["(a+)+$"]) === "(a+)+$"; the typeof guard must stop coercion. + const r = crawlSchema(JSON.parse('{"pattern":["(a+)+$"]}')); + ok(!r.errors.some((e) => e.schemaPath === "#/redos")); + }); + + test("a null denylist site does not crash the crawl", () => { + for (const key of [ + "properties", + "$defs", + "definitions", + "dependentSchemas", + "dependentRequired", + ]) { + const r = crawlSchema(JSON.parse(`{"${key}":null}`)); + ok(Array.isArray(r.errors), `${key}:null must not throw`); + } + }); + + test("a denylist site that is an array is not treated as a key map", () => { + const r = crawlSchema(JSON.parse('{"properties":["__proto__"]}')); + ok(!r.errors.some((e) => e.schemaPath === "#/dangerous-name")); + }); + + test("a null patternProperties/dependentRequired does not crash", () => { + ok( + Array.isArray( + crawlSchema(JSON.parse('{"patternProperties":null}')).errors, + ), + ); + ok( + Array.isArray( + crawlSchema(JSON.parse('{"dependentRequired":null}')).errors, + ), + ); + }); + + test("a non-string required entry is ignored", () => { + const r = crawlSchema(JSON.parse('{"required":[123,{"x":1}]}')); + ok(!r.errors.some((e) => e.schemaPath === "#/dangerous-name")); + }); + + test("an inherited or non-string $ref is not collected", () => { + ok( + crawlSchema(Object.create({ $ref: "https://x.example/s" })).refs + .length === 0, + ); + ok(crawlSchema(JSON.parse('{"$ref":123}')).refs.length === 0); + }); + + test("an internal '#'-prefixed $ref is not collected as remote", () => { + ok(crawlSchema(JSON.parse('{"$ref":"#/$defs/x"}')).refs.length === 0); + }); +}); + +// --- A1: ReDoS-analysis memory/work bounding (OOM defense) --- +// A single sub-1KB schema with multiple evil patternProperties keys used to OOM +// the scanner: the per-pattern work was unbounded (memory grew faster than the +// time deadline could fire), and the deadline was only checked once per stack +// pop, never between patterns. The fix is layered: (1) a per-pattern maxSteps +// bound, (2) a deadline check before each pattern analysis, (3) a hard cap on +// the total number of patterns analyzed. +describe("crawlSchema ReDoS-analysis bounds (A1)", () => { + const EVIL = "^(a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z)+"; + + test("exposes a heap-budget const and MAX_REDOS_PATTERNS cap", () => { + strictEqual(typeof REDOS_HEAP_BUDGET_BYTES, "number"); + ok(REDOS_HEAP_BUDGET_BYTES > 0, "REDOS_HEAP_BUDGET_BYTES must be positive"); + // Must sit below a single evil pattern's ~270MB footprint (so the breaker + // fires after the first one) yet leave headroom under a 600MB heap. + ok( + REDOS_HEAP_BUDGET_BYTES <= 256 * 1024 * 1024, + "REDOS_HEAP_BUDGET_BYTES must stay under 256MB so it bails before OOM", + ); + strictEqual(typeof MAX_REDOS_PATTERNS, "number"); + ok(MAX_REDOS_PATTERNS > 0, "MAX_REDOS_PATTERNS must be positive"); + }); + + // REGRESSION: legitimate complex-but-safe patterns must NOT be reported as + // ReDoS. maxSteps=100 wrongly fail-closed semver (hitMaxSteps); removing it + // restores the library-default verdict, which is SAFE for all of these. + test("does not flag legitimate safe patterns as ReDoS", () => { + const safePatterns = [ + // semver: SAFE at default maxSteps, but hitMaxSteps at maxSteps<=250. + "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$", + // ISO date. + "^\\d{4}-\\d{2}-\\d{2}$", + // uuid. + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + // slug. + "^[a-z0-9]+(?:-[a-z0-9]+)*$", + ]; + const patternProperties = {}; + for (const p of safePatterns) { + patternProperties[p] = { type: "string" }; + } + const r = crawlSchema({ patternProperties }); + const redos = r.errors.filter((e) => e.schemaPath === "#/redos"); + strictEqual( + redos.length, + 0, + `safe patterns must not be flagged; got: ${redos + .map((e) => e.params.pattern) + .join(", ")}`, + ); + // Also exercise the top-level `pattern` path for semver specifically. + const semver = crawlSchema({ + type: "string", + pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$", + }); + strictEqual( + semver.errors.filter((e) => e.schemaPath === "#/redos").length, + 0, + "semver must not be flagged as ReDoS at the library default", + ); + }); + + // HEAP CIRCUIT BREAKER (primary memory bound): an injected memoryUsage that + // crosses an injected budget must stop analysis early and emit exactly one + // incomplete #/redos-budget finding that survives --ignore. + test("heap budget breaker stops analysis early and emits one incomplete finding", () => { + const patternProperties = {}; + for (let i = 0; i < 5; i++) { + patternProperties[`^safe${i}$`] = { type: "string" }; + } + // Baseline 100, then values stepping up by 50 per read. With a budget of + // 100, the breaker fires when current-baseline (100) > 100 is first true: + // reads are 100 (baseline), 150, 200, 250, ... so 250-100=150>100 trips on + // the 4th read (before the 3rd pattern, since the baseline read is read #1). + let calls = 0; + const memoryUsage = () => 100 + 50 * calls++; + const r = crawlSchema({ patternProperties }, 32, { + memoryUsage, + redosHeapBudgetBytes: 100, + }); + const budget = r.errors.filter( + (e) => e.schemaPath === "#/redos-budget" && e.keyword === "heap", + ); + strictEqual(budget.length, 1, "exactly one heap-budget finding"); + strictEqual(budget[0].params.incomplete, true, "must be marked incomplete"); + // Analysis stopped early: fewer than all 5 keys were analyzed. (Safe keys + // emit no #/redos finding, so we assert via the breaker firing + that the + // memoryUsage reader was called fewer times than 1 baseline + 5 patterns.) + ok( + calls < 6, + `analysis must stop early; memoryUsage called ${calls} times`, + ); + // Survives --ignore because it is incomplete. + const kept = crawlSchema({ patternProperties }, 32, { + memoryUsage: (() => { + let c = 0; + return () => 100 + 50 * c++; + })(), + redosHeapBudgetBytes: 100, + ignore: ["#/redos-budget"], + }); + // crawlSchema itself does not apply --ignore (analyze() does), so verify the + // finding shape carries incomplete:true, which applyIgnore honors. + strictEqual( + kept.errors.filter( + (e) => e.schemaPath === "#/redos-budget" && e.keyword === "heap", + ).length, + 1, + "heap-budget finding is present regardless of ignore", + ); + }); + + // Boundary pin for Stryker: exactly AT the budget (current-baseline == budget) + // must NOT trip the breaker (`<=` keeps the comparison false). The breaker reads + // heap once per analyzed pattern, so two patternProperties keys give a baseline + // read (100) then a second read (200): delta is EXACTLY the budget (100), which + // must NOT trip. Kills `<=`->`<` (which would fire at exactly the budget). + test("heap delta exactly equal to budget does not trip the breaker (<=)", () => { + let calls = 0; + // Read #1 (baseline) = 100; read #2 = 200 -> delta exactly 100 == budget. + const memoryUsage = () => (calls++ === 0 ? 100 : 200); + const r = crawlSchema( + { + patternProperties: { + "^safe1$": { type: "string" }, + "^safe2$": { type: "string" }, + }, + }, + 32, + { memoryUsage, redosHeapBudgetBytes: 100 }, + ); + strictEqual( + r.errors.filter((e) => e.schemaPath === "#/redos-budget").length, + 0, + "delta exactly equal to budget must not trip (<= keeps it false)", + ); + ok(calls >= 2, "the second pattern's read must have happened"); + }); + + // Companion to the boundary: delta = budget + 1 MUST trip, and the emitted heap + // finding's exact shape is pinned so the message StringLiteral and the + // incomplete BooleanLiteral mutants are killed. + test("heap delta of budget+1 trips and emits the exact heap finding", () => { + let calls = 0; + // Read #1 (baseline) = 100; read #2 = 201 -> delta 101 = budget + 1. + const memoryUsage = () => (calls++ === 0 ? 100 : 201); + const r = crawlSchema( + { + patternProperties: { + "^safe1$": { type: "string" }, + "^safe2$": { type: "string" }, + }, + }, + 32, + { memoryUsage, redosHeapBudgetBytes: 100 }, + ); + const heap = r.errors.filter( + (e) => e.schemaPath === "#/redos-budget" && e.keyword === "heap", + ); + strictEqual(heap.length, 1, "budget+1 must trip exactly once"); + strictEqual(heap[0].keyword, "heap"); + strictEqual(heap[0].schemaPath, "#/redos-budget"); + deepStrictEqual(heap[0].params, { budget: 100, incomplete: true }); + strictEqual(heap[0].params.incomplete, true); + strictEqual( + heap[0].message, + "ReDoS analysis heap budget of 100 bytes exceeded; remaining patterns not analyzed", + ); + }); + + // Single-fire guard: two SEPARATE pattern nodes both over budget must emit only + // ONE heap finding (the `!redosHeapReported` guard latches). Kills the + // ConditionalExpression (forcing `true` re-emits) and the BooleanLiteral + // (`redosHeapReported = false` never latches, so it re-emits). + test("the heap breaker reports at most once across multiple over-budget pattern nodes", () => { + let calls = 0; + // First analyzed pattern (read #1) = 0 baseline; every later read = 1e6, + // far over the budget, so the SECOND and THIRD pattern nodes both exceed it. + const memoryUsage = () => (calls++ === 0 ? 0 : 1_000_000); + const r = crawlSchema( + { + properties: { + a: { type: "string", pattern: "a" }, + b: { type: "string", pattern: "b" }, + c: { type: "string", pattern: "c" }, + }, + }, + 32, + { memoryUsage, redosHeapBudgetBytes: 100 }, + ); + const heap = r.errors.filter( + (e) => e.schemaPath === "#/redos-budget" && e.keyword === "heap", + ); + strictEqual( + heap.length, + 1, + "the heap finding must be emitted exactly once even with multiple over-budget nodes", + ); + // The heap finding's instancePath is the tripping node's `${path}/pattern`, + // never the empty string (pins the path template passed to redosHeapExceeded). + ok( + /^\/properties\/[abc]\/pattern$/.test(heap[0].instancePath), + `heap instancePath must name the tripping pattern node, got "${heap[0].instancePath}"`, + ); + }); + + // PRIMARY (heap breaker, OOM repro): many distinct evil patternProperties keys + // each grow the heap by ~270MB at the library default. The heap circuit breaker + // (default budget) must bail after the first one so the crawl returns WITHOUT + // crashing, emitting one incomplete heap-budget finding. (The manual 600MB + // check in the PR confirms no OOM; here the larger test-process heap simply + // proves we return cleanly and the breaker fired.) + test("40 distinct evil patternProperties keys return without crashing (heap breaker)", () => { + const patternProperties = {}; + for (let i = 0; i < 40; i++) { + patternProperties[`${EVIL}${i}$`] = { type: "string" }; + } + const r = crawlSchema({ patternProperties }); + strictEqual(r.timedOut, false, "must not have tripped the deadline"); + // The heap breaker bailed: at least one evil pattern was flagged before the + // budget tripped, and exactly one incomplete heap-budget finding is present. + const heap = r.errors.filter( + (e) => e.schemaPath === "#/redos-budget" && e.keyword === "heap", + ); + strictEqual(heap.length, 1, "the heap breaker must fire exactly once"); + strictEqual(heap[0].params.incomplete, true, "heap finding is incomplete"); + const redos = r.errors.filter( + (e) => e.keyword === "patternProperties" && e.schemaPath === "#/redos", + ); + ok( + redos.length >= 1 && redos.length < 40, + `breaker must stop early after flagging some keys; got ${redos.length}`, + ); + }); + + // Layer 1 also applies to top-level `pattern`: a single evil pattern is + // flagged fail-closed and completes quickly under the step bound. + test("a top-level evil pattern is flagged fail-closed under the step bound", () => { + const r = crawlSchema({ type: "string", pattern: `${EVIL}$` }); + const err = r.errors.find((e) => e.keyword === "pattern"); + ok(err, "expected a ReDoS finding for the evil pattern"); + strictEqual(err.schemaPath, "#/redos"); + }); + + // Once-per-pop deadline (top of the stack loop): an already-passed deadline + // must bail on the FIRST pop, before ANY structural check runs. The schema has + // NO pattern, so the per-pattern deadline guards cannot mask this one; a node + // with minLength > maxLength would emit a finding if the loop body ran. Killing + // the once-per-pop ConditionalExpression: bailing means the timeout finding is + // present AND the minLength finding is absent. + test("an already-passed deadline bails on the first pop before any structural check", () => { + const r = crawlSchema({ type: "string", minLength: 5, maxLength: 1 }, 32, { + deadline: 0, + }); + ok( + r.errors.some((e) => e.keyword === "timeout"), + "must emit the timeout finding on the first pop", + ); + strictEqual(r.timedOut, true); + ok( + !r.errors.some((e) => e.keyword === "minLength"), + "must bail BEFORE the minLength<=maxLength structural check runs", + ); + strictEqual(r.errors.length, 1, "only the timeout finding is emitted"); + }); + + // Layer 2: the deadline is checked before each top-level pattern analysis, so + // an already-passed deadline bails to the timeout path BEFORE any ReDoS work. + test("an already-passed deadline bails before analyzing a top-level pattern", () => { + const r = crawlSchema({ type: "string", pattern: `${EVIL}$` }, 32, { + deadline: 0, + }); + ok( + r.errors.some((e) => e.keyword === "timeout"), + "must emit the timeout finding", + ); + strictEqual(r.timedOut, true); + ok( + !r.errors.some((e) => e.keyword === "pattern"), + "must NOT have run ReDoS analysis after the deadline", + ); + }); + + // Layer 2: the deadline is also checked inside the patternProperties key loop. + test("an already-passed deadline bails before analyzing patternProperties keys", () => { + const r = crawlSchema( + { patternProperties: { [`${EVIL}1$`]: { type: "string" } } }, + 32, + { deadline: 0 }, + ); + ok( + r.errors.some((e) => e.keyword === "timeout"), + "must emit the timeout finding", + ); + strictEqual(r.timedOut, true); + ok( + !r.errors.some((e) => e.schemaPath === "#/redos"), + "must NOT have run ReDoS analysis after the deadline", + ); + }); + + // Layer 3 (defense in depth): a hard cap on the TOTAL number of patterns + // analyzed. Once exceeded, no further patterns are analyzed and exactly one + // fail-closed budget finding is emitted. + // SAFE keys (cheap, never trip the heap breaker) isolate the count cap. A + // non-tripping memoryUsage is injected so the heap breaker is out of the way. + // The reader is called once per ANALYZED pattern (plus the baseline read), so + // the call count proves analysis stops exactly at the cap. + test("exceeding MAX_REDOS_PATTERNS stops analysis and emits one incomplete budget finding", () => { + const patternProperties = {}; + const total = MAX_REDOS_PATTERNS + 5; + for (let i = 0; i < total; i++) { + patternProperties[`^ok${i}$`] = { type: "string" }; + } + let reads = 0; + const memoryUsage = () => { + reads++; + return 0; // never grows, so the heap breaker never fires + }; + const r = crawlSchema({ patternProperties }, 32, { memoryUsage }); + const budget = r.errors.filter((e) => e.schemaPath === "#/redos-budget"); + strictEqual(budget.length, 1, "exactly one budget finding"); + strictEqual(budget[0].keyword, "pattern"); + strictEqual(budget[0].params.incomplete, true, "count cap is incomplete"); + ok( + budget[0].message.includes(String(MAX_REDOS_PATTERNS)), + "budget message names the cap", + ); + // Per-pattern heap reads stop once the count cap fires. The count check + // runs BEFORE the heap read and short-circuits it, so the reader is called + // exactly once per analyzed pattern (MAX_REDOS_PATTERNS) and never for the + // skipped keys (the first call also captures the baseline). + strictEqual( + reads, + MAX_REDOS_PATTERNS, + "only the first MAX_REDOS_PATTERNS keys are analyzed; the rest are skipped", + ); + }); + + // Single-fire guard for the COUNT cap: two over-cap top-level `pattern` nodes + // (the `pattern` path does NOT break on a tripped budget, unlike the + // patternProperties path) must still emit only ONE budget finding. Kills the + // `if (!redosBudgetReported)` ConditionalExpression (forcing `true` re-emits). + test("the count cap reports exactly once across two over-cap pattern nodes", () => { + const properties = {}; + // MAX analyzed + 2 over-cap nodes. Distinct safe patterns so each is its own + // analyzed pattern and none is flagged as ReDoS. + for (let i = 0; i < MAX_REDOS_PATTERNS + 2; i++) { + properties[`p${i}`] = { type: "string", pattern: `lit${i}` }; + } + const r = crawlSchema({ properties }, 32, { memoryUsage: () => 0 }); + const budget = r.errors.filter((e) => e.schemaPath === "#/redos-budget"); + strictEqual( + budget.length, + 1, + "two over-cap pattern nodes must still emit exactly one budget finding", + ); + strictEqual(budget[0].keyword, "pattern"); + strictEqual(budget[0].params.incomplete, true); + // The budget finding's instancePath is the over-cap node's `${path}/pattern`, + // never the empty string (pins the path template passed to redosBudgetExceeded). + ok( + /^\/properties\/p\d+\/pattern$/.test(budget[0].instancePath), + `budget instancePath must name the over-cap pattern node, got "${budget[0].instancePath}"`, + ); + }); + + // The cap counts top-level `pattern` analyses too (shared budget), so a schema + // at exactly the cap of patterns does NOT emit a budget finding (strict >). + test("exactly MAX_REDOS_PATTERNS patterns does not trip the budget (strict >)", () => { + const patternProperties = {}; + for (let i = 0; i < MAX_REDOS_PATTERNS; i++) { + patternProperties[`^ok${i}$`] = { type: "string" }; + } + const r = crawlSchema({ patternProperties }, 32, { memoryUsage: () => 0 }); + ok( + !r.errors.some((e) => e.schemaPath === "#/redos-budget"), + "exactly-at-cap must not trip the budget", + ); + }); +}); + +// --- A4: collected refs are capped before the hostname cap --- +// crawlSchema used to push every remote $ref/$dynamicRef into result.refs with +// no bound; the hostname cap only applies later on the distinct-hostname map. +// A hard cap on result.refs length is a backstop (overall bounded by +// MAX_SCHEMA_SIZE) that stops collecting once exceeded and records one finding. +describe("crawlSchema collected-refs cap (A4)", () => { + test("exposes a positive MAX_COLLECTED_REFS const", () => { + strictEqual(typeof MAX_COLLECTED_REFS, "number"); + ok(MAX_COLLECTED_REFS > 0, "MAX_COLLECTED_REFS must be positive"); + }); + + // Build a schema carrying MORE distinct remote refs than the cap, nested so the + // crawl reaches them all. Each ref is to a distinct host so none de-duplicate. + const manyRefsSchema = (n) => { + const $defs = {}; + for (let i = 0; i < n; i++) { + $defs[`d${i}`] = { $ref: `https://refcap-${i}.invalid/s.json` }; + } + return { $defs }; + }; + + test("result.refs is capped at MAX_COLLECTED_REFS and a truncation finding is recorded", () => { + const r = crawlSchema(manyRefsSchema(MAX_COLLECTED_REFS + 10)); + ok( + r.refs.length <= MAX_COLLECTED_REFS, + `refs (${r.refs.length}) must not exceed the cap (${MAX_COLLECTED_REFS})`, + ); + const trunc = r.errors.filter((e) => e.schemaPath === "#/refs-truncated"); + strictEqual(trunc.length, 1, "exactly one truncation finding"); + ok( + trunc[0].message.includes(String(MAX_COLLECTED_REFS)), + "truncation message names the cap", + ); + // Pin the full finding shape so the instancePath/keyword StringLiteral and the + // params ObjectLiteral / incomplete BooleanLiteral mutants are all killed. + const t = trunc[0]; + strictEqual(t.schemaPath, "#/refs-truncated"); + strictEqual(t.keyword, "$ref"); + // instancePath is the path to the ref that first hit the cap; it must be a + // real JSON pointer ending at a $ref/$dynamicRef site, never the empty string. + ok(t.instancePath.length > 0, "instancePath must not be empty"); + ok( + /\/\$(ref|dynamicRef)$/.test(t.instancePath), + `instancePath must point at a ref site, got ${t.instancePath}`, + ); + deepStrictEqual(t.params, { + limit: MAX_COLLECTED_REFS, + incomplete: true, + }); + strictEqual(t.params.incomplete, true); + strictEqual( + t.message, + `more than ${MAX_COLLECTED_REFS} remote $ref(s); remaining refs not collected for SSRF analysis`, + ); + }); + + test("a schema with refs at or under the cap collects them all with no truncation finding", () => { + const r = crawlSchema(manyRefsSchema(MAX_COLLECTED_REFS)); + strictEqual(r.refs.length, MAX_COLLECTED_REFS, "all refs collected"); + ok( + !r.errors.some((e) => e.schemaPath === "#/refs-truncated"), + "no truncation finding at exactly the cap (strict >)", + ); + }); +}); + +// --- injectable monotonic clock: deterministic deadline branches --- +// crawlSchema reads the clock through options.now (defaulting to Date.now), the +// same injection pattern as options.memoryUsage. That makes every deadline branch +// reachable in a fast test: we can return a value UNDER the deadline on one read +// and OVER it on the next, so the per-pattern / per-key bails (which fire only +// between the once-per-pop check and the next check) are exercised deterministically. +describe("crawlSchema injected clock (deadline branches)", () => { + // Returns a clock that yields the given values in order, repeating the last + // value once the sequence is exhausted. + const stepClock = (...values) => { + let i = 0; + return () => values[Math.min(i++, values.length - 1)]; + }; + + // C: deadlinePassed semantics. A clock UNDER the deadline must NOT bail, so a + // clean schema is analyzed normally (no timeout finding). Kills the whole- + // condition ConditionalExpression forced-true (which would bail wrongly). + test("a clock under the deadline does not bail (no timeout finding)", () => { + const r = crawlSchema({ type: "string", minLength: 1, maxLength: 5 }, 32, { + deadline: 5_000, + now: () => 1_000, + }); + strictEqual( + r.timedOut, + false, + "must not time out when clock is under deadline", + ); + ok(!r.errors.some((e) => e.keyword === "timeout"), "no timeout finding"); + }); + + // C: a clock OVER the deadline bails on the first pop. Kills the whole-condition + // ConditionalExpression forced-false (which would never bail). + test("a clock over the deadline bails on the first pop", () => { + const r = crawlSchema({ type: "string", minLength: 5, maxLength: 1 }, 32, { + deadline: 1_000, + now: () => 2_000, + }); + ok( + r.errors.some((e) => e.keyword === "timeout"), + "must emit the timeout finding", + ); + strictEqual(r.timedOut, true); + ok( + !r.errors.some((e) => e.keyword === "minLength"), + "must bail before the structural check", + ); + }); + + // C: the deadline boundary is EXCLUSIVE. A clock reading EXACTLY at the deadline + // must NOT bail. Kills the EqualityOperator `>`->`>=` (which would bail at the + // boundary and lose the structural finding). + test("a clock exactly at the deadline does not bail (> is exclusive)", () => { + const r = crawlSchema({ type: "string", minLength: 5, maxLength: 1 }, 32, { + deadline: 1_000, + now: () => 1_000, + }); + strictEqual(r.timedOut, false, "exactly at the deadline must not bail"); + ok( + !r.errors.some((e) => e.keyword === "timeout"), + "no timeout finding at the boundary", + ); + ok( + r.errors.some((e) => e.keyword === "minLength"), + "the structural check still runs at the boundary", + ); + }); + + // A: per-pattern deadline bail in the top-level `pattern` block. The clock is + // UNDER the deadline at the once-per-pop check (call #1) and OVER it at the + // per-pattern check (call #2), so the per-pattern guard fires. Kills both the + // ConditionalExpression (if(false) would skip the bail and analyze the pattern) + // and the BlockStatement (an empty block would neither push the finding nor + // return, so analysis would continue). + test("per-pattern deadline bail fires between the once-per-pop and per-pattern checks", () => { + // Two reads per pop on the single node: top-of-loop (under) then per-pattern + // (over). A safe pattern is used so, absent the bail, NO pattern finding + // would appear and the crawl would complete cleanly. + const r = crawlSchema({ type: "string", pattern: "^[a-z]+$" }, 32, { + deadline: 1_000, + now: stepClock(100, 2_000), + }); + ok( + r.errors.some((e) => e.keyword === "timeout"), + "the per-pattern check must bail to the timeout path", + ); + strictEqual(r.timedOut, true); + ok( + !r.errors.some((e) => e.schemaPath === "#/redos"), + "the per-pattern bail must occur before any ReDoS analysis", + ); + strictEqual(r.errors.length, 1, "only the timeout finding is emitted"); + }); + + // B: per-key deadline bail in the patternProperties loop. The patternProperties + // values are BOOLEAN subschemas (true), so they are never pushed as further nodes + // to crawl. That is deliberate: the ONLY nodes popped are the root and the + // patternProperties object itself, and the clock stays UNDER the deadline on every + // top-of-loop read. The only read that can go OVER is the per-key check on the + // SECOND key. So the timeout finding can ONLY come from the per-key guard: + // - real code: #1 root top-of-loop (100, under), #2 key "^aaa$" per-key + // (200, under), #3 key "^bbb$" per-key (2000, OVER) -> bail. + // - if(false) mutant: the per-key reads vanish, leaving #1 root (100) and #2 + // patternProperties-object top-of-loop (200), both UNDER -> + // no timeout -> this test fails, killing the mutant. + // - empty-block mutant: the per-key reads still run (#2 200, #3 2000) but never + // bail; the clock then returns UNDER once more (#4 100) for + // the patternProperties-object top-of-loop read, so no + // timeout is produced -> killed. The clock returns OVER only + // ONCE (the bbb per-key read) and then drops back under, + // which is what lets it kill BOTH mutants at once. + // Kills both the ConditionalExpression and the BlockStatement for the per-key guard. + test("per-key deadline bail fires before a later patternProperties key", () => { + const r = crawlSchema( + { patternProperties: { "^aaa$": true, "^bbb$": true } }, + 32, + { deadline: 1_000, now: stepClock(100, 200, 2_000, 100) }, + ); + ok( + r.errors.some((e) => e.keyword === "timeout"), + "the per-key check must bail to the timeout path", + ); + strictEqual(r.timedOut, true); + }); + + // A safe top-level pattern with an under-deadline clock on BOTH reads must be + // analyzed normally (no timeout): proves the per-pattern guard does NOT bail when + // the clock stays under, complementing the bail test above so the conditional is + // pinned on both sides. + test("per-pattern guard does not bail when the clock stays under the deadline", () => { + const r = crawlSchema({ type: "string", pattern: "^(a+)+$" }, 32, { + deadline: 1_000, + now: stepClock(100, 200), + }); + strictEqual(r.timedOut, false, "must not bail when the clock stays under"); + ok( + r.errors.some((e) => e.schemaPath === "#/redos"), + "the pattern is analyzed and flagged when no bail occurs", + ); + }); +}); diff --git a/tests/cli.fuzz.js b/tests/cli.fuzz.js new file mode 100644 index 0000000..a1da59a --- /dev/null +++ b/tests/cli.fuzz.js @@ -0,0 +1,210 @@ +import test from "node:test"; +import fc from "fast-check"; +import { analyze, crawlSchema, isPrivateIP, MAX_DEPTH } from "../cli.js"; + +// Recursively-generated, schema-shaped objects: keys are drawn from real JSON +// Schema keywords and leaves include adversarial strings (prototype-pollution +// vectors, catastrophic-backtracking regexes, remote $ref URLs). This is the +// untrusted attack surface the analysis engine ingests, so the properties below +// assert the engine's output CONTRACT rather than any specific finding. + +const SCHEMA_KEYWORDS = [ + "type", + "properties", + "patternProperties", + "required", + "$ref", + "$dynamicRef", + "pattern", + "minimum", + "maximum", + "minLength", + "maxLength", + "items", + "const", + "enum", + "default", + "$defs", + "additionalProperties", + "propertyNames", + "allOf", + "anyOf", + "minItems", + "maxItems", + "dependentRequired", +]; + +const TYPE_NAMES = [ + "string", + "integer", + "number", + "array", + "object", + "boolean", + "null", +]; + +const ADVERSARIAL_STRINGS = [ + "__proto__", + "constructor", + "prototype", + ".*", + "(a+)+$", + "^(a|a)*$", + "^([a-z]+)*$", + "https://x.example.com/s.json", + "http://127.0.0.1/s.json", + "#/$defs/x", + "", +]; + +const leafValue = fc.oneof( + fc.string({ maxLength: 12 }), + fc.integer({ min: -1000, max: 1000 }), + fc.boolean(), + fc.constant(null), + fc.constantFrom(...TYPE_NAMES), + fc.constantFrom(...ADVERSARIAL_STRINGS), +); + +// A bounded, recursive arbitrary producing schema-shaped objects. Object keys +// are biased toward real keywords; values are nested schema-shaped objects, +// arrays of them, or leaf values. +const schemaArb = fc.letrec((tie) => ({ + node: fc.oneof( + { maxDepth: 5, withCrossShrink: true }, + leafValue, + fc.array(tie("node"), { maxLength: 4 }), + fc.dictionary( + fc.oneof( + fc.constantFrom(...SCHEMA_KEYWORDS), + fc.constantFrom(...ADVERSARIAL_STRINGS), + fc.string({ maxLength: 8 }), + ), + tie("node"), + { maxKeys: 6 }, + ), + ), +})).node; + +// Documented input-validation errors analyze() is allowed to throw. Any OTHER +// throw is an engine bug and fails the property. +const isDocumentedThrow = (err) => + err instanceof TypeError || err instanceof RangeError; + +const assertErrorContract = (errors) => { + for (const err of errors) { + if (typeof err.instancePath !== "string") { + throw new Error( + `error.instancePath not a string: ${JSON.stringify(err)}`, + ); + } + if (typeof err.schemaPath !== "string") { + throw new Error(`error.schemaPath not a string: ${JSON.stringify(err)}`); + } + if (typeof err.keyword !== "string") { + throw new Error(`error.keyword not a string: ${JSON.stringify(err)}`); + } + } +}; + +test("fuzz: analyze() never throws (except documented) and resolves to an Array", async () => { + await fc.assert( + fc.asyncProperty(schemaArb, async (schema) => { + let errors; + try { + // offline:true => NO network/DNS is ever performed on untrusted input. + errors = await analyze(schema, { + offline: true, + analysisTimeoutMs: 2000, + }); + } catch (err) { + if (isDocumentedThrow(err)) return; + throw new Error( + `analyze threw undocumented ${err?.constructor?.name}: ${err?.message} for ${JSON.stringify(schema)}`, + ); + } + if (!Array.isArray(errors)) { + throw new Error( + `analyze did not resolve to an Array for ${JSON.stringify(schema)}`, + ); + } + assertErrorContract(errors); + }), + { numRuns: 1000 }, + ); +}); + +test("fuzz: crawlSchema() never throws and honours its result contract", () => { + fc.assert( + fc.property(schemaArb, (schema) => { + let result; + try { + result = crawlSchema(schema); + } catch (err) { + throw new Error( + `crawlSchema threw ${err?.constructor?.name}: ${err?.message} for ${JSON.stringify(schema)}`, + ); + } + if (!result || typeof result !== "object") { + throw new Error( + `crawlSchema did not return an object for ${JSON.stringify(schema)}`, + ); + } + if (!Array.isArray(result.errors)) { + throw new Error("crawlSchema().errors is not an Array"); + } + if (!Array.isArray(result.refs)) { + throw new Error("crawlSchema().refs is not an Array"); + } + if (typeof result.depth !== "number") { + throw new Error("crawlSchema().depth is not a number"); + } + // The crawler bails the moment depth would exceed MAX_DEPTH, so the + // reported depth can reach MAX_DEPTH+1 (the over-cap level that triggered + // the bail) but never more. + if (result.depth > MAX_DEPTH + 1) { + throw new Error( + `crawlSchema().depth ${result.depth} exceeds MAX_DEPTH+1 (${MAX_DEPTH + 1})`, + ); + } + assertErrorContract(result.errors); + }), + { numRuns: 1000 }, + ); +}); + +test("fuzz: isPrivateIP() never throws and always returns a boolean", () => { + const ipv4Arb = fc + .tuple( + fc.integer({ min: 0, max: 300 }), + fc.integer({ min: 0, max: 300 }), + fc.integer({ min: 0, max: 300 }), + fc.integer({ min: 0, max: 300 }), + ) + .map((octets) => octets.join(".")); + + const hexGroup = fc + .integer({ min: 0, max: 0x1ffff }) + .map((n) => n.toString(16)); + const ipv6Arb = fc + .array(hexGroup, { minLength: 0, maxLength: 9 }) + .map((groups) => groups.join(":")); + + fc.assert( + fc.property(fc.oneof(ipv4Arb, ipv6Arb), (ip) => { + let out; + try { + out = isPrivateIP(ip); + } catch (err) { + throw new Error( + `isPrivateIP threw ${err?.constructor?.name}: ${err?.message} for "${ip}"`, + ); + } + if (typeof out !== "boolean") { + throw new Error(`isPrivateIP("${ip}") returned non-boolean: ${out}`); + } + }), + { numRuns: 1000 }, + ); +}); diff --git a/tests/cli.ip.test.js b/tests/cli.ip.test.js index 8bfadf8..b43276c 100644 --- a/tests/cli.ip.test.js +++ b/tests/cli.ip.test.js @@ -101,4 +101,165 @@ describe("isPrivateIP IPv6 extended", () => { test("IPv4-mapped dotted with zone ID classified private", () => { strictEqual(isPrivateIP("::ffff:192.168.1.1%eth0"), true); }); + + test("IPv4-mapped hex with invalid hex groups classified private (fail-closed)", () => { + strictEqual(isPrivateIP("0:0:0:0:0:ffff:0808:gggg"), true); + }); + + test("IPv4-mapped hex with valid hex still classified private", () => { + strictEqual(isPrivateIP("0:0:0:0:0:ffff:7f00:1"), true); + }); +}); + +// Each address sits just outside one reserved range, so it is public only while +// that range's bounds are intact. They lock the exact CIDR edges: broadening any +// single conjunct (an octet test flipped to always-true) would misclassify one of +// these as private, which is an SSRF allow-list hole. +describe("isPrivateIP near-miss public addresses", () => { + const nearMissPublic = [ + ["10.2.3.999", "octet above 255 voids the dotted-quad match"], + ["10.2.3.-5", "negative octet voids the dotted-quad match"], + ["100.0.0.1", "100.x below CGN b>=64"], + ["100.200.0.1", "100.x above CGN b<=127"], + ["1.254.0.1", "b=254 but a!=169 (not link-local)"], + ["169.0.0.1", "a=169 but b!=254 (not link-local)"], + ["172.0.0.1", "172.x below private b>=16"], + ["172.200.0.1", "172.x above private b<=31"], + ["192.5.0.1", "a=192 but b not 0/168"], + ["192.0.5.1", "192.0.x with third octet not 0/2"], + ["192.5.2.1", "third octet 2 but b!=0 (not TEST-NET-1)"], + ["198.0.0.1", "a=198 but b not 18/19/51"], + ["198.0.100.1", "third octet 100 but b!=51 (not TEST-NET-2)"], + ["198.51.5.1", "a=198 b=51 but third octet!=100"], + ["203.5.113.1", "third octet 113 but b!=0 (not TEST-NET-3)"], + ["203.0.5.1", "a=203 b=0 but third octet!=113"], + // Wrong first octet but otherwise-matching range: the leading `a === N` + // check must hold, or the whole range collapses to "any IP whose other + // octets match" (e.g. a CGN match for any b in 64..127). + ["8.100.0.1", "b in CGN range but a!=100"], + ["8.20.0.1", "b in 172-private range but a!=172"], + ["8.0.2.1", "b=0 third=2 but a!=192 (not TEST-NET-1)"], + ["8.0.0.1", "b=0 third=0 but a!=192 (not IETF 192.0.0.0/24)"], + ["8.168.0.1", "b=168 but a!=192 (not 192.168/16)"], + ["8.18.0.1", "b in benchmark range but a!=198"], + ["8.51.100.1", "b=51 third=100 but a!=198 (not TEST-NET-2)"], + ["8.0.113.1", "b=0 third=113 but a!=203 (not TEST-NET-3)"], + ["fc00:1", "two-group malformed IPv6 is not expanded to a ULA"], + ["::ffff:0808:0808", "IPv4-mapped hex resolving to public 8.8.8.8"], + ]; + for (const [ip, desc] of nearMissPublic) { + test(`should allow public: ${ip} (${desc})`, () => { + strictEqual(isPrivateIP(ip), false); + }); + } +}); + +// Addresses sitting exactly on an inclusive CIDR edge: tightening a `<=`/`>=` to a +// strict comparison, or breaking IPv6 leading-zero/`::` normalization, would drop +// them from the private set. +describe("isPrivateIP inclusive boundaries and IPv6 normalization", () => { + const boundaryPrivate = [ + ["100.127.0.1", "CGN upper bound b<=127"], + ["172.31.0.1", "172.16.0.0/12 upper bound b<=31"], + ["::0001", "compressed loopback with leading-zero group"], + ["::0:1", "compressed loopback via right-hand groups"], + // Compressed IPv4-mapped loopback: the per-group zero strip in the `::` + // branch must be anchored (^), or "7f00" loses an interior zero and the + // mapped IPv4 decodes to a public address. + ["::ffff:7f00:1", "compressed IPv4-mapped loopback (::ffff:7f00:1)"], + // Malformed 5-hex-digit link-local group: an over-long group parses to a + // value > 0xffff, which the first-group guard rejects fail-closed. + ["fe800::1", "malformed fe80-prefixed group still private (fail-closed)"], + ]; + for (const [ip, desc] of boundaryPrivate) { + test(`should detect private: ${ip} (${desc})`, () => { + strictEqual(isPrivateIP(ip), true); + }); + } +}); + +// IPv6 ranges added: NAT64 64:ff9b::/96, 6to4 2002::/16, link-local fe80::/10, +// site-local fec0::/10, documentation 2001:db8::/32. Each row pins one boundary +// edge so broadening a prefix/range conjunct misclassifies it as a SSRF hole. +describe("isPrivateIP IPv6 extended ranges", () => { + const privateRows = [ + // NAT64 64:ff9b::/96 embeds an IPv4 in the last two hex groups. + ["64:ff9b::7f00:1", "NAT64 embeds 127.0.0.1 (private)"], + // 6to4 2002::/16 embeds an IPv4 in groups 1 and 2. + ["2002:7f00:1::", "6to4 embeds 127.0.0.1 (private)"], + // Link-local fe80::/10 spans fe80-febf, not just literal fe80. + ["fe80::", "link-local lower edge fe80"], + ["febf::", "link-local upper edge febf"], + // Site-local fec0::/10 spans fec0-feff. + ["fec0::", "site-local lower edge fec0"], + ["feff::", "site-local upper edge feff"], + ["ff00::", "multicast lower edge (existing)"], + // Documentation 2001:db8::/32. + ["2001:db8::", "documentation 2001:db8::/32"], + ]; + for (const [ip, desc] of privateRows) { + test(`should detect private: ${ip} (${desc})`, () => { + strictEqual(isPrivateIP(ip), true); + }); + } + + const publicRows = [ + ["64:ff9b::808:808", "NAT64 embeds 8.8.8.8 (public)"], + ["64:ff9a::7f00:1", "wrong NAT64 prefix (group1 != ff9b)"], + ["2002:808:808::", "6to4 embeds 8.8.8.8 (public)"], + ["fe7f::", "just below link-local fe80"], + ["2001:db7::", "just below documentation db8"], + ["2001:db9::", "just above documentation db8"], + ]; + for (const [ip, desc] of publicRows) { + test(`should allow public: ${ip} (${desc})`, () => { + strictEqual(isPrivateIP(ip), false); + }); + } + + // Fail-closed: malformed hex in the embedded NAT64/6to4 IPv4 groups parses to + // NaN; bit-math on NaN would forge a public-looking IPv4, so block instead. + test("NAT64 with malformed embedded hex is private (fail-closed)", () => { + strictEqual(isPrivateIP("64:ff9b::zzzz:1"), true); + }); + + test("6to4 with malformed embedded hex is private (fail-closed)", () => { + strictEqual(isPrivateIP("2002:zzzz:1::"), true); + }); + + // Full-form (no `::`) addresses with leading-zero groups exercise the non-`::` + // per-group leading-zero strip. The strip must be anchored `^0+(?=.)` (keep at + // least one digit) so an interior group like "0db8" normalizes to "db8": + // - mutating the regex to `^0+(?!.)` or the replacement to a literal leaves + // "0db8" intact, so the 2001:db8 documentation match fails and the address + // is misclassified as PUBLIC. Asserting it is PRIVATE kills both mutants. + test("full-form 2001:0db8 documentation address (no ::) is private after leading-zero strip", () => { + strictEqual(isPrivateIP("2001:0db8:0000:0000:0000:0000:0000:0001"), true); + }); + + // A full-form PUBLIC address whose first group is NOT 2001 (and second not db8) + // must stay public. Forcing the `groups[0] === "2001" && groups[1] === "db8"` + // documentation conjunct to `true` would misclassify it as private; asserting + // public kills that ConditionalExpression mutant. + test("full-form public IPv6 (2606:4700:..) is not private (kills 2001/db8 conditional)", () => { + strictEqual(isPrivateIP("2606:4700:4700:0000:0000:0000:0000:1111"), false); + }); + + // The documentation match requires BOTH conjuncts: a public address whose + // SECOND group is "db8" but whose FIRST group is NOT 2001 (here 2003) must stay + // public. Mutating the left conjunct `groups[0] === "2001"` to `true` would + // classify it private on the db8 second group alone; asserting public kills it. + test("full-form db8 second group but non-2001 first group stays public (kills left conjunct)", () => { + strictEqual(isPrivateIP("2003:0db8:0000:0000:0000:0000:0000:0001"), false); + }); + + // Non-hex first group (not fc/fd/ff) parses to NaN; fail-closed to private. + test("malformed non-hex first group is private (fail-closed)", () => { + strictEqual(isPrivateIP("gggg::1"), true); + }); + + // Over-long first group (> 0xffff) is malformed IPv6; fail-closed to private. + test("over-long first group is private (fail-closed)", () => { + strictEqual(isPrivateIP("10000::1"), true); + }); }); diff --git a/tests/cli.perf.js b/tests/cli.perf.js new file mode 100644 index 0000000..782b3a9 --- /dev/null +++ b/tests/cli.perf.js @@ -0,0 +1,73 @@ +import test from "node:test"; +import { Bench } from "tinybench"; +import { analyze, crawlSchema, MAX_DEPTH } from "../cli.js"; + +// Benchmarks the ANALYSIS ENGINE (analyze / crawlSchema), the code that ingests +// untrusted input. Like index.perf.js these are tracked numbers, not gated +// thresholds: the suite only fails if a benchmark throws. All runs use +// offline:true so no network/DNS occurs. + +test("perf: analyze engine benchmarks", async () => { + const suite = new Bench({ name: "sast-json-schema-engine" }); + + const smallCleanSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string", maxLength: 100, pattern: "^[a-z]+$" }, + age: { type: "integer", minimum: 0, maximum: 150 }, + }, + required: ["name"], + unevaluatedProperties: false, + }; + + // 200 distinct SIMPLE, safe patternProperties keys. This is the regression + // watch for the heap circuit breaker: analyze() calls process.memoryUsage() + // before EVERY pattern, so this case measures that per-pattern overhead on a + // realistic many-pattern schema that should never trip the breaker. + const manyPatternProps = {}; + for (let i = 0; i < 200; i++) { + // Each pattern is distinct and trivially safe (anchored literal prefix). + manyPatternProps[`^k${i}_[a-z0-9]{1,8}$`] = { + type: "string", + maxLength: 64, + }; + } + const manyPatternsSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + patternProperties: manyPatternProps, + }; + + // A linear chain of nested object properties reaching close to MAX_DEPTH. + // Each `properties`+child level adds two depth steps, so build ~MAX_DEPTH-2 + // levels to stay just under the bail cap (so the full tree is crawled). + const buildDeepSchema = (levels) => { + let node = { type: "string", maxLength: 10 }; + for (let i = 0; i < levels; i++) { + node = { + type: "object", + properties: { child: node }, + }; + } + return node; + }; + const deepSchema = buildDeepSchema(Math.floor((MAX_DEPTH - 2) / 2)); + + suite + .add("analyze small clean schema", async () => { + await analyze(smallCleanSchema, { offline: true }); + }) + .add( + "analyze 200 simple patternProperties (heap-breaker watch)", + async () => { + await analyze(manyPatternsSchema, { offline: true }); + }, + ) + .add("crawlSchema deeply-nested schema near MAX_DEPTH", () => { + crawlSchema(deepSchema); + }); + + await suite.run(); + console.table(suite.table()); +}); diff --git a/tests/cli.run.test.js b/tests/cli.run.test.js new file mode 100644 index 0000000..3e2ab13 --- /dev/null +++ b/tests/cli.run.test.js @@ -0,0 +1,572 @@ +import { ok, rejects, strictEqual } from "node:assert"; +import { describe, test } from "node:test"; +import { run } from "../cli.js"; +import pkg from "../package.json" with { type: "json" }; + +// Drives the CLI entrypoint in-process with injected I/O, so the whole arg +// parsing / file reading / output formatting path is unit-testable (the spawned +// subprocess tests in cli.test.js can't attribute coverage to it). +const CLEAN = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/clean.json", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", +}); +const DIRTY = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/dirty.json", + type: "string", +}); + +// files: map matched by path suffix -> file content; sizes: suffix -> byte size. +const runCli = async (argv, { files = {}, sizes = {} } = {}) => { + const out = { log: [], error: [], write: [] }; + const match = (map, p) => { + const key = Object.keys(map).find((k) => p.endsWith(k)); + return key === undefined ? undefined : map[key]; + }; + const io = { + log: (m) => out.log.push(String(m)), + error: (m) => out.error.push(String(m)), + write: (s) => out.write.push(String(s)), + readFile: async (p) => { + const c = match(files, p); + if (c === undefined) { + const e = new Error(`ENOENT: no such file, open '${p}'`); + throw e; + } + return c; + }, + stat: async (p) => { + const s = match(sizes, p); + if (s === undefined) { + if (match(files, p) !== undefined) + return { size: match(files, p).length }; + throw new Error(`ENOENT: no such file, stat '${p}'`); + } + return { size: s }; + }, + }; + const code = await run(argv, io); + return { code, ...out }; +}; + +describe("run() argument handling", () => { + test("--help prints usage and exits 0", async () => { + const r = await runCli(["--help"]); + strictEqual(r.code, 0); + ok(r.log.join("\n").includes("Usage: sast-json-schema [options] ")); + ok(r.log.join("\n").includes("--format ")); + }); + + test("-h is the help alias", async () => { + const r = await runCli(["-h"]); + strictEqual(r.code, 0); + ok(r.log.join("\n").includes("Usage:")); + }); + + test("--version prints the package version and exits 0", async () => { + const r = await runCli(["--version"]); + strictEqual(r.code, 0); + strictEqual(r.log.join(""), pkg.version); + }); + + test("-v is the version alias", async () => { + const r = await runCli(["-v"]); + strictEqual(r.code, 0); + strictEqual(r.log.join(""), pkg.version); + }); + + test("an unknown flag is a usage error (exit 2)", async () => { + const r = await runCli(["--no-such-flag", "x.json"]); + strictEqual(r.code, 2); + ok(r.error.join("\n").startsWith("Error: ")); + }); + + test("an invalid --format is rejected", async () => { + const r = await runCli(["--format", "xml", "x.json"]); + strictEqual(r.code, 2); + ok( + r.error + .join("\n") + .includes('--format must be "human", "json", or "sarif", got "xml"'), + ); + }); + + test("an invalid --lang is rejected", async () => { + const r = await runCli(["--lang", "elvish", "x.json"]); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("--lang must be one of")); + ok(r.error.join("\n").includes('got "elvish"')); + }); + + test("a missing file argument is a usage error", async () => { + const r = await runCli([]); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("missing required argument ")); + }); +}); + +describe("run() file handling", () => { + test("an unreadable file exits 2", async () => { + const r = await runCli(["missing.json"]); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes('cannot read file "missing.json"')); + }); + + test("invalid JSON in the file exits 2", async () => { + const r = await runCli(["bad.json"], { + files: { "bad.json": "{not json" }, + }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes('invalid JSON in file "bad.json"')); + }); + + test("a file larger than the size limit exits 2 at the gate", async () => { + const r = await runCli(["big.json", "--max-schema-size", "10"], { + files: { "big.json": CLEAN }, + sizes: { "big.json": 100 }, + }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("schema file exceeds 10 byte size limit")); + }); + + test("an invalid --max-schema-size defers to analyze (no misleading gate error)", async () => { + // 3.5 is invalid; the file gate falls back to the default and lets analyze() + // raise the TypeError, surfaced as an "analyzing schema" error. + const r = await runCli(["s.json", "--max-schema-size", "3.5"], { + files: { "s.json": CLEAN }, + sizes: { "s.json": 50 }, + }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("analyzing schema")); + ok(r.error.join("\n").includes("maxSchemaSize")); + }); + + test("an unsupported $schema surfaces as an analyze error (exit 2)", async () => { + const r = await runCli(["s.json"], { + files: { "s.json": JSON.stringify({ $schema: "http://bogus/v1" }) }, + }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("analyzing schema")); + }); +}); + +describe("run() output formats", () => { + test("a clean schema reports no issues and exits 0 (human)", async () => { + const r = await runCli(["clean.json", "--offline"], { + files: { "clean.json": CLEAN }, + }); + strictEqual(r.code, 0); + ok(r.log.join("\n").includes("clean.json has no issues")); + }); + + test("a schema with issues exits 1 (human)", async () => { + const r = await runCli(["dirty.json", "--offline"], { + files: { "dirty.json": DIRTY }, + }); + strictEqual(r.code, 1); + ok(r.log.join("\n").includes("dirty.json has issues")); + // the human format dumps the error array as pretty JSON + ok(r.log.join("\n").includes("instancePath")); + }); + + test("json format with no issues writes [] and exits 0", async () => { + const r = await runCli(["clean.json", "--offline", "--format", "json"], { + files: { "clean.json": CLEAN }, + }); + strictEqual(r.code, 0); + strictEqual(r.write.join("").trim(), "[]"); + strictEqual(r.error.length, 0); + }); + + test("json format with issues writes the array and exits 1", async () => { + const r = await runCli(["dirty.json", "--offline", "--format", "json"], { + files: { "dirty.json": DIRTY }, + }); + strictEqual(r.code, 1); + const parsed = JSON.parse(r.write.join("")); + ok(Array.isArray(parsed) && parsed.length > 0); + ok(r.error.join("\n").includes("issue(s)")); + }); + + test("sarif format with issues writes SARIF 2.1.0 and exits 1", async () => { + const r = await runCli(["dirty.json", "--offline", "--format", "sarif"], { + files: { "dirty.json": DIRTY }, + }); + strictEqual(r.code, 1); + const sarif = JSON.parse(r.write.join("")); + strictEqual(sarif.version, "2.1.0"); + ok(sarif.runs[0].results.length > 0); + ok(r.error.join("\n").includes("issue(s)")); + }); + + test("sarif format with no issues writes an empty-results SARIF and exits 0", async () => { + const r = await runCli(["clean.json", "--offline", "--format", "sarif"], { + files: { "clean.json": CLEAN }, + }); + strictEqual(r.code, 0); + const sarif = JSON.parse(r.write.join("")); + strictEqual(sarif.runs[0].results.length, 0); + strictEqual(r.error.length, 0); + }); +}); + +describe("run() option plumbing", () => { + test("--ignore suppresses a matching finding", async () => { + const dirty = await runCli(["d.json", "--offline"], { + files: { "d.json": DIRTY }, + }); + strictEqual(dirty.code, 1); + // Discover an instancePath to ignore, then suppress it. + const errs = JSON.parse( + ( + await runCli(["d.json", "--offline", "--format", "json"], { + files: { "d.json": DIRTY }, + }) + ).write.join(""), + ); + const target = errs[0].instancePath; + const r = await runCli( + ["d.json", "--offline", "--format", "json", "--ignore", target], + { files: { "d.json": DIRTY } }, + ); + const remaining = JSON.parse(r.write.join("")); + ok(!remaining.some((e) => e.instancePath === target)); + }); + + test("--ref-schema-files marks its $id hostname safe (skips that SSRF host)", async () => { + const schema = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/root.json", + $ref: "https://safe-ref-host.invalid/x.json", + }); + const refSchema = JSON.stringify({ + $id: "https://safe-ref-host.invalid/ref.json", + }); + const r = await runCli( + [ + "s.json", + "--dns-total-timeout-ms", + "0", + "--format", + "json", + "-r", + "ref.json", + ], + { files: { "s.json": schema, "ref.json": refSchema } }, + ); + const errs = JSON.parse(r.write.join("")); + ok( + !errs.some((e) => e.keyword === "ssrf"), + "the ref-schema-files $id hostname must be treated as safe", + ); + }); + + test("a non-URL $id in a ref-schema-file is ignored (no crash)", async () => { + const schema = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/root.json", + type: "string", + maxLength: 5, + pattern: "^[a-z]+$", + }); + const r = await runCli(["s.json", "--offline", "-r", "ref.json"], { + files: { + "s.json": schema, + "ref.json": JSON.stringify({ $id: "not-a-url" }), + }, + }); + strictEqual(r.code, 0); + }); +}); + +const ENUM_BIG = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/enum.json", + type: "string", + maxLength: 100, + enum: Array.from({ length: 2000 }, (_, i) => `v${i}`), +}); +const PROPS_BIG = (() => { + const props = {}; + for (let i = 0; i < 1100; i++) + props[`p${i}`] = { type: "string", maxLength: 10, pattern: "^[a-z]+$" }; + return JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/props.json", + type: "object", + properties: props, + required: ["p0"], + unevaluatedProperties: false, + maxProperties: 2000, + }); +})(); +const REMOTE = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/remote.json", + $ref: "https://run-ssrf-host.invalid/x.json", +}); + +const jsonErrors = (r) => JSON.parse(r.write.join("")); + +// Re-uses the runCli helper and CLEAN/DIRTY fixtures defined above. +describe("run() default I/O wiring", () => { + test("--version uses the default logger (no injected io)", async () => { + strictEqual(await run(["--version"]), 0); + }); + test("an arg error uses the default error logger", async () => { + strictEqual(await run(["--nope", "x.json"]), 2); + }); + test("reading a real fixture uses default readFile/stat/write", async () => { + // No io injected: exercises the real fs + process.stdout.write defaults. + const code = await run([ + "tests/fixtures/boolean.json", + "--offline", + "--format", + "json", + ]); + ok(code === 0 || code === 1, `expected 0 or 1, got ${code}`); + }); +}); + +describe("run() readJsonFile read failure", () => { + test("a file that stats but cannot be read exits 2", async () => { + // stat succeeds (size provided) but readFile has no entry -> throws. + const r = await runCli(["x.json"], { sizes: { "x.json": 10 } }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes('cannot read file "x.json"')); + }); +}); + +describe("run() offline defaults to false", () => { + test("a remote $ref is SSRF-checked when --offline is omitted", async () => { + const r = await runCli( + ["s.json", "--dns-total-timeout-ms", "0", "--format", "json"], + { files: { "s.json": REMOTE } }, + ); + ok( + jsonErrors(r).some((e) => e.keyword === "ssrf"), + "default (non-offline) run must perform the SSRF check", + ); + }); +}); + +describe("run() --lang error lists the languages", () => { + test("the error enumerates langs comma-separated", async () => { + const r = await runCli(["--lang", "elvish", "x.json"]); + ok(r.error.join("\n").includes("js, py")); + }); +}); + +describe("run() file-size gate boundaries", () => { + test("--max-schema-size 0 fails at the gate (not analyze)", async () => { + const r = await runCli(["s.json", "--max-schema-size", "0"], { + files: { "s.json": CLEAN }, + sizes: { "s.json": 50 }, + }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("schema file exceeds 0 byte size limit")); + }); + test("a negative --max-schema-size defers to analyze", async () => { + // `=` form so parseArgs reads -1 as the value, not a flag. + const r = await runCli(["s.json", "--max-schema-size=-1"], { + files: { "s.json": CLEAN }, + sizes: { "s.json": 50 }, + }); + strictEqual(r.code, 2); + ok(r.error.join("\n").includes("analyzing schema")); + }); + test("a file exactly at the limit passes the gate (strict >)", async () => { + const size = CLEAN.length; + const r = await runCli(["s.json", "--max-schema-size", String(size)], { + files: { "s.json": CLEAN }, + sizes: { "s.json": size }, + }); + strictEqual(r.code, 0); + ok(r.log.join("\n").includes("no issues")); + }); +}); + +describe("run() override option plumbing", () => { + test("--override-max-items is forwarded to analyze", async () => { + const without = jsonErrors( + await runCli(["e.json", "--offline", "--format", "json"], { + files: { "e.json": ENUM_BIG }, + }), + ); + ok(without.some((x) => x.keyword === "maxItems")); + const withOv = jsonErrors( + await runCli( + [ + "e.json", + "--offline", + "--format", + "json", + "--override-max-items", + "5000", + ], + { files: { "e.json": ENUM_BIG } }, + ), + ); + ok(!withOv.some((x) => x.keyword === "maxItems")); + }); + + test("--override-max-properties is forwarded to analyze", async () => { + const withOv = jsonErrors( + await runCli( + [ + "p.json", + "--offline", + "--format", + "json", + "--override-max-properties", + "5000", + ], + { files: { "p.json": PROPS_BIG } }, + ), + ); + ok(!withOv.some((x) => x.keyword === "maxProperties")); + }); + + test("--override-max-depth is forwarded to analyze", async () => { + // a nested schema so maxDepth 0 actually trips depth-exceeded. + const nested = JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/nested.json", + type: "object", + properties: { a: { type: "string", maxLength: 10, pattern: "^[a-z]+$" } }, + required: ["a"], + unevaluatedProperties: false, + maxProperties: 5, + }); + const r = await runCli( + ["c.json", "--offline", "--format", "json", "--override-max-depth", "0"], + { files: { "c.json": nested } }, + ); + ok(jsonErrors(r).some((x) => x.keyword === "depth")); + }); + + test("--analysis-timeout-ms is forwarded to analyze", async () => { + const r = await runCli( + ["c.json", "--offline", "--format", "json", "--analysis-timeout-ms", "0"], + { files: { "c.json": CLEAN } }, + ); + ok(jsonErrors(r).some((x) => x.keyword === "timeout")); + }); + + test("--max-ssrf-hostnames is forwarded to analyze", async () => { + const r = await runCli( + ["s.json", "--format", "json", "--max-ssrf-hostnames", "0"], + { files: { "s.json": REMOTE } }, + ); + const ssrf = jsonErrors(r).find((x) => x.keyword === "ssrf"); + ok(ssrf, "max-ssrf-hostnames 0 must trip the hostname cap"); + ok(ssrf.message.includes("too many distinct")); + }); +}); + +describe("run() propagates unexpected (non-CliExit) errors", () => { + test("an io error during output is not swallowed", async () => { + await rejects( + run(["clean.json", "--offline"], { + readFile: async () => CLEAN, + stat: async () => ({ size: CLEAN.length }), + log: () => { + throw new Error("boom"); + }, + }), + ); + }); +}); + +describe("run() default I/O actually writes to the console", () => { + test("default log goes to console.log", async () => { + const orig = console.log; + const cap = []; + console.log = (m) => cap.push(String(m)); + try { + await run(["--version"]); + } finally { + console.log = orig; + } + ok(cap.join("").includes(pkg.version)); + }); + + test("default error goes to console.error", async () => { + const orig = console.error; + const cap = []; + console.error = (m) => cap.push(String(m)); + try { + await run(["--bad-flag", "x.json"]); + } finally { + console.error = orig; + } + ok(cap.join("").startsWith("Error: ")); + }); + + test("default write goes to process.stdout.write", async () => { + const orig = process.stdout.write; + const cap = []; + process.stdout.write = (s) => { + cap.push(String(s)); + return true; + }; + try { + await run([ + "tests/fixtures/boolean.json", + "--offline", + "--format", + "json", + ]); + } finally { + process.stdout.write = orig; + } + // the test runner also writes to stdout, so look for the JSON chunk. + ok(cap.some((s) => s.trimStart().startsWith("["))); + }); +}); + +describe("run() ref-schema-files and dns budget specifics", () => { + test("an unreadable --ref-schema-files file errors with its label", async () => { + const r = await runCli(["s.json", "--offline", "-r", "no-ref.json"], { + files: { + "s.json": JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/s.json", + type: "string", + maxLength: 5, + pattern: "^[a-z]+$", + }), + }, + }); + strictEqual(r.code, 2); + ok( + r.error + .join("\n") + .includes('cannot read --ref-schema-files file "no-ref.json"'), + ); + }); + + test("--dns-total-timeout-ms 0 produces a budget-exceeded ssrf finding", async () => { + const r = await runCli( + ["s.json", "--dns-total-timeout-ms", "0", "--format", "json"], + { files: { "s.json": REMOTE } }, + ); + const ssrf = jsonErrors(r).find((e) => e.keyword === "ssrf"); + ok(ssrf, "expected an ssrf finding"); + ok( + ssrf.message.includes("budget"), + "dnsTotalTimeoutMs:0 must fail closed on the budget (proves the key is read)", + ); + }); + + test("without --max-schema-size a normal file passes the size gate", async () => { + const r = await runCli(["clean.json", "--offline"], { + files: { "clean.json": CLEAN }, + }); + strictEqual(r.code, 0); + ok(!r.error.join("\n").includes("byte size limit")); + }); +}); diff --git a/tests/cli.sast.test.js b/tests/cli.sast.test.js index 5169467..c2ddb16 100644 --- a/tests/cli.sast.test.js +++ b/tests/cli.sast.test.js @@ -1,6 +1,6 @@ -import { ok, strictEqual } from "node:assert"; +import { ok, strictEqual, throws } from "node:assert"; import { describe, test } from "node:test"; -import sast, { analyze, MAX_SCHEMA_SIZE } from "../cli.js"; +import sast, { analyze, crawlSchema, MAX_SCHEMA_SIZE } from "../cli.js"; test("sast should return a validate function", () => { const validate = sast(); @@ -244,3 +244,37 @@ describe("sast validate vs analyze consistency", () => { ); }); }); + +describe("sast() $schema URL normalization", () => { + test("a trailing '#' on the $schema URL still resolves the draft", () => { + // kills the `.replace(/#$/, "")` step + const validate = sast({ + $schema: "https://json-schema.org/draft/2020-12/schema#", + }); + strictEqual(typeof validate, "function"); + }); + + test("a protocol-relative $schema URL still resolves the draft", () => { + // kills the `.replace(/^(?:https?:)?\/\//, "")` step + const validate = sast({ + $schema: "//json-schema.org/draft/2020-12/schema", + }); + strictEqual(typeof validate, "function"); + }); + + test("an unsupported $schema throws naming the value", () => { + throws( + () => sast({ $schema: "http://bogus.example/v1" }), + /Unsupported \$schema: http:\/\/bogus\.example\/v1/, + ); + }); +}); + +describe("resolveDangerousNames unknown language", () => { + test("an unknown lang throws listing the valid options comma-separated", () => { + throws( + () => crawlSchema({ type: "object" }, 32, { lang: "klingon" }), + /unknown lang "klingon", expected one of: js, py, /, + ); + }); +}); diff --git a/tests/cli.test.js b/tests/cli.test.js index 9da7dc9..c59dc8a 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -32,6 +32,121 @@ describe("cli.", () => { ok(r.stdout.includes("--format")); }); + test("--help documents the new resource-limit flags", async () => { + const r = await runCli(["--help"]); + strictEqual(r.code, 0); + ok(r.stdout.includes("--max-schema-size")); + ok(r.stdout.includes("--analysis-timeout-ms")); + ok(r.stdout.includes("--max-ssrf-hostnames")); + ok(r.stdout.includes("--dns-total-timeout-ms")); + }); + + test("--max-schema-size below schema size exits 2 with size error", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "schema.json"); + await writeFile( + path, + JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }), + ); + const r = await runCli(["--offline", "--max-schema-size", "5", path]); + strictEqual(r.code, 2); + ok(r.stderr.includes("size")); + }); + + test("--max-schema-size is applied to the file-size check, not just analyze()", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "padded.json"); + // A clean schema whose compact serialization is tiny, padded with + // trailing whitespace so the FILE is far larger than the schema. With a + // limit between the two, only a file-size check (not the serialized-size + // check inside analyze) will reject it. + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }; + await writeFile(path, `${JSON.stringify(schema)}${" ".repeat(5000)}`); + const r = await runCli(["--offline", "--max-schema-size", "1000", path]); + strictEqual(r.code, 2); + ok(r.stderr.includes("exceeds")); + }); + + test("--max-schema-size with a non-integer reports a validation error, not a file-size error", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "schema.json"); + await writeFile( + path, + JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }), + ); + const r = await runCli(["--offline", "--max-schema-size", "3.5", path]); + strictEqual(r.code, 2); + ok(r.stderr.includes("non-negative integer")); + ok(!r.stderr.includes("byte size limit")); + }); + + test("--analysis-timeout-ms 0 reports a timeout finding (exit 1)", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "schema.json"); + await writeFile( + path, + JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }), + ); + const r = await runCli(["--offline", "--analysis-timeout-ms", "0", path]); + strictEqual(r.code, 1); + }); + + test("--max-ssrf-hostnames with non-integer exits 2", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "schema.json"); + await writeFile( + path, + JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }), + ); + const r = await runCli(["--offline", "--max-ssrf-hostnames", "abc", path]); + strictEqual(r.code, 2); + }); + test("--help documents exit codes", async () => { const r = await runCli(["--help"]); strictEqual(r.code, 0); @@ -39,6 +154,8 @@ describe("cli.", () => { ok(/0\b.*no issues/i.test(r.stdout)); ok(/1\b.*issues/i.test(r.stdout)); ok(/2\b.*(usage|tool)/i.test(r.stdout)); + // exit-code descriptions name the resource-limit conditions (matches README) + ok(/depth-exceeded/i.test(r.stdout)); }); test("--version prints a semver-looking string", async () => { @@ -59,6 +176,35 @@ describe("cli.", () => { ok(r.stderr.includes("cannot read file")); }); + test("nonexistent --ref-schema-files exits 2", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "schema.json"); + await writeFile( + path, + JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + maxLength: 10, + pattern: "^[a-z]+$", + }), + ); + // The main is gated by stat() first; ref files are read directly, + // so a missing ref file is the only path that reaches readJsonFile's + // readFile error branch. + const r = await runCli([ + "--offline", + "-r", + "/tmp/does-not-exist-ref-xyz.json", + path, + ]); + strictEqual(r.code, 2); + ok(r.stderr.includes("cannot read --ref-schema-files file")); + }); + test("oversized file is rejected before being read", async () => { const { mkdtemp, open } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); @@ -204,6 +350,26 @@ describe("cli.", () => { ok(r.stdout.includes("has no issues")); }); + test("schema with issues prints human-format header and exits 1", async () => { + const { writeFile, mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "sast-test-")); + const path = join(dir, "insecure.json"); + await writeFile( + path, + JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "test", + type: "string", + pattern: "^(a+)+$", + }), + ); + const r = await runCli(["--offline", path]); + strictEqual(r.code, 1); + ok(r.stdout.includes("has issues")); + }); + test("--override-max-depth with non-integer exits 2", async () => { const { writeFile, mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os");