From 8dfe18703ba528e0430e05b1a969829b1c243e33 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 28 Sep 2026 14:39:46 -0400 Subject: [PATCH] First public release Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 12 + .gitignore | 9 + .gitlab-ci.yml | 6 + CHANGELOG.md | 15 + CONTRIBUTING.md | 21 + LICENSE | 21 + README.md | 119 +++ SECURITY.md | 11 + package-lock.json | 1504 ++++++++++++++++++++++++++++++++++ package.json | 43 + src/evaluate.ts | 98 +++ src/index.ts | 4 + src/report.ts | 90 ++ src/rules.ts | 110 +++ src/types.ts | 103 +++ test/policy-enforced.test.ts | 211 +++++ tsconfig.json | 8 + 17 files changed, 2385 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/evaluate.ts create mode 100644 src/index.ts create mode 100644 src/report.ts create mode 100644 src/rules.ts create mode 100644 src/types.ts create mode 100644 test/policy-enforced.test.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbb0173 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.env.* +!.env.example +.npmrc +dist-cjs/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e5a37d2 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,6 @@ +stages: [test] + +test: + stage: test + image: node:22-alpine + script: [npm ci, npm run build, npm test] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d34f58b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.2.0 - 2026-09-28 + +First public release under MIT. + +- `evaluatePolicy()` and `evaluateRule()` run data-defined policy rules over + a set of files. Pure, with no I/O and no git. +- A rule no file was subject to is reported as NOT ASSESSED, never as a pass. +- Patterns with the `g` or `y` flag are tested through a fresh copy, so + `lastIndex` cannot skip files. +- Exemptions use a comment marker and are counted and cited. +- Violations cite the file and line; passes name the files they checked. +- Rule factories: `orgScopingRule`, `inOverflowRule`, `claimRule`. +- `toReport()` and `formatReport()` produce an evidence-record report. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7c3d37d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Issues and merge requests are welcome at +https://gitlab.com/extant2000/policy-enforced. + +## Ground rules + +- A test that cannot fail proves nothing. If you fix a bug, add a test and + check that it fails against the unfixed code before you submit. +- Measure, don't assume. Two modules with the same line count can still be + different programs. +- Keep dependencies minimal. Every new runtime dependency needs a reason. +- Explain why in comments, not what. The what is already in the code. +- A breaking change needs a major version bump and a note in CHANGELOG.md. + +## Before you open a merge request + +Run both of these and make sure they pass: + + npm run build + npm test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34e49b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Extant 2000 LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d33ce15 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# PolicyEnforced + +Check that a stated policy is enforced in the code, not only written down. + +PolicyEnforced is a small policy gate engine. A rule is data: which paths it +covers, what content makes a file subject to it, what content satisfies it, +and how an author documents an exemption. The engine is pure. You pass in the +files (usually the ones a change touched) and it returns what was checked, +what passed, what was exempted and what failed. + +## What it checks and why + +Policy gates written as shell scripts tend to get copied from app to app and +edited in place. Some copies drift for no reason. Others have to differ: an +org-scoping check for an app that filters by an `org_id` column would flag +every handler in an app that has no tenant columns and authorises through +RBAC capability checks. One shared script cannot serve both, and a separate +copy per app is the wrong fix. So the engine is shared and the patterns are +parameters. + +```ts +// An app that scopes rows by tenant column +orgScopingRule({ + guards: /requireOrg|resolveOrg/, + inlineTenantFilter: /\.eq\((['"])(organization_id|org_id|owner_id)\1/, +}) + +// An app with no tenant columns that uses RBAC checks +orgScopingRule({ + serviceClients: /createServiceClient|createClient\(/, + guards: /requireAuth|requireProjectCap|requireAdminCap/, +}) +``` + +The engine is built against three failure modes. + +1. A vacuous run is not a pass. `passed` and `vacuous` are separate fields, + and a rule that no file was subject to is reported as NOT ASSESSED. "No + changed file was covered by this rule" is not evidence that the policy + holds. A gate that prints a check mark for both is indistinguishable from + one that does nothing, which is the same defect as a test that cannot fail. +2. Regex state can skip files. A rule built with a `/g` pattern carries + `lastIndex` from one file to the next, so later files can pass unexamined + and the gate goes green while covering nothing. Every pattern is tested + through a fresh non-global copy. +3. A gate with no escape hatch gets switched off. The first legitimate + cross-tenant cron job would force someone to disable it. Exemptions are a + documented comment marker, and they are counted and cited in the report so + they stay visible. + +A pass names the files it checked, and a violation cites the file and line +where the rule's trigger matched. A result of "3 files checked" that does not +say which files is an asserted result, which is the kind of claim this library +exists to catch. + +One limit to keep in mind: the org-scoping rule confirms that a guard is +present. It does not prove the guard filters to the caller's own rows. That +still needs tests and review. + +## Rules included + +- `orgScopingRule` flags a handler that uses a service-role client (row-level + security bypassed) with no authorization guard, tenant filter or exemption. +- `inOverflowRule` flags a dynamic `.in('col', ids)` filter with no chunking + helper. With supabase-js, a long id list goes into the query string, the + request fails, and `data || []` turns that into an empty result with no + error. A literal array is bounded and is not flagged. +- `claimRule` flags a public claim (for example "99.9% uptime SLA" on a + pricing page) when nothing in the code enforces it. + +You can also write a `PolicyRule` object directly. + +## Usage + +```ts +import { evaluatePolicy, formatReport, orgScopingRule, inOverflowRule } from '@extant2000/policy-enforced' + +const rules = [ + orgScopingRule({ guards: /requireOrg|resolveOrg/, inlineTenantFilter: /\.eq\((['"])(org_id|owner_id)\1/ }), + inOverflowRule({ chunkHelpers: /selectInChunks/ }), +] + +const files = [ + { path: 'server/api/invoices.get.ts', content: "const db = createServiceClient()\nreturn db.from('invoices').select()" }, + { path: 'server/api/contacts.get.ts', content: "await requireOrg(event)\nconst db = createServiceClient()" }, +] + +const report = evaluatePolicy(rules, files) +console.log({ passed: report.passed, vacuous: report.vacuous }) +for (const v of report.violations) console.log(`${v.ruleId} ${v.path}:${v.line} ${v.excerpt}`) + +// The full report for a CI log, with remedies under each violation +console.log(formatReport(report)) +``` + +`toReport(report)` returns the same result as a `CapabilityReport` from +`@extant2000/evidence-record`. `evaluateRule(rule, files)` runs a single rule. + +## Sample output + +Produced by running the example above (first two lines): + +``` +{ passed: false, vacuous: false } +org-scoping server/api/invoices.get.ts:1 const db = createServiceClient() +``` + +In the full report, `in-overflow` shows as NOT ASSESSED, since neither file +used a dynamic `.in()` filter. + +## Install + +``` +npm install @extant2000/policy-enforced +``` + +## License + +MIT. Copyright (c) 2026 Extant 2000 LLC. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5fd4f2c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +## Reporting a vulnerability + +Please report vulnerabilities privately by email to security@extant2000.com. +Do not open a public issue or merge request for a security problem. + +Include the affected version, a description of the issue, and steps or a test +that reproduce it if you have them. + +We aim to acknowledge every report within 5 business days. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..71877d3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1504 @@ +{ + "name": "@extant2000/policy-enforced", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@extant2000/policy-enforced", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extant2000/evidence-record": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..126661d --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "@extant2000/policy-enforced", + "version": "0.2.0", + "private": false, + "description": "Verify that a stated policy is enforced in the code, not only written in a document.", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "https://gitlab.com/extant2000/policy-enforced.git" + }, + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "author": "Extant 2000 LLC", + "homepage": "https://gitlab.com/extant2000/policy-enforced", + "bugs": { + "url": "https://gitlab.com/extant2000/policy-enforced/-/issues" + } +} diff --git a/src/evaluate.ts b/src/evaluate.ts new file mode 100644 index 0000000..9477a71 --- /dev/null +++ b/src/evaluate.ts @@ -0,0 +1,98 @@ +import type { + PolicyReport, + PolicyRule, + RuleResult, + SourceFile, + Violation, +} from './types.js' + +function matchesPath(path: string, includes: string[]): boolean { + return includes.some(i => path.includes(i)) +} + +/** + * A regex with the `g` flag carries `lastIndex` between `.test()` calls, so the + * same pattern reused across files silently starts matching from wherever the + * previous file left off — producing false passes that look like clean runs. + * Test against a fresh, non-global copy every time. + */ +function test(re: RegExp, content: string): boolean { + return new RegExp(re.source, re.flags.replace(/[gy]/g, '')).test(content) +} + +/** + * Locate the trigger match so a finding can cite a line, not just a file. + * + * Scans line by line rather than counting newlines before a whole-file match + * index, because the trigger may legitimately span nothing more than one line + * and this keeps the excerpt exactly what the reader will see in the editor. + */ +function locate(re: RegExp, content: string): { line: number, excerpt: string } | undefined { + const bare = new RegExp(re.source, re.flags.replace(/[gy]/g, '')) + const lines = content.split('\n') + for (let i = 0; i < lines.length; i++) { + if (bare.test(lines[i]!)) return { line: i + 1, excerpt: lines[i]!.trim().slice(0, 120) } + } + // The trigger matched the file but not any single line — a multi-line + // pattern. Cite the file without a line rather than inventing one. + return undefined +} + +/** Evaluate one rule over already-loaded files. Pure — no I/O, no git. */ +export function evaluateRule(rule: PolicyRule, files: SourceFile[]): RuleResult { + let considered = 0 + let subject = 0 + let exempted = 0 + const violations: Violation[] = [] + const compliantPaths: string[] = [] + const exemptedPaths: string[] = [] + + for (const f of files) { + if (!matchesPath(f.path, rule.pathIncludes)) continue + considered++ + + if (!test(rule.trigger, f.content)) continue + subject++ + + if (rule.allow.some(a => test(a, f.content))) { + compliantPaths.push(f.path) + continue + } + + if (f.content.includes(rule.exemptMarker)) { + exempted++ + exemptedPaths.push(f.path) + continue + } + + const at = locate(rule.trigger, f.content) + violations.push({ + ruleId: rule.id, + path: f.path, + reason: rule.description, + line: at?.line, + excerpt: at?.excerpt, + }) + } + + return { rule, considered, subject, exempted, violations, compliantPaths, exemptedPaths } +} + +/** + * Evaluate every rule. + * + * `vacuous` is reported separately from `passed`. A run where no changed file + * was subject to any rule proves nothing — treating it as a pass is how a gate + * ends up green for months while covering nothing, which is the same defect as + * a test that cannot fail. + */ +export function evaluatePolicy(rules: PolicyRule[], files: SourceFile[]): PolicyReport { + const results = rules.map(r => evaluateRule(r, files)) + const violations = results.flatMap(r => r.violations) + return { + results, + violations, + passed: violations.length === 0, + vacuous: results.every(r => r.subject === 0), + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6fb437c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export * from './types.js' +export * from './evaluate.js' +export * from './rules.js' +export * from './report.js' diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..b925d4a --- /dev/null +++ b/src/report.ts @@ -0,0 +1,90 @@ +import { + type CapabilityReport, + type Finding, + evidence, + formatReport as renderReport, + type FormatOptions, +} from '@extant2000/evidence-record' +import type { PolicyReport, RuleResult } from './types.js' + +/** + * One finding per rule, in the shared vocabulary. + * + * The three-way split is the point of this library and now has three-way + * types to carry it: + * + * nothing was subject to the rule -> not-assessed (used to print VACUOUS) + * files were subject and complied -> pass, citing the files it checked + * files were subject and did not -> fail, citing file:line and the line + * + * "Nothing changed that this rule covers" and "the policy holds" are different + * statements, and a gate that prints ✓ for both is indistinguishable from one + * that does nothing. + */ +function findingFor(r: RuleResult): Finding { + const doc = evidence.document(r.rule.description, r.rule.id) + + if (r.violations.length > 0) { + return { + id: r.rule.id, + summary: `${r.rule.id} — ${r.violations.length} file(s) subject to the rule did not satisfy it`, + determination: 'fail', + severity: 'high', + detail: r.rule.remedy.join(' '), + evidence: [ + ...r.violations.map(v => evidence.file(v.path, v.line, v.excerpt, v.reason)), + doc, + ], + } + } + + if (r.subject === 0) { + return { + id: r.rule.id, + summary: `${r.rule.id} — ${r.considered} file(s) examined, none subject to the rule`, + determination: 'not-assessed', + severity: 'medium', + detail: 'Nothing was checked against this rule. That is not evidence the policy holds.', + // Deliberately empty: the whole content of this finding is that there + // was nothing to observe. + evidence: [], + } + } + + const ex = r.exempted ? `, ${r.exempted} exempted` : '' + return { + id: r.rule.id, + summary: `${r.rule.id} — ${r.subject} file(s) checked and compliant${ex}`, + determination: 'pass', + severity: 'info', + evidence: [ + ...r.compliantPaths.map(p => evidence.file(p, undefined, undefined, 'satisfied the rule')), + // Exemptions are evidence too, and printing them is what keeps an + // exemption from being a quiet way to pass. + ...r.exemptedPaths.map(p => evidence.file(p, undefined, undefined, `exempted via "${r.rule.exemptMarker}"`)), + doc, + ], + } +} + +/** Convert a policy run into the portfolio-standard report shape. */ +export function toReport(report: PolicyReport): CapabilityReport { + const considered = report.results.reduce((n, r) => n + r.considered, 0) + return { + capability: 'PolicyEnforced', + scope: `${report.results.length} rule(s) over ${considered} file(s)`, + examined: considered, + findings: report.results.map(findingFor), + } +} + +/** + * Render a report for a CI log. + * + * Delegates to the shared renderer. The distinction that used to be carried + * by the word VACUOUS is now carried by NOT ASSESSED plus a sentence, which + * means the same thing without requiring a glossary. + */ +export function formatReport(report: PolicyReport, opts: FormatOptions = {}): string { + return renderReport(toReport(report), opts) +} diff --git a/src/rules.ts b/src/rules.ts new file mode 100644 index 0000000..b5c30bb --- /dev/null +++ b/src/rules.ts @@ -0,0 +1,110 @@ +import type { PolicyRule } from './types.js' + +/** + * Rule factories for three common gates. + * + * Each takes the parts that vary per app. An org-scoping gate differs between + * apps precisely because these lists differ (one app guards with + * `requireOrg` and tenant columns, another with RBAC capability checks), so + * they are parameters, not constants. + */ + +export interface OrgScopingOptions { + /** Accessors that BYPASS row-level security. */ + serviceClients?: RegExp + /** Named guards that make a service-role handler safe. */ + guards: RegExp + /** Optional: inline tenant-column filter that also counts as scoped. */ + inlineTenantFilter?: RegExp + pathIncludes?: string[] +} + +/** + * A changed handler using a service-role client (RLS bypassed) must carry an + * authorization check or a documented exemption. + * + * Diff-scoped on purpose: it does not police pre-existing unguarded endpoints + * (track those separately), only new or changed code where the author is right there. + * A gate nobody trusts is worse than no gate. + * + * LIMITATION, inherited and worth restating: this confirms a guard is PRESENT, + * not that it filters the caller's own rows. Value-correctness is for tests and + * review. + */ +export function orgScopingRule(opts: OrgScopingOptions): PolicyRule { + const allow = [opts.guards] + if (opts.inlineTenantFilter) allow.push(opts.inlineTenantFilter) + + return { + id: 'org-scoping', + description: 'service-role handler with no authorization check or exemption', + pathIncludes: opts.pathIncludes ?? ['server/api/'], + trigger: opts.serviceClients + ?? /useServerSupabase|serverSupabaseServiceRole|createServiceClient|createClient\(/, + allow, + exemptMarker: 'org-scoping-exempt:', + remedy: [ + 'Add an authorization guard, or scope the query by a tenant column.', + 'If the route is genuinely cross-tenant (cron, token, webhook, admin),', + 'document it: // org-scoping-exempt: ', + ], + } +} + +/** + * A changed file adding a dynamic `.in()` filter must chunk it. + * + * The bug: supabase-js puts every id in the query string, so past roughly 400 + * UUIDs the request exceeds the header limit and dies — returning null, which + * `data || []` turns into a convincing empty state on top of real rows. It does + * not error. That is why this is a gate and not a code review note. + */ +export function inOverflowRule(opts: { chunkHelpers: RegExp, pathIncludes?: string[] }): PolicyRule { + return { + id: 'in-overflow', + description: 'dynamic .in() filter with no chunking helper or exemption', + pathIncludes: opts.pathIncludes ?? ['server/', 'app/'], + // `.in('col', someVariable)` — a literal array is bounded and fine. + trigger: /\.in\(\s*['"][^'"]+['"]\s*,\s*(?!\[)[A-Za-z_$]/, + allow: [opts.chunkHelpers], + exemptMarker: 'in-overflow-ok:', + remedy: [ + 'Chunk the filter (selectInChunks or equivalent), or document why the', + 'list is bounded: // in-overflow-ok: ', + ], + } +} + +/** A rule asserting a stated claim has an enforcement mechanism behind it. */ +export interface ClaimOptions { + id: string + /** Where the claim is made (marketing copy, docs, policy pages). */ + claimPaths: string[] + /** The claim text. */ + claim: RegExp + /** Evidence that it is actually enforced. */ + enforcedBy: RegExp + remedy?: string[] +} + +/** + * The general "advertised == implemented" shape. + * + * The typical case: uptime or SLA language appears on pricing and marketing + * pages with no server-side enforcement anywhere. A public claim with nothing + * behind it is a compliance risk, not just a documentation gap. + */ +export function claimRule(opts: ClaimOptions): PolicyRule { + return { + id: opts.id, + description: 'public claim with no enforcement behind it', + pathIncludes: opts.claimPaths, + trigger: opts.claim, + allow: [opts.enforcedBy], + exemptMarker: `${opts.id}-exempt:`, + remedy: opts.remedy ?? [ + 'Either implement the enforcement, or remove/soften the claim.', + `If enforcement lives elsewhere, document it: // ${opts.id}-exempt: `, + ], + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4b15639 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,103 @@ +/** + * A policy that must hold in the running codebase, not merely in a document. + * + * Policy gates written as shell scripts tend to share one engine and differ + * only in their patterns. An org-scoping gate copied across several apps can + * legitimately end up with a distinct copy per app, and that divergence is + * CORRECT, not drift: one app scopes by an `org_id` tenant column, another has + * no tenant columns at all and authorises via RBAC capability checks. Forcing + * one script would break one of them. + * + * So the engine is shared and the policy is data. + */ + +export interface PolicyRule { + /** Stable id, used in output and to reference a rule. */ + id: string + /** One line: what must be true. */ + description: string + + /** + * Only files whose path matches one of these substrings/patterns are + * considered. Kept deliberately simple — a rule that needs a real glob + * engine is usually a rule that wants narrowing instead. + */ + pathIncludes: string[] + + /** + * The file is SUBJECT to this rule only if its content matches. This is the + * risky construct — a service-role client, an unbounded `.in()`, and so on. + */ + trigger: RegExp + + /** + * Any match here makes the file compliant. Multiple entries are OR-ed, + * because there is usually more than one legitimate way to satisfy a policy. + */ + allow: RegExp[] + + /** + * A comment marker that documents a deliberate exemption, e.g. + * `org-scoping-exempt:`. Exemptions are part of a workable gate: without one, + * the legitimate cross-tenant cron job forces someone to disable the check + * entirely. + */ + exemptMarker: string + + /** Lines printed under a violation telling the author how to fix it. */ + remedy: string[] +} + +export interface SourceFile { + path: string + content: string +} + +export interface Violation { + ruleId: string + path: string + /** Why it failed, in one line. */ + reason: string + /** + * 1-indexed line where the trigger matched — the construct that made this + * file subject to the rule. A violation that names only the file sends the + * reader hunting; this points at the line that did it. + */ + line?: number + /** The matched line, trimmed. */ + excerpt?: string +} + +export interface RuleResult { + rule: PolicyRule + /** Files the rule actually examined (matched pathIncludes). */ + considered: number + /** Files that were subject to it (matched the trigger). */ + subject: number + /** Files excused by an exemption marker. */ + exempted: number + violations: Violation[] + /** + * Paths that were subject to the rule and satisfied it. + * + * Retained so a PASS can cite what it checked. A gate that reports "✓ 3 + * files checked" without naming them is an asserted result, and this + * library's whole premise is that written policy and enforced policy are + * different things. + */ + compliantPaths: string[] + /** Paths excused by the exemption marker — counted, never hidden. */ + exemptedPaths: string[] +} + +export interface PolicyReport { + results: RuleResult[] + violations: Violation[] + passed: boolean + /** + * True when there was nothing to check. Distinguished from `passed` on + * purpose: "no changed files matched" is not evidence the policy holds, and + * a report that conflates them is the same lie as a test that cannot fail. + */ + vacuous: boolean +} diff --git a/test/policy-enforced.test.ts b/test/policy-enforced.test.ts new file mode 100644 index 0000000..cf5a31e --- /dev/null +++ b/test/policy-enforced.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import { overall, validateReport } from '@extant2000/evidence-record' +import { + claimRule, + evaluatePolicy, + evaluateRule, + formatReport, + toReport, + inOverflowRule, + orgScopingRule, + type SourceFile, +} from '../src/index.js' + +const f = (path: string, content: string): SourceFile => ({ path, content }) + +// An app that scopes rows by tenant columns. +const tenantOrg = orgScopingRule({ + guards: /requireOrg|resolveOrg|assertContactInOrg/, + inlineTenantFilter: /\.eq\((['"])(organization_id|org_id|owner_id|user_id)\1/, +}) + +// An app with no tenant columns at all, using RBAC capability checks instead. +const rbacOrg = orgScopingRule({ + serviceClients: /createServiceClient|createClient\(/, + guards: /requireAuth|requireProjectCap|requireAdminCap|getRequestClient/, +}) + +describe('orgScopingRule — the divergence that made this a library', () => { + it('tenant-column app: a service-role handler with no guard is a violation', () => { + const r = evaluateRule(tenantOrg, [ + f('server/api/x.ts', 'const s = createServiceClient()\ns.from("t").select()'), + ]) + expect(r.violations).toHaveLength(1) + }) + + it('tenant-column app: an inline tenant filter satisfies it', () => { + const r = evaluateRule(tenantOrg, [ + f('server/api/x.ts', `const s = createServiceClient()\ns.from("t").select().eq('org_id', id)`), + ]) + expect(r.violations).toHaveLength(0) + }) + + it('RBAC app: a capability check satisfies it, with NO tenant column', () => { + // This is why one shared script could not work. The RBAC app has no org_id + // anywhere; under the tenant-column rule this same file would be a false + // violation. + const rbacFile = f('server/api/y.ts', + 'await requireProjectCap(event, id, "view")\nconst s = createServiceClient()') + expect(evaluateRule(rbacOrg, [rbacFile]).violations).toHaveLength(0) + expect(evaluateRule(tenantOrg, [rbacFile]).violations).toHaveLength(1) + }) + + it('a documented exemption excuses the file and is counted', () => { + const r = evaluateRule(tenantOrg, [ + f('server/api/cron.ts', 'createServiceClient()\n// org-scoping-exempt: nightly cron, cross-tenant by design'), + ]) + expect(r.violations).toHaveLength(0) + expect(r.exempted).toBe(1) + }) + + it('ignores files outside the path scope', () => { + const r = evaluateRule(tenantOrg, [f('app/pages/x.vue', 'createServiceClient()')]) + expect(r.considered).toBe(0) + expect(r.violations).toHaveLength(0) + }) + + it('ignores a file that never touches a service-role client', () => { + const r = evaluateRule(tenantOrg, [f('server/api/x.ts', 'const s = getRequestClient(event)')]) + expect(r.considered).toBe(1) + expect(r.subject).toBe(0) + }) +}) + +describe('inOverflowRule', () => { + const rule = inOverflowRule({ chunkHelpers: /selectInChunks/ }) + + it('flags a dynamic .in() with no chunking', () => { + const r = evaluateRule(rule, [f('server/x.ts', `supabase.from('t').select().in('id', ids)`)]) + expect(r.violations).toHaveLength(1) + }) + + it('accepts a chunked call', () => { + const r = evaluateRule(rule, [ + f('server/x.ts', `selectInChunks(ids, p => supabase.from('t').select().in('id', p))`), + ]) + expect(r.violations).toHaveLength(0) + }) + + it('does NOT flag a literal array — that list is bounded', () => { + const r = evaluateRule(rule, [f('server/x.ts', `.in('status', ['a','b'])`)]) + expect(r.subject).toBe(0) + }) +}) + +describe('claimRule — advertised == implemented', () => { + // The classic gap: uptime or SLA language in marketing copy with nothing + // in the code that enforces it. + const rule = claimRule({ + id: 'uptime-claim', + claimPaths: ['src/views/', 'app/pages/'], + claim: /99\.\d+%\s*uptime|uptime\s*SLA/i, + enforcedBy: /uptimeCredit|slaEnforcer/, + }) + + it('flags an uptime claim with no enforcement', () => { + const r = evaluateRule(rule, [f('src/views/Pricing.vue', '

