From 94bd0a84419df3dbdb98601b2a9586db614ec38b Mon Sep 17 00:00:00 2001 From: will Farrell Date: Tue, 14 Apr 2026 15:02:06 -0600 Subject: [PATCH 1/6] chore: dep update Signed-off-by: will Farrell --- biome.json | 2 +- package-lock.json | 70 +++++++++++++++++++++++------------------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/biome.json b/biome.json index c3e7534..09cd887 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package-lock.json b/package-lock.json index ec03c13..91b6a56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,9 +97,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.11.tgz", - "integrity": "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.12.tgz", + "integrity": "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -113,20 +113,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.11", - "@biomejs/cli-darwin-x64": "2.4.11", - "@biomejs/cli-linux-arm64": "2.4.11", - "@biomejs/cli-linux-arm64-musl": "2.4.11", - "@biomejs/cli-linux-x64": "2.4.11", - "@biomejs/cli-linux-x64-musl": "2.4.11", - "@biomejs/cli-win32-arm64": "2.4.11", - "@biomejs/cli-win32-x64": "2.4.11" + "@biomejs/cli-darwin-arm64": "2.4.12", + "@biomejs/cli-darwin-x64": "2.4.12", + "@biomejs/cli-linux-arm64": "2.4.12", + "@biomejs/cli-linux-arm64-musl": "2.4.12", + "@biomejs/cli-linux-x64": "2.4.12", + "@biomejs/cli-linux-x64-musl": "2.4.12", + "@biomejs/cli-win32-arm64": "2.4.12", + "@biomejs/cli-win32-x64": "2.4.12" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.11.tgz", - "integrity": "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.12.tgz", + "integrity": "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.11.tgz", - "integrity": "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.12.tgz", + "integrity": "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==", "cpu": [ "x64" ], @@ -158,9 +158,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.11.tgz", - "integrity": "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.12.tgz", + "integrity": "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==", "cpu": [ "arm64" ], @@ -178,9 +178,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.11.tgz", - "integrity": "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.12.tgz", + "integrity": "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==", "cpu": [ "arm64" ], @@ -198,9 +198,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.11.tgz", - "integrity": "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.12.tgz", + "integrity": "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw==", "cpu": [ "x64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.11.tgz", - "integrity": "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.12.tgz", + "integrity": "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew==", "cpu": [ "x64" ], @@ -238,9 +238,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.11.tgz", - "integrity": "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.12.tgz", + "integrity": "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==", "cpu": [ "arm64" ], @@ -255,9 +255,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.11.tgz", - "integrity": "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.12.tgz", + "integrity": "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==", "cpu": [ "x64" ], From 17a1d93d2c1580f7d5e109683450f58241656ab3 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Fri, 17 Apr 2026 05:01:05 -0600 Subject: [PATCH 2/6] chore: dep update Signed-off-by: will Farrell --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91b6a56..8942686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1639,9 +1639,9 @@ } }, "node_modules/fast-check": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.6.0.tgz", - "integrity": "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", "dev": true, "funding": [ { @@ -2955,9 +2955,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "peer": true, From 4c6577e52b528821e2ed275f023b3f8f9e6ece72 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Sat, 18 Apr 2026 18:05:37 -0600 Subject: [PATCH 3/6] chore: clean up Signed-off-by: will Farrell --- .github/dependabot.yml | 13 +- .github/package.json | 1 + .github/workflows/ossf-scorecard.yml | 2 +- .github/workflows/release.yml | 4 +- .github/workflows/test-dast.yml | 2 + .github/workflows/test-lint.yml | 8 +- .github/workflows/test-perf.yml | 2 + .github/workflows/test-sast.yml | 32 +++ .github/workflows/test-types.yml | 2 + .github/workflows/test-unit.yml | 5 + license.json => .license.config.json | 4 +- .license.template | 2 + LICENSE | 2 +- __test__/e2e.sh | 45 ++-- cli.js | 18 +- commands/_utils.js | 20 ++ commands/deref.js | 55 +--- commands/ftl.js | 16 +- commands/sast.js | 330 +---------------------- commands/sast.test.js | 181 +++++++++++++ commands/transpile.js | 25 +- commands/validate.js | 33 +-- deref.js | 34 +++ index.js | 3 + index.test.js | 6 + index.tst.ts | 7 + license.template | 2 - package.json | 22 +- sast.js | 377 ++++++++++++++++++++++++++- sast.test.js | 62 ++++- transpile.js | 41 +-- validate.js | 28 +- validate.test.js | 4 +- 33 files changed, 863 insertions(+), 525 deletions(-) rename license.json => .license.config.json (87%) create mode 100644 .license.template create mode 100644 commands/_utils.js create mode 100644 deref.js delete mode 100644 license.template diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 255e74b..34bfca4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,4 +13,15 @@ updates: directory: "/" schedule: interval: "weekly" - cooldown-period: 15 + cooldown: + default-days: 15 + groups: + dev-dependencies: + dependency-type: "development" + update-types: + - "minor" + - "patch" + prod-dependencies: + dependency-type: "production" + update-types: + - "patch" diff --git a/.github/package.json b/.github/package.json index ea1bd2f..1b05f5b 100644 --- a/.github/package.json +++ b/.github/package.json @@ -1,6 +1,7 @@ { "name": "@willfarrell/ajv-cmd-github-workflows", "version": "0.0.1", + "description": "Workspace for CI-only dev tools (e.g. lockfile-lint) that should not be installed by users or bloat the top-level dependency graph.", "private": true, "engines": { "node": ">=24.0" diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index d1805b5..12bd2da 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -18,7 +18,7 @@ jobs: id-token: write steps: - name: 'Checkout code' - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: 'Run analysis' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4f5326..04fa033 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ permissions: jobs: build: name: Build - if: ${{ github.event.pull_request.merged }} + if: ${{ github.event.pull_request.merged && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest permissions: contents: read @@ -25,6 +25,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: diff --git a/.github/workflows/test-dast.yml b/.github/workflows/test-dast.yml index b770c56..8fdc97b 100644 --- a/.github/workflows/test-dast.yml +++ b/.github/workflows/test-dast.yml @@ -17,6 +17,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml index 000a357..8fce92b 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -16,9 +16,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org @@ -27,4 +29,4 @@ jobs: npm ci --ignore-scripts - name: Linting run: | - ./node_modules/.bin/biome ci --no-errors-on-unmatched + npm run test:lint diff --git a/.github/workflows/test-perf.yml b/.github/workflows/test-perf.yml index 2ae31e6..1f54db8 100644 --- a/.github/workflows/test-perf.yml +++ b/.github/workflows/test-perf.yml @@ -17,6 +17,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: diff --git a/.github/workflows/test-sast.yml b/.github/workflows/test-sast.yml index 7a74cdc..df285f7 100644 --- a/.github/workflows/test-sast.yml +++ b/.github/workflows/test-sast.yml @@ -20,6 +20,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Trivy uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: @@ -38,6 +40,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: @@ -57,6 +61,28 @@ jobs: exit-code: 0 format: table + license: + name: "License headers" + runs-on: ubuntu-latest + if: (github.actor != 'dependabot[bot]') + steps: + - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + cache: npm + - name: Install dependencies + run: | + npm ci --ignore-scripts + - name: License check + run: | + npm run test:sast:license + lockfile: name: "lockfile-lint: SAST package-lock.json" runs-on: ubuntu-latest @@ -64,6 +90,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: @@ -91,6 +119,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@babb554ede22fd5605947329c4d04d8e7a0b8155 # v2.27.7 with: @@ -112,5 +142,7 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: semgrep run: semgrep ci diff --git a/.github/workflows/test-types.yml b/.github/workflows/test-types.yml index 192df75..02d66e8 100644 --- a/.github/workflows/test-types.yml +++ b/.github/workflows/test-types.yml @@ -17,6 +17,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index dd1c647..9dac669 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -17,6 +17,8 @@ jobs: steps: - 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: @@ -32,3 +34,6 @@ jobs: - name: Unit tests run: | npm run test:unit + - name: E2E tests + run: | + npm run test:e2e diff --git a/license.json b/.license.config.json similarity index 87% rename from license.json rename to .license.config.json index 21ff47e..1e1220b 100644 --- a/license.json +++ b/.license.config.json @@ -1,5 +1,5 @@ { - "license": "license.template", + "license": ".license.template", "licenseFormats": { "js|ts": { "eachLine": { @@ -14,7 +14,7 @@ "fixtures/*", "commitlint.config.cjs", "LICENSE", - "license.template", + ".license.template", "**/.gitignore", "**/*.fuzz.js", "**/*.perf.js", diff --git a/.license.template b/.license.template new file mode 100644 index 0000000..d9c4a9f --- /dev/null +++ b/.license.template @@ -0,0 +1,2 @@ +Copyright 2022-2026 will Farrell, and ajv-cmd contributors. +SPDX-License-Identifier: MIT diff --git a/LICENSE b/LICENSE index 660d68c..7bd429e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 will Farrell +Copyright (c) 2026 will Farrell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/__test__/e2e.sh b/__test__/e2e.sh index ffeba25..821d070 100755 --- a/__test__/e2e.sh +++ b/__test__/e2e.sh @@ -1,23 +1,32 @@ #!/usr/bin/env bash +set -euo pipefail -function bundle { - echo "validate ${1}" - node cli.js validate ${1} --valid \ - --strict true --coerce-types array --all-errors true --use-defaults empty - - echo "sast audit ${1}" - node cli.js sast ${1} - - echo "transpile ${1}" - node cli.js transpile ${1} \ - --strict true --coerce-types array --all-errors true --use-defaults empty \ - -o ${1%.json}.js - - #cat ${1/schema/data} - - echo "test ${1}" - node --input-type=module -e "import validate from '${1%.json}.js'; import data from '${1/schema/data}' assert {type:'json'}; const valid = validate(data); console.log(valid, JSON.stringify(validate.errors))" +bundle() { + local schema="$1" + local data="${schema/schema/data}" + local out="${schema%.json}.js" + + echo "validate ${schema}" + node cli.js validate "${schema}" --valid \ + --strict true --coerce-types array --all-errors --use-defaults empty + + echo "sast ${schema}" + node cli.js sast "${schema}" + + echo "transpile ${schema}" + node cli.js transpile "${schema}" \ + --strict true --coerce-types array --all-errors --use-defaults empty \ + -o "${out}" + + echo "test ${schema}" + node --input-type=module -e " + import validate from '${out}'; + import data from '${data}' with { type: 'json' }; + const valid = validate(data); + console.log(valid, JSON.stringify(validate.errors)); + " + + rm -f "${out}" } bundle ./__test__/formats.schema.json - diff --git a/cli.js b/cli.js index 1fbbc02..af74467 100755 --- a/cli.js +++ b/cli.js @@ -1,9 +1,9 @@ #!/usr/bin/env -S node --disable-warning=DEP0040 // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -// --disable-warning=DEP0040 [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead. -// #!/usr/bin/env -S node --experimental-json-modules --no-warnings --no-deprecation +// --disable-warning=DEP0040 suppresses: [DEP0040] DeprecationWarning: The `punycode` module is deprecated. +import { createRequire } from "node:module"; import { Command, Option } from "commander"; import deref from "./commands/deref.js"; import ftl from "./commands/ftl.js"; @@ -11,13 +11,13 @@ import sast from "./commands/sast.js"; import transpile from "./commands/transpile.js"; import validate from "./commands/validate.js"; -//import metadata from './package.json' assert { type: 'json' } +const { version } = createRequire(import.meta.url)("./package.json"); const program = new Command() .name("ajv") - //.version(metadata.version) + .version(version) .description( - "Transpile JSON-Schema (.json) files to JavaScript (.js or .mjs) using ajv", + "Validate, transpile, dereference, and audit JSON-Schema files using AJV", ); program @@ -71,8 +71,6 @@ program program .command("transpile") .argument("", "Path to the JSON-Schema file to transpile") - //.addOption(new Option('--ftl ', 'Path to ftl file') - // Docs: https://ajv.js.org/packages/ajv-cli.html .addOption( new Option( @@ -165,6 +163,12 @@ program "Override the max properties limit (default 1024). Removes maxProperties errors when the property count is within this limit. Values <= 1024 are a no-op.", ), ) + .addOption( + new Option( + "--ignore ", + "Suppress errors by `instancePath` or `instancePath:keyword` (exact match). Each ignored error is logged.", + ), + ) .addOption( new Option( "-o, --output ", diff --git a/commands/_utils.js b/commands/_utils.js new file mode 100644 index 0000000..e863efa --- /dev/null +++ b/commands/_utils.js @@ -0,0 +1,20 @@ +// Copyright 2026 will Farrell, and ajv-cmd contributors. +// SPDX-License-Identifier: MIT +import { readFile, stat } from "node:fs/promises"; + +export const assertFile = async (filepath) => { + const stats = await stat(filepath); + if (!stats.isFile()) { + throw new Error(`${filepath} is not a file`); + } +}; + +export const readJson = async (filepath) => { + const raw = await readFile(filepath, { encoding: "utf8" }); + return JSON.parse(raw); +}; + +export const loadRefSchemas = async (paths) => { + if (!paths?.length) return undefined; + return Promise.all(paths.map(readJson)); +}; diff --git a/commands/deref.js b/commands/deref.js index 49309a7..a5376b1 100755 --- a/commands/deref.js +++ b/commands/deref.js @@ -1,33 +1,19 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { readFile, stat, writeFile } from "node:fs/promises"; -import { dereference } from "@apidevtools/json-schema-ref-parser"; - -const fileExists = async (filepath) => { - const stats = await stat(filepath); - if (!stats.isFile()) { - throw new Error(`${filepath} is not a file`); - } -}; +import { writeFile } from "node:fs/promises"; +import deref from "../deref.js"; +import { assertFile, loadRefSchemas, readJson } from "./_utils.js"; export default async (input, options) => { - await fileExists(input); - const jsonSchema = await readFile(input, { encoding: "utf8" }).then((res) => - JSON.parse(res), - ); + await assertFile(input); + + const jsonSchema = await readJson(input); if (options?.refSchemaFiles) { - const refSchemas = []; - for (const schemaFilePath of options.refSchemaFiles) { - const refSchemaFile = await readFile(schemaFilePath, { - encoding: "utf8", - }).then((res) => JSON.parse(res)); - refSchemas.push(refSchemaFile); - } - mockFetch(refSchemas); + options.schemas = await loadRefSchemas(options.refSchemaFiles); } - const json = await dereference(jsonSchema); + const json = await deref(jsonSchema, options); if (typeof options.output === "string") { await writeFile(options.output, JSON.stringify(json), "utf8"); @@ -37,28 +23,3 @@ export default async (input, options) => { console.log(JSON.stringify(json)); } }; - -const mockFetch = (schemas) => { - const _fetch = fetch; - - const cache = {}; - const enc = new TextEncoder(); - for (let i = schemas.length; i--; ) { - const schema = schemas[i]; - if (schema.$id) { - cache[schema.$id] = enc.encode(JSON.stringify(schema)); - } - } - - globalThis.fetch = async (...args) => { - if (cache[args[0].href]) { - return Promise.resolve({ - status: 200, - body: true, - arrayBuffer: async () => cache[args[0].href], - }); - } - - return _fetch(...args); - }; -}; diff --git a/commands/ftl.js b/commands/ftl.js index cd6dca7..75565ab 100755 --- a/commands/ftl.js +++ b/commands/ftl.js @@ -1,17 +1,11 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { readFile, stat, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import transpile from "../ftl.js"; +import { assertFile } from "./_utils.js"; -const fileExists = async (filepath) => { - const stats = await stat(filepath); - if (!stats.isFile()) { - throw new Error(`${filepath} is not a file`); - } -}; - -const ftl = async (input, options) => { - await fileExists(input); +export default async (input, options) => { + await assertFile(input); const ftl = await readFile(input, { encoding: "utf8" }); @@ -23,5 +17,3 @@ const ftl = async (input, options) => { } return js; }; - -export default ftl; diff --git a/commands/sast.js b/commands/sast.js index 893881a..cf4bf5d 100644 --- a/commands/sast.js +++ b/commands/sast.js @@ -1,90 +1,19 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { lookup } from "node:dns/promises"; -import { readFile, stat, writeFile } from "node:fs/promises"; -import { isSafePattern } from "redos-detector"; -import sast from "../sast.js"; - -const fileExists = async (filepath) => { - const stats = await stat(filepath); - if (!stats.isFile()) { - throw new Error(`${filepath} is not a file`); - } -}; +import { writeFile } from "node:fs/promises"; +import { analyze } from "../sast.js"; +import { assertFile, loadRefSchemas, readJson } from "./_utils.js"; export default async (input, options) => { - await fileExists(input); + await assertFile(input); - const jsonSchema = await readFile(input, { encoding: "utf8" }).then((res) => - JSON.parse(res), - ); + const jsonSchema = await readJson(input); if (options?.refSchemaFiles) { - const refSchemas = []; - for (const schemaFilePath of options.refSchemaFiles) { - const refSchemaFile = await readFile(schemaFilePath, { - encoding: "utf8", - }).then((res) => JSON.parse(res)); - refSchemas.push(refSchemaFile); - } - options.schemas = refSchemas; + options.schemas = await loadRefSchemas(options.refSchemaFiles); } - const maxDepth = - options.overrideMaxDepth != null - ? Number(options.overrideMaxDepth) - : MAX_DEPTH; - - // Single crawl: depth, min/max, patterns, $ref collection - const crawl = crawlSchema(jsonSchema, maxDepth); - - let errors = []; - - if (crawl.depthExceeded) { - errors.push({ - instancePath: "", - schemaPath: "#/depth", - keyword: "depth", - params: { depth: crawl.depth, limit: maxDepth }, - message: `must NOT have depth greater than ${maxDepth}`, - }); - } else { - // Only run meta-schema validation if depth is safe - const validate = sast(jsonSchema, options); - validate(jsonSchema, options); - if (validate.errors) errors.push(...validate.errors); - - // Append crawl findings after validate errors - errors.push(...crawl.errors); - - const ssrfErrors = await resolveSSRFRefs(crawl.refs); - errors.push(...ssrfErrors); - - if (options.overrideMaxItems != null && errors.length) { - const limit = Number(options.overrideMaxItems); - errors = errors.filter((err) => { - if (err.schemaPath === "#/definitions/safeArrayItemsLimits/maxItems") { - const arr = resolveInstancePath(jsonSchema, err.instancePath); - return !Array.isArray(arr) || arr.length > limit; - } - return true; - }); - } - if (options.overrideMaxProperties != null && errors.length) { - const limit = Number(options.overrideMaxProperties); - errors = errors.filter((err) => { - if ( - err.schemaPath === - "#/definitions/safeObjectPropertiesLimits/maxProperties" - ) { - const obj = resolveInstancePath(jsonSchema, err.instancePath); - if (typeof obj !== "object" || obj === null) return true; - return Object.keys(obj).length > limit; - } - return true; - }); - } - } + const errors = await analyze(jsonSchema, options); if (errors.length) { if (typeof options.output === "string") { @@ -92,7 +21,7 @@ export default async (input, options) => { } else if (options.output === true) { return errors; } else { - console.log(input, "has issues", stringify(errors)); + console.log(input, "has issues", JSON.stringify(errors, null, 2)); } if (options.fail) { process.exit(1); @@ -101,246 +30,3 @@ export default async (input, options) => { console.log(input, "has no issues"); } }; - -const MAX_DEPTH = 32; - -const crawlSchema = (obj, maxDepth) => { - const result = { depth: 0, depthExceeded: false, errors: [], refs: [] }; - if (typeof obj !== "object" || obj === null) return result; - - result.depth = 1; - const stack = [[obj, "", 1]]; - - while (stack.length > 0) { - const [current, path, currentDepth] = stack.pop(); - - // minLength / maxLength - if ( - Object.hasOwn(current, "minLength") && - Object.hasOwn(current, "maxLength") && - current.minLength > current.maxLength - ) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minLength", - keyword: "minLength", - params: { - minLength: current.minLength, - maxLength: current.maxLength, - }, - message: "minLength must be less than or equal to maxLength", - }); - } - - // minimum / exclusiveMinimum / maximum / exclusiveMaximum - { - const hasMin = Object.hasOwn(current, "minimum"); - const hasExMin = Object.hasOwn(current, "exclusiveMinimum"); - const hasMax = Object.hasOwn(current, "maximum"); - const hasExMax = Object.hasOwn(current, "exclusiveMaximum"); - if ((hasMin || hasExMin) && (hasMax || hasExMax)) { - const effectiveMin = - hasMin && hasExMin - ? Math.max(current.minimum, current.exclusiveMinimum) - : hasMin - ? current.minimum - : current.exclusiveMinimum; - const effectiveMax = - hasMax && hasExMax - ? Math.min(current.maximum, current.exclusiveMaximum) - : hasMax - ? current.maximum - : current.exclusiveMaximum; - if (!(effectiveMin < effectiveMax)) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minimum", - keyword: "minimum", - params: { - ...(hasMin && { minimum: current.minimum }), - ...(hasExMin && { - exclusiveMinimum: current.exclusiveMinimum, - }), - ...(hasMax && { maximum: current.maximum }), - ...(hasExMax && { - exclusiveMaximum: current.exclusiveMaximum, - }), - }, - message: "minimum must be less than maximum", - }); - } - } - } - - // minItems / maxItems - if ( - Object.hasOwn(current, "minItems") && - Object.hasOwn(current, "maxItems") && - current.minItems > current.maxItems - ) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minItems", - keyword: "minItems", - params: { - minItems: current.minItems, - maxItems: current.maxItems, - }, - message: "minItems must be less than or equal to maxItems", - }); - } - - // minContains / maxContains - if ( - Object.hasOwn(current, "minContains") && - Object.hasOwn(current, "maxContains") && - current.minContains > current.maxContains - ) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minContains", - keyword: "minContains", - params: { - minContains: current.minContains, - maxContains: current.maxContains, - }, - message: "minContains must be less than or equal to maxContains", - }); - } - - // ReDoS pattern check - if ( - Object.hasOwn(current, "pattern") && - typeof current.pattern === "string" - ) { - const patternResult = isSafePattern(current.pattern); - if (!patternResult.safe) { - result.errors.push({ - instancePath: `${path}/pattern`, - schemaPath: "#/redos", - keyword: "pattern", - params: { pattern: current.pattern }, - message: "pattern is vulnerable to ReDoS", - }); - } - } - - // Collect remote $ref URLs for DNS resolution - if ( - Object.hasOwn(current, "$ref") && - typeof current.$ref === "string" && - !current.$ref.startsWith("#") - ) { - try { - const url = new URL(current.$ref); - result.refs.push({ - hostname: url.hostname, - ref: current.$ref, - path: `${path}/$ref`, - }); - } catch { - // not a valid URL, skip - } - } - - // Traverse children, tracking depth - for (const key in current) { - if (Object.hasOwn(current, key)) { - const value = current[key]; - if (typeof value === "object" && value !== null) { - const newDepth = currentDepth + 1; - if (newDepth > result.depth) result.depth = newDepth; - if (result.depth > maxDepth) { - result.depthExceeded = true; - return result; - } - stack.push([value, `${path}/${key}`, newDepth]); - } - } - } - } - - return result; -}; - -const isPrivateIP = (ip) => { - const parts = ip.split(".").map(Number); - if (parts.length === 4 && parts.every((p) => p >= 0 && p <= 255)) { - if (parts[0] === 10) return true; // 10.0.0.0/8 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16 - if (parts[0] === 127) return true; // 127.0.0.0/8 - if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16 - if (parts[0] === 0) return true; // 0.0.0.0/8 - if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // 100.64.0.0/10 (CGN) - if (parts[0] === 198 && parts[1] >= 18 && parts[1] <= 19) return true; // 198.18.0.0/15 - } - // IPv6 private/reserved - const lower = ip.toLowerCase(); - if (lower === "::1" || lower === "::") return true; - if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local - if (lower.startsWith("fe80")) return true; // link-local - if (lower.startsWith("::ffff:")) { - return isPrivateIP(lower.slice(7)); // IPv4-mapped IPv6 - } - return false; -}; - -const resolveSSRFRefs = async (refs) => { - const errors = []; - const hostnameMap = new Map(); - for (const entry of refs) { - if (!hostnameMap.has(entry.hostname)) { - hostnameMap.set(entry.hostname, []); - } - hostnameMap.get(entry.hostname).push(entry); - } - for (const [hostname, entries] of hostnameMap) { - try { - const { address } = await lookup(hostname); - if (isPrivateIP(address)) { - for (const { ref, path } of entries) { - errors.push({ - instancePath: path, - schemaPath: "#/ssrf", - keyword: "ssrf", - params: { ref, hostname, resolvedIP: address }, - message: `$ref hostname "${hostname}" resolves to private IP ${address}`, - }); - } - } - } catch { - for (const { ref, path } of entries) { - errors.push({ - instancePath: path, - schemaPath: "#/ssrf", - keyword: "ssrf", - params: { ref, hostname }, - message: `$ref hostname "${hostname}" does not resolve`, - }); - } - } - } - return errors; -}; - -const resolveInstancePath = (obj, pointer) => { - if (typeof obj !== "object" || obj === null) return undefined; - if (!pointer) return obj; - const parts = pointer.split("/").slice(1); - let current = obj; - for (const part of parts) { - if (typeof current !== "object" || current === null) return undefined; - if (!Object.hasOwn(current, part)) return undefined; - current = current[part]; - } - return current; -}; - -const stringify = (arr) => { - let str = "[\n"; - for (let i = 0, l = arr.length; i < l; i++) { - str += `${JSON.stringify(arr[i]) + (i < l - 1 ? "," : "")}\n`; - } - return `${str}]`; -}; diff --git a/commands/sast.test.js b/commands/sast.test.js index 949a970..57706e9 100644 --- a/commands/sast.test.js +++ b/commands/sast.test.js @@ -674,6 +674,187 @@ test("cmd sast should remove maxProperties error when override-max-properties al } }); +test("cmd sast should ignore error by instancePath", async (t) => { + const _mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + name: { + type: "string", + maxLength: 100, + pattern: "[a-z]+\\w+", + }, + }, + required: ["name"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const tempFile = fixture("_ignore-path.schema.json"); + await writeFile(tempFile, JSON.stringify(schema)); + try { + const result = await sastCmd(tempFile, { + output: true, + ignore: ["/properties/name/pattern"], + }); + if (result) { + const redosErr = result.find( + (e) => e.instancePath === "/properties/name/pattern", + ); + strictEqual(redosErr, undefined); + } + } finally { + await unlink(tempFile).catch(() => {}); + } +}); + +test("cmd sast should ignore error by instancePath:keyword", async (t) => { + const _mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string", maxLength: 50 }, + minItems: 10, + maxItems: 3, + }, + }, + required: ["tags"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const tempFile = fixture("_ignore-keyword.schema.json"); + await writeFile(tempFile, JSON.stringify(schema)); + try { + const matched = await sastCmd(tempFile, { + output: true, + ignore: ["/properties/tags:minItems"], + }); + const minItemsErr = matched?.find( + (e) => e.keyword === "minItems" && e.instancePath === "/properties/tags", + ); + strictEqual(minItemsErr, undefined); + + const unmatched = await sastCmd(tempFile, { + output: true, + ignore: ["/properties/tags:maxItems"], + }); + ok(Array.isArray(unmatched)); + const stillThere = unmatched.find( + (e) => e.keyword === "minItems" && e.instancePath === "/properties/tags", + ); + ok(stillThere); + } finally { + await unlink(tempFile).catch(() => {}); + } +}); + +test("cmd sast should not drop unrelated errors when --ignore is given", async (t) => { + const _mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + name: { + type: "string", + maxLength: 100, + pattern: "[a-z]+\\w+", + }, + tags: { + type: "array", + items: { type: "string", maxLength: 50 }, + minItems: 10, + maxItems: 3, + }, + }, + required: ["name", "tags"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const tempFile = fixture("_ignore-unrelated.schema.json"); + await writeFile(tempFile, JSON.stringify(schema)); + try { + const result = await sastCmd(tempFile, { + output: true, + ignore: ["/properties/name/pattern"], + }); + ok(Array.isArray(result)); + const redosErr = result.find( + (e) => e.instancePath === "/properties/name/pattern", + ); + strictEqual(redosErr, undefined); + const minItemsErr = result.find( + (e) => e.keyword === "minItems" && e.instancePath === "/properties/tags", + ); + ok(minItemsErr); + } finally { + await unlink(tempFile).catch(() => {}); + } +}); + +test("cmd sast should be a no-op when --ignore is empty or undefined", async (t) => { + const _mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + name: { + type: "string", + maxLength: 100, + pattern: "[a-z]+\\w+", + }, + }, + required: ["name"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const tempFile = fixture("_ignore-empty.schema.json"); + await writeFile(tempFile, JSON.stringify(schema)); + try { + const resultEmpty = await sastCmd(tempFile, { + output: true, + ignore: [], + }); + ok(Array.isArray(resultEmpty)); + ok(resultEmpty.find((e) => e.instancePath === "/properties/name/pattern")); + + const resultUndef = await sastCmd(tempFile, { output: true }); + ok(Array.isArray(resultUndef)); + ok(resultUndef.find((e) => e.instancePath === "/properties/name/pattern")); + } finally { + await unlink(tempFile).catch(() => {}); + } +}); + +test("cmd sast should log when an error is ignored", async (t) => { + const mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + name: { + type: "string", + maxLength: 100, + pattern: "[a-z]+\\w+", + }, + }, + required: ["name"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const tempFile = fixture("_ignore-log.schema.json"); + await writeFile(tempFile, JSON.stringify(schema)); + try { + await sastCmd(tempFile, { + output: true, + ignore: ["/properties/name/pattern"], + }); + const logged = mockLog.mock.calls + .map((c) => c.arguments.join(" ")) + .join("\n"); + ok(/ignored .* at \/properties\/name\/pattern/.test(logged)); + } finally { + await unlink(tempFile).catch(() => {}); + } +}); + test("cmd sast should keep maxProperties error when override-max-properties is too low", async (t) => { const _mockLog = t.mock.method(console, "log", () => {}); const constObj = {}; diff --git a/commands/transpile.js b/commands/transpile.js index 619216a..957c46d 100755 --- a/commands/transpile.js +++ b/commands/transpile.js @@ -1,31 +1,16 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { readFile, stat, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import transpile from "../transpile.js"; - -const fileExists = async (filepath) => { - const stats = await stat(filepath); - if (!stats.isFile()) { - throw new Error(`${filepath} is not a file`); - } -}; +import { assertFile, loadRefSchemas, readJson } from "./_utils.js"; export default async (input, options) => { - await fileExists(input); + await assertFile(input); - const jsonSchema = await readFile(input, { encoding: "utf8" }).then((res) => - JSON.parse(res), - ); + const jsonSchema = await readJson(input); if (options?.refSchemaFiles) { - const refSchemas = []; - for (const schemaFilePath of options.refSchemaFiles) { - const refSchemaFile = await readFile(schemaFilePath, { - encoding: "utf8", - }).then((res) => JSON.parse(res)); - refSchemas.push(refSchemaFile); - } - options.schemas = refSchemas; + options.schemas = await loadRefSchemas(options.refSchemaFiles); } const js = await transpile(jsonSchema, options); diff --git a/commands/validate.js b/commands/validate.js index 72661ac..9d379b9 100755 --- a/commands/validate.js +++ b/commands/validate.js @@ -1,42 +1,19 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { readFile, stat } from "node:fs/promises"; import validate from "../validate.js"; - -const fileExists = async (filepath) => { - const stats = await stat(filepath); - if (!stats.isFile()) { - throw new Error(`${filepath} is not a file`); - } -}; +import { assertFile, loadRefSchemas, readJson } from "./_utils.js"; export default async (input, options) => { - await fileExists(input); + await assertFile(input); - const jsonSchema = await readFile(input, { encoding: "utf8" }).then((res) => - JSON.parse(res), - ); + const jsonSchema = await readJson(input); if (options?.refSchemaFiles) { - const refSchemas = []; - for (const schemaFilePath of options.refSchemaFiles) { - const refSchemaFile = await readFile(schemaFilePath, { - encoding: "utf8", - }).then((res) => JSON.parse(res)); - refSchemas.push(refSchemaFile); - } - options.schemas = refSchemas; + options.schemas = await loadRefSchemas(options.refSchemaFiles); } if (options?.testDataFiles) { - const testDataFiles = []; - for (const testDataFilePath of options.testDataFiles) { - const testDataFile = await readFile(testDataFilePath, { - encoding: "utf8", - }).then((res) => JSON.parse(res)); - testDataFiles.push(testDataFile); - } - options.testData = testDataFiles; + options.testData = await loadRefSchemas(options.testDataFiles); } const valid = await validate(jsonSchema, options); diff --git a/deref.js b/deref.js new file mode 100644 index 0000000..b3a3d78 --- /dev/null +++ b/deref.js @@ -0,0 +1,34 @@ +// Copyright 2026 will Farrell, and ajv-cmd contributors. +// SPDX-License-Identifier: MIT +import { dereference } from "@apidevtools/json-schema-ref-parser"; + +export const deref = async (schema, options = {}) => { + if (options.schemas?.length) installFetchCache(options.schemas); + return dereference(schema); +}; + +export default deref; + +const installFetchCache = (schemas) => { + const originalFetch = fetch; + + const cache = {}; + const enc = new TextEncoder(); + for (let i = schemas.length; i--; ) { + const schema = schemas[i]; + if (schema.$id) { + cache[schema.$id] = enc.encode(JSON.stringify(schema)); + } + } + + globalThis.fetch = async (...args) => { + if (cache[args[0].href]) { + return { + status: 200, + body: true, + arrayBuffer: async () => cache[args[0].href], + }; + } + return originalFetch(...args); + }; +}; diff --git a/index.js b/index.js index cae4a31..d82764b 100644 --- a/index.js +++ b/index.js @@ -4,12 +4,14 @@ import { compile as compileImport, instance as instanceImport, } from "./compile.js"; +import derefImport from "./deref.js"; import ftlImport from "./ftl.js"; import transpileImport from "./transpile.js"; import validateImport from "./validate.js"; export const instance = instanceImport; export const compile = compileImport; +export const deref = derefImport; export const transpile = transpileImport; export const validate = validateImport; export const ftl = ftlImport; @@ -17,6 +19,7 @@ export const ftl = ftlImport; export default { instance, compile, + deref, ftl, transpile, validate, diff --git a/index.test.js b/index.test.js index ecef867..140209e 100644 --- a/index.test.js +++ b/index.test.js @@ -2,6 +2,7 @@ import { ok, strictEqual } from "node:assert"; import test from "node:test"; import defaults, { compile, + deref, ftl, instance, transpile, @@ -16,6 +17,10 @@ test("index should export compile function", () => { ok(typeof compile === "function"); }); +test("index should export deref function", () => { + ok(typeof deref === "function"); +}); + test("index should export transpile function", () => { ok(typeof transpile === "function"); }); @@ -31,6 +36,7 @@ test("index should export ftl function", () => { test("index default export should contain all functions", () => { strictEqual(defaults.instance, instance); strictEqual(defaults.compile, compile); + strictEqual(defaults.deref, deref); strictEqual(defaults.transpile, transpile); strictEqual(defaults.validate, validate); strictEqual(defaults.ftl, ftl); diff --git a/index.tst.ts b/index.tst.ts index 9f02511..0426169 100644 --- a/index.tst.ts +++ b/index.tst.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "tstyche"; import ajvCmd, { compile, + deref, ftl, instance, transpile, @@ -21,6 +22,11 @@ describe("ajv-cmd", () => { expect(compile).type.toBeAssignableTo(); }); + test("deref is a function", () => { + // biome-ignore lint/complexity/noBannedTypes: intentional generic function type check + expect(deref).type.toBeAssignableTo(); + }); + test("transpile is a function", () => { // biome-ignore lint/complexity/noBannedTypes: intentional generic function type check expect(transpile).type.toBeAssignableTo(); @@ -40,6 +46,7 @@ describe("ajv-cmd", () => { expect(ajvCmd).type.toBe<{ instance: typeof instance; compile: typeof compile; + deref: typeof deref; ftl: typeof ftl; transpile: typeof transpile; validate: typeof validate; diff --git a/license.template b/license.template deleted file mode 100644 index 1ba1e12..0000000 --- a/license.template +++ /dev/null @@ -1,2 +0,0 @@ -Copyright 2026 will Farrell, and ajv-cmd contributors. -SPDX-License-Identifier: MIT diff --git a/package.json b/package.json index 5734f01..a36dd61 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "index.js", "compile.cjs", "compile.js", + "deref.cjs", + "deref.js", "ftl.cjs", "ftl.js", "transpile.cjs", @@ -87,19 +89,22 @@ } }, "scripts": { - "prepare": "husky", + "prepare": "husky || true", "prepublishOnly": "npm run build", - "build": "npm run build:index && npm run build:compile && npm run build:ftl && npm run build:transpile && npm run build:validate && npm run build:sast", + "build": "npm run build:index && npm run build:compile && npm run build:deref && npm run build:ftl && npm run build:transpile && npm run build:validate && npm run build:sast", "build:index": "./node_modules/.bin/esbuild index.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=index.cjs", "build:compile": "./node_modules/.bin/esbuild compile.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=compile.cjs", + "build:deref": "./node_modules/.bin/esbuild deref.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=deref.cjs", "build:ftl": "./node_modules/.bin/esbuild ftl.js --platform=node --format=cjs --target=node18 --bundle --allow-overwrite --outfile=ftl.cjs", "build:transpile": "./node_modules/.bin/esbuild transpile.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=transpile.cjs", "build:validate": "./node_modules/.bin/esbuild validate.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=validate.cjs", "build:sast": "./node_modules/.bin/esbuild sast.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=sast.cjs", - "test": "npm run test:lint && npm run test:unit && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast", + "pretest": "npm run build", + "test": "npm run test:lint && npm run test:unit && npm run test:e2e && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast", "test:unit": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=90 --test-coverage-functions=90 ./*.test.js ./commands/*.test.js", + "test:e2e": "./__test__/e2e.sh", "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:trivy", - "test:sast:license": "license-check-and-add check -f license.json", + "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", "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 --exit-code 1 --disable-telemetry .", @@ -111,13 +116,14 @@ "test:coverage": "node --test --experimental-test-coverage ./*.test.js ./commands/*.test.js", "git:pre-commit": "npm run git:lint-staged && npm run git:test-staged", "git:commit-msg": "commitlint --config commitlint.config.cjs --edit", - "git:lint-staged": "npm run test:lint", + "git:lint-staged": "npm run test:lint:staged", "git:unit-staged": "node --test", "git:test-staged": "npm run git:unit-staged", "lint": "biome check --write --no-errors-on-unmatched", - "test:lint": "biome check --staged --no-errors-on-unmatched", - "release:license:add": "license-check-and-add add -f license.json", - "release:license:remove": "license-check-and-add remove -f license.json" + "test:lint": "biome ci --no-errors-on-unmatched", + "test:lint:staged": "biome check --staged --no-errors-on-unmatched", + "release:license:add": "license-check-and-add add -f .license.config.json", + "release:license:remove": "license-check-and-add remove -f .license.config.json" }, "repository": { "type": "git", diff --git a/sast.js b/sast.js index 09ef1c6..02d6939 100644 --- a/sast.js +++ b/sast.js @@ -1,25 +1,384 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { createRequire } from "node:module"; +import { lookup } from "node:dns/promises"; import Ajv from "ajv/dist/2020.js"; - -//import sastSchema from 'sast-json-schema/index.json' with { type: 'json' } +import { isSafePattern } from "redos-detector"; +import schema201909 from "sast-json-schema/2019-09.json" with { type: "json" }; +import schema202012 from "sast-json-schema/2020-12.json" with { type: "json" }; +import schemaDraft04 from "sast-json-schema/draft-04.json" with { + type: "json", +}; +import schemaDraft06 from "sast-json-schema/draft-06.json" with { + type: "json", +}; +import schemaDraft07 from "sast-json-schema/draft-07.json" with { + type: "json", +}; const defaultOptions = { strictTypes: false, allErrors: true, }; -const sastSchema = createRequire(import.meta.url)( - "sast-json-schema/index.json", +const DEFAULT_VERSION = "2020-12"; + +// Pre-compiled SAST meta-schema validators, keyed by draft version. Compiled +// once at module load so every sast() / analyze() call reuses the same +// validator. +const builtSchemas = new Map( + [ + ["2020-12", schema202012], + ["2019-09", schema201909], + ["draft-07", schemaDraft07], + ["draft-06", schemaDraft06], + ["draft-04", schemaDraft04], + ].map(([version, metaSchema]) => [ + version, + new Ajv(defaultOptions).compile(metaSchema), + ]), ); -export const sast = (_schema, options = {}) => { - options = { ...defaultOptions, ...options }; - const ajv = new Ajv(options); - const validate = ajv.compile(sastSchema); +// Maps a user schema's $schema URL to the matching draft version. Accepts +// both http/https and the optional trailing "#" fragment. +const schemaVersion = (url) => { + if (!url) return DEFAULT_VERSION; + for (const version of builtSchemas.keys()) { + if (url.includes(version)) return version; + } + return undefined; +}; + +export const MAX_DEPTH = 32; +// Returns the pre-compiled SAST validator for the draft declared by +// `schema.$schema`. Defaults to 2020-12 when $schema is absent. +export const sast = (schema, _options = {}) => { + const version = schemaVersion(schema?.$schema); + const validate = builtSchemas.get(version); + if (!validate) { + throw new Error(`Unsupported $schema: ${schema?.$schema}`); + } return validate; }; export default sast; + +// Runs a full SAST analysis on `schema`. Returns an array of AJV-style error +// objects. Never touches the filesystem, never prints, never exits the process. +export const analyze = async (schema, options = {}) => { + const maxDepth = + options.overrideMaxDepth != null + ? Number(options.overrideMaxDepth) + : MAX_DEPTH; + + const crawl = crawlSchema(schema, maxDepth); + + if (crawl.depthExceeded) { + return [ + { + instancePath: "", + schemaPath: "#/depth", + keyword: "depth", + params: { depth: crawl.depth, limit: maxDepth }, + message: `must NOT have depth greater than ${maxDepth}`, + }, + ]; + } + + let errors = []; + const validate = sast(schema, options); + validate(schema, options); + if (validate.errors) errors.push(...validate.errors); + errors.push(...crawl.errors); + + const ssrfErrors = await resolveSSRFRefs(crawl.refs); + errors.push(...ssrfErrors); + + if (options.overrideMaxItems != null && errors.length) { + const limit = Number(options.overrideMaxItems); + errors = errors.filter((err) => { + if (err.schemaPath === "#/definitions/safeArrayItemsLimits/maxItems") { + const arr = resolveInstancePath(schema, err.instancePath); + return !Array.isArray(arr) || arr.length > limit; + } + return true; + }); + } + if (options.overrideMaxProperties != null && errors.length) { + const limit = Number(options.overrideMaxProperties); + errors = errors.filter((err) => { + if ( + err.schemaPath === + "#/definitions/safeObjectPropertiesLimits/maxProperties" + ) { + const obj = resolveInstancePath(schema, err.instancePath); + if (typeof obj !== "object" || obj === null) return true; + return 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}`; + if (ignore.has(pathKey)) { + console.log( + `ignored ${err.keyword} at ${err.instancePath} (matched --ignore ${pathKey})`, + ); + return false; + } + if (ignore.has(keywordKey)) { + console.log( + `ignored ${err.keyword} at ${err.instancePath} (matched --ignore ${keywordKey})`, + ); + return false; + } + return true; + }); + } + + return errors; +}; + +// Single-pass crawler that records: max depth, range/length inconsistencies, +// ReDoS patterns, and remote $ref URLs (for later SSRF resolution). +// Depth semantics: each object-valued key counts as one level, so a schema +// `{properties: {a: {properties: {b: {...}}}}}` reaches depth 5 (root, +// 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) => { + const result = { depth: 0, depthExceeded: false, errors: [], refs: [] }; + if (typeof obj !== "object" || obj === null) return result; + + result.depth = 1; + const stack = [[obj, "", 1]]; + + while (stack.length > 0) { + const [current, path, currentDepth] = stack.pop(); + + if ( + Object.hasOwn(current, "minLength") && + Object.hasOwn(current, "maxLength") && + current.minLength > current.maxLength + ) { + result.errors.push({ + instancePath: path, + schemaPath: "#/minLength", + keyword: "minLength", + params: { + minLength: current.minLength, + maxLength: current.maxLength, + }, + message: "minLength must be less than or equal to maxLength", + }); + } + + { + const hasMin = Object.hasOwn(current, "minimum"); + const hasExMin = Object.hasOwn(current, "exclusiveMinimum"); + const hasMax = Object.hasOwn(current, "maximum"); + const hasExMax = Object.hasOwn(current, "exclusiveMaximum"); + if ((hasMin || hasExMin) && (hasMax || hasExMax)) { + const effectiveMin = + hasMin && hasExMin + ? Math.max(current.minimum, current.exclusiveMinimum) + : hasMin + ? current.minimum + : current.exclusiveMinimum; + const effectiveMax = + hasMax && hasExMax + ? Math.min(current.maximum, current.exclusiveMaximum) + : hasMax + ? current.maximum + : current.exclusiveMaximum; + if (!(effectiveMin < effectiveMax)) { + result.errors.push({ + instancePath: path, + schemaPath: "#/minimum", + keyword: "minimum", + params: { + ...(hasMin && { minimum: current.minimum }), + ...(hasExMin && { + exclusiveMinimum: current.exclusiveMinimum, + }), + ...(hasMax && { maximum: current.maximum }), + ...(hasExMax && { + exclusiveMaximum: current.exclusiveMaximum, + }), + }, + message: "minimum must be less than maximum", + }); + } + } + } + + if ( + Object.hasOwn(current, "minItems") && + Object.hasOwn(current, "maxItems") && + current.minItems > current.maxItems + ) { + result.errors.push({ + instancePath: path, + schemaPath: "#/minItems", + keyword: "minItems", + params: { + minItems: current.minItems, + maxItems: current.maxItems, + }, + message: "minItems must be less than or equal to maxItems", + }); + } + + if ( + Object.hasOwn(current, "minContains") && + Object.hasOwn(current, "maxContains") && + current.minContains > current.maxContains + ) { + result.errors.push({ + instancePath: path, + schemaPath: "#/minContains", + keyword: "minContains", + params: { + minContains: current.minContains, + maxContains: current.maxContains, + }, + message: "minContains must be less than or equal to maxContains", + }); + } + + if ( + Object.hasOwn(current, "pattern") && + typeof current.pattern === "string" + ) { + const patternResult = isSafePattern(current.pattern); + if (!patternResult.safe) { + result.errors.push({ + instancePath: `${path}/pattern`, + schemaPath: "#/redos", + keyword: "pattern", + params: { pattern: current.pattern }, + message: "pattern is vulnerable to ReDoS", + }); + } + } + + if ( + Object.hasOwn(current, "$ref") && + typeof current.$ref === "string" && + !current.$ref.startsWith("#") + ) { + try { + const url = new URL(current.$ref); + result.refs.push({ + hostname: url.hostname, + ref: current.$ref, + path: `${path}/$ref`, + }); + } catch { + // not a valid URL, skip + } + } + + for (const key in current) { + if (Object.hasOwn(current, key)) { + const value = current[key]; + if (typeof value === "object" && value !== null) { + const newDepth = currentDepth + 1; + if (newDepth > result.depth) result.depth = newDepth; + if (result.depth > maxDepth) { + result.depthExceeded = true; + return result; + } + stack.push([value, `${path}/${key}`, newDepth]); + } + } + } + } + + return result; +}; + +// RFC 1918 + loopback + link-local + CGN + TEST-NETs + multicast + reserved. +// Used to block $ref URLs whose hostname resolves to an internal/private IP. +export const isPrivateIP = (ip) => { + const parts = ip.split(".").map(Number); + if ( + parts.length === 4 && + parts.every((p) => Number.isInteger(p) && p >= 0 && p <= 255) + ) { + const [a, b] = parts; + if (a === 0) return true; // 0.0.0.0/8 "this" network + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGN + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0.0/24 IETF + if (a === 192 && b === 0 && parts[2] === 2) return true; // 192.0.2.0/24 TEST-NET-1 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + 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 + 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 + } + + const lower = ip.toLowerCase(); + if (lower === "::1" || lower === "::") return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local + if (lower.startsWith("fe80")) return true; // link-local + if (lower.startsWith("ff")) return true; // multicast + if (lower.startsWith("::ffff:")) { + return isPrivateIP(lower.slice(7)); // IPv4-mapped IPv6 + } + return false; +}; + +export const resolveSSRFRefs = async (refs) => { + const hostnameMap = new Map(); + for (const entry of refs) { + if (!hostnameMap.has(entry.hostname)) { + hostnameMap.set(entry.hostname, []); + } + hostnameMap.get(entry.hostname).push(entry); + } + const results = await Promise.all( + [...hostnameMap.entries()].map(async ([hostname, entries]) => { + try { + const { address } = await lookup(hostname); + if (!isPrivateIP(address)) return []; + return entries.map(({ ref, path }) => ({ + instancePath: path, + schemaPath: "#/ssrf", + keyword: "ssrf", + params: { ref, hostname, resolvedIP: address }, + message: `$ref hostname "${hostname}" resolves to private IP ${address}`, + })); + } catch { + return entries.map(({ ref, path }) => ({ + instancePath: path, + schemaPath: "#/ssrf", + keyword: "ssrf", + params: { ref, hostname }, + message: `$ref hostname "${hostname}" does not resolve`, + })); + } + }), + ); + return results.flat(); +}; + +const resolveInstancePath = (obj, pointer) => { + if (typeof obj !== "object" || obj === null) return undefined; + if (!pointer) return obj; + const parts = pointer.split("/").slice(1); + let current = obj; + for (const part of parts) { + if (typeof current !== "object" || current === null) return undefined; + if (!Object.hasOwn(current, part)) return undefined; + current = current[part]; + } + return current; +}; diff --git a/sast.test.js b/sast.test.js index a752c5a..397b999 100644 --- a/sast.test.js +++ b/sast.test.js @@ -1,6 +1,6 @@ import { ok, strictEqual } from "node:assert"; import test from "node:test"; -import sast from "./sast.js"; +import sast, { analyze } from "./sast.js"; test("sast should return a validate function", () => { const validate = sast(); @@ -45,3 +45,63 @@ test("sast default export should be sast function", async () => { const mod = await import("./sast.js"); strictEqual(mod.default, mod.sast); }); + +test("analyze should filter errors matching options.ignore by instancePath", async (t) => { + const _mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + name: { + type: "string", + maxLength: 100, + pattern: "[a-z]+\\w+", + }, + }, + required: ["name"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const errors = await analyze(schema, { + ignore: ["/properties/name/pattern"], + }); + const redos = errors.find( + (e) => e.instancePath === "/properties/name/pattern", + ); + strictEqual(redos, undefined); +}); + +test("analyze should filter errors matching options.ignore by instancePath:keyword", async (t) => { + const _mockLog = t.mock.method(console, "log", () => {}); + const schema = { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string", maxLength: 50 }, + minItems: 10, + maxItems: 3, + }, + }, + required: ["tags"], + maxProperties: 10, + unevaluatedProperties: false, + }; + const matched = await analyze(schema, { + ignore: ["/properties/tags:minItems"], + }); + strictEqual( + matched.find( + (e) => e.keyword === "minItems" && e.instancePath === "/properties/tags", + ), + undefined, + ); + + const unmatched = await analyze(schema, { + ignore: ["/properties/tags:maxItems"], + }); + ok( + unmatched.find( + (e) => e.keyword === "minItems" && e.instancePath === "/properties/tags", + ), + ); +}); diff --git a/transpile.js b/transpile.js index 15cf140..a439135 100644 --- a/transpile.js +++ b/transpile.js @@ -73,27 +73,30 @@ export const transpile = async (schema, options = {}) => { const bridgeFile = join(__dirname, `${bridgeModuleName}.cjs`); const cleanupFiles = [file]; - if (needsBridge) { - await writeFile(bridgeFile, bridgeModuleContent, "utf8"); - cleanupFiles.push(bridgeFile); + if (needsBridge) cleanupFiles.push(bridgeFile); + + try { + if (needsBridge) { + await writeFile(bridgeFile, bridgeModuleContent, "utf8"); + } + await writeFile(file, js, "utf8"); + + await build({ + entryPoints: [file], + platform: "node", + format: "esm", + bundle: true, + minify: true, + legalComments: "none", + allowOverwrite: true, + outfile: file, + }); + + js = await readFile(file, { encoding: "utf8" }); + } finally { + await Promise.all(cleanupFiles.map((f) => unlink(f).catch(() => {}))); } - await writeFile(file, js, "utf8"); - - await build({ - entryPoints: [file], - platform: "node", - format: "esm", - bundle: true, - minify: true, - legalComments: "none", - allowOverwrite: true, - outfile: file, - }); - - js = await readFile(file, { encoding: "utf8" }); - await Promise.all(cleanupFiles.map((f) => unlink(f))); - return js; }; diff --git a/validate.js b/validate.js index e18271f..fb2354b 100644 --- a/validate.js +++ b/validate.js @@ -2,40 +2,26 @@ // SPDX-License-Identifier: MIT import { compile } from "./compile.js"; -//import spec from 'ajv/dist/refs/json-schema-2020-12/schema.json' assert { type: 'json' } - const defaultOptions = { allErrors: true, // required for `errorMessage` }; -export const test = async (schema, options = {}) => { +export const validate = async (schema, options = {}) => { options = { ...defaultOptions, ...options }; - let validate, valid; - // Spec check - // const ajv = new Ajv(options) - // validate = ajv.compile(spec) - // valid = validate(schema) - // ajv.removeSchema() - // if (!valid) { - // console.error('Schema is not spec compliant') - // return valid - // } - - // Compile check + let compiled; try { - validate = compile(schema, options); + compiled = compile(schema, options); } catch (e) { console.error(e.message); - return valid; + return undefined; } - // Data Check let testSuccess = true; for (const data of options?.testData ?? []) { - valid = validate(structuredClone(data)); + const valid = compiled(structuredClone(data)); if (!valid) { - console.error(validate.errors); + console.error(compiled.errors); testSuccess = false; } } @@ -43,4 +29,4 @@ export const test = async (schema, options = {}) => { return testSuccess; }; -export default test; +export default validate; diff --git a/validate.test.js b/validate.test.js index 0e63a09..b9cc8be 100644 --- a/validate.test.js +++ b/validate.test.js @@ -54,7 +54,7 @@ test("validate should not mutate test data", async () => { deepStrictEqual(testData, original); }); -test("validate default export should be test function", async () => { +test("validate default export should be validate function", async () => { const mod = await import("./validate.js"); - strictEqual(mod.default, mod.test); + strictEqual(mod.default, mod.validate); }); From 4b031061c254e8ee45f21f055da9d6fc2b6cd848 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Sat, 18 Apr 2026 19:43:01 -0600 Subject: [PATCH 4/6] feat: update sast command Signed-off-by: will Farrell --- __test__/formats.schema.json | 2 + __test__/large-enum.schema.json | 2 + __test__/secure.schema.json | 2 + cli.js | 20 +- commands/sast.test.js | 38 +--- package-lock.json | 16 +- package.json | 10 +- sast.js | 391 +------------------------------- 8 files changed, 50 insertions(+), 431 deletions(-) diff --git a/__test__/formats.schema.json b/__test__/formats.schema.json index 311771d..b8474ff 100644 --- a/__test__/formats.schema.json +++ b/__test__/formats.schema.json @@ -1,4 +1,6 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/formats", "type": "object", "additionalProperties": true, "properties": { diff --git a/__test__/large-enum.schema.json b/__test__/large-enum.schema.json index 18a5fd2..62761bb 100644 --- a/__test__/large-enum.schema.json +++ b/__test__/large-enum.schema.json @@ -1,4 +1,6 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/large-enum", "type": "object", "properties": { "status": { diff --git a/__test__/secure.schema.json b/__test__/secure.schema.json index 260b004..e59d0c5 100644 --- a/__test__/secure.schema.json +++ b/__test__/secure.schema.json @@ -1,4 +1,6 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/secure", "type": "object", "properties": { "name": { diff --git a/cli.js b/cli.js index af74467..5b701d9 100755 --- a/cli.js +++ b/cli.js @@ -166,7 +166,25 @@ program .addOption( new Option( "--ignore ", - "Suppress errors by `instancePath` or `instancePath:keyword` (exact match). Each ignored error is logged.", + "Suppress errors by `instancePath` or `instancePath:keyword` (exact match).", + ), + ) + .addOption( + new Option( + "--offline", + "Skip DNS lookups for remote $ref URLs (disables SSRF resolution).", + ).preset(true), + ) + .addOption( + new Option( + "--dns-timeout-ms ", + "Per-hostname DNS lookup timeout in ms for SSRF checks (default 5000).", + ), + ) + .addOption( + new Option( + "--dns-concurrency ", + "Max concurrent DNS lookups for SSRF checks (default 10).", ), ) .addOption( diff --git a/commands/sast.test.js b/commands/sast.test.js index 57706e9..72d2d1d 100644 --- a/commands/sast.test.js +++ b/commands/sast.test.js @@ -364,7 +364,7 @@ test("cmd sast should add error when exclusiveMinimum >= exclusiveMaximum", asyn try { const result = await sastCmd(tempFile, { output: true }); ok(Array.isArray(result)); - const err = result.find((e) => e.keyword === "minimum"); + const err = result.find((e) => e.keyword === "exclusiveMaximum"); ok(err); strictEqual(err.instancePath, "/properties/score"); strictEqual(err.params.exclusiveMinimum, 10); @@ -394,7 +394,7 @@ test("cmd sast should add error when minimum >= exclusiveMaximum", async (t) => try { const result = await sastCmd(tempFile, { output: true }); ok(Array.isArray(result)); - const err = result.find((e) => e.keyword === "minimum"); + const err = result.find((e) => e.keyword === "exclusiveMaximum"); ok(err); strictEqual(err.instancePath, "/properties/score"); strictEqual(err.params.minimum, 10); @@ -824,37 +824,6 @@ test("cmd sast should be a no-op when --ignore is empty or undefined", async (t) } }); -test("cmd sast should log when an error is ignored", async (t) => { - const mockLog = t.mock.method(console, "log", () => {}); - const schema = { - type: "object", - properties: { - name: { - type: "string", - maxLength: 100, - pattern: "[a-z]+\\w+", - }, - }, - required: ["name"], - maxProperties: 10, - unevaluatedProperties: false, - }; - const tempFile = fixture("_ignore-log.schema.json"); - await writeFile(tempFile, JSON.stringify(schema)); - try { - await sastCmd(tempFile, { - output: true, - ignore: ["/properties/name/pattern"], - }); - const logged = mockLog.mock.calls - .map((c) => c.arguments.join(" ")) - .join("\n"); - ok(/ignored .* at \/properties\/name\/pattern/.test(logged)); - } finally { - await unlink(tempFile).catch(() => {}); - } -}); - test("cmd sast should keep maxProperties error when override-max-properties is too low", async (t) => { const _mockLog = t.mock.method(console, "log", () => {}); const constObj = {}; @@ -875,8 +844,7 @@ test("cmd sast should keep maxProperties error when override-max-properties is t ok(Array.isArray(result)); const err = result.find( (e) => - e.schemaPath === - "#/definitions/safeObjectPropertiesLimits/maxProperties", + e.schemaPath === "#/$defs/safeObjectPropertiesLimits/maxProperties", ); ok(err); } finally { diff --git a/package-lock.json b/package-lock.json index 8942686..1ea27a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,7 @@ "ajv-keywords": "5.1.0", "commander": "14.0.3", "esbuild": "^0.28.0", - "redos-detector": "6.1.4", - "sast-json-schema": "^0.1.0" + "sast-json-schema": "0.2.3" }, "bin": { "ajv": "cli.js" @@ -2798,13 +2797,20 @@ } }, "node_modules/sast-json-schema": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/sast-json-schema/-/sast-json-schema-0.1.0.tgz", - "integrity": "sha512-aVtzdQ3jYop1if6YcSeVzCC8VD+iXqg00IUBuXEGWelSVKmcc4iJMN/CfKlakzXzeNmXfX1dwXpNigGDvaufpg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/sast-json-schema/-/sast-json-schema-0.2.3.tgz", + "integrity": "sha512-84DyjPfOHYodSRPGeCXQEOSmUnByNLnqEkOlzWcsJyniVvjinVjDtSUZK3PtWz45e3dee0NZX3aJp2CA2l2XfQ==", "license": "MIT", "workspaces": [ ".github" ], + "dependencies": { + "ajv": "8.18.0", + "redos-detector": "6.1.4" + }, + "bin": { + "sast-json-schema": "cli.js" + }, "engines": { "node": ">=24" }, diff --git a/package.json b/package.json index a36dd61..d1d2400 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "transpile.js", "validate.cjs", "validate.js", - "sast.cjs", "sast.js" ], "main": "index.js", @@ -66,9 +65,6 @@ "./sast": { "import": { "default": "./sast.js" - }, - "require": { - "default": "./sast.cjs" } }, "./transpile": { @@ -91,14 +87,13 @@ "scripts": { "prepare": "husky || true", "prepublishOnly": "npm run build", - "build": "npm run build:index && npm run build:compile && npm run build:deref && npm run build:ftl && npm run build:transpile && npm run build:validate && npm run build:sast", + "build": "npm run build:index && npm run build:compile && npm run build:deref && npm run build:ftl && npm run build:transpile && npm run build:validate", "build:index": "./node_modules/.bin/esbuild index.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=index.cjs", "build:compile": "./node_modules/.bin/esbuild compile.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=compile.cjs", "build:deref": "./node_modules/.bin/esbuild deref.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=deref.cjs", "build:ftl": "./node_modules/.bin/esbuild ftl.js --platform=node --format=cjs --target=node18 --bundle --allow-overwrite --outfile=ftl.cjs", "build:transpile": "./node_modules/.bin/esbuild transpile.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=transpile.cjs", "build:validate": "./node_modules/.bin/esbuild validate.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=validate.cjs", - "build:sast": "./node_modules/.bin/esbuild sast.js --platform=node --format=cjs --target=node18 --allow-overwrite --outfile=sast.cjs", "pretest": "npm run build", "test": "npm run test:lint && npm run test:unit && npm run test:e2e && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast", "test:unit": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=90 --test-coverage-functions=90 ./*.test.js ./commands/*.test.js", @@ -154,8 +149,7 @@ "ajv-keywords": "5.1.0", "commander": "14.0.3", "esbuild": "^0.28.0", - "redos-detector": "6.1.4", - "sast-json-schema": "^0.1.0" + "sast-json-schema": "0.2.3" }, "devDependencies": { "@biomejs/biome": "^2.0.0", diff --git a/sast.js b/sast.js index 02d6939..5d5e0bc 100644 --- a/sast.js +++ b/sast.js @@ -1,384 +1,11 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT -import { lookup } from "node:dns/promises"; -import Ajv from "ajv/dist/2020.js"; -import { isSafePattern } from "redos-detector"; -import schema201909 from "sast-json-schema/2019-09.json" with { type: "json" }; -import schema202012 from "sast-json-schema/2020-12.json" with { type: "json" }; -import schemaDraft04 from "sast-json-schema/draft-04.json" with { - type: "json", -}; -import schemaDraft06 from "sast-json-schema/draft-06.json" with { - type: "json", -}; -import schemaDraft07 from "sast-json-schema/draft-07.json" with { - type: "json", -}; - -const defaultOptions = { - strictTypes: false, - allErrors: true, -}; - -const DEFAULT_VERSION = "2020-12"; - -// Pre-compiled SAST meta-schema validators, keyed by draft version. Compiled -// once at module load so every sast() / analyze() call reuses the same -// validator. -const builtSchemas = new Map( - [ - ["2020-12", schema202012], - ["2019-09", schema201909], - ["draft-07", schemaDraft07], - ["draft-06", schemaDraft06], - ["draft-04", schemaDraft04], - ].map(([version, metaSchema]) => [ - version, - new Ajv(defaultOptions).compile(metaSchema), - ]), -); - -// Maps a user schema's $schema URL to the matching draft version. Accepts -// both http/https and the optional trailing "#" fragment. -const schemaVersion = (url) => { - if (!url) return DEFAULT_VERSION; - for (const version of builtSchemas.keys()) { - if (url.includes(version)) return version; - } - return undefined; -}; - -export const MAX_DEPTH = 32; - -// Returns the pre-compiled SAST validator for the draft declared by -// `schema.$schema`. Defaults to 2020-12 when $schema is absent. -export const sast = (schema, _options = {}) => { - const version = schemaVersion(schema?.$schema); - const validate = builtSchemas.get(version); - if (!validate) { - throw new Error(`Unsupported $schema: ${schema?.$schema}`); - } - return validate; -}; - -export default sast; - -// Runs a full SAST analysis on `schema`. Returns an array of AJV-style error -// objects. Never touches the filesystem, never prints, never exits the process. -export const analyze = async (schema, options = {}) => { - const maxDepth = - options.overrideMaxDepth != null - ? Number(options.overrideMaxDepth) - : MAX_DEPTH; - - const crawl = crawlSchema(schema, maxDepth); - - if (crawl.depthExceeded) { - return [ - { - instancePath: "", - schemaPath: "#/depth", - keyword: "depth", - params: { depth: crawl.depth, limit: maxDepth }, - message: `must NOT have depth greater than ${maxDepth}`, - }, - ]; - } - - let errors = []; - const validate = sast(schema, options); - validate(schema, options); - if (validate.errors) errors.push(...validate.errors); - errors.push(...crawl.errors); - - const ssrfErrors = await resolveSSRFRefs(crawl.refs); - errors.push(...ssrfErrors); - - if (options.overrideMaxItems != null && errors.length) { - const limit = Number(options.overrideMaxItems); - errors = errors.filter((err) => { - if (err.schemaPath === "#/definitions/safeArrayItemsLimits/maxItems") { - const arr = resolveInstancePath(schema, err.instancePath); - return !Array.isArray(arr) || arr.length > limit; - } - return true; - }); - } - if (options.overrideMaxProperties != null && errors.length) { - const limit = Number(options.overrideMaxProperties); - errors = errors.filter((err) => { - if ( - err.schemaPath === - "#/definitions/safeObjectPropertiesLimits/maxProperties" - ) { - const obj = resolveInstancePath(schema, err.instancePath); - if (typeof obj !== "object" || obj === null) return true; - return 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}`; - if (ignore.has(pathKey)) { - console.log( - `ignored ${err.keyword} at ${err.instancePath} (matched --ignore ${pathKey})`, - ); - return false; - } - if (ignore.has(keywordKey)) { - console.log( - `ignored ${err.keyword} at ${err.instancePath} (matched --ignore ${keywordKey})`, - ); - return false; - } - return true; - }); - } - - return errors; -}; - -// Single-pass crawler that records: max depth, range/length inconsistencies, -// ReDoS patterns, and remote $ref URLs (for later SSRF resolution). -// Depth semantics: each object-valued key counts as one level, so a schema -// `{properties: {a: {properties: {b: {...}}}}}` reaches depth 5 (root, -// 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) => { - const result = { depth: 0, depthExceeded: false, errors: [], refs: [] }; - if (typeof obj !== "object" || obj === null) return result; - - result.depth = 1; - const stack = [[obj, "", 1]]; - - while (stack.length > 0) { - const [current, path, currentDepth] = stack.pop(); - - if ( - Object.hasOwn(current, "minLength") && - Object.hasOwn(current, "maxLength") && - current.minLength > current.maxLength - ) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minLength", - keyword: "minLength", - params: { - minLength: current.minLength, - maxLength: current.maxLength, - }, - message: "minLength must be less than or equal to maxLength", - }); - } - - { - const hasMin = Object.hasOwn(current, "minimum"); - const hasExMin = Object.hasOwn(current, "exclusiveMinimum"); - const hasMax = Object.hasOwn(current, "maximum"); - const hasExMax = Object.hasOwn(current, "exclusiveMaximum"); - if ((hasMin || hasExMin) && (hasMax || hasExMax)) { - const effectiveMin = - hasMin && hasExMin - ? Math.max(current.minimum, current.exclusiveMinimum) - : hasMin - ? current.minimum - : current.exclusiveMinimum; - const effectiveMax = - hasMax && hasExMax - ? Math.min(current.maximum, current.exclusiveMaximum) - : hasMax - ? current.maximum - : current.exclusiveMaximum; - if (!(effectiveMin < effectiveMax)) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minimum", - keyword: "minimum", - params: { - ...(hasMin && { minimum: current.minimum }), - ...(hasExMin && { - exclusiveMinimum: current.exclusiveMinimum, - }), - ...(hasMax && { maximum: current.maximum }), - ...(hasExMax && { - exclusiveMaximum: current.exclusiveMaximum, - }), - }, - message: "minimum must be less than maximum", - }); - } - } - } - - if ( - Object.hasOwn(current, "minItems") && - Object.hasOwn(current, "maxItems") && - current.minItems > current.maxItems - ) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minItems", - keyword: "minItems", - params: { - minItems: current.minItems, - maxItems: current.maxItems, - }, - message: "minItems must be less than or equal to maxItems", - }); - } - - if ( - Object.hasOwn(current, "minContains") && - Object.hasOwn(current, "maxContains") && - current.minContains > current.maxContains - ) { - result.errors.push({ - instancePath: path, - schemaPath: "#/minContains", - keyword: "minContains", - params: { - minContains: current.minContains, - maxContains: current.maxContains, - }, - message: "minContains must be less than or equal to maxContains", - }); - } - - if ( - Object.hasOwn(current, "pattern") && - typeof current.pattern === "string" - ) { - const patternResult = isSafePattern(current.pattern); - if (!patternResult.safe) { - result.errors.push({ - instancePath: `${path}/pattern`, - schemaPath: "#/redos", - keyword: "pattern", - params: { pattern: current.pattern }, - message: "pattern is vulnerable to ReDoS", - }); - } - } - - if ( - Object.hasOwn(current, "$ref") && - typeof current.$ref === "string" && - !current.$ref.startsWith("#") - ) { - try { - const url = new URL(current.$ref); - result.refs.push({ - hostname: url.hostname, - ref: current.$ref, - path: `${path}/$ref`, - }); - } catch { - // not a valid URL, skip - } - } - - for (const key in current) { - if (Object.hasOwn(current, key)) { - const value = current[key]; - if (typeof value === "object" && value !== null) { - const newDepth = currentDepth + 1; - if (newDepth > result.depth) result.depth = newDepth; - if (result.depth > maxDepth) { - result.depthExceeded = true; - return result; - } - stack.push([value, `${path}/${key}`, newDepth]); - } - } - } - } - - return result; -}; - -// RFC 1918 + loopback + link-local + CGN + TEST-NETs + multicast + reserved. -// Used to block $ref URLs whose hostname resolves to an internal/private IP. -export const isPrivateIP = (ip) => { - const parts = ip.split(".").map(Number); - if ( - parts.length === 4 && - parts.every((p) => Number.isInteger(p) && p >= 0 && p <= 255) - ) { - const [a, b] = parts; - if (a === 0) return true; // 0.0.0.0/8 "this" network - if (a === 10) return true; // 10.0.0.0/8 private - if (a === 127) return true; // 127.0.0.0/8 loopback - if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGN - if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local - if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private - if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0.0/24 IETF - if (a === 192 && b === 0 && parts[2] === 2) return true; // 192.0.2.0/24 TEST-NET-1 - if (a === 192 && b === 168) return true; // 192.168.0.0/16 private - 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 - 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 - } - - const lower = ip.toLowerCase(); - if (lower === "::1" || lower === "::") return true; - if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local - if (lower.startsWith("fe80")) return true; // link-local - if (lower.startsWith("ff")) return true; // multicast - if (lower.startsWith("::ffff:")) { - return isPrivateIP(lower.slice(7)); // IPv4-mapped IPv6 - } - return false; -}; - -export const resolveSSRFRefs = async (refs) => { - const hostnameMap = new Map(); - for (const entry of refs) { - if (!hostnameMap.has(entry.hostname)) { - hostnameMap.set(entry.hostname, []); - } - hostnameMap.get(entry.hostname).push(entry); - } - const results = await Promise.all( - [...hostnameMap.entries()].map(async ([hostname, entries]) => { - try { - const { address } = await lookup(hostname); - if (!isPrivateIP(address)) return []; - return entries.map(({ ref, path }) => ({ - instancePath: path, - schemaPath: "#/ssrf", - keyword: "ssrf", - params: { ref, hostname, resolvedIP: address }, - message: `$ref hostname "${hostname}" resolves to private IP ${address}`, - })); - } catch { - return entries.map(({ ref, path }) => ({ - instancePath: path, - schemaPath: "#/ssrf", - keyword: "ssrf", - params: { ref, hostname }, - message: `$ref hostname "${hostname}" does not resolve`, - })); - } - }), - ); - return results.flat(); -}; - -const resolveInstancePath = (obj, pointer) => { - if (typeof obj !== "object" || obj === null) return undefined; - if (!pointer) return obj; - const parts = pointer.split("/").slice(1); - let current = obj; - for (const part of parts) { - if (typeof current !== "object" || current === null) return undefined; - if (!Object.hasOwn(current, part)) return undefined; - current = current[part]; - } - return current; -}; +export { + MAX_DEPTH, + analyze, + crawlSchema, + isPrivateIP, + resolveSSRFRefs, + sast, +} from "sast-json-schema/cli"; +export { sast as default } from "sast-json-schema/cli"; From e50cc585f72cb7e4c1dd64f939639e77d68a6728 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Sat, 18 Apr 2026 19:43:22 -0600 Subject: [PATCH 5/6] chore: version bump Signed-off-by: will Farrell --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ea27a2..3cb843e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ajv-cmd", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ajv-cmd", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "workspaces": [ ".github" diff --git a/package.json b/package.json index d1d2400..08278ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ajv-cmd", - "version": "0.11.0", + "version": "0.12.0", "description": "Deref, Validate, Transpile, and Test JSON-Schema (.json) files using ajv", "type": "module", "engines": { From 9794e76b3199a511c355653e8d2a06f794509d97 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Sat, 18 Apr 2026 20:19:17 -0600 Subject: [PATCH 6/6] ci: fix lint Signed-off-by: will Farrell --- .license.template | 2 +- sast.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.license.template b/.license.template index d9c4a9f..1ba1e12 100644 --- a/.license.template +++ b/.license.template @@ -1,2 +1,2 @@ -Copyright 2022-2026 will Farrell, and ajv-cmd contributors. +Copyright 2026 will Farrell, and ajv-cmd contributors. SPDX-License-Identifier: MIT diff --git a/sast.js b/sast.js index 5d5e0bc..9dc817c 100644 --- a/sast.js +++ b/sast.js @@ -1,11 +1,11 @@ // Copyright 2026 will Farrell, and ajv-cmd contributors. // SPDX-License-Identifier: MIT export { - MAX_DEPTH, analyze, crawlSchema, isPrivateIP, + MAX_DEPTH, resolveSSRFRefs, sast, + sast as default, } from "sast-json-schema/cli"; -export { sast as default } from "sast-json-schema/cli";