99.9% uptime SLA

')]) + expect(r.violations).toHaveLength(1) + }) + + it('passes when enforcement is present', () => { + const r = evaluateRule(rule, [ + f('src/views/Pricing.vue', '

99.9% uptime SLA

\nimport { uptimeCredit } from "~/lib"'), + ]) + expect(r.violations).toHaveLength(0) + }) +}) + +describe('regex state safety', () => { + it('a global-flag rule does not skip files via lastIndex', () => { + // A `g` regex carries lastIndex between .test() calls. Reusing one across + // files makes later files silently pass — a green gate covering nothing. + const sticky = orgScopingRule({ + serviceClients: /createServiceClient/g, + guards: /requireOrg/g, + }) + const r = evaluateRule(sticky, [ + f('server/api/a.ts', 'createServiceClient()'), + f('server/api/b.ts', 'createServiceClient()'), + f('server/api/c.ts', 'createServiceClient()'), + ]) + expect(r.violations).toHaveLength(3) + }) +}) + +describe('evaluatePolicy + formatReport', () => { + it('distinguishes "nothing was checked" from a real pass', () => { + // "nothing changed that this covers" is not "the policy holds". + const nothing = evaluatePolicy([tenantOrg], [f('server/api/x.ts', 'export default 1')]) + expect(nothing.passed).toBe(true) + expect(nothing.vacuous).toBe(true) + expect(overall(toReport(nothing))).toBe('not-assessed') + const nothingText = formatReport(nothing) + expect(nothingText).toContain('NOT ASSESSED') + expect(nothingText).toContain('not evidence the policy holds') + // The word it replaced needed a glossary. + expect(nothingText).not.toContain('VACUOUS') + + const real = evaluatePolicy([tenantOrg], [ + f('server/api/x.ts', `createServiceClient()\n.eq('org_id', o)`), + ]) + expect(real.passed).toBe(true) + expect(real.vacuous).toBe(false) + expect(overall(toReport(real))).toBe('pass') + }) + + it('a pass cites the files it actually checked', () => { + // A gate that reports "3 files checked" without naming them is an + // asserted result, which is the defect this library exists to find. + const real = evaluatePolicy([tenantOrg], [ + f('server/api/x.ts', `createServiceClient()\n.eq('org_id', o)`), + ]) + expect(toReport(real).findings[0]!.evidence.length).toBeGreaterThan(0) + expect(formatReport(real, { evidence: 'full' })).toContain('server/api/x.ts') + }) + + it('aggregates violations across rules and prints remedies', () => { + const rep = evaluatePolicy([tenantOrg, inOverflowRule({ chunkHelpers: /selectInChunks/ })], [ + f('server/api/a.ts', 'createServiceClient()'), + f('server/b.ts', `.in('id', ids)`), + ]) + expect(rep.passed).toBe(false) + expect(rep.violations).toHaveLength(2) + const text = formatReport(rep, { evidence: 'full' }) + expect(text).toContain('org-scoping') + expect(text).toContain('in-overflow') + expect(text).toContain('org-scoping-exempt:') + expect(overall(toReport(rep))).toBe('fail') + }) + + it('cites the line that triggered the rule, not just the file', () => { + const rep = evaluatePolicy([tenantOrg], [ + f('server/api/a.ts', 'const x = 1\nconst y = 2\ncreateServiceClient()'), + ]) + expect(rep.violations[0]!.line).toBe(3) + expect(rep.violations[0]!.excerpt).toBe('createServiceClient()') + expect(formatReport(rep)).toContain('server/api/a.ts:3') + }) + + it('counts exemptions as evidence rather than hiding them', () => { + const rep = evaluatePolicy([tenantOrg], [ + f('server/api/a.ts', 'createServiceClient() // org-scoping-exempt: cross-tenant cron'), + ]) + expect(rep.passed).toBe(true) + const text = formatReport(rep, { evidence: 'full' }) + expect(text).toContain('exempted via') + expect(text).toContain('server/api/a.ts') + }) + + it('every conclusion carries a citation', () => { + const rep = evaluatePolicy([tenantOrg, inOverflowRule({ chunkHelpers: /selectInChunks/ })], [ + f('server/api/a.ts', 'createServiceClient()'), + f('server/b.ts', `.in('id', ids)`), + ]) + expect(validateReport(toReport(rep))).toEqual([]) + }) + + it('an empty file set is vacuous, never a pass claim', () => { + const rep = evaluatePolicy([tenantOrg], []) + expect(rep.vacuous).toBe(true) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9f56c30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "declaration": true, "outDir": "dist", "rootDir": "src", + "strict": true, "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}