diff --git a/.dockerignore b/.dockerignore index 037d409290..cc17db240a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,10 @@ apps/frontend/tests apps/backend/test test **/node_modules + +# VitePress documentation site (ADR-005 §2.1) — the app image builds from +# explicit COPY paths, so docs/ never entered it implicitly; this makes the +# exclusion explicit. NOTE: shipping the BUILT docs inside the app image for +# offline/airgapped use (Aaron, 2026-08-11) is a separate packaging change and +# will need this entry narrowed (source + node_modules out, built output in). +docs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..041254e47f --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,13 @@ +# Commits that changed formatting only, and should be skipped by `git blame`. +# +# Enable locally (once per clone): +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# GitHub honours this file automatically in its blame view. +# +# Add the full 40-character SHA of a pure-formatting commit below, one per line, +# with a comment naming it. Only add commits that changed NOTHING but formatting +# — if a commit mixes formatting with behavior, blame must not skip it. + +# (none yet — the initial `yarn format` adoption commit belongs here once the +# repo-wide Prettier reformat lands) diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml new file mode 100644 index 0000000000..27a3088dbe --- /dev/null +++ b/.github/workflows/build-rpm.yml @@ -0,0 +1,212 @@ +name: Build RPM + +# One workflow for both purposes, following redis/memtier_benchmark: +# every PR and master push proves the RPM still builds and installs, and a +# published release additionally uploads the artifacts. Publishing steps are +# gated on the event rather than split into a second workflow, so the thing +# that ships is the thing CI exercised. + +on: + pull_request: + paths: + - 'packaging/**' + - 'VERSION' + - '.github/workflows/build-rpm.yml' + push: + branches: [master] + paths: + - 'packaging/**' + - 'VERSION' + - '.github/workflows/build-rpm.yml' + release: + types: [published] + workflow_dispatch: + inputs: + heimdall_cli_ref: + description: 'heimdall-cli ref to build against (tag, branch, or SHA)' + required: false + default: 'main' + +env: + # Release builds must pin a tag. Everything else tracks main. + # This value is what the RPM records as the CLI it shipped. + HEIMDALL_CLI_REF: ${{ inputs.heimdall_cli_ref || 'main' }} + +jobs: + build: + name: ${{ matrix.distro }} / ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - distro: el8 + image: rockylinux:8 + arch: x86_64 + runner: ubuntu-latest + - distro: el9 + image: rockylinux:9 + arch: x86_64 + runner: ubuntu-latest + # Native ARM runners — free for public repositories, and roughly + # 5-10x faster than QEMU emulation for a Node + Go build. The Go CLI + # is compiled natively here rather than cross-compiled. + - distro: el8 + image: rockylinux:8 + arch: aarch64 + runner: ubuntu-24.04-arm + - distro: el9 + image: rockylinux:9 + arch: aarch64 + runner: ubuntu-24.04-arm + + steps: + # A bare Rocky container has neither git nor make. actions/checkout needs + # git, and every subsequent step goes through the Makefile — including + # `make deps`, which is what installs the real build dependencies. Both + # have to be bootstrapped here or `make deps` cannot run itself. + - name: Bootstrap git and make + run: | + dnf install -y git make + git --version + make --version | head -1 + + - uses: actions/checkout@v6 + with: + # Full history and tags: `make sources` archives the tag matching the + # VERSION file. A shallow clone has no tags and the build would fail. + fetch-depth: 0 + fetch-tags: true + + - name: Mark workspace safe + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install build dependencies + working-directory: packaging/rpm + run: make deps + + - name: Install Go (for the heimdall-cli build) + run: dnf install -y golang + + - name: Verify spec and repository versions agree + working-directory: packaging/rpm + run: make check-version + + # Releases build from the tag matching VERSION. CI builds from HEAD, + # because on a feature branch the tag for an in-progress version does not + # exist yet. DEV=1 makes that explicit rather than silently producing an + # RPM that claims to be a release. + - name: Build RPM + working-directory: packaging/rpm + env: + HEIMDALL_CLI_REF: ${{ env.HEIMDALL_CLI_REF }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "release" ]; then + make rpm + else + make rpm DEV=1 + fi + + - name: Lint the built packages (advisory) + working-directory: packaging/rpm + continue-on-error: true + run: make lint-rpm + + - name: Record what was built + working-directory: packaging/rpm + run: | + find rpmbuild/RPMS rpmbuild/SRPMS -name '*.rpm' -printf '%f\n' | sort + echo "heimdall-cli ref: ${HEIMDALL_CLI_REF}" + + - uses: actions/upload-artifact@v7 + with: + name: rpm-${{ matrix.distro }}-${{ matrix.arch }} + path: | + packaging/rpm/rpmbuild/RPMS/**/*.rpm + packaging/rpm/rpmbuild/SRPMS/*.rpm + retention-days: 7 + if-no-files-found: error + + # The highest-value check: install into a clean container with NO build + # dependencies pre-installed, so a missing Requires: fails here rather than on + # a customer's host. Verifies %files claims via rpm -ql. + smoke-test: + name: install ${{ matrix.distro }} / ${{ matrix.arch }} + needs: build + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - {distro: el8, image: rockylinux:8, arch: x86_64, runner: ubuntu-latest} + - {distro: el9, image: rockylinux:9, arch: x86_64, runner: ubuntu-latest} + - {distro: el8, image: rockylinux:8, arch: aarch64, runner: ubuntu-24.04-arm} + - {distro: el9, image: rockylinux:9, arch: aarch64, runner: ubuntu-24.04-arm} + + steps: + - uses: actions/download-artifact@v8 + with: + name: rpm-${{ matrix.distro }}-${{ matrix.arch }} + path: rpms + + - name: Install the package and its dependencies + run: | + dnf install -y epel-release || true + rpm_file=$(find rpms -name "heimdall-server-*.${{ matrix.arch }}.rpm" | head -1) + test -n "$rpm_file" || { echo "::error::no binary RPM found"; exit 1; } + echo "Installing $rpm_file" + dnf install -y "$rpm_file" + + - name: Verify the package contents match its manifest + run: | + rpm -q heimdall-server + rpm -V heimdall-server || true # config file changes are expected + echo "--- files ---" + rpm -ql heimdall-server | head -40 + echo "--- heimdall-cli is present and reports provenance ---" + test -x /usr/bin/heimdall-cli + /usr/bin/heimdall-cli --version + + - name: Verify the unit file is valid + run: | + dnf install -y systemd + systemd-analyze verify /usr/lib/systemd/system/heimdall-server.service || true + + publish: + name: Attach RPMs to the release + needs: [build, smoke-test] + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: write # upload release assets + id-token: write # build provenance attestation + attestations: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: rpm-* + path: rpms + merge-multiple: true + + - name: List artifacts + run: find rpms -name '*.rpm' -printf '%f\n' | sort + + # The modern equivalent of GPG-signing in CI: a signed, verifiable + # statement of what built these artifacts and from where. Same + # supply-chain story as npm provenance, without a release key in secrets. + - uses: actions/attest-build-provenance@v4 + with: + subject-path: 'rpms/**/*.rpm' + + # Pinned to a commit SHA: this is a third-party action holding + # contents: write, and a mutable tag can be repointed at any time. + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + files: rpms/**/*.rpm + fail_on_unmatched_files: true diff --git a/.github/workflows/hdfconverter-tests.yml b/.github/workflows/hdfconverter-tests.yml index 1c65b4ab39..94109d2860 100644 --- a/.github/workflows/hdfconverter-tests.yml +++ b/.github/workflows/hdfconverter-tests.yml @@ -52,6 +52,11 @@ jobs: - name: Start Mock Sonarqube Server run: yarn run cypress-test mock-json & + - name: Validate there are no changes between the Tailwind source and the generated assets + run: | + yarn hdf-converters prebuild + yarn hdf-converters validate-generated + - name: Run unit tests run: yarn hdf-converters test:ci env: diff --git a/.github/workflows/push-to-npm.yml b/.github/workflows/push-to-npm.yml index bf2b924a0b..55f6569eb6 100644 --- a/.github/workflows/push-to-npm.yml +++ b/.github/workflows/push-to-npm.yml @@ -33,6 +33,23 @@ jobs: - name: Pack all items that are published as packages run: yarn pack:all + # The published packages rewrite "main" from src/index.ts to lib/index.js + # in prepack and restore it in postpack. Yarn 1 has no try/finally around + # those lifecycle scripts, so a failed pack skips the restore and leaves a + # mutated manifest behind. Running --parallel across three packages means + # one failure can strand another package's manifest. Fail loudly here + # rather than publish a tarball built from a corrupted tree. + - name: Verify manifests were restored after pack + run: | + if ! git diff --exit-code -- '*package.json'; then + echo "::error::package.json was left modified after pack:all — a prepack rewrite was not restored by postpack. Do not publish from this tree." + exit 1 + fi + if git ls-files --others --exclude-standard | grep -q 'package.json.orig'; then + echo "::error::A stray package.json.orig remains after pack:all — postpack did not run." + exit 1 + fi + - name: Publish Heimdall Lite to NPM if: always() run: npx -y npm@latest publish --access public apps/frontend/mitre-heimdall-lite*.tgz diff --git a/.gitignore b/.gitignore index 799fa4e2ba..a9b0681cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,25 @@ certs/*.pem # Database Content data/* + +# prepack/postpack manifest backup. +# The published packages rewrite "main" from src/index.ts to lib/index.js at +# pack time (npm has never supported publishConfig manifest overrides, and +# npm 13 will hard-error on them, so the rewrite is the only option). +# Yarn 1's pack has no try/finally around the lifecycle scripts, so a failed +# pack skips postpack and strands this file next to a mutated package.json. +package.json.orig + +# VitePress documentation site (ADR-005). docs/ is deliberately OUTSIDE the yarn +# workspaces globs ("apps/*", "libs/*", "test"), so it has its own package.json +# and yarn.lock and root `yarn install` never sees it. Bare `node_modules` above +# already covers docs/node_modules; VitePress's cache and build output do not. +docs/node_modules +docs/.vitepress/cache +docs/.vitepress/dist + +# Playwright MCP writes console logs and page snapshots into the repository as +# it drives the browser — one directory per session, created unconditionally +# and never cleaned up. Live-test evidence belongs in card notes, not in the +# tree, so this is ignored rather than committed. +.playwright-mcp/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..5936d5d5d2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,33 @@ +# Prettier owns formatting for SOURCE. Everything below is either build output, +# third-party/generated content, or fixture data that is compared byte-for-byte +# by tests — reformatting any of it changes meaning, not just appearance. + +# Build output and dependencies +**/dist +**/lib +**/node_modules +yarn.lock +package-lock.json + +# Test fixture corpora. 365 MB across 249 files; the mapper specs read these +# with readFileSync/JSON.parse and compare results against them, so reformatting +# would both take enormous time and risk changing what the tests assert. +libs/hdf-converters/sample_jsons/ + +# Generated assets guarded by the `validate-generated` script, which fails the +# build if their committed bytes change (tailwind style.css + the embedded +# strings derived from it). +libs/hdf-converters/data/ + +# Generated sources — same set the ESLint config ignores, kept in sync. +libs/inspecjs/src/generated_parsers/ +libs/hdf-converters/src/ckl-mapper/jsonixMapping.ts + +# Static data tables, not code: giant literal maps with no logic. Formatting +# them produces enormous diffs and zero benefit. +**/*MappingData.ts +apps/frontend/src/utilities/cci_util.ts + +# The documentation site is an isolated project (ADR-005 §2.1) with its own +# package.json and toolchain; it is not formatted by the application's tooling. +docs/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000000..84cc551fd2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "singleQuote": true +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..438c407246 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +# Contributing to Heimdall + +Thank you for considering a contribution. Heimdall is used to review security +and compliance results across a lot of very different environments, so +correctness and clear reporting matter more here than speed. + +## Code of Conduct + +This project follows the [Code of Conduct](CODE_OF_CONDUCT.md). By +participating you agree to uphold it. + +## Reporting Bugs + +Open a [GitHub issue](https://github.com/mitre/heimdall2/issues) and include: + +- **What you expected** and **what happened instead** +- **Steps to reproduce** +- **Which component** — Heimdall Server, Heimdall Lite, `@mitre/hdf-converters`, + or `inspecjs` +- **Environment details** — OS, Node version, browser, deployment method + (Docker, RPM, or source) +- **A sample input file** where relevant, with anything sensitive removed + +For converter bugs, the input file is usually the single most useful thing you +can attach. Scan output frequently contains hostnames and configuration +detail — sanitize before sharing, or send it privately. + +## Reporting Security Issues + +**Do not open a public issue.** See [SECURITY.md](SECURITY.md) for the private +reporting process. + +## Development Process + +### Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork**: + ```bash + git clone git@github.com:your-username/heimdall2.git + cd heimdall2 + ``` +3. **Add the upstream remote**: + ```bash + git remote add upstream git@github.com:mitre/heimdall2.git + ``` +4. **Create a feature branch**: + ```bash + git switch -c feature/your-feature-name + ``` + +### Development Setup + +Heimdall is a Yarn workspaces monorepo managed with lerna. Node >= 22.18.0 is +required (see `engines`). + +1. **Install dependencies** from the repository root: + ```bash + yarn install + ``` + +2. **Set up the database** (Heimdall Server only — Heimdall Lite needs none): + ```bash + cp apps/backend/.env-example apps/backend/.env + yarn backend sequelize db:create + yarn backend sequelize db:migrate + ``` + +3. **Start in development mode**: + ```bash + yarn start:dev + ``` + +Workspace commands are proxied from the root — `yarn backend `, +`yarn frontend `, `yarn hdf-converters `, `yarn inspecjs `, +`yarn common `. + +### Repository Layout + +| Path | Contents | +|---|---| +| `apps/backend` | NestJS API server, Sequelize models, authentication | +| `apps/frontend` | Vue 2 application — also published as Heimdall Lite | +| `libs/hdf-converters` | Converters between scan formats and OHDF | +| `libs/inspecjs` | OHDF schema definitions and helpers | +| `libs/common` | Types shared between front and back end | +| `libs/password-complexity` | Password rule checks shared by both | +| `packaging/rpm` | RPM spec, systemd unit, SELinux policy | +| `test` | Cypress end-to-end UI tests | + +### Making Changes + +1. **Follow existing patterns.** Read the surrounding code before introducing a + new approach — matching what is already there is usually better than + importing a pattern from elsewhere. + +2. **Write tests first.** Every change to behaviour needs a test that fails + before the change and passes after. + ```bash + yarn backend test:ci + yarn frontend test:ci + yarn hdf-converters test:ci + yarn inspecjs test:ci + ``` + +3. **Typecheck separately.** The test runners use swc and **do not typecheck**: + ```bash + yarn backend build + ``` + +4. **Lint.** Use the workspace-scoped scripts: + ```bash + yarn backend lint:ci + yarn frontend lint:ci + ``` + Do not silence security-plugin findings with disable comments — fix the code. + +5. **Update documentation** — the README for user-facing changes, and + `CHANGELOG` for anything notable. + +### Adding a Converter + +New format support is the most common contribution. A converter needs: + +- The mapper in `libs/hdf-converters/src/` +- A sample input under `libs/hdf-converters/sample_jsons/_mapper/sample_input_report/` +- Expected OHDF output alongside it, committed as a fixture +- A test in `libs/hdf-converters/test/mappers/forward/` + +Use **real scan output** as the sample where you can, sanitized of hostnames and +customer detail. Synthetic input is acceptable when a real sample would be +excessive, but it must reflect the format accurately. + +### Commit Messages + +Use conventional prefixes — `feat:`, `fix:`, `test:`, `docs:`, `chore:`, +`build:`, `refactor:` — with a body explaining *why*, not just what. + +```text +feat: add support for Foo scanner output + +- Maps Foo severity levels onto OHDF impact +- Handles the multi-result form Foo emits for grouped checks +``` + +### Pull Requests + +1. Rebase on the latest `master` +2. Confirm the full suite passes and the build typechecks +3. Describe what changed and how you verified it +4. Link any related issue + +Draft PRs are welcome if you would like feedback before the work is finished. + +## Style Guidelines + +### TypeScript / JavaScript + +ESLint and Prettier are authoritative: + +```bash +yarn lint # fix +yarn lint:ci # check +``` + +- Prefer explicit types on exported functions +- Avoid `any` — if the type is genuinely unknown, use `unknown` and narrow +- Handle errors explicitly; do not swallow them in a bare `catch` + +### Vue + +- Follow the Vue 2 style guide and the conventions already in `apps/frontend` +- Keep components focused — extract shared logic rather than duplicating it + +## Getting Help + +- [GitHub Discussions](https://github.com/mitre/heimdall2/discussions) +- [Wiki](https://github.com/mitre/heimdall2/wiki) +- [GitHub Issues](https://github.com/mitre/heimdall2/issues) + +Thank you for contributing to Heimdall. diff --git a/Dockerfile b/Dockerfile index 2dbeba3d79..b06672b4e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,11 @@ COPY --from=builder --chown=1001 /src/dist/ dist/ COPY --chmod=755 cmd.sh /usr/local/bin/ +# ADR-006 §11: libuv reads UV_THREADPOOL_SIZE at first threadpool use, before +# app config loads — it must be process environment, never ConfigService. 8 +# threads + the in-app KDF limiter (concurrency 2) keep fs/dns from starving. +ENV UV_THREADPOOL_SIZE=8 + USER 1001 CMD ["/usr/local/bin/cmd.sh"] diff --git a/LICENSE.md b/LICENSE.md index 61c479f00a..d5978ea2c4 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,33 +1,60 @@ -© 2025 The MITRE Corporation. +# License + +Copyright © 2026 The MITRE Corporation. Approved for Public Release; Distribution Unlimited. Case Number 18-3678. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain a +copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -- Redistributions of source code must retain the above copyright/ digital rights legend, this list of conditions and the following Notice. - -- Redistributions in binary form must reproduce the above copyright copyright/ digital rights legend, this list of conditions and the following Notice in the documentation and/or other materials provided with the distribution. - -- Neither the name of The MITRE Corporation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - -NOTICE - -MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE file included with this project. - -This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. - -For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. - -DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +## Redistribution Terms + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +- Redistributions of source code must retain the above copyright/digital + rights legend, this list of conditions and the following Notice. +- Redistributions in binary form must reproduce the above + copyright/digital rights legend, this list of conditions and the + following Notice in the documentation and/or other materials provided + with the distribution. +- Neither the name of The MITRE Corporation nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +## Notice + +The MITRE Corporation grants permission to reproduce, distribute, modify, and +otherwise use this software to the extent permitted by the licensed terms +provided in the LICENSE file included with this project. + +This software was produced by The MITRE Corporation for the U.S. Government +under contract. As such the U.S. Government has certain use and data +rights in this software. No use other than those granted to the U.S. +Government, or to those acting on behalf of the U.S. Government, under +these contract arrangements is authorized without the express written +permission of The MITRE Corporation. + +Some files in this codebase were generated by generative AI, under the +direction and review of The MITRE Corporation employees, for the purpose of +development efficiency. All AI-generated code functionality was validated +by standard quality and assurance testing. + +For further information, please contact The MITRE Corporation, +Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, +(703) 983-6000. + +## Third-Party Content + +DISA STIGs. Please visit https://cyber.mil/stigs/downloads for full +terms of use. diff --git a/README.md b/README.md index 7d89875aee..455c21f52e 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ This repository contains the source code for Heimdall's [Backend](https://github ### Video -![](https://github.com/mitre/docs-mitre-inspec/raw/master/images/Heimdall_demo.gif) +![Heimdall demo animation](https://github.com/mitre/docs-mitre-inspec/raw/master/images/Heimdall_demo.gif) ### Hosted @@ -81,7 +81,7 @@ This repository contains the source code for Heimdall's [Backend](https://github
-[Heimdall Server](https://mitre-heimdall-staging.herokuapp.com/)    +[Heimdall Server](https://mitre-heimdall-staging.herokuapp.com/)    Deploy to Heroku ## Heimdall (Lite) vs Heimdall with Backend (Server) @@ -184,7 +184,7 @@ Heimdall's frontend container image is distributed on [DockerHub](https://hub.do ./setup-docker-env.bat ``` -> [!TIP] +> **Tip:** > If you would like to further configure your Docker-based Heimdall deployment, edit the .env file located in the root directory generated after running the `setup-docker-env.sh` or `setup-docker-env.bat` scripts 6. Heimdall might need certificates to access the open internet or internal resources (ex. an LDAP server). Please convert any certificates into PEM files and place them in `./certs/` where they will be automatically ingested. Alternatively, you can place a shell script that will retrieve those certs in that directory, and modify the `command` attribute underneath the `certs` service in the `docker-compose.yml` to run that script. @@ -250,19 +250,19 @@ Cloud.gov is a [FEDRAMP moderate Platform-as-a-Service (PaaS)](https://marketpla 2. Install the cf-cli - https://cloud.gov/docs/getting-started/setup/ 3. Run the following commands in a terminal window from the Heimdall source directory. -``` +```bash $ cd ~/Documents/Github/Heimdall2 $ cf login -a api.fr.cloud.gov --sso # Follow the link to copy the Temporary Authentication Code when prompted ``` 4. Setup a demo application space -``` +```bash $ cf target -o sandbox-rename create-space heimdall2-rename ``` 5. Create a PostgreSQL database -``` +```bash # Update manifest.yml file to rename application and database key name $ cf marketplace $ cf create-service aws-rds medium-psql heimdall2-rename @@ -457,7 +457,7 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve You can also manually edit the `apps/backend/.env` file in a text editor and set additional optional configuration values. For more info on configuration values see [Enviroment Variables Configuration](https://github.com/mitre/heimdall2/wiki/Environment-Variables-Configuration). -> [!NOTE] +> **Note:** > The .env file in the root repository is for the Docker deployment of the Heimdall application. Running a local build will use the .env file in the `apps/backend` directory for the database configurations. 6. Build the project: @@ -482,11 +482,30 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve This will start both the frontend and backend in development mode, meaning any changes you make to the source code will take effect immediately. Please note we already have a Visual Studio Code workspace file you can use to organize your workspace. +### Run modes and ports + +Development mode (`yarn start:dev`) runs **two servers**: + +- **Backend (NestJS) — port `3000`:** API only — `/health`, `/server`, `/authn`, etc. It does **not** serve the UI in dev mode, so browsing `localhost:3000` returns a JSON 404 for `dist/frontend/index.html`. This is expected. +- **Frontend (webpack dev server) — port `8080` by default:** **The app you browse.** Hot-reloads on code changes and proxies API calls to the backend (`API_PROXY_TARGET` in `apps/frontend/.env.development`). If 8080 is busy the dev server picks the next free port — **read the `Local: http://localhost:` line it prints.** + +To run the whole app on **one URL** (`localhost:3000`, production-style, no hot-reload): + +```bash +yarn start:built +``` + +This builds the frontend to `dist/frontend/` and the backend to `dist/`, then serves both from the backend on port 3000. + +> **Warning:** Do not set `PORT` in `apps/backend/.env` for local development. The backend already defaults to 3000. The frontend's port and proxy are configured independently in `apps/frontend/.env.development` (personal overrides go in the gitignored `apps/frontend/.env.development.local`). +> +> The backend's `.env` is for **development**. The backend test suite pins its own environment (`NODE_ENV=test`) and derives its own database (`heimdall-server-test` — create it once with `NODE_ENV=test yarn backend sequelize db:create db:migrate`), so running tests never requires editing `.env`. + ### Debugging Heimdall Server If you are using Visual Studio Code, it is very simple to debug this application locally. First open up the Visual Studio Code workspace and ensure the [Node debugger Auto Attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach) feature in Visual Studio Code is enabled. Next, open the integrated Visual Studio Code terminal and run: -``` +```bash yarn backend start:debug ``` @@ -496,26 +515,34 @@ Visual Studio Code will then automatically attach a debugger and stop and any br If you only want to make changes to the frontend (heimdall-lite) use the following command: - yarn frontend start:dev +```bash +yarn frontend start:dev +``` ### Lint and fix files To validate and lint your code run: - yarn run lint +```bash +yarn run lint +``` ### Compile and minify the frontend and backend for production - yarn build +```bash +yarn build +``` ### Run tests To test your code to make sure everything still works: - # Run Frontend Vue Tests - yarn frontend test - # Run Backend Nest Tests (see note) - yarn backend test:ci-cov +```bash +# Run Frontend Vue Tests +yarn frontend test +# Run Backend Nest Tests (see note) +yarn backend test:ci-cov +``` **NOTE:** The `Backend Nest Tests` will remove (BULKDELETE) all entries in the configured PostgreSQL server for the following tables: - EvaluationTags @@ -530,12 +557,14 @@ To test your code to make sure everything still works: The application includes an End-to-End (E2E) frontend and Backend tests (built using the [cypress.io](https://www.cypress.io/) framework). The E2E tests performed is to validate that Heimdall Server is running as intended. In order to run these tests, a running instance of the application is required. - CYPRESS_TESTING=true yarn start:dev - CYPRESS_BASE_URL=http://localhost:8080 yarn test:ui:open +```bash +CYPRESS_TESTING=true yarn start:dev +CYPRESS_BASE_URL=http://localhost:8080 yarn test:ui:open +``` The first command will start an instance of Heimdall Server and exposes additional routes required to allow the tests to run. The second will open the Cypress UI which will run the tests any time code changes are made. -> [!NOTE] +> **Note:** > When running the tests locally, tests that integrate with external services such as LDAP or Splunk will fail without having that external service running and configured. If these failures occur locally and local development does not impact the code relevant to those tests, you may consider permitting these failing tests locally and check that they pass in the pipeline in lieu of standing up local services only for testing purposes. #### Building the Heimdall Docker containers locally diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..fd3a84b85a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,119 @@ +# Security Policy + +## Reporting Security Issues + +The MITRE SAF team takes security seriously. If you discover a security +vulnerability in Heimdall, please report it responsibly. + +### Contact Information + +- **Email**: [saf-security@mitre.org](mailto:saf-security@mitre.org) +- **GitHub**: Use the [Security tab](https://github.com/mitre/heimdall2/security) + to report vulnerabilities privately + +Please do not open a public issue for a security vulnerability. + +### What to Include + +1. **Description** of the vulnerability +2. **Steps to reproduce** the issue +3. **Potential impact** assessment +4. **Affected component** — Heimdall Server, Heimdall Lite, or one of the + published libraries (`@mitre/hdf-converters`, `inspecjs`) +5. **Suggested fix** (if you have one) + +### Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Initial Assessment**: Within 7 days +- **Fix Timeline**: Varies by severity + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| Latest release | ✅ Yes | +| Older releases | ❌ No — upgrade to the latest release | + +Heimdall ships as a Docker image, an RPM, and npm packages. Security fixes are +issued against the latest release of each. + +## Security Best Practices + +### For Deployers + +- **Terminate TLS in front of Heimdall.** The application sets HSTS and CSP + headers via Helmet, but headers cannot enforce transport. Run it behind a + TLS reverse proxy; serving it over plain HTTP will also break asset loading. +- **Use enterprise authentication.** Heimdall supports LDAP, OIDC, GitHub, + GitLab, Google, and Okta. Prefer these over local accounts in production. +- **Protect the environment file.** `DATABASE_PASSWORD`, `JWT_SECRET`, and + `API_KEY_SECRET` live there. Restrict it to the service account. +- **Set `API_KEY_SECRET` if API keys are enabled.** API key support is + disabled when it is unset — do not deploy with a placeholder value. +- **Use database TLS.** Configure `DATABASE_SSL` and the associated + certificate settings for connections that leave the host. +- **Scope evaluation visibility.** Evaluations can be public, group-scoped, or + private. Review group membership before importing sensitive scan results. + +### For Contributors + +- **Dependency scanning**: run `yarn audit` before submitting a PR +- **Credential handling**: never log or expose credentials, tokens, or + evaluation contents +- **Input validation**: validate at the trust boundary — DTO/pipe layer for + API input, and parameterize every database query +- **No linter suppressions for security rules**: the ESLint security plugin + findings must be fixed in code, not disabled +- **Test security behaviour**: authorization changes need tests covering the + denied path, not just the allowed one + +## Security Testing + +```bash +# Full test suites +yarn backend test:ci +yarn frontend test:ci + +# Type checking (the test runners do not typecheck) +yarn backend build + +# Lint, including the security ruleset +yarn backend lint:ci +yarn frontend lint:ci + +# Vulnerable dependency check +yarn audit +``` + +Container images are scanned with Syft/Grype in CI (see +`.github/workflows/anchore-syft.yml`). + +## Known Security Considerations + +### Authentication and Authorization + +- Local passwords are stored as salted, iterated hashes — never in plaintext +- Password complexity is enforced at 15 characters with all four character + classes and no run of four or more from a single class +- Authorization uses CASL ability rules; evaluation and group access is + checked per request rather than at the route level alone +- The login endpoint is rate limited per IP; there is no per-account lockout + +### API Keys + +- API keys are JWTs; only a hash of the signature is stored server-side +- **A lost API key cannot be recovered — it must be regenerated** +- API key support is disabled entirely when `API_KEY_SECRET` is unset + +### Data Protection + +- Evaluation data may contain hostnames, configuration detail, and finding + evidence from scanned systems — treat the database and its backups as + sensitive +- Use TLS for all external connections + +### Container Security + +- Images are based on Red Hat UBI and run as a non-root user +- Keep base images updated and rescan on rebuild diff --git a/apps/backend/.env-example b/apps/backend/.env-example index 1a3d724fa2..0acc4e56c3 100644 --- a/apps/backend/.env-example +++ b/apps/backend/.env-example @@ -1,4 +1,6 @@ -# For more information on any of these variables, see https://github.com/mitre/heimdall2/wiki/Environment-Variables-Configuration#github +# The canonical reference for every variable below — required/default/effect, and +# the traps — is docs/site/getting-started/environment-variables.md. This file is a +# starting template, not the reference; when the two disagree, the reference wins. # If a variable does not have a value assigned, remove the variable. (e.g if you aren't using a custom DATABASE_NAME, remove the DATABASE_NAME line.) @@ -10,8 +12,8 @@ CLASSIFICATION_BANNER_TEXT_COLOR= ## Backend -NODE_ENV= -PORT= +NODE_ENV= +PORT= ADMIN_EMAIL= ADMIN_USES_EXTERNAL_AUTH= @@ -22,8 +24,10 @@ JWT_SECRET= JWT_EXPIRE_TIME= API_KEY_SECRET= MAX_FILE_UPLOAD_SIZE= +WARNING_BANNER= ## Database +DATABASE_URL= DATABASE_HOST= DATABASE_PORT= DATABASE_USERNAME= @@ -35,12 +39,24 @@ DATABASE_SSL_KEY= DATABASE_SSL_CA= +## Password hashing (validated-module PBKDF2; out-of-range values throw at startup) +FIPS_MODE= +PASSWORD_HASH_ALGORITHM= +PASSWORD_HASH_ITERATIONS= +PASSWORD_MAX_LENGTH= +PASSWORD_KDF_CONCURRENCY= +PASSWORD_HASH_WRITE_ENABLED= + ## Reverse proxy +# Read by the setup scripts and the NGINX template, never by the application itself. NGINX_HOST= ## External interfaces SPLUNK_HOST_URL= TENABLE_HOST_URL= +TENABLE_ADDITIONAL_HOST_URLS= +TENABLE_ALLOW_PRIVATE_ADDRESSES= +FORCE_TENABLE_FRONTEND= # Authentication @@ -75,11 +91,18 @@ GITHUB_ENTERPRISE_INSTANCE_API_URL= GITLAB_CLIENTSECRET= +# GITLAB_SECRET is the legacy name for GITLAB_CLIENTSECRET and is still accepted. +# Set only one; GITLAB_CLIENTSECRET wins when both are present. GITLAB_BASEURL= OKTA_DOMAIN=".okta.com" OKTA_CLIENTID= OKTA_CLIENTSECRET= +OKTA_ISSUER_URL= +OKTA_AUTHORIZATION_URL= +OKTA_TOKEN_URL= +OKTA_USER_INFO_URL= +OKTA_USE_HTTPS_PROXY= ## Custom OIDC Service OIDC_NAME= @@ -90,3 +113,10 @@ OIDC_USER_INFO_URL= OIDC_CLIENT_SECRET= OIDC_EXTERNAL_GROUPS= +OIDC_USES_PKCE_S256= +OIDC_USES_PKCE_PLAIN= +OIDC_USES_VERIFIED_EMAIL= +OIDC_USE_HTTPS_PROXY= + +## Proxy +HTTPS_PROXY= diff --git a/apps/backend/README.md b/apps/backend/README.md index d677338a76..b61176ef40 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -3,8 +3,9 @@ Create the database by setting the appropriate environment variables found in `.env-example` in `.env` Run the following to create, migrate, and seed the database: -* `npx yarn sequelize db:create` -* `npx yarn sequelize db:migrate` -* `npx yarn sequelize db:seed` + +- `npx yarn sequelize db:create` +- `npx yarn sequelize db:migrate` +- `npx yarn sequelize db:seed` Run the application `npm run start` diff --git a/apps/backend/config/app-config.ts b/apps/backend/config/app-config.ts new file mode 100644 index 0000000000..92e4d881f1 --- /dev/null +++ b/apps/backend/config/app-config.ts @@ -0,0 +1,198 @@ +import * as fs from 'fs'; +import * as dotenv from 'dotenv'; + +/** + * Resolves TLS material from a deployer-supplied value that is either inline + * PEM (contains -BEGIN) or a path to a PEM file. The label names the material + * in error messages (Key, Cert, CA). + */ +export function resolveSslMaterial( + value: string, + label: string, +): Buffer | string { + if (value.includes('-BEGIN')) { + return value; + } + try { + /* eslint-disable-next-line security/detect-non-literal-fs-filename -- No + code fix exists: reading the deployer-specified certificate path is + this function's purpose. The value comes from the host environment, + which the process owner controls — it is never request input. */ + return fs.readFileSync(value); + } catch (error) { + throw new Error(`SSL ${label} file does not exist or is unreadable`, { + cause: error, + }); + } +} + +// DATABASE_URL anatomy: scheme://user:password@host:port/name?query +const DATABASE_URL_PATTERN + = /^(?:[^\s#/:?]+:\/{2})?(?:(?[^\s#/?@]+)@)?(?[^\s#/?]+)?(?:\/(?[^\s#?]*))?(?:\?[^\s#]*)?(?:#\S*)?$/; +// The host:port boundary — the colon directly before a trailing port number. +const HOST_PORT_SEPARATOR = /:(?=\d+$)/v; + +export default class AppConfig { + private envConfig: Map; + + constructor() { + console.log('Attempting to read configuration file `.env`!'); + try { + const parsedConfig = dotenv.parse(fs.readFileSync('.env')); + this.envConfig = new Map(Object.entries(parsedConfig)); + console.log('Read config!'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.envConfig = new Map(); + // File probably does not exist + console.log('Unable to read configuration file `.env`!'); + console.log('Falling back to environment or undefined values!'); + } else { + throw error; + } + } + if (this.parseDatabaseUrl()) { + console.log( + 'DATABASE_URL parsed into smaller components (i.e. DATABASE_USER)', + ); + } + } + + get(key: string): string | undefined { + /* eslint-disable-next-line security/detect-object-injection -- + No code fix exists: process.env is the platform's exotic object with no + .get(), and reading a dynamically named variable requires bracket + access. Every caller passes a compile-time constant name; values are + operator-set. */ + return process.env[key] || this.envConfig.get(key); + } + + getDatabaseName(): string { + const databaseName = this.get('DATABASE_NAME'); + + if (databaseName !== undefined) { + return databaseName; + } + const nodeEnvironment = this.get('NODE_ENV'); + if (nodeEnvironment === undefined) { + throw new TypeError( + 'NODE_ENV and DATABASE_NAME are undefined. Unable to set database or use the default based on environment.', + ); + } + return `heimdall-server-${nodeEnvironment.toLowerCase()}`; + } + + getDbConfig() { + return { + database: this.getDatabaseName(), + dialect: 'postgres' as const, + dialectOptions: { ssl: this.getSSLConfig() }, + host: this.get('DATABASE_HOST') || '127.0.0.1', + password: this.get('DATABASE_PASSWORD') || '', + port: Number(this.get('DATABASE_PORT')) || 5432, + role: this.get('DATABASE_USERNAME') || 'postgres', + ssl: Boolean(this.get('DATABASE_SSL')), + user: this.get('DATABASE_USERNAME') || 'postgres', + username: this.get('DATABASE_USERNAME') || 'postgres', + }; + } + + getDefaultAdmin() { + return this.get('ADMIN_EMAIL') || 'admin@heimdall.local'; + } + + getExternalUrl(): string { + const external_url = this.get('EXTERNAL_URL'); + return external_url === undefined ? '' : external_url; + } + + getSplunkHostUrl(): string { + const splunk_host_url = this.get('SPLUNK_HOST_URL'); + return splunk_host_url === undefined ? '' : splunk_host_url; + } + + getSSLConfig() { + if ( + !this.get('DATABASE_SSL') + || this.get('DATABASE_SSL')?.toLowerCase() === 'false' + ) { + return false; + } + + let sslCA, sslCert, sslKey; + + if (typeof this.get('DATABASE_SSL_KEY') === 'string') { + sslKey = resolveSslMaterial(this.get('DATABASE_SSL_KEY')!, 'Key'); + } + + if (typeof this.get('DATABASE_SSL_CERT') === 'string') { + sslCert = resolveSslMaterial(this.get('DATABASE_SSL_CERT')!, 'Cert'); + } + + if (typeof this.get('DATABASE_SSL_CA') === 'string') { + sslCA = resolveSslMaterial(this.get('DATABASE_SSL_CA')!, 'CA'); + } + + return { + ca: sslCA, + cert: sslCert, + key: sslKey, + rejectUnauthorized: + this.get('DATABASE_SSL_INSECURE') + && this.get('DATABASE_SSL_INSECURE')?.toLowerCase() !== 'true', + }; + } + + getTenableHostUrl(): string { + const tenable_host_url = this.get('TENABLE_HOST_URL'); + return tenable_host_url === undefined ? '' : tenable_host_url; + } + + // Additional Tenable hosts a deployment is permitted to contact, beyond + // TENABLE_HOST_URL. One delimited string rather than a list, because every + // setting in this file is a single string; the allowlist module splits it. + getTenableAdditionalHostUrls(): string { + const additional = this.get('TENABLE_ADDITIONAL_HOST_URLS'); + return additional === undefined ? '' : additional; + } + + parseDatabaseUrl() { + const url = this.get('DATABASE_URL'); + if (url === undefined) { + return false; + } else { + const matches = DATABASE_URL_PATTERN.exec(url); + + if (matches === null) { + return false; + } + const {userinfo, hostPort, name} = matches.groups ?? {}; + + this.set( + 'DATABASE_USERNAME', + userinfo === undefined ? undefined : userinfo.split(':', 1)[0], + ); + this.set( + 'DATABASE_PASSWORD', + userinfo === undefined ? undefined : userinfo.split(':', 2)[1], + ); + this.set( + 'DATABASE_HOST', + hostPort === undefined ? undefined : hostPort.split(HOST_PORT_SEPARATOR, 1)[0], + ); + this.set( + 'DATABASE_NAME', + name === undefined ? undefined : name.split('/', 1)[0], + ); + this.set( + 'DATABASE_PORT', + hostPort === undefined ? undefined : hostPort.split(HOST_PORT_SEPARATOR, 2)[1], + ); + return true; + } + } + + set(key: string, value: string | undefined): void { + this.envConfig.set(key, value); + } +} diff --git a/apps/backend/config/app_config.ts b/apps/backend/config/app_config.ts deleted file mode 100644 index 098a298e8d..0000000000 --- a/apps/backend/config/app_config.ts +++ /dev/null @@ -1,195 +0,0 @@ -import * as dotenv from 'dotenv'; -import * as fs from 'fs'; - -export default class AppConfig { - private envConfig: {[key: string]: string | undefined}; - - constructor() { - console.log('Attempting to read configuration file `.env`!'); - try { - this.envConfig = dotenv.parse(fs.readFileSync('.env')); - console.log('Read config!'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - this.envConfig = {}; - // File probably does not exist - console.log('Unable to read configuration file `.env`!'); - console.log('Falling back to environment or undefined values!'); - } else { - throw error; - } - } - if (this.parseDatabaseUrl()) { - console.log( - 'DATABASE_URL parsed into smaller components (i.e. DATABASE_USER)' - ); - } - } - - set(key: string, value: string | undefined): void { - this.envConfig[key] = value; - } - - get(key: string): string | undefined { - return process.env[key] || this.envConfig[key]; - } - - getExternalUrl(): string { - const external_url = this.get('EXTERNAL_URL'); - if (external_url === undefined) { - return ''; - } else { - return external_url; - } - } - - getSplunkHostUrl(): string { - const splunk_host_url = this.get('SPLUNK_HOST_URL'); - if (splunk_host_url !== undefined) { - return splunk_host_url; - } else { - return ''; - } - } - - getTenableHostUrl(): string { - const tenable_host_url = this.get('TENABLE_HOST_URL'); - if (tenable_host_url !== undefined) { - return tenable_host_url; - } else { - return ''; - } - } - - getDatabaseName(): string { - const databaseName = this.get('DATABASE_NAME'); - const nodeEnvironment = this.get('NODE_ENV'); - - if (databaseName !== undefined) { - return databaseName; - } else if (nodeEnvironment !== undefined) { - return `heimdall-server-${nodeEnvironment.toLowerCase()}`; - } else { - throw new TypeError( - 'NODE_ENV and DATABASE_NAME are undefined. Unable to set database or use the default based on environment.' - ); - } - } - - getSSLConfig() { - if ( - !this.get('DATABASE_SSL') || - this.get('DATABASE_SSL')?.toLowerCase() === 'false' - ) { - return false; - } - - let sslKey, sslCert, sslCA; - - if (typeof this.get('DATABASE_SSL_KEY') === 'string') { - if (this.get('DATABASE_SSL_KEY')?.indexOf('-BEGIN') !== -1) { - sslKey = this.get('DATABASE_SSL_KEY'); - } else { - // Verify file exists - if (fs.statSync(this.get('DATABASE_SSL_KEY')!).isFile()) { - sslKey = fs.readFileSync(this.get('DATABASE_SSL_KEY')!); - } else { - throw new Error('SSL Key file does not exist'); - } - } - } - - if (typeof this.get('DATABASE_SSL_CERT') === 'string') { - if (this.get('DATABASE_SSL_CERT')?.indexOf('-BEGIN') !== -1) { - sslCert = this.get('DATABASE_SSL_CERT'); - } else { - // Verify file exists - if (fs.statSync(this.get('DATABASE_SSL_CERT')!).isFile()) { - sslCert = fs.readFileSync(this.get('DATABASE_SSL_CERT')!); - } else { - throw new Error('SSL Cert file does not exist'); - } - } - } - - if (typeof this.get('DATABASE_SSL_CA') === 'string') { - if (this.get('DATABASE_SSL_CA')?.indexOf('-BEGIN') !== -1) { - sslCA = this.get('DATABASE_SSL_CA'); - } else { - // Verify file exists - if (fs.statSync(this.get('DATABASE_SSL_CA')!).isFile()) { - sslCA = fs.readFileSync(this.get('DATABASE_SSL_CA')!); - } else { - throw new Error('SSL CA file does not exist'); - } - } - } - - return { - rejectUnauthorized: - this.get('DATABASE_SSL_INSECURE') && - this.get('DATABASE_SSL_INSECURE')?.toLowerCase() !== 'true', - key: sslKey, - cert: sslCert, - ca: sslCA - }; - } - - getDefaultAdmin() { - return this.get('ADMIN_EMAIL') || 'admin@heimdall.local'; - } - - getDbConfig() { - return { - username: this.get('DATABASE_USERNAME') || 'postgres', - user: this.get('DATABASE_USERNAME') || 'postgres', - role: this.get('DATABASE_USERNAME') || 'postgres', - password: this.get('DATABASE_PASSWORD') || '', - database: this.getDatabaseName(), - host: this.get('DATABASE_HOST') || '127.0.0.1', - port: Number(this.get('DATABASE_PORT')) || 5432, - dialect: 'postgres' as const, - dialectOptions: { - ssl: this.getSSLConfig() - }, - ssl: Boolean(this.get('DATABASE_SSL')) || false - }; - } - - parseDatabaseUrl() { - const url = this.get('DATABASE_URL'); - if (url === undefined) { - return false; - } else { - const pattern = - /^(?:([^:\/?#\s]+):\/{2})?(?:([^@\/?#\s]+)@)?([^\/?#\s]+)?(?:\/([^?#\s]*))?(?:[?]([^#\s]+))?\S*$/; - const matches = url.match(pattern); - - if (matches === null) { - return false; - } - - this.set( - 'DATABASE_USERNAME', - matches[2] !== undefined ? matches[2].split(':')[0] : undefined - ); - this.set( - 'DATABASE_PASSWORD', - matches[2] !== undefined ? matches[2].split(':')[1] : undefined - ); - this.set( - 'DATABASE_HOST', - matches[3] !== undefined ? matches[3].split(/:(?=\d+$)/)[0] : undefined - ); - this.set( - 'DATABASE_NAME', - matches[4] !== undefined ? matches[4].split('/')[0] : undefined - ); - this.set( - 'DATABASE_PORT', - matches[3] !== undefined ? matches[3].split(/:(?=\d+$)/)[1] : undefined - ); - return true; - } - } -} diff --git a/apps/backend/db/database.ts b/apps/backend/db/database.ts index b96eed96d3..8058c51f44 100644 --- a/apps/backend/db/database.ts +++ b/apps/backend/db/database.ts @@ -1,4 +1,4 @@ -import AppConfig from '../config/app_config'; +import AppConfig from '../config/app-config'; const appConfig = new AppConfig(); diff --git a/apps/backend/migrations/20200520195822-add_role_to_user.js b/apps/backend/migrations/20200520195822-add_role_to_user.js index 02335cc539..7f5ddfaca7 100644 --- a/apps/backend/migrations/20200520195822-add_role_to_user.js +++ b/apps/backend/migrations/20200520195822-add_role_to_user.js @@ -3,21 +3,17 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Users', 'role', { - type: Sequelize.STRING, - allowNull: false, - defaultValue: 'user' - }, { transaction: t }) - ]) + return queryInterface.addColumn('Users', 'role', { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'user' + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Users', 'role', { transaction: t }) - ]) + return queryInterface.removeColumn('Users', 'role', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20200707193725-create_tags_table.js b/apps/backend/migrations/20200707193725-create_tags_table.js index 63863eddea..98d43acdcf 100644 --- a/apps/backend/migrations/20200707193725-create_tags_table.js +++ b/apps/backend/migrations/20200707193725-create_tags_table.js @@ -1,8 +1,8 @@ 'use strict'; module.exports = { - up: (queryInterface, Sequelize) => { - return queryInterface.createTable('EvaluationTags', { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('EvaluationTags', { id: { allowNull: false, autoIncrement: true, @@ -35,23 +35,19 @@ module.exports = { allowNull: false } }) - .then(() => { - return queryInterface.addColumn('Evaluations', 'evaluationTagId', { - type: Sequelize.BIGINT, - references: { - model: 'EvaluationTags', - key: 'id' - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL' - }) + return queryInterface.addColumn('Evaluations', 'evaluationTagId', { + type: Sequelize.BIGINT, + references: { + model: 'EvaluationTags', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' }) }, - down: (queryInterface, Sequelize) => { - return queryInterface.removeColumn('Evaluations', 'evaluationTagId') - .then(() => { - return queryInterface.dropTable('EvaluationTags') - }); + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('Evaluations', 'evaluationTagId') + return queryInterface.dropTable('EvaluationTags') } }; diff --git a/apps/backend/migrations/20200720195534-add_data_to_evaluations.js b/apps/backend/migrations/20200720195534-add_data_to_evaluations.js index 4be85fc11e..c037424621 100644 --- a/apps/backend/migrations/20200720195534-add_data_to_evaluations.js +++ b/apps/backend/migrations/20200720195534-add_data_to_evaluations.js @@ -3,20 +3,16 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Evaluations', 'data', { - type: Sequelize.JSON, - allowNull: false - }, { transaction: t }), - ]) + return queryInterface.addColumn('Evaluations', 'data', { + type: Sequelize.JSON, + allowNull: false + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Evaluations', 'data', { transaction: t }) - ]) + return queryInterface.removeColumn('Evaluations', 'data', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20200915201406-remove_version_from_evaluations.js b/apps/backend/migrations/20200915201406-remove_version_from_evaluations.js index bf533ceda2..24aa6772da 100644 --- a/apps/backend/migrations/20200915201406-remove_version_from_evaluations.js +++ b/apps/backend/migrations/20200915201406-remove_version_from_evaluations.js @@ -3,24 +3,20 @@ module.exports = { up: (queryInterface, _) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Evaluations', 'version', { - transaction: t - }) - ]) + return queryInterface.removeColumn('Evaluations', 'version', { + transaction: t + }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Evaluations', 'version', { - type: Sequelize.STRING, - allowNull: false - }, { - transaction: t - }), - ]) + return queryInterface.addColumn('Evaluations', 'version', { + type: Sequelize.STRING, + allowNull: false + }, { + transaction: t + }) }) } }; diff --git a/apps/backend/migrations/20200915201418-add_filename_to_evaluations.js b/apps/backend/migrations/20200915201418-add_filename_to_evaluations.js index 5b002bc276..9ca5eb7e97 100644 --- a/apps/backend/migrations/20200915201418-add_filename_to_evaluations.js +++ b/apps/backend/migrations/20200915201418-add_filename_to_evaluations.js @@ -3,24 +3,20 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Evaluations', 'filename', { - type: Sequelize.STRING, - allowNull: false - }, { - transaction: t - }), - ]) + return queryInterface.addColumn('Evaluations', 'filename', { + type: Sequelize.STRING, + allowNull: false + }, { + transaction: t + }) }) }, down: (queryInterface, _) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Evaluations', 'filename', { - transaction: t - }) - ]) + return queryInterface.removeColumn('Evaluations', 'filename', { + transaction: t + }) }) } }; diff --git a/apps/backend/migrations/20201201195932-add_user_id_to_evaluations.js b/apps/backend/migrations/20201201195932-add_user_id_to_evaluations.js index 62ec468e95..4e7bde2e3b 100644 --- a/apps/backend/migrations/20201201195932-add_user_id_to_evaluations.js +++ b/apps/backend/migrations/20201201195932-add_user_id_to_evaluations.js @@ -3,25 +3,21 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Evaluations', 'userId', { - type: Sequelize.BIGINT, - references: { - model: 'Users', - key: 'id' - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL' - }, { transaction: t }), - ]) + return queryInterface.addColumn('Evaluations', 'userId', { + type: Sequelize.BIGINT, + references: { + model: 'Users', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Evaluations', 'userId', { transaction: t }) - ]) + return queryInterface.removeColumn('Evaluations', 'userId', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20201202224004-change_bigint_to_int.js b/apps/backend/migrations/20201202224004-change_bigint_to_int.js index 0ea7f28a8f..b3a2308285 100644 --- a/apps/backend/migrations/20201202224004-change_bigint_to_int.js +++ b/apps/backend/migrations/20201202224004-change_bigint_to_int.js @@ -3,21 +3,17 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.changeColumn('Users', 'loginCount', { - type: Sequelize.INTEGER - }, { transaction: t }) - ]) + return queryInterface.changeColumn('Users', 'loginCount', { + type: Sequelize.INTEGER + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.changeColumn('Users', 'loginCount', { - type: Sequelize.BIGINT - }, { transaction: t }) - ]) + return queryInterface.changeColumn('Users', 'loginCount', { + type: Sequelize.BIGINT + }, { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20201216181621-remove-key-from-evaluation-tags.js b/apps/backend/migrations/20201216181621-remove-key-from-evaluation-tags.js index 017097dfe2..66e1ebd36f 100644 --- a/apps/backend/migrations/20201216181621-remove-key-from-evaluation-tags.js +++ b/apps/backend/migrations/20201216181621-remove-key-from-evaluation-tags.js @@ -3,21 +3,17 @@ module.exports = { up: async (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('EvaluationTags', 'key', { - transaction: t - }) - ]) + return queryInterface.removeColumn('EvaluationTags', 'key', { + transaction: t + }) }) }, down: async (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('EvaluationTags', 'key', { - transaction: t - }) - ]) + return queryInterface.addColumn('EvaluationTags', 'key', { + transaction: t + }) }) } }; diff --git a/apps/backend/migrations/20210107173452-add_public_to_evaluations.js b/apps/backend/migrations/20210107173452-add_public_to_evaluations.js index 991e6620aa..2ecb85ee82 100644 --- a/apps/backend/migrations/20210107173452-add_public_to_evaluations.js +++ b/apps/backend/migrations/20210107173452-add_public_to_evaluations.js @@ -1,28 +1,28 @@ 'use strict'; module.exports = { - up: (queryInterface, Sequelize) => { - return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Evaluations', 'public', { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.transaction((t) => { + return queryInterface.addColumn('Evaluations', 'public', { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false - }, { transaction: t }), - ]).then(() => { - // Update all existing evaluations in the database to be public - // since we have no way of tracking who uploaded them. - // All evaluations going forward will be private. - queryInterface.bulkUpdate('Evaluations', { public: true }) - }) + }, { transaction: t }) }) + // Update all existing evaluations in the database to be public + // since we have no way of tracking who uploaded them. + // All evaluations going forward will be private. + // Runs after the transaction commits: the original chain never returned + // this promise, so the backfill always executed against the committed + // column — awaiting it inside the transaction would self-deadlock on the + // ACCESS EXCLUSIVE lock addColumn holds. This form keeps that working + // sequence but makes the migration's completion wait for the backfill. + return queryInterface.bulkUpdate('Evaluations', { public: true }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Evaluations', 'public', { transaction: t }) - ]) + return queryInterface.removeColumn('Evaluations', 'public', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20210128142318-add_account_creation_method_to_users.js b/apps/backend/migrations/20210128142318-add_account_creation_method_to_users.js index a2b3be9d87..f8c5d72d1e 100644 --- a/apps/backend/migrations/20210128142318-add_account_creation_method_to_users.js +++ b/apps/backend/migrations/20210128142318-add_account_creation_method_to_users.js @@ -4,21 +4,17 @@ const sequelize = require("sequelize"); module.exports = { up: async (queryInterface, Sequelize) => { - return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Users', 'creationMethod', { - type: sequelize.STRING, - defaultValue: 'local' - }) - ]) + return queryInterface.sequelize.transaction((_t) => { + return queryInterface.addColumn('Users', 'creationMethod', { + type: sequelize.STRING, + defaultValue: 'local' + }) }) }, down: async (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Users', 'creationMethod', { transaction: t }) - ]) + return queryInterface.removeColumn('Users', 'creationMethod', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20211015162550-add-per-user-jwt-secret.js b/apps/backend/migrations/20211015162550-add-per-user-jwt-secret.js index f933efc840..34e1cc1bf8 100644 --- a/apps/backend/migrations/20211015162550-add-per-user-jwt-secret.js +++ b/apps/backend/migrations/20211015162550-add-per-user-jwt-secret.js @@ -3,19 +3,15 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Users', 'jwtSecret', { - type: Sequelize.STRING - }, { transaction: t }) - ]) + return queryInterface.addColumn('Users', 'jwtSecret', { + type: Sequelize.STRING + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Users', 'jwtSecret', { transaction: t }), - ]) + return queryInterface.removeColumn('Users', 'jwtSecret', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20230201000000-add-api-key-type.js b/apps/backend/migrations/20230201000000-add-api-key-type.js index 78376d5a6c..c3820a8401 100644 --- a/apps/backend/migrations/20230201000000-add-api-key-type.js +++ b/apps/backend/migrations/20230201000000-add-api-key-type.js @@ -3,20 +3,16 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('ApiKeys', 'type', { - type: Sequelize.STRING, - defaultValue: 'user', - }, { transaction: t }) - ]) + return queryInterface.addColumn('ApiKeys', 'type', { + type: Sequelize.STRING, + defaultValue: 'user', + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('ApiKeys', 'type', { transaction: t }), - ]) + return queryInterface.removeColumn('ApiKeys', 'type', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20230202030024-add-group-id-to-evaluations.js b/apps/backend/migrations/20230202030024-add-group-id-to-evaluations.js index 3d4312cb72..f09ed7ea88 100644 --- a/apps/backend/migrations/20230202030024-add-group-id-to-evaluations.js +++ b/apps/backend/migrations/20230202030024-add-group-id-to-evaluations.js @@ -3,25 +3,21 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Evaluations', 'groupId', { - type: Sequelize.BIGINT, - references: { - model: 'Groups', - key: 'id' - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL' - }, { transaction: t }), - ]) + return queryInterface.addColumn('Evaluations', 'groupId', { + type: Sequelize.BIGINT, + references: { + model: 'Groups', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Evaluations', 'groupId', { transaction: t }) - ]) + return queryInterface.removeColumn('Evaluations', 'groupId', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20230202031912-add-group-id-to-api-key.js b/apps/backend/migrations/20230202031912-add-group-id-to-api-key.js index 8510213f3a..3ae5d863f1 100644 --- a/apps/backend/migrations/20230202031912-add-group-id-to-api-key.js +++ b/apps/backend/migrations/20230202031912-add-group-id-to-api-key.js @@ -3,25 +3,21 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('ApiKeys', 'groupId', { - type: Sequelize.BIGINT, - references: { - model: 'Groups', - key: 'id' - }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL' - }, { transaction: t }), - ]) + return queryInterface.addColumn('ApiKeys', 'groupId', { + type: Sequelize.BIGINT, + references: { + model: 'Groups', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('ApiKeys', 'groupId', { transaction: t }) - ]) + return queryInterface.removeColumn('ApiKeys', 'groupId', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20230712110759-add_desc_to_groups.js b/apps/backend/migrations/20230712110759-add_desc_to_groups.js index e9914bb368..72a34fc933 100644 --- a/apps/backend/migrations/20230712110759-add_desc_to_groups.js +++ b/apps/backend/migrations/20230712110759-add_desc_to_groups.js @@ -3,21 +3,17 @@ module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.addColumn('Groups', 'desc', { - type: Sequelize.TEXT, - allowNull: false, - defaultValue: '' - }, { transaction: t }), - ]) + return queryInterface.addColumn('Groups', 'desc', { + type: Sequelize.TEXT, + allowNull: false, + defaultValue: '' + }, { transaction: t }) }) }, down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.removeColumn('Groups', 'desc', { transaction: t }) - ]) + return queryInterface.removeColumn('Groups', 'desc', { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20230725092535-change-group-names-to-unique.js b/apps/backend/migrations/20230725092535-change-group-names-to-unique.js index 44ded7e370..f446718fe8 100644 --- a/apps/backend/migrations/20230725092535-change-group-names-to-unique.js +++ b/apps/backend/migrations/20230725092535-change-group-names-to-unique.js @@ -74,13 +74,11 @@ module.exports = { down: (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction((t) => { - return Promise.all([ - queryInterface.changeColumn('Groups', 'name', { - type: Sequelize.STRING, - allowNull: false, - unique: false - }, { transaction: t }) - ]) + return queryInterface.changeColumn('Groups', 'name', { + type: Sequelize.STRING, + allowNull: false, + unique: false + }, { transaction: t }) }) } }; diff --git a/apps/backend/migrations/20260810133411-create-hash-migration-marker.js b/apps/backend/migrations/20260810133411-create-hash-migration-marker.js new file mode 100644 index 0000000000..1252e68f78 --- /dev/null +++ b/apps/backend/migrations/20260810133411-create-hash-migration-marker.js @@ -0,0 +1,57 @@ +'use strict'; + +/** + * ADR-006 §12 mechanism 2 — the durable hash-migration marker table. + * + * This migration creates the TABLE ONLY. The marker ROW is planted on the + * FIRST PBKDF2 write (§12's settled planting trigger: first-write, not + * install — a row planted here would record something untrue): on a fresh + * install that first write is the admin bootstrap seeder's (cmd.sh runs + * db:seed:all before the app boots), otherwise PasswordService.hash plants + * it. Readers of the marker: the write-gate derivation itself (sticky), the + * §12 mechanism-3 startup refusal (the application refuses to start when + * markerVersion exceeds the write epoch its code understands), and §17's + * authenticated /health detail. + * + * DECISION RECORD (card heimdall2-e25.21 decision point): markerVersion is a + * DEDICATED WRITE-EPOCH INTEGER owned by the crypto module + * (SUPPORTED_HASH_MARKER_VERSION, currently 1 = PBKDF2-PHC writes), NOT the + * package.json semver. Reasons: (1) the comparison's subject is + * write-semantics capability, not package identity — an RPM Release-only + * bump (2.13.0-1 -> 2.13.0-2) changes neither, and a same-code repackage + * must not trip the refusal; (2) the repo's package versions are unreliable + * for comparison (root package.json is 0.0.0, backend 2.13.0 vs frontend + * 2.13.1 skew); (3) semver strings compare wrong lexicographically + * ('2.13.0' < '2.9.9') and would need parsing that an integer does not. + */ +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.createTable('HashMigrationMarkers', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.BIGINT + }, + markerVersion: { + allowNull: false, + type: Sequelize.INTEGER + }, + pbkdf2WritesBeganAt: { + allowNull: false, + type: Sequelize.DATE + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: async (queryInterface, _Sequelize) => { + return queryInterface.dropTable('HashMigrationMarkers'); + } +}; diff --git a/apps/backend/package.json b/apps/backend/package.json index 54af24de0a..99d93b3d74 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,6 +39,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/sequelize": "^11.0.0", "@nestjs/serve-static": "^5.0.3", + "@nestjs/terminus": "^11", "@types/connect-pg-simple": "^7.0.0", "@types/express": "^5.0.0", "@types/express-session": "^1.17.3", @@ -57,6 +58,7 @@ "connect-pg-simple": "^10.0.0", "dotenv": "^17.0.0", "eslint-plugin-import": "^2.20.1", + "express": "^5.2.1", "express-rate-limit": "^8.0.0", "express-session": "^1.17.1", "helmet": "^8.0.0", @@ -66,6 +68,7 @@ "lodash": "^4.17.23", "moment": "^2.29.1", "ms": "^2.1.3", + "multer": "^2.1.1", "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", @@ -84,11 +87,15 @@ "winston": "^3.3.3" }, "devDependencies": { + "@heimdall/password-hash-vectors": "^2.13.0", "@nestjs/testing": "^11.0.1", "@swc/core": "^1.13.0", "@types/mock-fs": "^4.10.0", "mock-fs": "^5.0.0", "unplugin-swc": "^1.5.5", "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.18.0" } } diff --git a/apps/backend/seed-support/demo-seed-helpers.js b/apps/backend/seed-support/demo-seed-helpers.js new file mode 100644 index 0000000000..b2e126bc51 --- /dev/null +++ b/apps/backend/seed-support/demo-seed-helpers.js @@ -0,0 +1,158 @@ +'use strict'; +// Shared pieces of the demo-data seed. Mirrors mitre/vulcan's +// `lib/seed_helpers.rb`: one roster, one shared password, and the guard that +// decides whether demo data may be created at all. +// +// This file is CommonJS and lives outside the TS project because sequelize-cli +// owns the seeders directory — the same constraint the administrator seeder +// documents. +const dotenv = require('dotenv'); +const fs = require('fs'); + +/** + * A shift/unshift keyboard walk: the eight physical keys `1 q a z 2 w s x` + * typed once unshifted and then again with shift held. Easy to type, hard to + * mistype, and trivially memorable — which is the point for a credential whose + * whole purpose is to be publicly documented. + * + * NOT Vulcan's `12qwaszx!@QWASZX`, despite this file otherwise mirroring + * Vulcan's convention. That string groups all six letters of each walk + * together, which trips heimdall's third validator — "no 4 consecutive + * characters of the same character class" (libs/password-complexity), a rule + * Vulcan's policy does not implement. Walking by COLUMN instead puts a digit + * at the head of each group, so no class ever runs past three: + * + * 1 qaz 2 wsx ! QAZ @ WSX + * d 3low d 3low sp 3up sp 3up + * + * The spec asserts this against the real policy module rather than by + * inspection, because a seeded password the application would reject is a + * broken seed. (Aaron, 2026-08-15.) + */ +const DEMO_PASSWORD = '1qaz2wsx!QAZ@WSX'; + +/** + * Email-as-role, per Vulcan, whose seed calls this out as being for "a + * 30-second login/logout test loop". + * + * The api-* pair is deliberately separate from the human logins, for the + * reason Vulcan records: one active session per account, so scripted token + * access never evicts a person's browser session. + * + * NOTE: `role` here is Users.role — the APP-WIDE concept (admin|user). + * GroupUsers.role (owner|member) is a different column and is seeded by + * heimdall2-sked.2. + */ +const DEMO_USERS = [ + { + email: 'admin@example.com', + firstName: 'Demo', + lastName: 'Admin', + role: 'admin', + }, + { + email: 'user@example.com', + firstName: 'Demo', + lastName: 'User', + role: 'user', + }, + { + email: 'api-admin@example.com', + firstName: 'API', + lastName: 'Admin', + role: 'admin', + }, + { + email: 'api-user@example.com', + firstName: 'API', + lastName: 'User', + role: 'user', + }, +]; + +const DEMO_EMAILS = DEMO_USERS.map((user) => user.email); + +/** + * One demo group, so the GROUP-SCOPED authorization paths are reachable. + * `Groups.name` is NOT NULL and UNIQUE (migration 20230725092535), `desc` is + * NOT NULL with a '' default (20230712110759), and `public` is NOT NULL — all + * three are supplied explicitly rather than relying on defaults. + */ +const DEMO_GROUP = { + desc: 'Seeded demo group. Development and test only — see the seed system docs.', + name: 'Demo Group', + public: false +}; + +/** + * Memberships REUSE the accounts from the user seeder rather than minting + * group-specific ones, following Vulcan's `05_memberships.rb`, which assigns + * memberships to its existing demo users. + * + * `role` here is GroupUsers.role — owner|member, scoped to one group. That is + * a different column from Users.role (admin|user) above, and confusing the two + * produces a seed that looks correct and exercises nothing. + */ +const DEMO_MEMBERSHIPS = [ + {email: 'admin@example.com', role: 'owner'}, + {email: 'user@example.com', role: 'member'} +]; + +/** + * Read `.env` and overlay the real environment, exactly as the administrator + * seeder does — process.env wins. + */ +function readEnvConfig() { + let envConfig = {}; + try { + envConfig = dotenv.parse(fs.readFileSync('.env')); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // No .env is normal outside development; fall through to process.env. + } + return {...envConfig, ...process.env}; +} + +/** + * Vulcan's two-concern pattern: production seeds always run, demo data is + * opt-in. `packaging/rpm/cmd.sh` runs `db:seed:all` on EVERY container start, + * so without this guard these known-credential accounts would be created in + * production. That is the whole reason the guard lives in code rather than in + * operator discipline. + */ +function demoSeedEnabled(envConfig) { + if (String(envConfig.SEED_DEMO_DATA || '').toLowerCase() === 'true') { + return true; + } + const nodeEnv = envConfig.NODE_ENV || 'development'; + return nodeEnv === 'development' || nodeEnv === 'test'; +} + +function resolvePassword(envConfig) { + return envConfig.SEED_PASSWORD || DEMO_PASSWORD; +} + +/** + * Honour the same tuning knob the application uses; when unset, fall through + * to the crypto module's own default (600000) rather than restating it here. + */ +function hashOptions(envConfig) { + // Number() rather than parseInt(): unset -> NaN and '' -> 0 both fail the + // `> 0` test below, so the guard is what makes the coercion safe here. + const iterations = Number(envConfig.PASSWORD_HASH_ITERATIONS); + return Number.isFinite(iterations) && iterations > 0 ? {iterations} : undefined; +} + +module.exports = { + DEMO_EMAILS, + DEMO_GROUP, + DEMO_MEMBERSHIPS, + DEMO_PASSWORD, + DEMO_USERS, + demoSeedEnabled, + hashOptions, + readEnvConfig, + resolvePassword, +}; diff --git a/apps/backend/seeders/20200514154327-create-administrator.js b/apps/backend/seeders/20200514154327-create-administrator.js index 9854982b27..96684e725a 100644 --- a/apps/backend/seeders/20200514154327-create-administrator.js +++ b/apps/backend/seeders/20200514154327-create-administrator.js @@ -1,9 +1,55 @@ 'use strict'; +// ADR-006 §4 site 8: the admin bootstrap must hash through the SINGLE +// FIPS-validated implementation, not bcrypt. This seeder is CommonJS, runs +// outside Nest DI and the TS build, and executes on every container start +// (cmd.sh runs db:seed:all), so it requires the COMPILED pure function. The +// `dist/src/` segment is load-bearing — nest build infers rootDir across +// src/db/config, emitting dist/src/crypto/password.js. `.sequelizerc` already +// depends on build output; a bad path is a boot crash loop under cmd.sh's +// `set -e`, not a degraded seed. +const {hashPassword} = require('../dist/src/crypto/password'); +const { + deriveHashWriteState, + SUPPORTED_HASH_MARKER_VERSION +} = require('../dist/src/crypto/hash-write-decision'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const dotenv = require('dotenv'); const fs = require('fs'); +// ADR-006 §12: site 8 sits inside the rollout write gate's scope. The +// DECISION is the same compiled pure function the Nest gate service uses +// (hash-write-decision.js) — this file only supplies the DB probes it cannot +// inject. cmd.sh runs this seeder BEFORE the app's first boot, so on a fresh +// install THIS file performs the first PBKDF2 write — and therefore plants +// the §12 durable marker (first-write trigger, not install-time: no write, +// no marker). Without that planting, the app's first derivation would see +// the seeded admin as "existing users, no marker" and default the gate OFF +// forever (AC-review round-1 finding). +async function hashWriteDecision(queryInterface, envConfig) { + const explicitSetting = envConfig.PASSWORD_HASH_WRITE_ENABLED; + if (explicitSetting === 'true' || explicitSetting === 'false') { + return deriveHashWriteState({ + explicitSetting, + markerPresent: false, + usersPresent: false + }); + } + const markers = await queryInterface.sequelize.query( + 'SELECT COUNT(id) FROM "HashMigrationMarkers"', + {type: queryInterface.sequelize.QueryTypes.SELECT} + ); + const users = await queryInterface.sequelize.query( + 'SELECT COUNT(id) FROM "Users"', + {type: queryInterface.sequelize.QueryTypes.SELECT} + ); + return deriveHashWriteState({ + explicitSetting, + markerPresent: markers[0].count !== '0', + usersPresent: users[0].count !== '0' + }); +} + module.exports = { up: async (queryInterface, _Sequelize) => { const result = await queryInterface.sequelize.query( @@ -46,14 +92,36 @@ module.exports = { console.log('You should change this password on first login.'); } - return queryInterface.bulkInsert( + let encryptedPassword; + let plantMarker = false; + const writeDecision = await hashWriteDecision(queryInterface, envConfig); + if (writeDecision.enabled) { + encryptedPassword = await hashPassword(password); + plantMarker = true; + } else { + // §12 rolling window: a pre-N pod must be able to read this admin + // credential, so fall back to bcrypt (cost 14, the historical + // parameter) — except under FIPS mode, where generating a bcrypt + // hash is itself a finding (V-222571): refuse loudly instead, the + // same coherence rule as PasswordService.hash. + if (crypto.getFips() === 1) { + throw new Error( + 'PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS ' + + 'mode: the admin bootstrap cannot generate a bcrypt fallback ' + + 'hash inside the validated boundary (V-222571). Enable PBKDF2 ' + + 'writes or disable FIPS mode.' + ); + } + encryptedPassword = await bcrypt.hash(password, 14); + } + await queryInterface.bulkInsert( 'Users', [ { firstName: 'Admin', email: email, role: 'admin', - encryptedPassword: bcrypt.hashSync(password, 14), + encryptedPassword: encryptedPassword, creationMethod: adminUsesExternalAuth ? 'ldap' : 'local', passwordChangedAt: new Date(), forcePasswordChange: true, @@ -63,6 +131,30 @@ module.exports = { ], {} ); + if (plantMarker) { + // §12 first-write planting: on a fresh install THIS was the first + // PBKDF2 write, and the app's later derivation reads the marker back + // (sticky). Idempotent — skip when a row for this epoch exists. + const planted = await queryInterface.sequelize.query( + 'SELECT COUNT(id) FROM "HashMigrationMarkers"', + {type: queryInterface.sequelize.QueryTypes.SELECT} + ); + if (planted[0].count === '0') { + await queryInterface.bulkInsert( + 'HashMigrationMarkers', + [ + { + markerVersion: SUPPORTED_HASH_MARKER_VERSION, + pbkdf2WritesBeganAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + } + ], + {} + ); + } + } + return; } else { console.log('Administrator exists. Skipping creation.'); return queryInterface.sequelize.query('SELECT 1+1 AS result'); diff --git a/apps/backend/seeders/20260815000000-create-demo-users.js b/apps/backend/seeders/20260815000000-create-demo-users.js new file mode 100644 index 0000000000..6c5ddc8d35 --- /dev/null +++ b/apps/backend/seeders/20260815000000-create-demo-users.js @@ -0,0 +1,111 @@ +'use strict'; +// Demo/test user seed — stable, documented credentials so a developer never +// has to invent accounts by hand or ask a colleague for a password. +// +// WHY THIS EXISTS: heimdall2 had exactly one seeder, which creates +// admin@heimdall.local with a random password printed once. On 2026-08-15 that +// cost a live debugging session — the only working accounts in the developer's +// database were ones an agent had registered through the signup form hours +// earlier. Mirrors mitre/vulcan's `db/seeds/data/00_users.rb`. +// +// The `dist/src/` require path is load-bearing for the same reason the +// administrator seeder documents: nest build infers rootDir across +// src/db/config, emitting dist/src/crypto/password.js. +const {hashPassword} = require('../dist/src/crypto/password'); +const { + DEMO_EMAILS, + DEMO_PASSWORD, + DEMO_USERS, + demoSeedEnabled, + hashOptions, + readEnvConfig, + resolvePassword + // Lives OUTSIDE seeders/ deliberately: sequelize-cli loads every .js in that + // directory as a seeder and calls up() on it, so a support module parked + // there kills `db:seed:all` — which cmd.sh runs under `set -e` before + // starting the app. Guarded by test/seeders-directory-guard.spec.ts. +} = require('../seed-support/demo-seed-helpers'); + +module.exports = { + DEMO_EMAILS, + DEMO_PASSWORD, + + async up(queryInterface) { + const envConfig = readEnvConfig(); + + if (!demoSeedEnabled(envConfig)) { + console.log( + 'Skipping demo user seed: not a development or test environment. ' + + 'Set SEED_DEMO_DATA=true to create demo accounts deliberately.' + ); + return; + } + + const existing = await queryInterface.sequelize.query( + 'SELECT email FROM "Users" WHERE email IN (:emails)', + { + replacements: {emails: DEMO_EMAILS}, + type: queryInterface.sequelize.QueryTypes.SELECT + } + ); + const present = new Set(existing.map((row) => row.email)); + const missing = DEMO_USERS.filter((user) => !present.has(user.email)); + + if (missing.length === 0) { + console.log('Demo users already present — nothing to seed.'); + return; + } + + const password = resolvePassword(envConfig); + const options = hashOptions(envConfig); + const now = new Date(); + + // Hashed per user, so each row carries its own salt. Reusing one hash + // across accounts would leak that they share a password. + // + // Always PBKDF2, never the administrator seeder's bcrypt fallback. That + // fallback exists for the ADR-006 §12 rolling window, where a pre-N pod + // must still read a credential written by a newer one — a situation that + // cannot arise here, because this seeder only ever runs in development and + // test. PBKDF2 is also the only FIPS-safe choice, and verifyPassword + // dispatches on the hash prefix, so these rows are readable regardless of + // how the write gate is currently set. This seeder deliberately does NOT + // plant the §12 marker: the administrator seeder owns that, and planting + // it from two places is exactly the defect an earlier AC review caught. + const rows = []; + for (const user of missing) { + rows.push({ + createdAt: now, + creationMethod: 'local', + email: user.email, + encryptedPassword: await hashPassword(password, options), + firstName: user.firstName, + // Demo logins must not be interrupted by a forced password change — + // the roster exists for a fast, repeatable login loop. + forcePasswordChange: false, + lastName: user.lastName, + passwordChangedAt: now, + role: user.role, + updatedAt: now + }); + } + + await queryInterface.bulkInsert('Users', rows, {}); + console.log( + `Seeded ${rows.length} demo user(s): ${rows + .map((row) => row.email) + .join(', ')}` + ); + console.log( + envConfig.SEED_PASSWORD + ? 'Demo password taken from SEED_PASSWORD.' + : `Demo password is the documented default: ${DEMO_PASSWORD}` + ); + }, + + async down(queryInterface) { + // Scoped to the roster. A bare bulkDelete('Users') would remove real + // accounts alongside the demo ones. + await queryInterface.bulkDelete('Users', {email: DEMO_EMAILS}); + } +}; diff --git a/apps/backend/seeders/20260815000100-create-demo-group.js b/apps/backend/seeders/20260815000100-create-demo-group.js new file mode 100644 index 0000000000..e6513f7d03 --- /dev/null +++ b/apps/backend/seeders/20260815000100-create-demo-group.js @@ -0,0 +1,154 @@ +'use strict'; +// Demo group seed — makes heimdall's GROUP-SCOPED authorization paths +// reachable without hand-building a group through the UI. +// +// This is not hypothetical coverage: the regression that started this work was +// GET /groups/my returning 500, and no seeded account could reproduce it. +// +// Mirrors mitre/vulcan's `db/seeds/data/05_memberships.rb`, which is a separate +// ordered file from `00_users.rb` and assigns memberships to the EXISTING demo +// users. The filename timestamp orders this after the user seeder. +const { + DEMO_GROUP, + DEMO_MEMBERSHIPS, + demoSeedEnabled, + readEnvConfig + // Outside seeders/ deliberately — see the note in the demo user seeder and + // test/seeders-directory-guard.spec.ts. +} = require('../seed-support/demo-seed-helpers'); + +const SELECT_USERS = + 'SELECT id, email FROM "Users" WHERE email IN (:emails)'; +const SELECT_GROUP = 'SELECT id FROM "Groups" WHERE name = :name'; +const SELECT_MEMBERSHIPS = + 'SELECT "userId" FROM "GroupUsers" WHERE "groupId" = :groupId'; + +function select(queryInterface, sql, replacements) { + return queryInterface.sequelize.query(sql, { + replacements, + type: queryInterface.sequelize.QueryTypes.SELECT + }); +} + +module.exports = { + DEMO_GROUP, + DEMO_MEMBERSHIPS, + + async up(queryInterface) { + const envConfig = readEnvConfig(); + + if (!demoSeedEnabled(envConfig)) { + console.log( + 'Skipping demo group seed: not a development or test environment. ' + + 'Set SEED_DEMO_DATA=true to create demo data deliberately.' + ); + return; + } + + const emails = DEMO_MEMBERSHIPS.map((membership) => membership.email); + const users = await select(queryInterface, SELECT_USERS, {emails}); + const idByEmail = new Map( + users.map((user) => [user.email, String(user.id)]) + ); + const absent = emails.filter((email) => !idByEmail.has(email)); + + if (absent.length > 0) { + // Return rather than throw: cmd.sh runs `db:seed:all` under `set -e`, so + // throwing here would abort the whole seed run — and in a container, the + // boot. The user seeder is ordered before this one, so this only happens + // when it was skipped or its accounts were removed. + console.log( + `Skipping demo group seed: demo user(s) not found — ${absent.join(', ')}. ` + + 'Run the demo user seeder first.' + ); + return; + } + + const now = new Date(); + let groupRows = await select(queryInterface, SELECT_GROUP, { + name: DEMO_GROUP.name + }); + + if (groupRows.length === 0) { + await queryInterface.bulkInsert( + 'Groups', + [ + { + createdAt: now, + desc: DEMO_GROUP.desc, + name: DEMO_GROUP.name, + public: DEMO_GROUP.public, + updatedAt: now + } + ], + {} + ); + // Groups.id is autoincrement, so the id is not knowable until it is read + // back. Re-select rather than assume. + groupRows = await select(queryInterface, SELECT_GROUP, { + name: DEMO_GROUP.name + }); + console.log(`Seeded demo group: ${DEMO_GROUP.name}`); + } + + const groupId = groupRows.length > 0 ? String(groupRows[0].id) : undefined; + if (groupId === undefined) { + console.log( + 'Skipping demo group memberships: the demo group could not be read back.' + ); + return; + } + + const existing = await select(queryInterface, SELECT_MEMBERSHIPS, { + groupId + }); + const alreadyMember = new Set( + existing.map((row) => String(row.userId)) + ); + const missing = DEMO_MEMBERSHIPS.filter( + (membership) => !alreadyMember.has(idByEmail.get(membership.email)) + ); + + if (missing.length === 0) { + console.log('Demo group memberships already present — nothing to seed.'); + return; + } + + await queryInterface.bulkInsert( + 'GroupUsers', + missing.map((membership) => ({ + createdAt: now, + groupId, + // GroupUsers.role — owner|member, scoped to this group. NOT + // Users.role, which is the app-wide admin|user concept. + role: membership.role, + updatedAt: now, + userId: idByEmail.get(membership.email) + })), + {} + ); + console.log( + `Seeded ${missing.length} demo group membership(s): ` + + missing.map((m) => `${m.email} as ${m.role}`).join(', ') + ); + }, + + async down(queryInterface) { + const groupRows = await select(queryInterface, SELECT_GROUP, { + name: DEMO_GROUP.name + }); + + if (groupRows.length > 0) { + // Memberships first: the FK is ON DELETE SET NULL, so removing the group + // alone would orphan its GroupUsers rows with a null groupId rather than + // remove them. + await queryInterface.bulkDelete('GroupUsers', { + groupId: String(groupRows[0].id) + }); + } + + // Scoped by name. The demo USERS belong to the user seeder's down() and are + // deliberately left alone here. + await queryInterface.bulkDelete('Groups', {name: DEMO_GROUP.name}); + } +}; diff --git a/apps/backend/src/admin/admin.controller.spec.ts b/apps/backend/src/admin/admin.controller.spec.ts new file mode 100644 index 0000000000..98f0571830 --- /dev/null +++ b/apps/backend/src/admin/admin.controller.spec.ts @@ -0,0 +1,188 @@ +import { ForbiddenError } from '@casl/ability'; +import type { INestApplication } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { sign } from 'jsonwebtoken'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; +import { ApiKey } from '../apikeys/apikey.model'; +import { JwtStrategy } from '../authn/jwt.strategy'; +import { AuthzModule } from '../authz/authz.module'; +import { CaslExceptionFilter } from '../casl/casl-exception.filter'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { HealthModule } from '../health/health.module'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AdminController } from './admin.controller'; +import { AdminModule } from './admin.module'; + +// Prefix-shaped literal for the §17 count queries — never verified as a +// credential, only matched against LIKE '$pbkdf2-%'. +const PBKDF2_SHAPED_HASH = '$pbkdf2-sha512$i=600000$c2FsdHNhbHQ$aGFzaGhhc2g'; +const USER_JWT_SECRET = 'admin-spec-session-secret'; + +function createRoleUser(role: string): Promise { + return User.create({ + creationMethod: 'local', + email: `admin-spec-${role}@example.com`, + encryptedPassword: PBKDF2_SHAPED_HASH, + jwtSecret: USER_JWT_SECRET, + role, + }); +} + +describe('AdminController Unit Tests', () => { + let app: INestApplication; + let baseUrl: string; + let adminController: AdminController; + let configService: ConfigService; + let databaseService: DatabaseService; + let module: TestingModule; + + // Mirrors AuthnService.login: same payload shape, same JWT_SECRET + + // per-user jwtSecret concatenation the JwtStrategy re-derives per request. + function signSessionToken(user: User): string { + return sign( + { + email: user.email, + forcePasswordChange: false, + role: user.role, + sub: user.id, + }, + String(configService.get('JWT_SECRET')) + user.jwtSecret, + { expiresIn: '600s' }, + ); + } + + beforeAll(async () => { + // The REAL AdminModule and HealthModule, not root-mounted controllers: + // each controller resolves its dependencies inside its own module, + // exactly as in app.module — a missing module import fails HERE, not + // only at live boot (found live: the original root-mounted harness + // masked AdminModule's missing ConfigModule import). Both modules + // mounted keeps the old-path-404 assertion honest. + module = await Test.createTestingModule({ + imports: [ + AdminModule, + AuthzModule, + ConfigModule, + CryptoModule, + DatabaseModule, + HealthModule, + SequelizeModule.forFeature([ + ApiKey, + Evaluation, + EvaluationTag, + Group, + GroupEvaluation, + GroupUser, + User, + ]), + ], + providers: [ + DatabaseService, + JwtStrategy, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + // The real app maps CASL ForbiddenError -> 403 through this filter. + { provide: APP_FILTER, useClass: CaslExceptionFilter }, + ], + }).compile(); + + adminController = module.get(AdminController, { strict: false }); + configService = module.get(ConfigService); + databaseService = module.get(DatabaseService); + + app = module.createNestApplication(); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address(); + if (address === null || typeof address !== 'object') { + throw new TypeError('expected the test server to bind a TCP port'); + } + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + configService.set('FIPS_MODE', undefined); + }); + + afterAll(async () => { + // Order matters: app.close() tears down the Nest app INCLUDING its + // Sequelize connection, so the cleanup query has to run first. + await databaseService.cleanAll(); + await app.close(); + }); + + describe('GET /admin/migration-status (authenticated migration report)', () => { + it('serves the migration detail to an admin JWT over HTTP', async () => { + const admin = await createRoleUser('admin'); + + const response = await fetch(`${baseUrl}/admin/migration-status`, { headers: { Authorization: `Bearer ${signSessionToken(admin)}` } }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + bcryptRemaining: { apiKeys: 0, users: 0 }, + fips: false, + fipsModeAsserted: false, + oldestUnmigratedLogin: null, + passwordHashWriteEnabled: true, + pbkdf2Migrated: { apiKeys: 0, users: 1 }, + }); + }); + + it('refuses HTTP requests without a JWT — 401 from JwtAuthGuard', async () => { + const response = await fetch(`${baseUrl}/admin/migration-status`); + expect(response.status).toBe(401); + }); + + it('refuses a non-admin JWT over HTTP with 403 (CASL -> CaslExceptionFilter)', async () => { + const basicUser = await createRoleUser('user'); + + const response = await fetch(`${baseUrl}/admin/migration-status`, { headers: { Authorization: `Bearer ${signSessionToken(basicUser)}` } }); + expect(response.status).toBe(403); + }); + + it('rejects a non-admin authenticated user with ForbiddenError (CASL admin check, direct call)', async () => { + const basicUser = await createRoleUser('user'); + + await expect( + adminController.getMigrationStatus({ user: basicUser }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + describe('the old path is gone (ratified rename, 2026-08-10)', () => { + it('GET /health/details returns 404 with the health routes mounted', async () => { + const admin = await createRoleUser('admin'); + + const response = await fetch(`${baseUrl}/health/details`, { headers: { Authorization: `Bearer ${signSessionToken(admin)}` } }); + expect(response.status).toBe(404); + }); + + it('the probe-safe health surface still serves — /health 200 unauthenticated', async () => { + const response = await fetch(`${baseUrl}/health`); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/apps/backend/src/admin/admin.controller.ts b/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000000..b7f5c575a3 --- /dev/null +++ b/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,45 @@ +import { ForbiddenError } from '@casl/ability'; +import { + Controller, + Get, + Request, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { HealthDetailsDto } from '../health/dto/health.dto'; +import { HealthService } from '../health/health.service'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; + +/** + * ADR-006 §17 (renamed 2026-08-10, ratified policy): the migration report is + * an ADMIN endpoint, not a health check — it moved out of the probe + * namespace so /health carries only the probe-safe surface. Same guard chain + * as StatisticsController: JwtAuthGuard + the CASL admin-only ViewStatistics + * action. + * + * NEVER wire this route into a container healthcheck or readiness probe: + * its counts are full Users/ApiKeys scans, uncached by design (§17 — + * self-inflicted outage). + */ +@Controller('admin') +@UseInterceptors(LoggingInterceptor) +export class AdminController { + constructor( + private readonly authz: AuthzService, + private readonly healthService: HealthService, + ) {} + + @Get('migration-status') + @UseGuards(JwtAuthGuard) + async getMigrationStatus( + @Request() request: { user: User }, + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + ForbiddenError.from(abac).throwUnlessCan(Action.ViewStatistics, User); + return this.healthService.getDetails(); + } +} diff --git a/apps/backend/src/admin/admin.module.ts b/apps/backend/src/admin/admin.module.ts new file mode 100644 index 0000000000..a3c41a4cbe --- /dev/null +++ b/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '../config/config.module'; +import { HealthModule } from '../health/health.module'; +import { AdminController } from './admin.controller'; + +/** + * ADR-006 §17: the admin surface. Holds the migration report (moved out of + * the probe namespace, ratified 2026-08-10); e25.24's bulk migration + * endpoints may extend it. HealthModule exports HealthService — the report's + * data source is unchanged by the rename. ConfigModule feeds the + * LoggingInterceptor (ConfigModule is NOT @Global in this app — found live: + * the app context failed to boot without it while the spec was green, + * because the original harness mounted the controller at root with a + * root-level ConfigModule; the spec now consumes this real module instead). + */ +@Module({ + controllers: [AdminController], + imports: [ConfigModule, HealthModule], +}) +export class AdminModule {} diff --git a/apps/backend/src/apikeys/apikey.controller.ts b/apps/backend/src/apikeys/apikey.controller.ts index 7811f814db..33d9a5494b 100644 --- a/apps/backend/src/apikeys/apikey.controller.ts +++ b/apps/backend/src/apikeys/apikey.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { BadRequestException, Body, @@ -11,22 +11,22 @@ import { Query, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthnService} from '../authn/authn.service'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {GroupsService} from '../groups/groups.service'; -import {APIKeysEnabled} from '../guards/api-keys-enabled.guard'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {ApiKeyService} from './apikey.service'; -import {APIKeyDto} from './dto/apikey.dto'; -import {CreateApiKeyDto} from './dto/create-apikey.dto'; -import {DeleteAPIKeyDto} from './dto/delete-apikey.dto'; -import {UpdateAPIKeyDto} from './dto/update-apikey.dto'; +import { AuthnService } from '../authn/authn.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { GroupsService } from '../groups/groups.service'; +import { APIKeysEnabled } from '../guards/api-keys-enabled.guard'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { ApiKeyService } from './apikey.service'; +import { APIKeyDto } from './dto/apikey.dto'; +import { CreateApiKeyDto } from './dto/create-apikey.dto'; +import { DeleteAPIKeyDto } from './dto/delete-apikey.dto'; +import { UpdateAPIKeyDto } from './dto/update-apikey.dto'; @UseInterceptors(LoggingInterceptor) @UseGuards(APIKeysEnabled) @@ -37,41 +37,15 @@ export class ApiKeyController { private readonly apiKeyService: ApiKeyService, private readonly authz: AuthzService, private readonly usersService: UsersService, - private readonly groupsService: GroupsService + private readonly groupsService: GroupsService, ) {} - @UseGuards(JwtAuthGuard) - @Get() - async findAPIKeys( - @Request() request: {user: User}, - @Query('userId') userId: string, - @Query('groupId') groupId: string - ): Promise { - const abac = this.authz.abac.createForUser(request.user); - - if (userId && groupId) { - throw new BadRequestException('Cannot specify both userId and groupId'); - } - - if (groupId) { - const group = await this.groupsService.findByPkBang(groupId); - ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); - return this.apiKeyService.findAllForGroup(group); - } else { - const user = userId - ? await this.usersService.findById(userId) - : request.user; - ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); - return this.apiKeyService.findAllForUser(user); - } - } - @UseGuards(JwtAuthGuard) @Post() async createAPIKey( - @Request() request: {user: User}, - @Body() createApiKeyDto: CreateApiKeyDto - ): Promise<{id: string; apiKey: string}> { + @Request() request: { user: User }, + @Body() createApiKeyDto: CreateApiKeyDto, + ): Promise<{ apiKey: string; id: string }> { const abac = this.authz.abac.createForUser(request.user); let target; @@ -97,9 +71,9 @@ export class ApiKeyController { @UseGuards(JwtAuthGuard) @Delete(':id') async deleteAPIKey( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() deleteApiKeyDto: DeleteAPIKeyDto + @Body() deleteApiKeyDto: DeleteAPIKeyDto, ): Promise { const apiKeyToDelete = await this.apiKeyService.findById(id); const abac = this.authz.abac.createForUser(request.user); @@ -107,11 +81,11 @@ export class ApiKeyController { if (apiKeyToDelete.type === 'user') { ForbiddenError.from(abac).throwUnlessCan( Action.Update, - apiKeyToDelete.user + apiKeyToDelete.user, ); } else if (apiKeyToDelete.type === 'group') { const group = await this.groupsService.findByPkBang( - apiKeyToDelete.groupId + apiKeyToDelete.groupId, ); ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); } else { @@ -124,24 +98,50 @@ export class ApiKeyController { return this.apiKeyService.remove(id); } + @UseGuards(JwtAuthGuard) + @Get() + async findAPIKeys( + @Request() request: { user: User }, + @Query('userId') userId: string, + @Query('groupId') groupId: string, + ): Promise { + if (userId && groupId) { + throw new BadRequestException('Cannot specify both userId and groupId'); + } + + const abac = this.authz.abac.createForUser(request.user); + + if (groupId) { + const group = await this.groupsService.findByPkBang(groupId); + ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); + return this.apiKeyService.findAllForGroup(group); + } else { + const user = userId + ? await this.usersService.findById(userId) + : request.user; + ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); + return this.apiKeyService.findAllForUser(user); + } + } + @UseGuards(JwtAuthGuard) @Put('/:id') async updateAPIKey( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() updateApiKeyDto: UpdateAPIKeyDto + @Body() updateApiKeyDto: UpdateAPIKeyDto, ): Promise { const apiKeyToUpdate = await this.apiKeyService.findById(id); const abac = this.authz.abac.createForUser(request.user); if (apiKeyToUpdate.type === 'group') { const group = await this.groupsService.findByPkBang( - apiKeyToUpdate.groupId + apiKeyToUpdate.groupId, ); ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); } else if (apiKeyToUpdate.type === 'user') { ForbiddenError.from(abac).throwUnlessCan( Action.Update, - apiKeyToUpdate.user + apiKeyToUpdate.user, ); } else { throw new BadRequestException('Unknown API key type'); diff --git a/apps/backend/src/apikeys/apikey.model.ts b/apps/backend/src/apikeys/apikey.model.ts index 941cc73c83..1c0cd52438 100644 --- a/apps/backend/src/apikeys/apikey.model.ts +++ b/apps/backend/src/apikeys/apikey.model.ts @@ -9,53 +9,49 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; @Table export class ApiKey extends Model { - @PrimaryKey - @AutoIncrement + @Column(DataType.STRING) + declare apiKey: string; + + @CreatedAt @AllowNull(false) - @Column(DataType.BIGINT) - declare id: string; + @Column(DataType.DATE) + declare createdAt: Date; - @ForeignKey(() => User) - @Column(DataType.BIGINT) - declare userId: string; + @BelongsTo(() => Group, { constraints: false }) + declare group: Group; @ForeignKey(() => Group) @Column(DataType.BIGINT) declare groupId: string; - @BelongsTo(() => User, { - constraints: false - }) - declare user: User; - - @BelongsTo(() => Group, { - constraints: false - }) - declare group: Group; + @PrimaryKey + @AutoIncrement + @AllowNull(false) + @Column(DataType.BIGINT) + declare id: string; @Column(DataType.STRING) declare name: string; - @Column(DataType.STRING) - declare apiKey: string; - @Column(DataType.STRING) declare type: string; - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; - @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; + + @BelongsTo(() => User, { constraints: false }) + declare user: User; + + @ForeignKey(() => User) + @Column(DataType.BIGINT) + declare userId: string; } diff --git a/apps/backend/src/apikeys/apikey.service.spec.ts b/apps/backend/src/apikeys/apikey.service.spec.ts new file mode 100644 index 0000000000..ad8b9502c4 --- /dev/null +++ b/apps/backend/src/apikeys/apikey.service.spec.ts @@ -0,0 +1,215 @@ +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { verifyPassword } from '../crypto/password'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { ApiKey } from './apikey.model'; +import { ApiKeyService } from './apikey.service'; + +// ADR-006 §7: narrow compare-and-swap writer for lazy API-key rehash. Same +// shape as UsersService.updateEncryptedPassword, against the ApiKeys.apiKey +// hash column. This spec did not exist before this card. +describe('ApiKeyService.updateApiKeyHash (§7 compare-and-swap)', () => { + let apiKeyService: ApiKeyService; + let databaseService: DatabaseService; + const ORIGINAL = '$pbkdf2-sha512$i=600000$origOrigOrigOrigOrig$origKeyOrig'; + const NEW = '$pbkdf2-sha512$i=600000$newnewnewnewnewnew$newKeyNewKey'; + let apiKeyId: string; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + // The full model set must be registered so ApiKey's @BelongsTo(User, + // Group) and their transitive associations (Group↔User through + // GroupUser, etc.) resolve — constraints:false still needs the models + // defined. Mirrors users.service.spec's registration set. + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ApiKeyService, ConfigService, DatabaseService], + }).compile(); + apiKeyService = module.get(ApiKeyService); + databaseService = module.get(DatabaseService); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + // Insert a key row with a known stored hash (associations are + // constraints:false and userId is nullable, so no owner is required). + const created = await ApiKey.create({ + apiKey: ORIGINAL, + name: 'cas-test', + type: 'user', + }); + apiKeyId = created.id; + }); + + it('returns 0 and writes nothing when the stored hash no longer matches originalHash', async () => { + const affected = await apiKeyService.updateApiKeyHash( + apiKeyId, + 'a-stale-hash-that-does-not-match', + NEW, + ); + expect(affected).toBe(0); + const reloaded = await ApiKey.findByPk(apiKeyId); + expect(reloaded?.apiKey).toBe(ORIGINAL); + }); + + it('returns 1 and swaps apiKey when originalHash matches', async () => { + const affected = await apiKeyService.updateApiKeyHash( + apiKeyId, + ORIGINAL, + NEW, + ); + expect(affected).toBe(1); + const reloaded = await ApiKey.findByPk(apiKeyId); + expect(reloaded?.apiKey).toBe(NEW); + }); + + it('does NOT bump updatedAt on a winning write (silent: true)', async () => { + const before = await ApiKey.findByPk(apiKeyId); + const beforeUpdatedAt = before?.updatedAt?.getTime(); + await apiKeyService.updateApiKeyHash(apiKeyId, ORIGINAL, NEW); + const after = await ApiKey.findByPk(apiKeyId); + expect(after?.updatedAt?.getTime()).toBe(beforeUpdatedAt); + }); + + it('does NOT touch name or type', async () => { + await apiKeyService.updateApiKeyHash(apiKeyId, ORIGINAL, NEW); + const after = await ApiKey.findByPk(apiKeyId); + expect(after?.name).toBe('cas-test'); + expect(after?.type).toBe('user'); + }); +}); + +// ADR-006 §2: exact prefix — algorithm AND iteration count pinned. Module +// scope so the regex is compiled once. +const PHC_SHA512_600K_PREFIX = /^\$pbkdf2-sha512\$i=600000\$/v; + +// ADR-006 §4 site 7: create() hashes the JWT signature (and ONLY the +// signature — §11/Scope: changing what is hashed invalidates every existing +// key) through PasswordService into the ApiKeys.apiKey column. Own harness so +// this suite controls API_KEY_SECRET (jwt.sign throws on an empty secret). +describe('ApiKeyService.create (§4 site 7 — PBKDF2 hash of the JWT signature)', () => { + let apiKeyService: ApiKeyService; + let databaseService: DatabaseService; + let owner: User; + const priorApiKeySecret = process.env.API_KEY_SECRET; + + beforeAll(async () => { + // AppConfig.get reads process.env first, live at each call. + process.env.API_KEY_SECRET = 'apikey-spec-secret'; + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ApiKeyService, ConfigService, DatabaseService], + }).compile(); + apiKeyService = module.get(ApiKeyService); + databaseService = module.get(DatabaseService); + }); + + afterAll(async () => { + if (priorApiKeySecret === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = priorApiKeySecret; + } + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + owner = await User.create({ + creationMethod: 'local', + email: 'apikey-owner@example.com', + encryptedPassword: 'placeholder-never-verified-in-this-suite', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('stores the JWT signature as a PBKDF2 PHC hash that round-trips through verifyPassword', async () => { + expect.assertions(4); + const result = await apiKeyService.create(owner, { + currentPassword: 'unused-by-service-layer', + name: 'site-7-key', + }); + // The caller receives the full JWT; the DB holds only a signature hash. + expect(result.apiKey.split('.', 3)).toHaveLength(3); + const stored = await ApiKey.findByPk(result.id); + expect(stored?.apiKey).toMatch(PHC_SHA512_600K_PREFIX); + const verification = await verifyPassword({ + hash: stored?.apiKey ?? '', + password: result.apiKey.split('.', 3)[2], + }); + expect(verification.valid).toBe(true); + expect(verification.needsRehash).toBe(false); + }); + + it('persists the signature hash BEFORE create() resolves (the hash write is awaited)', async () => { + expect.assertions(2); + // Found defect fixed in this card: the second save() was un-awaited, so + // create() could resolve before the hash hit the DB — a client using the + // key immediately could 403, and a failed save became an unhandled + // rejection. The spy calls through and records settlement: a save still + // in its Postgres round trip is 'incomplete' when create() resolves, so + // an un-awaited write can never report 'fulfilled' here. + const saveSpy = vi.spyOn(ApiKey.prototype, 'save'); + const result = await apiKeyService.create(owner, { + currentPassword: 'unused-by-service-layer', + name: 'awaited-key', + }); + expect(saveSpy.mock.settledResults.map(entry => entry.type)).toEqual([ + 'fulfilled', + 'fulfilled', + ]); + const stored = await ApiKey.findByPk(result.id); + expect(stored?.apiKey).toMatch(PHC_SHA512_600K_PREFIX); + }); +}); diff --git a/apps/backend/src/apikeys/apikey.service.ts b/apps/backend/src/apikeys/apikey.service.ts index f08bdfcd6a..180c547700 100644 --- a/apps/backend/src/apikeys/apikey.service.ts +++ b/apps/backend/src/apikeys/apikey.service.ts @@ -1,21 +1,22 @@ -import {Injectable, NotFoundException} from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {hash} from 'bcryptjs'; -import jwt from 'jsonwebtoken'; -import {CreateApiKeyDto} from '../apikeys/dto/create-apikey.dto'; -import {ConfigService} from '../config/config.service'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {ApiKey} from './apikey.model'; -import {APIKeyDto} from './dto/apikey.dto'; -import {UpdateAPIKeyDto} from './dto/update-apikey.dto'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { sign } from 'jsonwebtoken'; +import { CreateApiKeyDto } from '../apikeys/dto/create-apikey.dto'; +import { ConfigService } from '../config/config.service'; +import { PasswordService } from '../crypto/password.service'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { ApiKey } from './apikey.model'; +import { APIKeyDto } from './dto/apikey.dto'; +import { UpdateAPIKeyDto } from './dto/update-apikey.dto'; @Injectable() export class ApiKeyService { constructor( @InjectModel(ApiKey) private readonly apiKeyModel: typeof ApiKey, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly passwordService: PasswordService, ) {} async count(): Promise { @@ -23,65 +24,83 @@ export class ApiKeyService { } async create( - target: User | Group, - createApiKeyDto: CreateApiKeyDto - ): Promise<{id: string; name: string; apiKey: string}> { + target: Group | User, + createApiKeyDto: CreateApiKeyDto, + ): Promise<{ apiKey: string; id: string; name: string }> { const APIKeySecret = this.configService.get('API_KEY_SECRET') || ''; const newApiKey = new ApiKey({ - userId: target instanceof User ? target.id : undefined, groupId: target instanceof Group ? target.id : undefined, name: createApiKeyDto.name, - type: target instanceof User ? 'user' : 'group' + type: target instanceof User ? 'user' : 'group', + userId: target instanceof User ? target.id : undefined, }); await newApiKey.save(); - const newJWT = jwt.sign( - {keyId: newApiKey.id, createdAt: new Date()}, - APIKeySecret + const newJWT = sign( + { createdAt: new Date(), keyId: newApiKey.id }, + APIKeySecret, ); - // Since BCrypt has a 72 byte limit only hash the JWT signature - const JWTSignature = newJWT.split('.')[2]; - newApiKey.apiKey = await hash(JWTSignature, 14); - newApiKey.save(); - return {id: newApiKey.id, name: newApiKey.name, apiKey: newJWT}; + // ADR-006 §4 site 7: PBKDF2 via the validated module, PHC output (§2). + // Only the JWT signature is hashed — originally because of bcrypt's + // 72-byte limit, kept because changing what is hashed invalidates every + // existing key (§11/Scope). The save is awaited: create() must not + // resolve before the hash is persisted (found defect fixed in e25.12). + const JWTSignature = newJWT.split('.', 3)[2]; + newApiKey.apiKey = await this.passwordService.hash(JWTSignature); + await newApiKey.save(); + return { apiKey: newJWT, id: newApiKey.id, name: newApiKey.name }; } - async update( - id: string, - updateAPIKeyDto: UpdateAPIKeyDto - ): Promise { - const apiKey = await this.findById(id); - apiKey.name = updateAPIKeyDto.name; - return new APIKeyDto(await apiKey.save()); + async findAllForGroup(group: Group): Promise { + const apiKeys = await this.apiKeyModel.findAll({ where: { groupId: group.id } }); + return apiKeys.map(key => new APIKeyDto(key)); } - async remove(id: string): Promise { - const apiKeyToDestroy = await this.findById(id); - await apiKeyToDestroy.destroy(); - return new APIKeyDto(apiKeyToDestroy); + async findAllForUser(user: User): Promise { + const apiKeys = await this.apiKeyModel.findAll({ where: { userId: user.id } }); + return apiKeys.map(key => new APIKeyDto(key)); } async findById(id: string): Promise { - const apiKey = await this.apiKeyModel.findByPk(id, { - include: [User, Group] - }); - if (apiKey === null) { + const apiKey = await this.apiKeyModel.findByPk(id, { include: [User, Group] }); + if (!apiKey) { throw new NotFoundException('API key with given id not found'); - } else { - return apiKey; } + return apiKey; } - async findAllForUser(user: User): Promise { - const apiKeys = await this.apiKeyModel.findAll({ - where: {userId: user.id} - }); - return apiKeys.map((key) => new APIKeyDto(key)); + async remove(id: string): Promise { + const apiKeyToDestroy = await this.findById(id); + await apiKeyToDestroy.destroy(); + return new APIKeyDto(apiKeyToDestroy); } - async findAllForGroup(group: Group): Promise { - const apiKeys = await this.apiKeyModel.findAll({ - where: {groupId: group.id} - }); - return apiKeys.map((key) => new APIKeyDto(key)); + async update( + id: string, + updateAPIKeyDto: UpdateAPIKeyDto, + ): Promise { + const apiKey = await this.findById(id); + apiKey.name = updateAPIKeyDto.name; + return new APIKeyDto(await apiKey.save()); + } + + /** + * ADR-006 §7: narrow compare-and-swap writer for lazy API-key rehash — the + * ApiKeys equivalent of UsersService.updateEncryptedPassword. Rewrites the + * `apiKey` hash column ONLY, gated on the stored value still matching + * `originalHash`, silent so updatedAt is not bumped. Same shape as the Users + * writer (§4 names the updateLoginMetadata/updateUserSecret precedent; the + * ApiKey field is `apiKey`, so the method is named for it). Returns affected + * count — 0 means another writer won; the caller does nothing. + */ + async updateApiKeyHash( + id: string, + originalHash: string, + newHash: string, + ): Promise { + const [affected] = await this.apiKeyModel.update( + { apiKey: newHash }, + { fields: ['apiKey'], silent: true, where: { apiKey: originalHash, id } }, + ); + return affected; } } diff --git a/apps/backend/src/apikeys/apikeys.module.ts b/apps/backend/src/apikeys/apikeys.module.ts index 7d564d324b..56a801bbaf 100644 --- a/apps/backend/src/apikeys/apikeys.module.ts +++ b/apps/backend/src/apikeys/apikeys.module.ts @@ -1,34 +1,36 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {AuthnService} from '../authn/authn.service'; -import {AuthzModule} from '../authz/authz.module'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {TokenModule} from '../token/token.module'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {ApiKeyController} from './apikey.controller'; -import {ApiKey} from './apikey.model'; -import {ApiKeyService} from './apikey.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { AuthnService } from '../authn/authn.service'; +import { AuthzModule } from '../authz/authz.module'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { TokenModule } from '../token/token.module'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { ApiKeyController } from './apikey.controller'; +import { ApiKey } from './apikey.model'; +import { ApiKeyService } from './apikey.service'; @Module({ + controllers: [ApiKeyController], + exports: [SequelizeModule, ApiKeyService], imports: [ SequelizeModule.forFeature([ApiKey, User, Group]), AuthzModule, ConfigModule, + CryptoModule, ApiKeyModule, - TokenModule + TokenModule, ], providers: [ ConfigService, AuthnService, UsersService, GroupsService, - ApiKeyService + ApiKeyService, ], - exports: [SequelizeModule, ApiKeyService], - controllers: [ApiKeyController] }) export class ApiKeyModule {} diff --git a/apps/backend/src/apikeys/dto/apikey.dto.ts b/apps/backend/src/apikeys/dto/apikey.dto.ts index 7ea89280ed..1f909fc221 100644 --- a/apps/backend/src/apikeys/dto/apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/apikey.dto.ts @@ -1,11 +1,11 @@ -import {IApiKey} from '@heimdall/common/interfaces'; -import {ApiKey} from '../apikey.model'; +import type { IApiKey } from '@heimdall/common/interfaces'; +import type { ApiKey } from '../apikey.model'; export class APIKeyDto implements IApiKey { + readonly createdAt!: Date; readonly id!: string; readonly name!: string; readonly type!: string; - readonly createdAt!: Date; readonly updatedAt!: Date; constructor(apiKey: ApiKey) { diff --git a/apps/backend/src/apikeys/dto/create-apikey.dto.ts b/apps/backend/src/apikeys/dto/create-apikey.dto.ts index 2aa4ebce5c..ada23bd736 100644 --- a/apps/backend/src/apikeys/dto/create-apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/create-apikey.dto.ts @@ -1,10 +1,10 @@ -import {ICreateApiKey} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; +import { ICreateApiKey } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; export class CreateApiKeyDto implements ICreateApiKey { @IsString() @IsOptional() - readonly userId?: string; + readonly currentPassword!: string; @IsString() @IsOptional() @@ -12,13 +12,13 @@ export class CreateApiKeyDto implements ICreateApiKey { @IsString() @IsOptional() - readonly userEmail?: string; + readonly name?: string; @IsString() @IsOptional() - readonly name?: string; + readonly userEmail?: string; @IsString() @IsOptional() - readonly currentPassword!: string; + readonly userId?: string; } diff --git a/apps/backend/src/apikeys/dto/delete-apikey.dto.ts b/apps/backend/src/apikeys/dto/delete-apikey.dto.ts index fed9bfa7dc..0a07725085 100644 --- a/apps/backend/src/apikeys/dto/delete-apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/delete-apikey.dto.ts @@ -1,5 +1,5 @@ -import {IDeleteApiKey} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; +import { IDeleteApiKey } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; export class DeleteAPIKeyDto implements IDeleteApiKey { @IsString() diff --git a/apps/backend/src/apikeys/dto/update-apikey.dto.ts b/apps/backend/src/apikeys/dto/update-apikey.dto.ts index dc8f56632d..8215bef135 100644 --- a/apps/backend/src/apikeys/dto/update-apikey.dto.ts +++ b/apps/backend/src/apikeys/dto/update-apikey.dto.ts @@ -1,11 +1,11 @@ -import {IUpdateAPIKey} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; +import { IUpdateAPIKey } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; export class UpdateAPIKeyDto implements IUpdateAPIKey { - @IsString() - readonly name!: string; - @IsString() @IsOptional() readonly currentPassword!: string; + + @IsString() + readonly name!: string; } diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts index 89bf99a3fa..cb24d6c23d 100644 --- a/apps/backend/src/app.controller.ts +++ b/apps/backend/src/app.controller.ts @@ -1,7 +1,7 @@ -import {Controller, Get, UseInterceptors} from '@nestjs/common'; -import {ConfigService} from './config/config.service'; -import {StartupSettingsDto} from './config/dto/startup-settings.dto'; -import {LoggingInterceptor} from './interceptors/logging.interceptor'; +import { Controller, Get, UseInterceptors } from '@nestjs/common'; +import { ConfigService } from './config/config.service'; +import { StartupSettingsDto } from './config/dto/startup-settings.dto'; +import { LoggingInterceptor } from './interceptors/logging.interceptor'; @Controller() @UseInterceptors(LoggingInterceptor) diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 1bfd6ffbb5..c161b5c5da 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,32 +1,61 @@ -import {Module} from '@nestjs/common'; -import {APP_FILTER} from '@nestjs/core'; -import {ServeStaticModule} from '@nestjs/serve-static'; -import {join} from 'path'; -import {ApiKeyModule} from './apikeys/apikeys.module'; -import {AppController} from './app.controller'; -import {AppService} from './app.service'; -import {AuthnModule} from './authn/authn.module'; -import {AuthzModule} from './authz/authz.module'; -import {CaslExceptionFilter} from './casl/casl-exception.filter'; -import {ConfigModule} from './config/config.module'; -import {DatabaseModule} from './database/database.module'; -import {EvaluationTagsModule} from './evaluation-tags/evaluation-tags.module'; -import {EvaluationsModule} from './evaluations/evaluations.module'; -import {GroupEvaluationsModule} from './group-evaluations/group-evaluations.module'; -import {GroupUsersModule} from './group-users/group-users.module'; -import {GroupsModule} from './groups/groups.module'; -import {StatisticsModule} from './statistics/statistics.module'; -import {UsersModule} from './users/users.module'; -import {TenableModule} from './tenable/tenable.module'; +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { AdminModule } from './admin/admin.module'; +import { ApiKeyModule } from './apikeys/apikeys.module'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthnModule } from './authn/authn.module'; +import { AuthzModule } from './authz/authz.module'; +import { CaslExceptionFilter } from './casl/casl-exception.filter'; +import { ConfigModule } from './config/config.module'; +import { documentationRoot, frontendRoot } from './config/static-paths'; +import { CryptoModule } from './crypto/crypto.module'; +import { DatabaseModule } from './database/database.module'; +import { EvaluationTagsModule } from './evaluation-tags/evaluation-tags.module'; +import { EvaluationsModule } from './evaluations/evaluations.module'; +import { GroupEvaluationsModule } from './group-evaluations/group-evaluations.module'; +import { GroupUsersModule } from './group-users/group-users.module'; +import { GroupsModule } from './groups/groups.module'; +import { HealthModule } from './health/health.module'; +import { StatisticsModule } from './statistics/statistics.module'; +import { TenableModule } from './tenable/tenable.module'; +import { UsersModule } from './users/users.module'; + +// Matched against `pathname + '/'` by serve-static's exclude check, so this +// covers /docs itself as well as everything beneath it. +const DOCS_SUBTREE = '/docs{/*path}'; @Module({ controllers: [AppController], imports: [ - ServeStaticModule.forRoot({ - rootPath: join(__dirname, '..', '..', '..', '..', 'dist', 'frontend'), - renderPath: '*splat' - }), + // ONE forRoot call, docs entry FIRST. The loader iterates this array with + // forEach, so registration order is array order; two separate forRoot + // imports would instead depend on undocumented container-insertion order. + ServeStaticModule.forRoot( + { + // Both entries exclude the docs subtree from their RENDER FALLBACK. + // Only renderFn consults `exclude` — express.static does not — so real + // files under /docs are still served, while neither fallback answers a + // docs path that has no file. Without this, an unknown /docs/** path + // gets 200 plus either the docs home or the SPA shell; the operator + // needs a real 404. + exclude: [DOCS_SUBTREE], + rootPath: documentationRoot(), + serveRoot: '/docs', + // VitePress pre-renders every route to a real .html, so no history + // fallback is wanted; `extensions` gives clean URLs without one. + serveStaticOptions: { extensions: ['html'], index: 'index.html' }, + }, + { + exclude: [DOCS_SUBTREE], + renderPath: '*splat', + rootPath: frontendRoot(), + }, + ), ConfigModule, + CryptoModule, + AdminModule, ApiKeyModule, UsersModule, DatabaseModule, @@ -37,15 +66,16 @@ import {TenableModule} from './tenable/tenable.module'; GroupEvaluationsModule, GroupsModule, GroupUsersModule, + HealthModule, StatisticsModule, - TenableModule + TenableModule, ], providers: [ AppService, { provide: APP_FILTER, - useClass: CaslExceptionFilter - } - ] + useClass: CaslExceptionFilter, + }, + ], }) export class AppModule {} diff --git a/apps/backend/src/app.service.ts b/apps/backend/src/app.service.ts index e40ee35e43..552a7d61e1 100644 --- a/apps/backend/src/app.service.ts +++ b/apps/backend/src/app.service.ts @@ -1,59 +1,54 @@ +import os from 'os'; import { BeforeApplicationShutdown, Injectable, OnApplicationBootstrap, - OnApplicationShutdown + OnApplicationShutdown, } from '@nestjs/common'; -import os from 'os'; -import winston from 'winston'; +import { addColors, createLogger, format, transports } from 'winston'; @Injectable() export class AppService - implements - OnApplicationBootstrap, +implements BeforeApplicationShutdown, - OnApplicationShutdown -{ - private readonly line = '____________________________________________\n'; - private colors = winston.addColors({ + OnApplicationBootstrap, + OnApplicationShutdown { + private colors = addColors({ + error: 'red', info: 'cyan', + verbose: 'blue', warn: 'yellow', - error: 'red', - verbose: 'blue' }); - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.colorize({all: true}), - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), - winston.format.errors({stack: true}), - winston.format.align(), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (App Service): ${info.message}` - ) - ) + private readonly line = '____________________________________________\n'; + + public logger = createLogger({ + format: format.combine( + format.colorize({ all: true }), + format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), + format.errors({ stack: true }), + format.align(), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (App Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); + beforeApplicationShutdown(signal: string): void { + this.logger.info({ message: `Received ${signal}, starting shutdown for PID ${process.pid}` }); + } + onApplicationBootstrap(): void { this.logger.info({ message: `Started Heimdall Enterprise Server on ${os.hostname()} (${os.platform()} ${os.release()}) with PID ${ process.pid - } and UID ${process.getuid?.()}` + } and UID ${process.getuid?.()}`, }); } - beforeApplicationShutdown(signal: string): void { - this.logger.info({ - message: `Received ${signal}, starting shutdown for PID ${process.pid}` - }); - } onApplicationShutdown(signal: string): void { - this.logger.info({ - message: `Finished shutdown for ${signal} for PID ${process.pid}` - }); + this.logger.info({ message: `Finished shutdown for ${signal} for PID ${process.pid}` }); } } diff --git a/apps/backend/src/authn/apikey.strategy.ts b/apps/backend/src/authn/apikey.strategy.ts index 75db4e9df6..c93a286ac2 100644 --- a/apps/backend/src/authn/apikey.strategy.ts +++ b/apps/backend/src/authn/apikey.strategy.ts @@ -1,31 +1,27 @@ -import {ForbiddenException, Injectable} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import HeaderAPIKeyStrategy from 'passport-headerapikey'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @Injectable() export class APIKeyStrategy extends PassportStrategy( HeaderAPIKeyStrategy, - 'apikey' + 'apikey', ) { constructor(private readonly authnService: AuthnService) { - super({header: 'Authorization', prefix: 'Api-Key '}, false); + super({ header: 'Authorization', prefix: 'Api-Key ' }, false); } async validate( apikey: string, done: ( - exception: null | ForbiddenException, - user: Promise | boolean - ) => unknown + exception: ForbiddenException | null, + user: boolean | Promise, + ) => unknown, ) { const auth = this.authnService.validateApiKey(apikey); - if (await auth) { - return done(null, auth); - } else { - return done(new ForbiddenException('Bad Api-Key'), auth); - } + return done((await auth) ? null : new ForbiddenException('Bad Api-Key'), auth); } } diff --git a/apps/backend/src/authn/authn.controller.ts b/apps/backend/src/authn/authn.controller.ts index 20b75ec6a0..b3c9edac7b 100644 --- a/apps/backend/src/authn/authn.controller.ts +++ b/apps/backend/src/authn/authn.controller.ts @@ -6,185 +6,178 @@ import { Req, UseFilters, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; -import {Request} from 'express'; -import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {AuthenticationExceptionFilter} from '../filters/authentication-exception.filter'; -import {LocalAuthGuard} from '../guards/local-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { AuthGuard } from '@nestjs/passport'; +import { Request } from 'express'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; +import { AuthenticationExceptionFilter } from '../filters/authentication-exception.filter'; +import { LocalAuthGuard } from '../guards/local-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @UseInterceptors(LoggingInterceptor) @Controller('authn') export class AuthnController { private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Controller): ${info.message}` - ) - ) + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: this.loggingTimeFormat }), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (Authn Controller): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) {} + @Get('github/callback') + @UseGuards(AuthGuard('github')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromGithubLogin(@Req() request: Request): Promise { + this.logger.debug('in the github login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + this.setSessionCookies(request, session); + } + + @Get('gitlab/callback') + @UseGuards(AuthGuard('gitlab')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromGitlabLogin(@Req() request: Request): Promise { + this.logger.debug('in the gitlab login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + this.setSessionCookies(request, session); + } + + @Get('google/callback') + @UseGuards(AuthGuard('google')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromGoogle(@Req() request: Request): Promise { + this.logger.debug('in the google login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + this.setSessionCookies(request, session); + } + + @Get('oidc_callback') + @UseGuards(AuthGuard('oidc')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromOIDC(@Req() request: Request): Promise { + this.logger.debug('in the oidc login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + this.setSessionCookies(request, session); + } + + @Get('okta_callback') + @UseGuards(AuthGuard('okta')) + @UseFilters(new AuthenticationExceptionFilter()) + async getUserFromOkta(@Req() request: Request): Promise { + this.logger.debug('in the okta login callback func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + const session = await this.authnService.login(request.user as User); + this.setSessionCookies(request, session); + } + @UseGuards(LocalAuthGuard) @Post('login') async login( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the local login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - if (!this.configService.isLocalLoginAllowed()) { - throw new ForbiddenException( - 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.' - ); - } else { - return this.authnService.login(req.user as User); + this.logger.debug(JSON.stringify(request.session, null, 2)); + if (this.configService.isLocalLoginAllowed()) { + return this.authnService.login(request.user as User); } - } - - @UseGuards(AuthGuard('ldap')) - @Post('login/ldap') - async loginToLDAP( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { - this.logger.debug('in the ldap login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); + throw new ForbiddenException( + 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.', + ); } @Get('github') @UseGuards(AuthGuard('github')) @UseFilters(new AuthenticationExceptionFilter()) async loginToGithub( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the github login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('github/callback') - @UseGuards(AuthGuard('github')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromGithubLogin(@Req() req: Request): Promise { - this.logger.debug('in the github login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } @Get('gitlab') @UseGuards(AuthGuard('gitlab')) @UseFilters(new AuthenticationExceptionFilter()) async loginToGitlab( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the gitlab login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('gitlab/callback') - @UseGuards(AuthGuard('gitlab')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromGitlabLogin(@Req() req: Request): Promise { - this.logger.debug('in the gitlab login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } @Get('google') @UseGuards(AuthGuard('google')) @UseFilters(new AuthenticationExceptionFilter()) async loginToGoogle( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the google login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('google/callback') - @UseGuards(AuthGuard('google')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromGoogle(@Req() req: Request): Promise { - this.logger.debug('in the google login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } - @Get('okta') - @UseGuards(AuthGuard('okta')) - @UseFilters(new AuthenticationExceptionFilter()) - async loginToOkta( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { - this.logger.debug('in the okta login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); - } - - @Get('okta_callback') - @UseGuards(AuthGuard('okta')) - @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromOkta(@Req() req: Request): Promise { - this.logger.debug('in the okta login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + @UseGuards(AuthGuard('ldap')) + @Post('login/ldap') + async loginToLDAP( + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { + this.logger.debug('in the ldap login func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } @Get('oidc') @UseGuards(AuthGuard('oidc')) @UseFilters(new AuthenticationExceptionFilter()) async loginToOIDC( - @Req() req: Request - ): Promise<{userID: string; accessToken: string}> { + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { this.logger.debug('in the oidc login func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - return this.authnService.login(req.user as User); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } - @Get('oidc_callback') - @UseGuards(AuthGuard('oidc')) + @Get('okta') + @UseGuards(AuthGuard('okta')) @UseFilters(new AuthenticationExceptionFilter()) - async getUserFromOIDC(@Req() req: Request): Promise { - this.logger.debug('in the oidc login callback func'); - this.logger.debug(JSON.stringify(req.session, null, 2)); - const session = await this.authnService.login(req.user as User); - await this.setSessionCookies(req, session); + async loginToOkta( + @Req() request: Request, + ): Promise<{ accessToken: string; userID: string }> { + this.logger.debug('in the okta login func'); + this.logger.debug(JSON.stringify(request.session, null, 2)); + return this.authnService.login(request.user as User); } - async setSessionCookies( - req: Request, + setSessionCookies( + request: Request, session: { - userID: string; accessToken: string; - } - ): Promise { - req.res?.cookie('userID', session.userID, { - secure: this.configService.isInProductionMode() - }); - req.res?.cookie('accessToken', session.accessToken, { - secure: this.configService.isInProductionMode() - }); - req.res?.redirect('/'); + userID: string; + }, + ): void { + request.res?.cookie('userID', session.userID, { secure: this.configService.isInProductionMode() }); + request.res?.cookie('accessToken', session.accessToken, { secure: this.configService.isInProductionMode() }); + request.res?.redirect('/'); } } diff --git a/apps/backend/src/authn/authn.module.ts b/apps/backend/src/authn/authn.module.ts index a1a1280e2a..d18a76c19c 100644 --- a/apps/backend/src/authn/authn.module.ts +++ b/apps/backend/src/authn/authn.module.ts @@ -1,39 +1,42 @@ -import type {Agent} from 'http'; -import {Module} from '@nestjs/common'; -import {PassportModule} from '@nestjs/passport'; -import {AuthnController} from './authn.controller'; -import {ApiKeyModule} from '../apikeys/apikeys.module'; -import {ConfigModule} from '../config/config.module'; -import {GroupsModule} from '../groups/groups.module'; -import {TokenModule} from '../token/token.module'; -import {UsersModule} from '../users/users.module'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {AuthnService} from './authn.service'; -import {ConfigService} from '../config/config.service'; -import {GroupsService} from '../groups/groups.service'; -import {APIKeyStrategy} from './apikey.strategy'; -import {GithubStrategy} from './github.strategy'; -import {GitlabStrategy} from './gitlab.strategy'; -import {GoogleStrategy} from './google.strategy'; -import {JwtStrategy} from './jwt.strategy'; -import {LDAPStrategy} from './ldap.strategy'; -import {LocalStrategy} from './local.strategy'; -import {OidcStrategy} from './oidc.strategy'; -import {OktaStrategy} from './okta.strategy'; +import type { Agent } from 'http'; +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { ApiKeyModule } from '../apikeys/apikeys.module'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { GroupsModule } from '../groups/groups.module'; +import { GroupsService } from '../groups/groups.service'; +import { TokenModule } from '../token/token.module'; +import { UsersModule } from '../users/users.module'; +import { APIKeyStrategy } from './apikey.strategy'; +import { AuthnController } from './authn.controller'; +import { AuthnService } from './authn.service'; +import { GithubStrategy } from './github.strategy'; +import { GitlabStrategy } from './gitlab.strategy'; +import { GoogleStrategy } from './google.strategy'; +import { JwtStrategy } from './jwt.strategy'; +import { LDAPStrategy } from './ldap.strategy'; +import { LocalStrategy } from './local.strategy'; +import { OidcStrategy } from './oidc.strategy'; +import { OktaStrategy } from './okta.strategy'; async function buildHttpsProxyAgent(proxyUrl: string): Promise { - const {HttpsProxyAgent} = await import('https-proxy-agent'); + const { HttpsProxyAgent } = await import('https-proxy-agent'); return new HttpsProxyAgent(proxyUrl); } @Module({ + controllers: [AuthnController], imports: [ ApiKeyModule, UsersModule, PassportModule, TokenModule, ConfigModule, - GroupsModule + CryptoModule, + GroupsModule, ], providers: [ AuthnService, @@ -46,11 +49,12 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { LDAPStrategy, ApiKeyService, { + inject: [AuthnService, ConfigService, GroupsService], provide: OidcStrategy, useFactory: async ( authn: AuthnService, config: ConfigService, - groups: GroupsService + groups: GroupsService, ) => new OidcStrategy( authn, @@ -58,11 +62,11 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { groups, config.get('OIDC_USE_HTTPS_PROXY') === 'true' ? await buildHttpsProxyAgent(config.get('HTTPS_PROXY') ?? '') - : undefined + : undefined, ), - inject: [AuthnService, ConfigService, GroupsService] }, { + inject: [AuthnService, ConfigService], provide: OktaStrategy, useFactory: async (authn: AuthnService, config: ConfigService) => new OktaStrategy( @@ -70,11 +74,9 @@ async function buildHttpsProxyAgent(proxyUrl: string): Promise { config, config.get('OKTA_USE_HTTPS_PROXY') === 'true' ? await buildHttpsProxyAgent(config.get('HTTPS_PROXY') ?? '') - : undefined + : undefined, ), - inject: [AuthnService, ConfigService] - } + }, ], - controllers: [AuthnController] }) export class AuthnModule {} diff --git a/apps/backend/src/authn/authn.service.spec.ts b/apps/backend/src/authn/authn.service.spec.ts new file mode 100644 index 0000000000..4ef45f351e --- /dev/null +++ b/apps/backend/src/authn/authn.service.spec.ts @@ -0,0 +1,841 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { JwtService } from '@nestjs/jwt'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { hash } from 'bcryptjs'; +import { sign } from 'jsonwebtoken'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; +import { CREATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { AuthzModule } from '../authz/authz.module'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { HashMigrationMarker } from '../crypto/hash-migration-marker.model'; +import { HashWriteGateService } from '../crypto/hash-write-gate.service'; +import { hashPassword, KdfOverloadedError } from '../crypto/password'; +import { PasswordService } from '../crypto/password.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AuthnService } from './authn.service'; + +// Site 6 ONLY (ADR-006 §4). users.service.ts:79 invokes testPassword UNBOUND +// via AuthnService.prototype.testPassword(...) — it works only because the +// method never touches `this`. These tests call it with `this === undefined` +// (stricter than the production prototype-receiver call): ANY `this` access +// throws immediately. The full authn spec build-out (validateUser, +// validateApiKey) belongs to the sites-4/5 cards. +function userWith(encryptedPassword: string): User { + // testPassword reads exactly one field; a model instance needs the DB. + return { encryptedPassword } as User; +} + +describe('AuthnService.testPassword — site 6, pure and this-free', () => { + const PASSWORD = 'CorrectHorse15!x'; + const unboundTestPassword = AuthnService.prototype.testPassword; + + it('invoked unbound (no this) verifies a PBKDF2 hash without throwing (§4 structural constraint)', async () => { + const user = userWith(await hashPassword(PASSWORD)); + await expect( + unboundTestPassword.call(undefined, { currentPassword: PASSWORD }, user), + ).resolves.toBeUndefined(); + }); + + it('invoked unbound verifies a legacy bcrypt hash (FIPS off)', async () => { + const user = userWith(await hash(PASSWORD, 4)); + await expect( + unboundTestPassword.call(undefined, { currentPassword: PASSWORD }, user), + ).resolves.toBeUndefined(); + }); + + it('rejects a wrong password with ForbiddenException for BOTH hash formats', async () => { + const pbkdf2User = userWith(await hashPassword(PASSWORD)); + await expect( + unboundTestPassword.call( + undefined, + { currentPassword: 'WrongHorse15!x' }, + pbkdf2User, + ), + ).rejects.toThrow(ForbiddenException); + + const bcryptUser = userWith(await hash(PASSWORD, 4)); + await expect( + unboundTestPassword.call( + undefined, + { currentPassword: 'WrongHorse15!x' }, + bcryptUser, + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('rejects a missing currentPassword with ForbiddenException', async () => { + const user = userWith(await hashPassword(PASSWORD)); + await expect( + unboundTestPassword.call(undefined, {}, user), + ).rejects.toThrow(ForbiddenException); + }); +}); + +// 32 random bytes → 64 lowercase-hex chars (ADR-006 §6). Module scope so it is +// compiled once, not recompiled on every assertion. +const PLACEHOLDER_HEX_64 = /^[0-9a-f]{64}$/v; + +describe('AuthnService.validateOrCreateUser — external-auth placeholder (ADR-006 §6)', () => { + // Real-DB harness (127.0.0.1:5433). Every external-auth provider (github, + // gitlab, google, ldap, oidc, okta) provisions new users through this single + // method, which generates ONE placeholder password (authn.service.ts) fed to + // usersService.create(). validateApiKey + login are unused here, so the + // ApiKeyService and JwtService collaborators are inert stubs. + let authnService: AuthnService; + let usersService: UsersService; + let databaseService: DatabaseService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + AuthzModule, + CryptoModule, + ], + providers: [ + AuthzService, + ConfigService, + DatabaseService, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], + }).compile(); + + usersService = module.get(UsersService); + databaseService = module.get(DatabaseService); + + // AuthnService ⇄ UsersService is a circular import (users.service.ts calls + // AuthnService.prototype.testPassword unbound), so Nest cannot DI-resolve + // AuthnService here. validateOrCreateUser only uses this.usersService, so + // build it directly with the real UsersService and inert collaborators — + // apiKeyService/configService/jwtService are never touched on this path. + authnService = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + {} as PasswordService, + ); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + }); + + it('provisions an external-auth user with a 64-char (256-bit) placeholder and persists the record', async () => { + const email = 'ext-oauth-user@example.com'; + // Spy CALLS THROUGH — the real create() hashes + writes to the DB, so this + // is a genuine end-to-end provisioning. The plaintext placeholder is + // unrecoverable from the stored hash by design, so its length is asserted + // at the generation boundary: the DTO create() actually received. + const createSpy = vi.spyOn(usersService, 'create'); + + const user = await authnService.validateOrCreateUser( + email, + 'Ext', + 'User', + 'github', + ); + + expect(createSpy).toHaveBeenCalledTimes(1); + const dto = createSpy.mock.calls[0][0]; + // Exact 64 — NOT `< 128`, which would silently pass a future weakening. + expect(dto.password).toHaveLength(64); + expect(dto.passwordConfirmation).toHaveLength(64); + expect(dto.password).toBe(dto.passwordConfirmation); + expect(dto.password).toMatch(PLACEHOLDER_HEX_64); + + // Persisted in the real DB via the external-auth provisioning path. + expect(user.email).toBe(email); + const stored = await usersService.findByEmail(email); + expect(stored.creationMethod).toBe('github'); + }); +}); + +describe('AuthnService.validateUser — verify + CAS lazy rehash (ADR-006 §4 site 4, §7)', () => { + // Real-DB harness. validateUser is the primary migration path: a local login + // verifies through PasswordService and, on a still-bcrypt credential (FIPS + // off), lazily rehashes to PBKDF2 through the §7 compare-and-swap writer — + // never mutating the instance, never failing the login on a rehash error. + const { email, password } = CREATE_USER_DTO_TEST_OBJ; + let authnService: AuthnService; + let usersService: UsersService; + let databaseService: DatabaseService; + let passwordService: PasswordService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + AuthzModule, + CryptoModule, + ], + providers: [ + AuthzService, + ConfigService, + DatabaseService, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], + }).compile(); + + usersService = module.get(UsersService); + databaseService = module.get(DatabaseService); + passwordService = module.get(PasswordService); + + // Same circular-import reason as the validateOrCreateUser block: construct + // AuthnService directly. Only usersService, passwordService and the logger + // are exercised on this path. + authnService = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + passwordService, + ); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + vi.restoreAllMocks(); + }); + + // Seed a user, then overwrite the stored hash with a controlled value. create() + // still bcrypts (site 1 is a different card), so we set the exact hash we want + // to test the dispatch against. + async function seedUserWithStoredHash(storedHash: string): Promise { + const dto = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const created = await User.findByPk(dto.id); + if (created === null) { + throw new TypeError('seed failed: user not found after create'); + } + await created.update({ encryptedPassword: storedHash }, { silent: true }); + return created; + } + + it('rehashes a valid bcrypt login (FIPS off) to $pbkdf2- via the CAS writer', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(seeded.id); + const reloaded = await User.findByPk(seeded.id); + expect(reloaded?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); + }); + + it('a successful login rehash emits one info log with userId, from bcrypt, to pbkdf2-sha512, and iterations (§17)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain(`User`); + expect(logged.message).toContain('bcrypt'); + expect(logged.message).toContain('pbkdf2-sha512'); + // §17's field list ends at the iteration COUNT — never the password, + // hash, or salt. 600000 is the suite's configured iteration default. + expect(logged.message).toContain('600000'); + }); + + it('the rehash log NEVER carries the password or any hash/salt material (§17 anti-pattern)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + await authnService.validateUser(email, password); + + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).not.toContain(password); + const reloaded = await User.findByPk(seeded.id); + // The stored PHC string (salt + key material) must be absent; its salt + // segment alone is enough to prove leakage, so check the whole string. + expect(logged.message).not.toContain(reloaded?.encryptedPassword ?? ''); + }); + + it('skips the §7 rehash while the §12 write gate is off — login succeeds, the stored bcrypt hash is untouched, and the skip is logged', async () => { + const bcryptHash = await hash(password, 4); + const seeded = await seedUserWithStoredHash(bcryptHash); + const priorGateEnvironment = process.env.PASSWORD_HASH_WRITE_ENABLED; + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + try { + // Fresh gate + service chain: the §12 derivation is boot-scoped and the + // suite's DI singletons already cached writes-enabled. With an explicit + // env value the gate never queries its models, so the classes are + // passed unregistered (password.service.spec's pattern). + const gateConfig = new ConfigService(); + const gateOff = new HashWriteGateService( + HashMigrationMarker, + User, + gateConfig, + ); + const authnOff = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + new PasswordService(gateConfig, gateOff), + ); + const logSpy = vi + .spyOn(authnOff.logger, 'info') + .mockReturnValue(authnOff.logger); + + const result = await authnOff.validateUser(email, password); + + expect(result?.id).toBe(seeded.id); + const reloaded = await User.findByPk(seeded.id); + // needsRehash was true, but persistence must wait for the gate: the + // stored credential stays byte-identical bcrypt, readable by a pre-N + // pod during the §12 rolling window. + expect(reloaded?.encryptedPassword).toBe(bcryptHash); + expect(reloaded?.encryptedPassword.startsWith('$2b$')).toBe(true); + // §17: the gate-skipped rehash is still an event — the operator's only + // sign that migration debt is accruing behind a closed gate. + const gateSkipMessage = expect.stringContaining('writes are disabled'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: gateSkipMessage }), + ); + } finally { + if (priorGateEnvironment === undefined) { + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + } else { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorGateEnvironment; + } + } + }); + + it('does not revert a concurrent password change — the in-flight rehash CAS loses (0 affected)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + // The concurrent password change (H2) lands in the DB after this login's + // findByEmail read but before its CAS write. Move the DB to a real PBKDF2 + // H2 on a separate instance; hand validateUser the stale (bcrypt) view. + const concurrentHash = await hashPassword('Rotated#Pass88x'); + const databaseRow = await User.findByPk(seeded.id); + await databaseRow?.update( + { encryptedPassword: concurrentHash }, + { silent: true }, + ); + vi.spyOn(usersService, 'findByEmail').mockResolvedValueOnce(seeded); + + const result = await authnService.validateUser(email, password); + + expect(result?.id).toBe(seeded.id); // stale-but-valid login still succeeds + const reloaded = await User.findByPk(seeded.id); + expect(reloaded?.encryptedPassword).toBe(concurrentHash); // H2 survived + }); + + it('logs and still succeeds the login when the rehash write fails (§7)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(usersService, 'updateEncryptedPassword').mockRejectedValueOnce( + new Error('database unavailable'), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(seeded.id); + expect(logSpy).toHaveBeenCalledTimes(1); + const failureMessage = expect.stringContaining('rehash failed'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: failureMessage }), + ); + }); + + it('never mutates the Sequelize instance with the new hash (the racing login save cannot carry it)', async () => { + const bcryptHash = await hash(password, 4); + const seeded = await seedUserWithStoredHash(bcryptHash); + + const result = await authnService.validateUser(email, password); + + // Returned instance still holds the ORIGINAL hash — we never assigned the + // new one, so updateLoginMetadata's un-awaited save cannot persist it. + expect(result?.encryptedPassword).toBe(bcryptHash); + const reloaded = await User.findByPk(seeded.id); + expect(reloaded?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); + }); + + it('returns null (the generic 401) and WARNS with the user id when the result is requiresReset (FIPS-refused bcrypt, §17 operator-actionable)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(passwordService, 'verify').mockResolvedValueOnce({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + const warnSpy = vi + .spyOn(authnService.logger, 'warn') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const idInMessage = expect.stringContaining(seeded.id); + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: idInMessage }), + ); + }); + + it('logs the CAS-lost rehash at info as a skip — §7 benign case is still an event', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(usersService, 'updateEncryptedPassword').mockResolvedValueOnce(0); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, password); + + expect(result).not.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain(`User`); + expect(logged.message).toContain('compare-and-swap lost'); + }); + + it('WARNS and leaves the login unaffected when an oversized password reaches the rehash path (§9 skip)', async () => { + // Legacy bcrypt predates the §6 cap, so a stored credential for a + // 150-char password is realistic; bcrypt verifies it (truncating at + // byte 72) but the PBKDF2 hash path rejects it — §9: skip and log. + const oversized = 'Ov3r!'.repeat(30); + await seedUserWithStoredHash(await hash(oversized, 4)); + const warnSpy = vi + .spyOn(authnService.logger, 'warn') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateUser(email, oversized); + + expect(result).not.toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const [logged] = warnSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain('cannot be hashed under current policy'); + expect(logged.message).not.toContain(oversized); + }); + + it('runs the constant-work dummy and returns null for an absent user (timing mitigation)', async () => { + const verifySpy = vi.spyOn(passwordService, 'verify'); + + const result = await authnService.validateUser('ghost@nowhere.test', password); + + expect(result).toBeNull(); + // Same KDF cost a present user pays — an empty hash routes verifyPassword to + // its reject-with-constant-work path — so user-absent is timing-invisible. + expect(verifySpy).toHaveBeenCalledWith({ hash: '', password }); + }); + + // §11: the bounded KDF queue rejects with KdfOverloadedError when saturated. + // The ADR assigns the mapping to "the auth layer" — validateUser IS that layer + // for site 4. Unmapped, the error escapes as a 500 next to everyone else's 401, + // which is itself an enumeration oracle: under saturation the absent-user dummy + // consumes a KDF slot while a legacy bcrypt compare consumes none, separating + // "no such account" from "account still on bcrypt". + it('maps a saturated KDF queue to the generic failure, not a 500 (§11)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( + new KdfOverloadedError('KDF queue is full'), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + // Resolves null (-> LocalStrategy's generic 401). Must NOT reject. + await expect(authnService.validateUser(email, password)).resolves.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const idInMessage = expect.stringContaining(seeded.id); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: idInMessage }), + ); + }); + + it('maps a saturated KDF queue on the ABSENT-user path to the generic failure too (§11)', async () => { + vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( + new KdfOverloadedError('KDF queue is full'), + ); + + await expect( + authnService.validateUser('ghost@nowhere.test', password), + ).resolves.toBeNull(); + }); + + it('rethrows a non-overload error — a real bug must not be silently swallowed', async () => { + await seedUserWithStoredHash(await hash(password, 4)); + vi.spyOn(passwordService, 'verify').mockRejectedValueOnce( + new Error('unexpected failure'), + ); + + await expect(authnService.validateUser(email, password)).rejects.toThrow( + 'unexpected failure', + ); + }); + + it('leaves passwordChangedAt and forcePasswordChange unchanged after a rehash (§7 lifecycle)', async () => { + const seeded = await seedUserWithStoredHash(await hash(password, 4)); + const before = await User.findByPk(seeded.id); + const beforePwChanged = String(before?.passwordChangedAt); + const beforeForce = before?.forcePasswordChange; + + await authnService.validateUser(email, password); + + const after = await User.findByPk(seeded.id); + expect(after?.encryptedPassword.startsWith('$pbkdf2-')).toBe(true); + expect(String(after?.passwordChangedAt)).toBe(beforePwChanged); + expect(after?.forcePasswordChange).toBe(beforeForce); + }); +}); + +const API_KEY_SECRET = 'e2515-test-api-key-secret'; + +// Mirrors apikey.service.create(): sign {keyId, createdAt} with the secret, +// then store a hash of the JWT's signature segment (bcrypt has a 72-byte +// limit, which is why only the signature is hashed). +async function seedApiKey( + storedHashOfSignature: (signature: string) => Promise, +): Promise<{ apiKeyRow: ApiKey; ownerId: string; token: string }> { + const owner = await User.create({ + creationMethod: 'local', + email: `apikey-owner-${String(Date.now())}@example.com`, + encryptedPassword: await hash('irrelevant-for-this-path', 4), + role: 'user', + }); + const apiKeyRow = await ApiKey.create({ + name: 'e2515-test-key', + type: 'user', + userId: owner.id, + }); + const token = sign( + { createdAt: new Date(), keyId: apiKeyRow.id }, + API_KEY_SECRET, + ); + const signature = token.split('.', 3)[2]; + await apiKeyRow.update( + { apiKey: await storedHashOfSignature(signature) }, + { silent: true }, + ); + return { apiKeyRow, ownerId: owner.id, token }; +} + +describe('AuthnService.validateApiKey — verify + CAS rehash (ADR-006 §4 site 5, §7)', () => { + // Real-DB harness. API keys store a bcrypt hash of the JWT's SIGNATURE + // segment (apikey.service.create), so this path migrates exactly like + // validateUser but against ApiKeys.apiKey. §12: this path serves CI and the + // saf CLI — no human retries a 401 and a key cannot be recovered, only + // regenerated — so a failed rehash must never fail a valid key. + let authnService: AuthnService; + let apiKeyService: ApiKeyService; + let databaseService: DatabaseService; + let passwordService: PasswordService; + + beforeAll(async () => { + vi.stubEnv('API_KEY_SECRET', API_KEY_SECRET); + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ApiKeyService, ConfigService, DatabaseService], + }).compile(); + + apiKeyService = module.get(ApiKeyService); + databaseService = module.get(DatabaseService); + const configService = module.get(ConfigService); + passwordService = module.get(PasswordService); + + // validateApiKey uses apiKeyService, configService and passwordService + // only — usersService and jwtService are never touched on this path. + authnService = new AuthnService( + apiKeyService, + configService, + {} as UsersService, + {} as JwtService, + passwordService, + ); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + vi.unstubAllEnvs(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + vi.restoreAllMocks(); + }); + + it('rehashes a valid bcrypt-stored key to $pbkdf2- via the CAS writer, using the same default parameters as passwords', async () => { + const { apiKeyRow, ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + // Same defaults as the password path (§11 records the iteration + // inefficiency for API keys but this card must NOT diverge from it). + expect(reloaded?.apiKey.startsWith('$pbkdf2-sha512$i=600000$')).toBe(true); + expect(reloaded?.apiKey).not.toBe(apiKeyRow.apiKey); + }); + + it('skips the §7 rehash while the §12 write gate is off — the key validates, the stored bcrypt hash is untouched, and the skip is logged', async () => { + const { apiKeyRow, ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + const storedRow = await ApiKey.findByPk(apiKeyRow.id); + const storedBefore = storedRow?.apiKey; + const priorGateEnvironment = process.env.PASSWORD_HASH_WRITE_ENABLED; + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + try { + // Same fresh-chain reasoning as the validateUser suite's gate-off test. + // The real ConfigService reads the suite's stubbed API_KEY_SECRET. + const gateConfig = new ConfigService(); + const gateOff = new HashWriteGateService( + HashMigrationMarker, + User, + gateConfig, + ); + const authnOff = new AuthnService( + apiKeyService, + gateConfig, + {} as UsersService, + {} as JwtService, + new PasswordService(gateConfig, gateOff), + ); + const logSpy = vi + .spyOn(authnOff.logger, 'info') + .mockReturnValue(authnOff.logger); + + const result = await authnOff.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + // §12: this path serves CI and the saf CLI on pre-N pods too — the + // stored hash must stay byte-identical bcrypt while the gate is off. + expect(reloaded?.apiKey).toBe(storedBefore); + expect(reloaded?.apiKey.startsWith('$2b$')).toBe(true); + // §17: gate-skipped rehashes log too (same event as the login path). + const gateSkipMessage = expect.stringContaining('writes are disabled'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: gateSkipMessage }), + ); + } finally { + if (priorGateEnvironment === undefined) { + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + } else { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorGateEnvironment; + } + } + }); + + it('gates on jwt.verify BEFORE any KDF work — a forged token never reaches the KDF (§11 unauthenticated-reachability guard)', async () => { + await seedApiKey(signature => hash(signature, 4)); + const forged = sign( + { createdAt: new Date(), keyId: '1' }, + 'not-the-real-secret', + ); + const verifySpy = vi.spyOn(passwordService, 'verify'); + + const result = await authnService.validateApiKey(forged); + + expect(result).toBeNull(); + // The expensive path must be unreachable without a valid signature. + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it('still validates the key when the rehash write fails (§12 — no human retries a CI 401)', async () => { + const { ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + vi.spyOn(apiKeyService, 'updateApiKeyHash').mockRejectedValueOnce( + new Error('database unavailable'), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + expect(logSpy).toHaveBeenCalledTimes(1); + const failureMessage = expect.stringContaining('rehash failed'); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: failureMessage }), + ); + }); + + it('never mutates the ApiKey instance with the new hash (apikey.service.ts:44 has the same un-awaited save trap)', async () => { + const { apiKeyRow, token } = await seedApiKey(signature => + hash(signature, 4), + ); + const storedBefore = apiKeyRow.apiKey; + // Assert on the instance validateApiKey ACTUALLY works with — it fetches + // its own via findById, so asserting on the test's copy would prove + // nothing (it cannot change no matter what the service does). The spy + // calls through; mock.results holds the served instance. + const findByIdSpy = vi.spyOn(apiKeyService, 'findById'); + + await authnService.validateApiKey(token); + + const served = (await findByIdSpy.mock.results[0].value) as ApiKey; + // If the service assigned the new hash to this instance, the racing + // un-awaited save at apikey.service.ts:44 could persist it OUTSIDE the CAS + // predicate — the §7 revert. Both assertions fail if that ever happens. + expect(served.apiKey).toBe(storedBefore); + expect(served.changed()).toBe(false); + // ...while the DB itself did move to PBKDF2 through the CAS writer. + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + expect(reloaded?.apiKey.startsWith('$pbkdf2-')).toBe(true); + expect(storedBefore.startsWith('$2')).toBe(true); + }); + + it('refuses a FIPS-refused (requiresReset) key with the path generic failure and WARNS with the key id (§17 operator-actionable)', async () => { + const { apiKeyRow, token } = await seedApiKey(signature => + hash(signature, 4), + ); + vi.spyOn(passwordService, 'verify').mockResolvedValueOnce({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + const warnSpy = vi + .spyOn(authnService.logger, 'warn') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const idInMessage = expect.stringContaining(apiKeyRow.id); + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: idInMessage }), + ); + }); + + it('a successful key rehash emits one info log with apiKeyId, from bcrypt, to pbkdf2-sha512, and iterations (§17)', async () => { + const { apiKeyRow, token } = await seedApiKey(signature => + hash(signature, 4), + ); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect(result).not.toBeNull(); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain(`ApiKey`); + expect(logged.message).toContain('bcrypt'); + expect(logged.message).toContain('pbkdf2-sha512'); + expect(logged.message).toContain('600000'); + }); + + it('logs the CAS-lost key rehash at info as a skip (§7 benign case is still an event)', async () => { + const { ownerId, token } = await seedApiKey(signature => + hash(signature, 4), + ); + vi.spyOn(apiKeyService, 'updateApiKeyHash').mockResolvedValueOnce(0); + const logSpy = vi + .spyOn(authnService.logger, 'info') + .mockReturnValue(authnService.logger); + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + expect(logSpy).toHaveBeenCalledTimes(1); + const [logged] = logSpy.mock.calls[0] as [{ message: string }]; + expect(logged.message).toContain('compare-and-swap lost'); + }); + + it('returns null for a valid token whose stored hash does not match the signature', async () => { + const { apiKeyRow, token } = await seedApiKey(() => + hash('a-different-signature-entirely', 4), + ); + + const result = await authnService.validateApiKey(token); + + expect(result).toBeNull(); + // A failed verification must NOT rehash anything. + const reloaded = await ApiKey.findByPk(apiKeyRow.id); + expect(reloaded?.apiKey.startsWith('$2')).toBe(true); + }); + + it('does not rehash a key already stored as PBKDF2 (no churn)', async () => { + const { apiKeyRow, ownerId, token } = await seedApiKey(signature => + hashPassword(signature), + ); + const rowBefore = await ApiKey.findByPk(apiKeyRow.id); + const storedBefore = rowBefore?.apiKey; + + const result = await authnService.validateApiKey(token); + + expect((result as null | User)?.id).toBe(ownerId); + const rowAfter = await ApiKey.findByPk(apiKeyRow.id); + expect(rowAfter?.apiKey).toBe(storedBefore); + }); +}); diff --git a/apps/backend/src/authn/authn.service.ts b/apps/backend/src/authn/authn.service.ts index db2e9f3117..0529fe5dbc 100644 --- a/apps/backend/src/authn/authn.service.ts +++ b/apps/backend/src/authn/authn.service.ts @@ -1,124 +1,292 @@ +import * as crypto from 'crypto'; import { ForbiddenException, Injectable, - UnauthorizedException } from '@nestjs/common'; -import {JwtService} from '@nestjs/jwt'; -import {compare} from 'bcryptjs'; -import * as crypto from 'crypto'; -import jwt from 'jsonwebtoken'; +import { JwtService } from '@nestjs/jwt'; +import { verify } from 'jsonwebtoken'; import _ from 'lodash'; import moment from 'moment'; import ms from 'ms'; -import winston from 'winston'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {ConfigService} from '../config/config.service'; -import {Group} from '../groups/group.model'; -import {limitJWTTime} from '../token/token.providers'; -import {CreateUserDto} from '../users/dto/create-user.dto'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; +import { createLogger, format, transports } from 'winston'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { ConfigService } from '../config/config.service'; +import { + KdfOverloadedError, + PasswordHashError, + PasswordVerifyResult, + verifyPassword, +} from '../crypto/password'; +import { PasswordService } from '../crypto/password.service'; +import { Group } from '../groups/group.model'; +import { limitJWTTime } from '../token/token.providers'; +import { CreateUserDto } from '../users/dto/create-user.dto'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; @Injectable() export class AuthnService { private readonly line = '_______________________________________________\n'; + // unicorn/consistent-class-member-order (privates-first) and + // perfectionist/sort-classes (publics-first) are mutually exclusive on any + // mixed class — documented floor class (password.service.ts carries the + // same finding); perfectionist's order is kept. public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: this.loggingTimeFormat }), + format.printf( + info => + `${this.line}[${String(info.timestamp)}] (Authn Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); constructor( private readonly apiKeyService: ApiKeyService, private readonly configService: ConfigService, private readonly usersService: UsersService, - private readonly jwtService: JwtService + private readonly jwtService: JwtService, + private readonly passwordService: PasswordService, ) {} - async validateUser(email: string, password: string): Promise { - let user: User; + /** + * §11: the bounded KDF queue rejects with `KdfOverloadedError` when it is + * saturated, and the ADR assigns the mapping to "the auth layer" — this is + * that layer for site 4. Left unmapped the error escapes as a 500 alongside + * everyone else's 401, which is itself an enumeration oracle: under + * saturation the absent-user dummy consumes a KDF slot while a legacy + * `bcryptjs.compare` consumes none, separating "no such account" from + * "account still on bcrypt". Returns null on overload (caller fails + * generically); anything else is a real bug and propagates. + * + * `subject` is a pre-formatted label for the server-side log only — e.g. + * `User` or `ApiKey` — so both credential paths share this + * mapping without the helper knowing which one called it. + */ + private async verifyOrGenericFailure( + arguments_: { hash: string; password: string }, + subject?: string, + ): Promise { try { - user = await this.usersService.findByEmail(email); - } catch { - throw new UnauthorizedException('Incorrect Username or Password'); + return await this.passwordService.verify(arguments_); + } catch (error) { + if (error instanceof KdfOverloadedError) { + this.logger.info({ + message: `Credential verification rejected — KDF queue saturated${ + subject === undefined ? '' : ` for ${subject}` + }; returning the generic authentication failure.`, + }); + return null; + } + throw error; } - if (user && (await compare(password, user.encryptedPassword))) { - this.usersService.updateLoginMetadata(user); - return user; - } else { - return null; + } + + async login(user: { + email: string; + forcePasswordChange: boolean | undefined; + id: string; + role: string; + }): Promise<{ accessToken: string; userID: string }> { + const payload = { + email: user.email, + forcePasswordChange: user.forcePasswordChange, + role: user.role, + sub: user.id, + }; + // Users have their own JWT Secret to allow for session invalidation on sign out + const loginUser = await this.usersService.findById(user.id); + if ( + !loginUser.jwtSecret + || this.configService.get('ONE_SESSION_PER_USER')?.toLowerCase() === 'true' + ) { + // The new jwtSecret is assigned synchronously; only its persistence + // floats (legacy pattern — the token below signs with the new value). + void this.usersService.updateUserSecret(loginUser); } + if (payload.forcePasswordChange || user.role === 'admin') { + // Admin sessions are only valid for 10 minutes, for regular users give them 10 minutes to (hopefully) change their password. + const expireTime = moment(new Date(Date.now() + ms('600s'))).format( + this.loggingTimeFormat, + ); + this.logger.info({ message: `New session for User expires at ${expireTime}` }); + return { + accessToken: this.jwtService.sign(payload, { + expiresIn: '600s', + secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret, + }), + userID: user.id, + }; + } + const expiresIn = limitJWTTime( + this.configService.get('JWT_EXPIRE_TIME') || '60s', + false, + ); + const expireTime = moment(new Date(Date.now() + expiresIn)).format( + this.loggingTimeFormat, + ); + this.logger.info({ message: `New session for User expires at ${expireTime}` }); + return { + accessToken: this.jwtService.sign(payload, { secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret }), + userID: user.id, + }; + } + + splitName(fullName: string): { firstName: string; lastName: string } { + const nameArray = fullName.split(' '); + return { + firstName: nameArray[0], + lastName: nameArray.slice(1).join(' '), + }; } - async validateApiKey(apikey: string): Promise { + async testPassword( + this: void, + updateUserDto: { currentPassword?: string }, + user: User, + ): Promise { + // Site 6 (ADR-006 §4): MUST stay `this`-free — users.service.ts calls + // this method UNBOUND via AuthnService.prototype.testPassword(...), and + // UsersService cannot inject AuthnService (circular). `this: void` makes + // that constraint COMPILER-enforced: any future `this.` access in this + // body is a type error. The pure verifyPassword handles PBKDF2 + legacy + // bcrypt and never throws on malformed input, so no try/catch is needed. + const { valid } = await verifyPassword({ + hash: user.encryptedPassword, + password: updateUserDto.currentPassword || '', + }); + if (!valid) { + throw new ForbiddenException('Current password is incorrect'); + } + } + + async validateApiKey(apikey: string): Promise { const APIKeySecret = this.configService.get('API_KEY_SECRET'); if (APIKeySecret) { try { - const jwtPayload = jwt.verify(apikey, APIKeySecret) as { - token: string; - keyId: string; + const jwtPayload = verify(apikey, APIKeySecret) as { createdAt: Date; + keyId: string; + token: string; }; - const JWTSignature = apikey.split('.')[2]; + const JWTSignature = apikey.split('.', 3)[2]; if (_.has(jwtPayload, 'keyId')) { const matchingKey = await this.apiKeyService.findById( - jwtPayload.keyId + jwtPayload.keyId, ); - if (await compare(JWTSignature, matchingKey.apiKey)) { - if (matchingKey.type === 'user') { - return matchingKey.user; - } else if (matchingKey.type === 'group') { - return matchingKey.group; - } else { - return null; - } - } else { + // Site 5 (§4). jwt.verify above has already gated this path, so the + // KDF is unreachable without a valid signature (§11) — that ordering + // is load-bearing and must not be relaxed. + const result = await this.verifyOrGenericFailure( + { hash: matchingKey.apiKey, password: JWTSignature }, + `ApiKey`, + ); + if (result === null) { return null; } - } else { - return null; + const { needsRehash, requiresReset, valid } = result; + + if (requiresReset === true) { + // §3 refuse path under FIPS. Same generic failure this path + // already returns for every other error (Risks: no distinct + // response), recorded server-side so an operator can find the + // keys that must be regenerated — a key cannot be recovered. + // Warn, not info: §17 treats operator-actionable states as + // warnings (a key can only be regenerated, never recovered). + const message = `ApiKey is stored as a non-FIPS (bcrypt) hash; validation refused under FIPS mode. The key must be regenerated.`; + this.logger.warn({ message }); + return null; + } + + if (!valid) { + return null; + } + + if (needsRehash && !(await this.passwordService.writesEnabled())) { + // §17: gate-skipped rehashes log too (same event as the login + // path — migration debt accruing behind the closed §12 gate). + this.logger.info({ message: `Lazy API-key rehash for ApiKey skipped: PBKDF2 writes are disabled (§12 gate); the debt is re-reported on the next validation after the gate opens` }); + } else if (needsRehash) { + // §7 CAS rehash, same shape as validateUser but against the + // ApiKeys.apiKey column, behind the same §12 write gate (skip + // while PBKDF2 writes are disabled — pre-N pods must keep + // reading this row). Never mutates the instance — the formerly + // un-awaited save at apikey.service.ts create() was the same + // trap as the login path's. §12: this path serves CI and the + // saf CLI, where no human retries a 401, so a failed rehash + // must never fail an otherwise valid key. + const originalHash = matchingKey.apiKey; + try { + const newHash = await this.passwordService.hash(JWTSignature); + const affected = await this.apiKeyService.updateApiKeyHash( + matchingKey.id, + originalHash, + newHash, + ); + // §17: the ONLY record that this key's credential converted — + // field list ends at the iteration count, never the key/hash/ + // salt. Read from the PHC string actually written. + const phcParts = newHash.split('$'); + const toFormat = phcParts[1] ?? 'unknown'; + const iterationCount = (phcParts[2] ?? '').replace('i=', ''); + if (affected === 0) { + this.logger.info({ message: `Lazy API-key rehash for ApiKey skipped: another writer updated the credential first (compare-and-swap lost — benign, §7)` }); + } else { + this.logger.info({ message: `ApiKey credential converted: from bcrypt to ${toFormat} at ${iterationCount} iterations` }); + } + } catch (error) { + const reason + = error instanceof Error ? error.message : String(error); + const message = `Lazy API-key rehash failed for ApiKey; validation still succeeded: ${reason}`; + this.logger.info({ message }); + } + } + + if (matchingKey.type === 'user') { + return matchingKey.user; + } + return matchingKey.type === 'group' ? matchingKey.group : null; } + return null; } catch { return null; } - } else { - throw new ForbiddenException( - 'API Keys have been disabled as the API-Key secret is not set' - ); } + throw new ForbiddenException( + 'API Keys have been disabled as the API-Key secret is not set', + ); } async validateOrCreateUser( email: string, firstName: string, lastName: string, - creationMethod: string + creationMethod: string, ): Promise { let user: User; try { user = await this.usersService.findByEmail(email); } catch { - const randomPass = crypto.randomBytes(128).toString('hex'); - const createUser: CreateUserDto = { + // ADR-006 §6: 32 bytes → 64 hex chars = 256 bits of entropy for a + // credential that is never used to log in. Kept well under the 128-char + // PASSWORD_MAX_LENGTH so external-auth provisioning obeys the SAME hash- + // path length cap as every other create() — an exemption for these users + // would be a bypass waiting to be misused. + const randomPass = crypto.randomBytes(32).toString('hex'); + const newUserDto: CreateUserDto = { + creationMethod: creationMethod, email: email, - password: randomPass, - passwordConfirmation: randomPass, firstName: firstName, lastName: lastName, organization: '', - title: '', + password: randomPass, + passwordConfirmation: randomPass, role: 'user', - creationMethod: creationMethod + title: '', }; - await this.usersService.create(createUser); + await this.usersService.create(newUserDto); user = await this.usersService.findByEmail(email); } @@ -128,92 +296,111 @@ export class AuthnService { if (user.firstName !== firstName || user.lastName !== lastName) { user.firstName = firstName; user.lastName = lastName; - user.save(); + void user.save(); } - this.usersService.updateLoginMetadata(user); + // §7-documented deliberate float: awaiting would serialize every + // login behind this write. + void this.usersService.updateLoginMetadata(user); } return user; } - async login(user: { - id: string; - email: string; - role: string; - forcePasswordChange: boolean | undefined; - }): Promise<{userID: string; accessToken: string}> { - const payload = { - email: user.email, - sub: user.id, - role: user.role, - forcePasswordChange: user.forcePasswordChange - }; - // Users have their own JWT Secret to allow for session invalidation on sign out - const loginUser = await this.usersService.findById(user.id); - if ( - !loginUser.jwtSecret || - this.configService.get('ONE_SESSION_PER_USER')?.toLowerCase() === 'true' - ) { - this.usersService.updateUserSecret(loginUser); + async validateUser(email: string, password: string): Promise { + let user: User; + try { + user = await this.usersService.findByEmail(email); + } catch { + // Absent-user timing mitigation (ADR-006 Risks). Pay the same constant- + // work KDF cost a present user's verify would — an empty hash routes + // verifyPassword to its reject-with-constant-work path — so user-exists + // and user-absent are indistinguishable by timing. Then fail generically + // (LocalStrategy maps a null return to the same 401 as any failure). + await this.verifyOrGenericFailure({ hash: '', password }); + return null; } - if (payload.forcePasswordChange || user.role === 'admin') { - // Admin sessions are only valid for 10 minutes, for regular users give them 10 minutes to (hopefully) change their password. - const expireTime = moment(new Date(Date.now() + ms('600s'))).format( - this.loggingTimeFormat - ); - this.logger.info({ - message: `New session for User expires at ${expireTime}` - }); - return { - userID: user.id, - accessToken: this.jwtService.sign(payload, { - expiresIn: '600s', - secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret - }) - }; - } else { - const expiresIn = limitJWTTime( - this.configService.get('JWT_EXPIRE_TIME') || '60s', - false - ); - const expireTime = moment(new Date(Date.now() + expiresIn)).format( - this.loggingTimeFormat - ); - this.logger.info({ - message: `New session for User expires at ${expireTime}` - }); - return { - userID: user.id, - accessToken: this.jwtService.sign(payload, { - secret: this.configService.get('JWT_SECRET') + loginUser.jwtSecret - }) - }; + + const result = await this.verifyOrGenericFailure( + { hash: user.encryptedPassword, password }, + `User`, + ); + if (result === null) { + // KDF queue saturated — already logged; fail generically (§11). + return null; } - } + const { needsRehash, requiresReset, valid } = result; - splitName(fullName: string): {firstName: string; lastName: string} { - const nameArray = fullName.split(' '); - return { - firstName: nameArray[0], - lastName: nameArray.slice(1).join(' ') - }; - } + if (requiresReset === true) { + // §3 refuse path: a bcrypt credential encountered under FIPS mode. + // verifyPassword already paid the constant-work cost. Surface NOTHING + // distinct to the caller (Risks: enumeration oracle) — LocalStrategy maps + // this null to the same generic 401 as any other failure — but record it + // server-side so an operator can see who still needs to migrate. Warn, + // not info: §17 treats operator-actionable states as warnings. + this.logger.warn({ message: `User presented a non-FIPS (bcrypt) credential; login refused under FIPS mode. A password reset is required.` }); + return null; + } - async testPassword( - updateUserDto: {currentPassword?: string}, - user: User - ): Promise { - try { - if ( - !(await compare( - updateUserDto.currentPassword || '', - user.encryptedPassword - )) - ) { - throw new ForbiddenException('Current password is incorrect'); + if (!valid) { + return null; + } + + if (needsRehash && !(await this.passwordService.writesEnabled())) { + // §17: the gate-skipped rehash is still an event — the operator's only + // sign that migration debt is accruing behind the closed §12 gate. + this.logger.info({ message: `Lazy password rehash for User skipped: PBKDF2 writes are disabled (§12 gate); the debt is re-reported on the next login after the gate opens` }); + } else if (needsRehash) { + // §7 lazy rehash via compare-and-swap, behind the §12 write gate: while + // PBKDF2 writes are disabled (rolling-deploy window), the rehash is + // SKIPPED — a converted row would be unreadable by a pre-N pod, and the + // debt is re-reported on the next login after the gate opens. The + // narrow writer takes the user id and the ORIGINAL stored hash as the + // CAS predicate and NEVER mutates this instance — so the un-awaited + // updateLoginMetadata save below cannot carry the new hash outside the + // predicate and silently revert a concurrent password change. A failed + // or lost (0-row) rehash must never fail an otherwise successful login. + const originalHash = user.encryptedPassword; + try { + const newHash = await this.passwordService.hash(password); + const affected = await this.usersService.updateEncryptedPassword( + user.id, + originalHash, + newHash, + ); + // §17: since §7 forbids touching passwordChangedAt, these lines are + // the ONLY record that a credential converted. The field list ends at + // the iteration COUNT — never the password, hash, or salt. Format and + // iterations are read from the PHC string actually written, not from + // config, so the log states what is stored. + const phcParts = newHash.split('$'); + const toFormat = phcParts[1] ?? 'unknown'; + const iterationCount = (phcParts[2] ?? '').replace('i=', ''); + if (affected === 0) { + this.logger.info({ message: `Lazy password rehash for User skipped: another writer updated the credential first (compare-and-swap lost — benign, §7)` }); + } else { + this.logger.info({ message: `User credential converted: from bcrypt to ${toFormat} at ${iterationCount} iterations` }); + } + } catch (error) { + if (error instanceof PasswordHashError) { + // §9: an input the hash policy rejects — post-login the only + // reachable case is the length cap — can NEVER convert lazily, so + // warn is the operator-actionable severity (§17); transient + // failures below stay info because the next login retries them. + this.logger.warn({ message: `Lazy password rehash for User skipped (§9): the password cannot be hashed under current policy (${error.message}); login still succeeded but this credential cannot migrate lazily` }); + } else { + this.logger.info({ + message: `Lazy password rehash failed for User; login still succeeded: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } } - } catch { - throw new ForbiddenException('Current password is incorrect'); } + + // §7-documented deliberate float ("authn.service.ts already calls + // updateLoginMetadata without await [V]") — the CAS rehash design above + // exists BECAUSE this save races; void marks the intent. + void this.usersService.updateLoginMetadata(user); + return user; } } diff --git a/apps/backend/src/authn/github.strategy.ts b/apps/backend/src/authn/github.strategy.ts index 3c76d890c3..4b0ac93a20 100644 --- a/apps/backend/src/authn/github.strategy.ts +++ b/apps/backend/src/authn/github.strategy.ts @@ -1,80 +1,68 @@ -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import axios from 'axios'; -import {Strategy} from 'passport-github'; -import {ConfigService} from '../config/config.service'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Strategy } from 'passport-github'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; -interface GithubProfile { - name: string | null; - login: string; -} - -interface GithubEmail { +type GithubEmail = { email: string; verified: boolean; -} +}; + +type GithubProfile = { + login: string; + name: null | string; +}; @Injectable() export class GithubStrategy extends PassportStrategy(Strategy, 'github') { constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { super({ - clientID: configService.get('GITHUB_CLIENTID') || 'disabled', - clientSecret: configService.get('GITHUB_CLIENTSECRET') || 'disabled', authorizationURL: ` ${ - configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') || - configService.defaultGithubBaseURL + configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') + || configService.defaultGithubBaseURL }login/oauth/authorize`, + clientID: configService.get('GITHUB_CLIENTID') || 'disabled', + clientSecret: configService.get('GITHUB_CLIENTSECRET') || 'disabled', + passReqToCallback: true, + scope: 'user:email', tokenURL: `${ - configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') || - configService.defaultGithubBaseURL + configService.get('GITHUB_ENTERPRISE_INSTANCE_BASE_URL') + || configService.defaultGithubBaseURL }login/oauth/access_token`, userProfileURL: `${ - configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') || - configService.defaultGithubAPIURL + configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') + || configService.defaultGithubAPIURL }user`, - scope: 'user:email', - passReqToCallback: true }); } async validate( - req: Record, - accessToken: string + _request: Record, + accessToken: string, ): Promise { // Get user's linked emails from Github - const githubEmails = await axios - .get( - `${ - this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') || - this.configService.defaultGithubAPIURL - }user/emails`, - { - headers: {Authorization: `token ${accessToken}`} - } - ) - .then(({data}) => { - return data; - }); + const { data: githubEmails } = await axios.get( + `${ + this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') + || this.configService.defaultGithubAPIURL + }user/emails`, + { headers: { Authorization: `token ${accessToken}` } }, + ); // Get user's info - const userInfoResponse = await axios - .get( - `${ - this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') || - this.configService.defaultGithubAPIURL - }user`, - { - headers: {Authorization: `token ${accessToken}`} - } - ) - .then(({data}) => { - return data; - }); + const { data: userInfoResponse } = await axios.get( + `${ + this.configService.get('GITHUB_ENTERPRISE_INSTANCE_API_URL') + || this.configService.defaultGithubAPIURL + }user`, + { headers: { Authorization: `token ${accessToken}` } }, + ); let firstName = userInfoResponse.login; let lastName = ''; if (typeof userInfoResponse.name === 'string') { @@ -91,12 +79,11 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') { primaryEmail.email, firstName, lastName, - 'github' - ); - } else { - throw new UnauthorizedException( - 'Please verify your email with Github before logging into Heimdall.' + 'github', ); } + throw new UnauthorizedException( + 'Please verify your email with Github before logging into Heimdall.', + ); } } diff --git a/apps/backend/src/authn/gitlab.strategy.spec.ts b/apps/backend/src/authn/gitlab.strategy.spec.ts new file mode 100644 index 0000000000..9cfdd87087 --- /dev/null +++ b/apps/backend/src/authn/gitlab.strategy.spec.ts @@ -0,0 +1,72 @@ +import { Test } from '@nestjs/testing'; +import mock, { load, restore } from 'mock-fs'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + GITLAB_CANONICAL_SECRET_ENV, + GITLAB_LEGACY_SECRET_ENV, +} from '../../test/constants/environment-test.constant'; +import { ConfigService } from '../config/config.service'; +import { AuthnService } from './authn.service'; +import { GitlabStrategy } from './gitlab.strategy'; + +// The ConfigService unit tests prove getGitlabClientSecret resolves both +// spellings. These prove the STRATEGY asks for it — without them, reverting +// gitlab.strategy.ts to configService.get('GITLAB_SECRET') would leave every +// resolution test green while GitLab OAuth stayed broken for anyone who +// configured GITLAB_CLIENTSECRET from the documentation. +// passport-oauth2 hands the credential to its node-oauth client, which stores it +// as _clientSecret. Reading it is the only way to prove the resolved value +// reached passport rather than merely being computed. passport-gitlab2 ships no +// type definitions, so this needs no type assertion to reach. +function clientSecretGivenToPassport(strategy: GitlabStrategy): unknown { + return strategy._oauth2?._clientSecret; +} + +async function buildStrategy(environmentFile: string): Promise<{ + resolveSpy: ReturnType; + strategy: GitlabStrategy; +}> { + mock({ '.env': environmentFile, node_modules: load('node_modules') }); + const configService = new ConfigService(); + const resolveSpy = vi.spyOn(configService, 'getGitlabClientSecret'); + const moduleReference = await Test.createTestingModule({ + providers: [ + GitlabStrategy, + { provide: ConfigService, useValue: configService }, + { provide: AuthnService, useValue: {} }, + ], + }).compile(); + return { resolveSpy, strategy: moduleReference.get(GitlabStrategy) }; +} + +describe('GitlabStrategy', () => { + beforeAll(() => { + console.log(); + }); + + afterAll(() => { + restore(); + }); + + it('should pass the canonical secret to passport as clientSecret', async () => { + const { resolveSpy, strategy } = await buildStrategy( + GITLAB_CANONICAL_SECRET_ENV, + ); + expect(strategy).toBeInstanceOf(GitlabStrategy); + expect(resolveSpy).toHaveBeenCalledTimes(1); + expect(resolveSpy).toHaveReturnedWith('canonical-secret'); + // Asserting the RESOLVED VALUE ARRIVED, not merely that the getter ran. + // Without this, a mutation routing the secret to a different option key + // (clientID, say) leaves the spy assertions above green while GitLab OAuth + // is broken. passport-oauth2 keeps the credential on its OAuth2 client. + expect(clientSecretGivenToPassport(strategy)).toEqual('canonical-secret'); + }); + + it('should pass the legacy GITLAB_SECRET to passport as clientSecret', async () => { + const { resolveSpy, strategy } = await buildStrategy( + GITLAB_LEGACY_SECRET_ENV, + ); + expect(resolveSpy).toHaveReturnedWith('legacy-secret'); + expect(clientSecretGivenToPassport(strategy)).toEqual('legacy-secret'); + }); +}); diff --git a/apps/backend/src/authn/gitlab.strategy.ts b/apps/backend/src/authn/gitlab.strategy.ts index 8fa58d74c6..86ec99e3ef 100644 --- a/apps/backend/src/authn/gitlab.strategy.ts +++ b/apps/backend/src/authn/gitlab.strategy.ts @@ -1,49 +1,46 @@ -import {Injectable} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from 'passport-gitlab2'; -import {ConfigService} from '../config/config.service'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-gitlab2'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; -interface UserEmail { - value: string; -} - -interface GitlabProfile { - username: string; - emails: UserEmail[]; +type GitlabProfile = { displayName: string; -} + emails: UserEmail[]; + username: string; +}; + +type UserEmail = { value: string }; @Injectable() export class GitlabStrategy extends PassportStrategy(Strategy, 'gitlab') { constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { super({ - clientID: configService.get('GITLAB_CLIENTID') || 'disabled', - clientSecret: configService.get('GITLAB_SECRET') || 'disabled', baseURL: configService.get('GITLAB_BASEURL'), - callbackURL: - `${configService.getExternalUrl()}/authn/gitlab/callback` || 'disabled' + callbackURL: `${configService.getExternalUrl()}/authn/gitlab/callback`, + clientID: configService.get('GITLAB_CLIENTID') || 'disabled', + clientSecret: configService.getGitlabClientSecret() || 'disabled', }); } async validate( - accessToken: string, - refreshToken: string, - profile: GitlabProfile + _accessToken: string, + _refreshToken: string, + profile: GitlabProfile, ): Promise { const email = profile.emails[0].value; - const {firstName, lastName} = this.authnService.splitName( - profile.displayName + const { firstName, lastName } = this.authnService.splitName( + profile.displayName, ); return this.authnService.validateOrCreateUser( email, firstName, lastName, - 'gitlab' + 'gitlab', ); } } diff --git a/apps/backend/src/authn/google.strategy.spec.ts b/apps/backend/src/authn/google.strategy.spec.ts new file mode 100644 index 0000000000..502360f9b0 --- /dev/null +++ b/apps/backend/src/authn/google.strategy.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { ConfigService } from '../config/config.service'; +import type { AuthnService } from './authn.service'; +import { GoogleStrategy } from './google.strategy'; + +// passport-oauth2 keeps the configured callback on the instance; that value is +// what this test is about. +type ConfiguredStrategy = { _callbackURL: string }; + +function strategyFor(externalUrl: string): ConfiguredStrategy { + const configService = { + getExternalUrl: () => externalUrl, + get: () => 'test-value', + } as unknown as ConfigService; + + return new GoogleStrategy( + {} as AuthnService, + configService, + ) as unknown as ConfiguredStrategy; +} + +describe('GoogleStrategy callback URL', () => { + it('builds the callback from the external URL when one is configured', () => { + expect(strategyFor('https://heimdall.example.org')._callbackURL).toBe( + 'https://heimdall.example.org/authn/google/callback', + ); + }); + + // The callback used to be built inside a template literal followed by + // `|| 'disabled'`. A template literal is always truthy, so that fallback + // could never fire and an unset EXTERNAL_URL produced a bare path — unlike + // clientID and clientSecret, which do fall back to the placeholder. + it('falls back to the disabled placeholder when no external URL is set', () => { + expect(strategyFor('')._callbackURL).toBe('disabled'); + }); +}); diff --git a/apps/backend/src/authn/google.strategy.ts b/apps/backend/src/authn/google.strategy.ts index 5a71de37c1..08ac522315 100644 --- a/apps/backend/src/authn/google.strategy.ts +++ b/apps/backend/src/authn/google.strategy.ts @@ -1,60 +1,63 @@ -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {OAuth2Strategy} from 'passport-google-oauth'; -import {ConfigService} from '../config/config.service'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { OAuth2Strategy } from 'passport-google-oauth'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; -interface UserEmail { - value: string; - verified: boolean; -} - -interface GoogleProfile { +type GoogleProfile = { + emails: UserEmail[]; name: { familyName: string; givenName: string; }; - emails: UserEmail[]; -} +}; + +type UserEmail = { + value: string; + verified: boolean; +}; @Injectable() export class GoogleStrategy extends PassportStrategy(OAuth2Strategy, 'google') { constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { super({ + // The template is always truthy, so the sibling `|| 'disabled'` fallback + // never fired here; without an external URL there is no callback to + // build, which is exactly the disabled case. + callbackURL: configService.getExternalUrl() + ? `${configService.getExternalUrl()}/authn/google/callback` + : 'disabled', clientID: configService.get('GOOGLE_CLIENTID') || 'disabled', clientSecret: configService.get('GOOGLE_CLIENTSECRET') || 'disabled', - callbackURL: - `${configService.getExternalUrl()}/authn/google/callback` || 'disabled', - scope: ['email', 'profile'] + scope: ['email', 'profile'], }); } async validate( - accessToken: string, - refreshToken: string, - profile: GoogleProfile + _accessToken: string, + _refreshToken: string, + profile: GoogleProfile, ): Promise { - const {name, emails} = profile; + const { emails, name } = profile; const user = { email: emails[0], firstName: name.givenName, - lastName: name.familyName + lastName: name.familyName, }; if (user.email.verified) { return this.authnService.validateOrCreateUser( user.email.value, user.firstName, user.lastName, - 'google' - ); - } else { - throw new UnauthorizedException( - 'Please verify your email with Google before logging into Heimdall.' + 'google', ); } + throw new UnauthorizedException( + 'Please verify your email with Google before logging into Heimdall.', + ); } } diff --git a/apps/backend/src/authn/jwt.strategy.ts b/apps/backend/src/authn/jwt.strategy.ts index d59a268beb..cd25ac08d2 100644 --- a/apps/backend/src/authn/jwt.strategy.ts +++ b/apps/backend/src/authn/jwt.strategy.ts @@ -1,46 +1,49 @@ -import {IUser} from '@heimdall/common/interfaces'; -import {HttpException, Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import jwt from 'jsonwebtoken'; -import {ExtractJwt, Strategy} from 'passport-jwt'; -import {ConfigService} from '../config/config.service'; -import {UsersService} from '../users/users.service'; +import { IUser } from '@heimdall/common/interfaces'; +import { HttpException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { decode } from 'jsonwebtoken'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '../config/config.service'; +import { UsersService } from '../users/users.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private readonly configService: ConfigService, - private readonly usersService: UsersService + private readonly usersService: UsersService, ) { super({ + ignoreExpiration: false, jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKeyProvider: async ( + secretOrKeyProvider: ( _request: Express.Request, jwtToken: string, - done: (exception: null | HttpException, secret?: string) => unknown + done: (exception: HttpException | null, secret?: string) => unknown, ) => { - const decodedToken = jwt.decode(jwtToken) as { - sub: string; - }; - try { - const user = await usersService.findById(decodedToken.sub); - done(null, configService.get('JWT_SECRET') + user.jwtSecret); - } catch { - done( - new UnauthorizedException( - 'An exception occurred while validating your session' - ) - ); - } + // Callback-style API: the lookup reports its outcome through done(), + // so the promise is deliberately not handed back to passport, which + // would neither await nor catch it. + void (async () => { + const decodedToken = decode(jwtToken) as { sub: string }; + try { + const user = await usersService.findById(decodedToken.sub); + done(null, configService.get('JWT_SECRET') + user.jwtSecret); + } catch { + done( + new UnauthorizedException( + 'An exception occurred while validating your session', + ), + ); + } + })(); }, - ignoreExpiration: false }); } async validate(payload: { - sub: string; email: string; role: string; + sub: string; }): Promise { return this.usersService.findById(payload.sub); } diff --git a/apps/backend/src/authn/ldap.strategy.spec.ts b/apps/backend/src/authn/ldap.strategy.spec.ts new file mode 100644 index 0000000000..fd31441e8a --- /dev/null +++ b/apps/backend/src/authn/ldap.strategy.spec.ts @@ -0,0 +1,71 @@ +import { Test } from '@nestjs/testing'; +import { describe, expect, it, vi } from 'vitest'; +import { ConfigService } from '../config/config.service'; +import { AuthnService } from './authn.service'; +import { LDAPStrategy } from './ldap.strategy'; + +// The PassportStrategy mixin awaits validate() and hands its resolved value +// to passport's done() itself — that is the contract every sibling strategy +// (github, gitlab, okta, oidc) follows. Before the fix these tests pin, this +// strategy called done() directly with the UNAWAITED validateOrCreateUser +// promise, so req.user for LDAP logins was a pending Promise rather than a +// User, and done() fired a second time when the mixin completed. + +const VALIDATED_USER = Object.freeze({ + email: 'alice@example.com', + id: '42', +}); + +async function buildStrategy(): Promise<{ + strategy: LDAPStrategy; + validateOrCreateUser: ReturnType; +}> { + const validateOrCreateUser = vi.fn().mockResolvedValue(VALIDATED_USER); + const moduleReference = await Test.createTestingModule({ + providers: [ + LDAPStrategy, + { + provide: AuthnService, + useValue: { + splitName: vi + .fn() + .mockReturnValue({ firstName: 'Alice', lastName: 'Doe' }), + validateOrCreateUser, + }, + }, + { provide: ConfigService, useValue: { get: vi.fn() } }, + ], + }).compile(); + return { strategy: moduleReference.get(LDAPStrategy), validateOrCreateUser }; +} + +describe('LDAPStrategy', () => { + it('resolves validate() to the validated user for the mixin to hand to done()', async () => { + const { strategy, validateOrCreateUser } = await buildStrategy(); + await expect( + strategy.validate({ mail: 'alice@example.com', name: 'Alice Doe' }), + ).resolves.toBe(VALIDATED_USER); + expect(validateOrCreateUser).toHaveBeenCalledWith( + 'alice@example.com', + 'Alice', + 'Doe', + 'ldap', + ); + }); + + it('uses the first address when LDAP returns the mail field as an array', async () => { + const { strategy, validateOrCreateUser } = await buildStrategy(); + await expect( + strategy.validate({ + mail: ['first@example.com', 'second@example.com'], + name: 'Alice Doe', + }), + ).resolves.toBe(VALIDATED_USER); + expect(validateOrCreateUser).toHaveBeenCalledWith( + 'first@example.com', + 'Alice', + 'Doe', + 'ldap', + ); + }); +}); diff --git a/apps/backend/src/authn/ldap.strategy.ts b/apps/backend/src/authn/ldap.strategy.ts index 67363cef08..ab9fe1c6ba 100644 --- a/apps/backend/src/authn/ldap.strategy.ts +++ b/apps/backend/src/authn/ldap.strategy.ts @@ -1,87 +1,90 @@ -import {Injectable} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import * as fs from 'fs'; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; import _ from 'lodash'; import Strategy from 'passport-ldapauth'; -import {ConfigService} from '../config/config.service'; -import {AuthnService} from './authn.service'; +import { resolveSslMaterial } from '../../config/app-config'; +import { ConfigService } from '../config/config.service'; +import type { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @Injectable() export class LDAPStrategy extends PassportStrategy(Strategy, 'ldap') { static getSSLConfig(configService: ConfigService) { - const sslEnabled = - (configService.get('LDAP_SSL') ?? '').toLowerCase() === 'true'; - if (!sslEnabled) { + const isSslEnabled + = (configService.get('LDAP_SSL') ?? '').toLowerCase() === 'true'; + if (!isSslEnabled) { return false; } - let sslCA: string | Buffer | undefined = configService.get('LDAP_SSL_CA'); + let sslCA: Buffer | string | undefined = configService.get('LDAP_SSL_CA'); if (!sslCA) { throw new Error('SSL CA file or path to file not provided'); } - if (sslCA.indexOf('-BEGIN') === -1) { - if (fs.statSync(sslCA).isFile()) { - sslCA = fs.readFileSync(sslCA); - if (sslCA.indexOf('-BEGIN') === -1) { - throw new Error('SSL CA file at given path was not a certificate'); - } - } else { - throw new Error( - 'SSL CA file is neither a certificate nor is it a path to one' - ); + if (!sslCA.includes('-BEGIN')) { + sslCA = resolveSslMaterial(sslCA, 'CA'); + if (!sslCA.includes('-BEGIN')) { + throw new Error('SSL CA file at given path was not a certificate'); } } - const sslInsecure = - (configService.get('LDAP_SSL_INSECURE') ?? '').toLowerCase() === 'true'; + const isSslInsecure + = (configService.get('LDAP_SSL_INSECURE') ?? '').toLowerCase() === 'true'; return { - rejectUnauthorized: !sslInsecure, - ca: sslCA + ca: sslCA, + rejectUnauthorized: !isSslInsecure, }; } constructor( private readonly authnService: AuthnService, - private readonly configService: ConfigService + private readonly configService: ConfigService, ) { const sslConfig = LDAPStrategy.getSSLConfig(configService); super({ server: { - url: `${sslConfig ? 'ldaps' : 'ldap'}://${configService.get( - 'LDAP_HOST' - )}:${configService.get('LDAP_PORT') || '389'}`, - bindDN: configService.get('LDAP_BINDDN'), bindCredentials: configService.get('LDAP_PASSWORD'), + bindDN: configService.get('LDAP_BINDDN'), searchBase: configService.get('LDAP_SEARCHBASE') || 'disabled', searchFilter: - configService.get('LDAP_SEARCHFILTER') || - '(sAMAccountName={{username}})', + configService.get('LDAP_SEARCHFILTER') + || '(sAMAccountName={{username}})', + url: `${sslConfig ? 'ldaps' : 'ldap'}://${configService.get( + 'LDAP_HOST', + )}:${configService.get('LDAP_PORT') || '389'}`, ...(sslConfig && { tlsOptions: { + ca: sslConfig.ca, rejectUnauthorized: sslConfig.rejectUnauthorized, - ca: sslConfig.ca - } - }) - } + }, + }), + }, }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async validate(user: unknown, done: any) { - const {firstName, lastName} = this.authnService.splitName( - _.get(user, this.configService.get('LDAP_NAMEFIELD') || 'name') + // The PassportStrategy mixin awaits this and hands the resolved user to + // passport's done() itself — the contract every sibling strategy follows. + // Calling done() here directly passed the UNAWAITED promise as req.user + // and fired done() a second time when the mixin completed. + validate(user: unknown): Promise { + const { firstName, lastName } = this.authnService.splitName( + _.get(user, this.configService.get('LDAP_NAMEFIELD') || 'name'), ); const email: string = _.get( user, - this.configService.get('LDAP_MAILFIELD') || 'mail' + this.configService.get('LDAP_MAILFIELD') || 'mail', ); - const validatedUser = this.authnService.validateOrCreateUser( + return this.authnService.validateOrCreateUser( + // `.at(0)` returns `string | undefined`, but validateOrCreateUser + // requires `string`. Index access keeps the exact runtime behavior this + // has always had. Closing the gap properly means deciding what should + // happen when an LDAP user has no email address — an authentication + // behavior change, not a lint fix. Tracked as heimdall2-86f6.14. + // eslint-disable-next-line unicorn/prefer-at Array.isArray(email) ? email[0] : email, firstName, lastName, - 'ldap' + 'ldap', ); - return done(null, validatedUser); } } diff --git a/apps/backend/src/authn/local.strategy.ts b/apps/backend/src/authn/local.strategy.ts index b777ba6f16..f6b6b7eb55 100644 --- a/apps/backend/src/authn/local.strategy.ts +++ b/apps/backend/src/authn/local.strategy.ts @@ -1,15 +1,13 @@ -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from 'passport-local'; -import {User} from '../users/user.model'; -import {AuthnService} from './authn.service'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { User } from '../users/user.model'; +import { AuthnService } from './authn.service'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { constructor(private readonly authnService: AuthnService) { - super({ - usernameField: 'email' - }); + super({ usernameField: 'email' }); } async validate(email: string, password: string): Promise { diff --git a/apps/backend/src/authn/oidc.strategy.ts b/apps/backend/src/authn/oidc.strategy.ts index a56eaf1708..42f078461f 100644 --- a/apps/backend/src/authn/oidc.strategy.ts +++ b/apps/backend/src/authn/oidc.strategy.ts @@ -1,74 +1,72 @@ -import type {Agent} from 'http'; -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from '@govtechsg/passport-openidconnect'; -import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {GroupsService} from '../groups/groups.service'; -import {AuthnService} from './authn.service'; +import type { Agent } from 'http'; +import { Strategy } from '@govtechsg/passport-openidconnect'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; +import { GroupsService } from '../groups/groups.service'; +import { AuthnService } from './authn.service'; -interface OIDCProfile { - id: string; - displayName: string; - name: {familyName: string; givenName: string}; - emails: [{value: string}]; - _raw: string; +type OIDCProfile = { _json: { - given_name: string; - family_name: string; email: string; email_verified: boolean; + family_name: string; + given_name: string; groups: string[]; }; -} + _raw: string; + displayName: string; + emails: [{ value: string }]; + id: string; + name: { familyName: string; givenName: string }; +}; @Injectable() -//eslint-disable-next-line @typescript-eslint/no-explicit-any -- Passport v11 changed their types and many 3rd party strategies are not compatible with the types despite actually still working just fine + export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: this.loggingTimeFormat }), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (Authn Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); constructor( private readonly authnService: AuthnService, private readonly configService: ConfigService, private readonly groupsService: GroupsService, - private readonly httpsAgent?: Agent + private readonly httpsAgent?: Agent, ) { super( { - issuer: configService.get('OIDC_ISSUER') || 'disabled', + agent: httpsAgent, authorizationURL: configService.get('OIDC_AUTHORIZATION_URL') || 'disabled', - tokenURL: configService.get('OIDC_TOKEN_URL') || 'disabled', - userInfoURL: configService.get('OIDC_USER_INFO_URL') || 'disabled', + callbackURL: `${configService.getExternalUrl()}/authn/oidc_callback`, clientID: configService.get('OIDC_CLIENTID') || 'disabled', clientSecret: configService.get('OIDC_CLIENT_SECRET') || 'disabled', - callbackURL: `${configService.getExternalUrl()}/authn/oidc_callback`, + issuer: configService.get('OIDC_ISSUER') || 'disabled', pkce: configService.get('OIDC_USES_PKCE_S256') === 'true' ? 'S256' - : configService.get('OIDC_USES_PKCE_PLAIN') === 'true' + : (configService.get('OIDC_USES_PKCE_PLAIN') === 'true' ? 'plain' - : undefined, - scope: ['openid', 'email', 'profile'], - skipUserProfile: false, + : undefined), proxy: configService.get('OIDC_USE_HTTPS_PROXY') === 'true' ? true : undefined, - agent: httpsAgent + scope: ['openid', 'email', 'profile'], + skipUserProfile: false, + tokenURL: configService.get('OIDC_TOKEN_URL') || 'disabled', + userInfoURL: configService.get('OIDC_USER_INFO_URL') || 'disabled', }, // using the 9-arity function so that we can access the underlying JSON response and extract the 'email_verified' attribute async ( @@ -79,9 +77,9 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { _idToken: string, _accessToken: string, _refreshToken: string, - _params: object, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + _parameters: object, + + done: any, ) => { return this.validate( _issuer, @@ -91,10 +89,10 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { _idToken, _accessToken, _refreshToken, - _params, - done + _parameters, + done, ); - } + }, ); } @@ -106,28 +104,28 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { _idToken: string, _accessToken: string, _refreshToken: string, - _params: object, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + _parameters: object, + + done: any, ) { this.logger.debug('in oidc strategy file'); this.logger.debug(JSON.stringify(uiProfile, null, 2)); const userData = uiProfile._json; - const {given_name, family_name, email, email_verified, groups} = userData; + const { email, email_verified, family_name, given_name, groups } = userData; if ( - this.configService.get('OIDC_USES_VERIFIED_EMAIL') === 'false' || - email_verified + this.configService.get('OIDC_USES_VERIFIED_EMAIL') === 'false' + || email_verified ) { const user = await this.authnService.validateOrCreateUser( email, given_name, family_name, - 'oidc' + 'oidc', ); if ( - this.configService.get('OIDC_EXTERNAL_GROUPS') === 'true' && - groups !== undefined + this.configService.get('OIDC_EXTERNAL_GROUPS') === 'true' + && groups !== undefined ) { await this.groupsService.syncUserGroups(user, groups); } @@ -136,8 +134,8 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { } return done( new UnauthorizedException( - 'Please verify your name and email with your identity provider before logging into Heimdall.' - ) + 'Please verify your name and email with your identity provider before logging into Heimdall.', + ), ); } } diff --git a/apps/backend/src/authn/okta.strategy.ts b/apps/backend/src/authn/okta.strategy.ts index 03ba745add..5d2ea1a527 100644 --- a/apps/backend/src/authn/okta.strategy.ts +++ b/apps/backend/src/authn/okta.strategy.ts @@ -1,89 +1,87 @@ -import type {Agent} from 'http'; -import {Injectable, UnauthorizedException} from '@nestjs/common'; -import {PassportStrategy} from '@nestjs/passport'; -import {Strategy} from '@govtechsg/passport-openidconnect'; -import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {AuthnService} from './authn.service'; +import type { Agent } from 'http'; +import { Strategy } from '@govtechsg/passport-openidconnect'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; +import { AuthnService } from './authn.service'; type Profile = { - provider: string; - id: string; displayName: string; + emails: { value: string }[]; + id: string; name: { familyName: string; givenName: string; middleName: string; }; - emails: {value: string}[]; + provider: string; }; @Injectable() -//eslint-disable-next-line @typescript-eslint/no-explicit-any -- Passport v11 changed their types and many 3rd party strategies are not compatible with the types despite actually still working just fine + export class OktaStrategy extends PassportStrategy(Strategy as any, 'okta') { private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: this.loggingTimeFormat }), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (Authn Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); constructor( private readonly authnService: AuthnService, private readonly configService: ConfigService, - private readonly httpsAgent?: Agent + private readonly httpsAgent?: Agent, ) { super( { - issuer: - configService.get('OKTA_ISSUER_URL') || - `https://${configService.get('OKTA_DOMAIN')}` || - 'disabled', + agent: httpsAgent, authorizationURL: - configService.get('OKTA_AUTHORIZATION_URL') || - `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/authorize`, - tokenURL: - configService.get('OKTA_TOKEN_URL') || - `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/token`, - userInfoURL: - configService.get('OKTA_USER_INFO_URL') || - `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/userinfo`, + configService.get('OKTA_AUTHORIZATION_URL') + || `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/authorize`, + callbackURL: `${configService.getExternalUrl()}/authn/okta_callback`, clientID: configService.get('OKTA_CLIENTID') || 'disabled', clientSecret: configService.get('OKTA_CLIENTSECRET') || 'disabled', - callbackURL: `${configService.getExternalUrl()}/authn/okta_callback`, - scope: ['openid', 'email', 'profile'], - skipUserProfile: false, + issuer: + configService.get('OKTA_ISSUER_URL') + || `https://${configService.get('OKTA_DOMAIN')}` + || 'disabled', proxy: configService.get('OKTA_USE_HTTPS_PROXY') === 'true' ? true : undefined, - agent: httpsAgent + scope: ['openid', 'email', 'profile'], + skipUserProfile: false, + tokenURL: + configService.get('OKTA_TOKEN_URL') + || `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/token`, + userInfoURL: + configService.get('OKTA_USER_INFO_URL') + || `https://${configService.get('OKTA_DOMAIN') || 'disabled'}/oauth2/v1/userinfo`, }, // Okta has no concept of a 'verified' email - the account has to have an email address associated with it - which is why we can use the 3-arity function since we don't need access to the underlying JSON response async ( _issuer: string, profile: Profile, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + + done: any, ) => { return this.validate(_issuer, profile, done); - } + }, ); } async validate( _issuer: string, profile: Profile, - //eslint-disable-next-line @typescript-eslint/no-explicit-any - done: any + + done: any, ) { this.logger.debug('in okta strategy file'); this.logger.debug(JSON.stringify(profile, null, 2)); @@ -92,7 +90,7 @@ export class OktaStrategy extends PassportStrategy(Strategy as any, 'okta') { profile.emails[0].value, profile.name.givenName, profile.name.familyName, - 'okta' + 'okta', ); return done(null, user); } diff --git a/apps/backend/src/authn/rehash-lifecycle.spec.ts b/apps/backend/src/authn/rehash-lifecycle.spec.ts new file mode 100644 index 0000000000..7fce00624b --- /dev/null +++ b/apps/backend/src/authn/rehash-lifecycle.spec.ts @@ -0,0 +1,293 @@ +import type { JwtService } from '@nestjs/jwt'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { hashSync } from 'bcryptjs'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; +import { UPDATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; +import type { ApiKeyService } from '../apikeys/apikey.service'; +import { CaslAbilityFactory } from '../casl/casl-ability.factory'; +import type { ConfigService } from '../config/config.service'; +import { ConfigService as RealConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { PasswordService } from '../crypto/password.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AuthnService } from './authn.service'; + +/** + * ADR-006 §7 lifecycle regression suite (heimdall2-e25.17): a lazy rehash is + * INVISIBLE to every lifecycle mechanism — it changes only the stored + * representation of the credential. §7's corrected test scope governs: + * lastLogin/loginCount/updatedAt change on login BY DESIGN + * (updateLoginMetadata), so this suite asserts exactly what must NOT change: + * passwordChangedAt (the password-expiry clock) and forcePasswordChange. + * §6: bcrypt's silent 72-byte truncation must be GONE after conversion. + */ + +// Legacy fixture cost: verification accepts any cost factor, and cost 10 +// keeps fixture minting fast — production cost (14) would add ~1s per hash +// for no additional coverage. +const LEGACY_BCRYPT_COST = 10; +const KNOWN_PASSWORD = 'correct horse battery staple 42!'; +const NEW_PASSWORD = 'aB1!cD2@eF3#gH4$x9'; +// 100 ASCII chars (1 byte each): bcrypt silently truncates at byte 72, so +// the tail beyond it is invisible to legacy verification (§6). +const LONG_PASSWORD = 'L0ng!'.repeat(20); +const LONG_PASSWORD_WRONG_TAIL + = LONG_PASSWORD.slice(0, 72) + 'X'.repeat(28); +// Seeded well in the past so an accidental rewrite is unambiguous. +const SEEDED_CHANGED_AT = new Date('2026-01-15T12:00:00.000Z'); +const PBKDF2_PREFIX = /^\$pbkdf2-sha512\$/v; + +function createBcryptUser( + email: string, + password: string, + hasPendingForcedChange = false, +): Promise { + return User.create({ + creationMethod: 'local', + email, + encryptedPassword: hashSync(password, LEGACY_BCRYPT_COST), + forcePasswordChange: hasPendingForcedChange, + passwordChangedAt: SEEDED_CHANGED_AT, + }); +} + +/** + * §7 known wrinkle: migration-built DBs store passwordChangedAt as + * VARCHAR(255) while synchronize-built DBs use DATE — so the column round- + * trips as string OR Date depending on how the test DB was built. Compare + * type-agnostically: Dates by ISO value, everything else byte-identical. + */ +function normalizeTimestamp(value: unknown): null | string { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value === 'string') { + return value; + } + // §7 names exactly two storage types; anything else is a new defect and + // must fail loudly, never stringify into a comparison. + throw new TypeError( + `passwordChangedAt round-tripped as unexpected type: ${typeof value}`, + ); +} + +describe('Rehash lifecycle regression suite (ADR-006 §7 corrected scope)', () => { + let authnService: AuthnService; + let databaseService: DatabaseService; + let usersService: UsersService; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + CryptoModule, + ], + providers: [ + RealConfigService, + DatabaseService, + UsersService, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], + }).compile(); + + databaseService = module.get(DatabaseService); + usersService = module.get(UsersService); + + // AuthnService ⇄ UsersService is a circular import (users.service.ts + // calls AuthnService.prototype.testPassword unbound), so Nest cannot + // DI-resolve AuthnService here — the authn.service.spec.ts pattern: + // construct it directly with the REAL collaborators this suite exercises + // (usersService, passwordService) and inert stand-ins for the ones the + // validateUser path never touches (apiKeyService, configService, + // jwtService). + authnService = new AuthnService( + {} as ApiKeyService, + {} as ConfigService, + usersService, + {} as JwtService, + module.get(PasswordService), + ); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + it('a login-triggered rehash leaves passwordChangedAt byte-identical and forcePasswordChange false, asserted after user.reload()', async () => { + const user = await createBcryptUser( + 'rehash-lifecycle@example.com', + KNOWN_PASSWORD, + ); + await user.reload(); + const changedAtBefore = normalizeTimestamp( + user.getDataValue('passwordChangedAt'), + ); + expect(changedAtBefore).not.toBeNull(); + + const validated = await authnService.validateUser( + user.email, + KNOWN_PASSWORD, + ); + expect(validated).not.toBeNull(); + + await user.reload(); + // The conversion must have HAPPENED for the unchanged-assertions to mean + // anything (reviewer round 1: vacuous in isolation without this). + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + expect( + normalizeTimestamp(user.getDataValue('passwordChangedAt')), + ).toBe(changedAtBefore); + expect(user.forcePasswordChange).toBe(false); + }); + + it('rehash preserves a PENDING mandated change: forcePasswordChange true stays true across conversion', async () => { + const user = await createBcryptUser( + 'pending-forced-change@example.com', + KNOWN_PASSWORD, + true, + ); + + expect( + await authnService.validateUser(user.email, KNOWN_PASSWORD), + ).not.toBeNull(); + + await user.reload(); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + // A rehash that cleared this flag would silently skip a mandated + // password change — the compliance fix cancelling a security response. + expect(user.forcePasswordChange).toBe(true); + }); + + it('rehash converts the stored credential: encryptedPassword changed and $pbkdf2-prefixed, read from reload not memory', async () => { + const user = await createBcryptUser( + 'rehash-converts@example.com', + KNOWN_PASSWORD, + ); + const bcryptHashBefore = user.encryptedPassword; + + expect( + await authnService.validateUser(user.email, KNOWN_PASSWORD), + ).not.toBeNull(); + + await user.reload(); + expect(user.encryptedPassword).not.toBe(bcryptHashBefore); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + }); + + it('BEFORE rehash a >72-char password verifies with a wrong tail — documents legacy bcrypt truncation (§6)', async () => { + const user = await createBcryptUser( + 'legacy-truncation@example.com', + LONG_PASSWORD, + ); + + // Characters 73-100 differ; bcrypt never sees them. + expect( + await authnService.validateUser(user.email, LONG_PASSWORD_WRONG_TAIL), + ).not.toBeNull(); + }); + + it('AFTER rehash character 73+ is validated: the wrong tail fails and the full password succeeds (§6 truncation gone)', async () => { + const user = await createBcryptUser( + 'truncation-gone@example.com', + LONG_PASSWORD, + ); + + // Convert with the CORRECT full-length password. + expect( + await authnService.validateUser(user.email, LONG_PASSWORD), + ).not.toBeNull(); + await user.reload(); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + + // The same wrong tail that verified under bcrypt now fails... + expect( + await authnService.validateUser(user.email, LONG_PASSWORD_WRONG_TAIL), + ).toBeNull(); + // ...and the genuine full-length password still succeeds. + expect( + await authnService.validateUser(user.email, LONG_PASSWORD), + ).not.toBeNull(); + }); + + it('a GENUINE password change via usersService.update still sets the lifecycle fields (the narrow writer did not leak into the real path)', async () => { + // Seeded TRUE so the expected clear is a real transition, not the + // default value (reviewer round 1: false -> false was vacuous). + const user = await createBcryptUser( + 'genuine-change@example.com', + KNOWN_PASSWORD, + true, + ); + const admin = await User.create({ + creationMethod: 'local', + email: 'genuine-change-admin@example.com', + encryptedPassword: hashSync(KNOWN_PASSWORD, LEGACY_BCRYPT_COST), + role: 'admin', + }); + const abac = new CaslAbilityFactory().createForUser(admin); + await user.reload(); + const changedAtBefore = normalizeTimestamp( + user.getDataValue('passwordChangedAt'), + ); + + await usersService.update( + user, + { + ...UPDATE_USER_DTO_TEST_OBJ, + currentPassword: KNOWN_PASSWORD, + forcePasswordChange: false, + password: NEW_PASSWORD, + passwordConfirmation: NEW_PASSWORD, + }, + abac, + ); + + await user.reload(); + // The expiry clock DOES move on a genuine change — the exact opposite of + // the rehash contract above. Compared against the reloaded stored value, + // not the seed constant, so a format difference can never mask a no-op. + expect( + normalizeTimestamp(user.getDataValue('passwordChangedAt')), + ).not.toBe(changedAtBefore); + expect(user.forcePasswordChange).toBe(false); + expect(user.encryptedPassword).toMatch(PBKDF2_PREFIX); + expect( + await authnService.validateUser(user.email, NEW_PASSWORD), + ).not.toBeNull(); + }); +}); diff --git a/apps/backend/src/authz/authz.module.ts b/apps/backend/src/authz/authz.module.ts index 5bf19fe54a..0254de6a12 100644 --- a/apps/backend/src/authz/authz.module.ts +++ b/apps/backend/src/authz/authz.module.ts @@ -1,9 +1,9 @@ -import {Global, Module} from '@nestjs/common'; -import {AuthzService} from './authz.service'; +import { Global, Module } from '@nestjs/common'; +import { AuthzService } from './authz.service'; @Global() @Module({ + exports: [AuthzService], providers: [AuthzService], - exports: [AuthzService] }) export class AuthzModule {} diff --git a/apps/backend/src/authz/authz.service.ts b/apps/backend/src/authz/authz.service.ts index 50c83ba96e..518d8af274 100644 --- a/apps/backend/src/authz/authz.service.ts +++ b/apps/backend/src/authz/authz.service.ts @@ -1,5 +1,5 @@ -import {Injectable} from '@nestjs/common'; -import {CaslAbilityFactory} from '../casl/casl-ability.factory'; +import { Injectable } from '@nestjs/common'; +import { CaslAbilityFactory } from '../casl/casl-ability.factory'; @Injectable() export class AuthzService { diff --git a/apps/backend/src/casl/casl-ability.factory.spec.ts b/apps/backend/src/casl/casl-ability.factory.spec.ts index f7974445b1..bc50deed55 100644 --- a/apps/backend/src/casl/casl-ability.factory.spec.ts +++ b/apps/backend/src/casl/casl-ability.factory.spec.ts @@ -1,11 +1,11 @@ -import {MongoAbility} from '@casl/ability'; -import {beforeEach, describe, expect, it} from 'vitest'; +import type { MongoAbility } from '@casl/ability'; +import { beforeEach, describe, expect, it } from 'vitest'; import { ADMIN_WITH_ID, - TEST_USER_WITH_ID + TEST_USER_WITH_ID, } from '../../test/constants/users-test.constant'; -import {User} from '../users/user.model'; -import {Action, CaslAbilityFactory} from './casl-ability.factory'; +import { User } from '../users/user.model'; +import { Action, CaslAbilityFactory } from './casl-ability.factory'; describe('CaslAbilityFactory', () => { let abilityFactory: CaslAbilityFactory; @@ -22,20 +22,20 @@ describe('CaslAbilityFactory', () => { expect( userAbility.can( Action.Read, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( userAbility.can( Action.Update, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( userAbility.can( Action.Delete, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); }); @@ -43,26 +43,26 @@ describe('CaslAbilityFactory', () => { expect( userAbility.can( Action.DeleteNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); expect( userAbility.can( Action.UpdateNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); expect( userAbility.can( Action.SkipForcePasswordChange, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); expect( userAbility.can( Action.UpdateRole, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeFalsy(); }); @@ -74,20 +74,20 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.Delete, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.DeleteNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.DeleteNoPassword, - Object.assign(User.prototype, ADMIN_WITH_ID) - ) + Object.assign(User.prototype, ADMIN_WITH_ID), + ), ).toBeFalsy(); }); @@ -95,14 +95,14 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.Update, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.Update, - Object.assign(User.prototype, ADMIN_WITH_ID) - ) + Object.assign(User.prototype, ADMIN_WITH_ID), + ), ).toBeTruthy(); }); @@ -110,14 +110,14 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.UpdateNoPassword, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); expect( adminAbility.can( Action.UpdateNoPassword, - Object.assign(User.prototype, ADMIN_WITH_ID) - ) + Object.assign(User.prototype, ADMIN_WITH_ID), + ), ).toBeFalsy(); }); @@ -125,8 +125,8 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.SkipForcePasswordChange, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); }); @@ -134,8 +134,8 @@ describe('CaslAbilityFactory', () => { expect( adminAbility.can( Action.UpdateRole, - Object.assign(User.prototype, TEST_USER_WITH_ID) - ) + Object.assign(User.prototype, TEST_USER_WITH_ID), + ), ).toBeTruthy(); }); }); diff --git a/apps/backend/src/casl/casl-ability.factory.ts b/apps/backend/src/casl/casl-ability.factory.ts index 7dd0ddc9f8..35eee64886 100644 --- a/apps/backend/src/casl/casl-ability.factory.ts +++ b/apps/backend/src/casl/casl-ability.factory.ts @@ -3,59 +3,69 @@ import { createMongoAbility, ExtractSubjectType, InferSubjects, - MongoAbility + MongoAbility, } from '@casl/ability'; -import {Injectable} from '@nestjs/common'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; - -type AllTypes = typeof User | typeof Evaluation | typeof Group; - -type Subjects = InferSubjects | 'all'; -type PossibleAbilities = [Action, Subjects]; +import { Injectable } from '@nestjs/common'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; export enum Action { - Manage = 'manage', // manage is a special keyword in CASL which represents "any" action. + AddEvaluation = 'add-evaluation', Create = 'create', - Read = 'read', - Update = 'update', Delete = 'delete', + DeleteNoPassword = 'delete-no-password', + ForceRegistration = 'force-registration', + Manage = 'manage', // manage is a special keyword in CASL which represents "any" action. + Read = 'read', ReadAll = 'read-all', ReadSlim = 'read-slim', - DeleteNoPassword = 'delete-no-password', - UpdateNoPassword = 'update-no-password', + RemoveEvaluation = 'remove-evaluation', SkipForcePasswordChange = 'skip-force-password-change', + Update = 'update', + UpdateNoPassword = 'update-no-password', UpdateRole = 'update-role', - AddEvaluation = 'add-evaluation', - RemoveEvaluation = 'remove-evaluation', ViewStatistics = 'view-statistics', - ForceRegistration = 'force-registration' } -interface UserQuery extends User { - id: User['id']; - 'GroupUser.role': GroupUser['role']; - GroupUser: GroupUser; -} +export type AppAbility = MongoAbility; +type AllTypes = typeof Evaluation | typeof Group | typeof User; -interface GroupQuery extends Group { +type EvaluationQuery = Evaluation & { + 'groups.users': UserQuery[]; + 'groups.users.id': User['id']; +}; + +type GroupQuery = Group & { users: UserQuery[]; 'users.id': User['id']; -} +}; -interface EvaluationQuery extends Evaluation { - 'groups.users': UserQuery[]; - 'groups.users.id': User['id']; -} +type PossibleAbilities = [Action, Subjects]; -export type AppAbility = MongoAbility; +type Subjects = 'all' | InferSubjects; + +type UserQuery = User & { + GroupUser: GroupUser; + 'GroupUser.role': GroupUser['role']; + id: User['id']; +}; @Injectable() export class CaslAbilityFactory { + // This provides the ability to use the same codepath for validating + // user abilities and non-registered user abilities. Useful for the + // few anonymous endpoints we have. + createForAnonymous(): MongoAbility { + const { build, cannot } = new AbilityBuilder(createMongoAbility); + cannot(Action.Manage, 'all'); + + return build(); + } + createForUser(user: User): MongoAbility { - const {can, cannot, build} = new AbilityBuilder(createMongoAbility); + const { build, can, cannot } = new AbilityBuilder(createMongoAbility); if (user.role === 'admin') { // all is a special keyword in CASL that represents "any subject". // read-write access to everything @@ -63,65 +73,41 @@ export class CaslAbilityFactory { // Read statistics about this heimdall deployment can(Action.ViewStatistics, 'all'); // Force admins to supply their password when editing their own user. - cannot(Action.Manage, User, {id: user.id}); + cannot(Action.Manage, User, { id: user.id }); } can([Action.ReadSlim], User); - can([Action.Read, Action.Update, Action.Delete], User, {id: user.id}); + can([Action.Read, Action.Update, Action.Delete], User, { id: user.id }); can([Action.Create], Group); - can([Action.Read], Group, {public: true}); + can([Action.Read], Group, { public: true }); // Trying to compare the whole object here doesn't work since the // user object includes `GroupUser` and therefore the passed in user // is not equal to the user on the Group can( [Action.Read, Action.AddEvaluation, Action.RemoveEvaluation], Group, - { - 'users.id': user.id - } + { 'users.id': user.id }, ); - can([Action.Manage], Group, { - users: { - $elemMatch: {id: user.id, 'GroupUser.role': 'owner'} - } - }); + can([Action.Manage], Group, { users: { $elemMatch: { 'GroupUser.role': 'owner', id: user.id } } }); // This really isn't the best method to do this since // it requires every evaluation to have a join on Groups and then another join on Users can([Action.Create], Evaluation); - can(Action.Read, Evaluation, {public: true}); + can(Action.Read, Evaluation, { public: true }); - can([Action.Manage], Evaluation, { - userId: user.id - }); + can([Action.Manage], Evaluation, { userId: user.id }); - can([Action.Read], Evaluation, { - 'groups.users.id': user.id - }); + can([Action.Read], Evaluation, { 'groups.users.id': user.id }); - can([Action.Manage], Evaluation, { - 'groups.users': { - $elemMatch: {id: user.id, 'GroupUser.role': 'owner'} - } - }); + can([Action.Manage], Evaluation, { 'groups.users': { $elemMatch: { 'GroupUser.role': 'owner', id: user.id } } }); return build({ - detectSubjectType: (object) => - object.constructor as ExtractSubjectType + detectSubjectType: object => + object.constructor as ExtractSubjectType, }); } - - // This provides the ability to use the same codepath for validating - // user abilities and non-registered user abilities. Useful for the - // few anonymous endpoints we have. - createForAnonymous(): MongoAbility { - const {cannot, build} = new AbilityBuilder(createMongoAbility); - cannot(Action.Manage, 'all'); - - return build(); - } } diff --git a/apps/backend/src/casl/casl-exception.filter.ts b/apps/backend/src/casl/casl-exception.filter.ts index 2b2bc2ddd4..60c3ba5c11 100644 --- a/apps/backend/src/casl/casl-exception.filter.ts +++ b/apps/backend/src/casl/casl-exception.filter.ts @@ -1,16 +1,22 @@ -import {ForbiddenError} from '@casl/ability'; -import {ArgumentsHost, Catch, ForbiddenException} from '@nestjs/common'; -import {BaseExceptionFilter} from '@nestjs/core'; +import { ForbiddenError } from '@casl/ability'; +import { ArgumentsHost, Catch, ForbiddenException } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; @Catch() export class CaslExceptionFilter extends BaseExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { // Transform Casl Exception from ForbiddenError to ForbiddenException, // which Nest will properly transform into a 403 error. + /* eslint-disable promise/valid-params -- No code fix exists: Nest's + ExceptionFilter contract mandates a method NAMED catch(exception, + host), and the promise plugin's syntactic check assumes any two- + argument .catch() call is Promise.prototype.catch. These are super + calls to BaseExceptionFilter.catch, not promises. */ if (exception instanceof ForbiddenError) { super.catch(new ForbiddenException(exception.message), host); } else { super.catch(exception, host); } + /* eslint-enable promise/valid-params */ } } diff --git a/apps/backend/src/config/config.module.ts b/apps/backend/src/config/config.module.ts index 24c9feff7d..c9b90a74fa 100644 --- a/apps/backend/src/config/config.module.ts +++ b/apps/backend/src/config/config.module.ts @@ -1,8 +1,8 @@ -import {Module} from '@nestjs/common'; -import {ConfigService} from './config.service'; +import { Module } from '@nestjs/common'; +import { ConfigService } from './config.service'; @Module({ + exports: [ConfigService], providers: [ConfigService], - exports: [ConfigService] }) export class ConfigModule {} diff --git a/apps/backend/src/config/config.service.spec.ts b/apps/backend/src/config/config.service.spec.ts index 5f24415668..2151e715b5 100644 --- a/apps/backend/src/config/config.service.spec.ts +++ b/apps/backend/src/config/config.service.spec.ts @@ -1,28 +1,33 @@ import * as dotenv from 'dotenv'; -import mock from 'mock-fs'; -import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import mock, { file, load, restore } from 'mock-fs'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { DATABASE_URL_MOCK_ENV, + DATABASE_URL_WITH_QUERY_MOCK_ENV, ENV_MOCK_FILE, - SIMPLE_ENV_MOCK_FILE -} from '../../test/constants/env-test.constant'; -import {ConfigService} from './config.service'; + GITLAB_BOTH_SECRETS_ENV, + GITLAB_CANONICAL_SECRET_ENV, + GITLAB_EMPTY_CANONICAL_SECRET_ENV, + GITLAB_LEGACY_SECRET_ENV, + SIMPLE_ENV_MOCK_FILE, +} from '../../test/constants/environment-test.constant'; +import { ConfigService } from './config.service'; +import { resolveSslMaterial } from '../../config/app-config'; // If you run the test without --silent , you need to add console.log() before you mock out the file system in the beforeAll() or it'll throw an error (this is a documented bug which can be found at https://github.com/tschaub/mock-fs/issues/234). If you run the test with --silent (which we do by default), you don't need the log statement. describe('Config Service', () => { - beforeAll(async () => { - // eslint-disable-next-line no-console + beforeAll(() => { console.log(); // Used as an empty file system mock({ // No files created (.env file does not exist yet), but pull through node_modules so the testing framework can run - node_modules: mock.load('node_modules') + node_modules: load('node_modules'), }); }); afterAll(() => { // Restore the fs binding to the real file system - mock.restore(); + restore(); }); describe('Tests the get function when .env file does not exist', () => { @@ -36,10 +41,10 @@ describe('Config Service', () => { // Used to make sure logs are outputted new ConfigService(); expect(consoleSpy).toHaveBeenCalledWith( - 'Unable to read configuration file `.env`!' + 'Unable to read configuration file `.env`!', ); expect(consoleSpy).toHaveBeenCalledWith( - 'Falling back to environment or undefined values!' + 'Falling back to environment or undefined values!', ); }); }); @@ -47,9 +52,7 @@ describe('Config Service', () => { describe('Tests the get function when .env file does exist', () => { beforeAll(() => { // Mock .env file - mock({ - '.env': ENV_MOCK_FILE - }); + mock({ '.env': ENV_MOCK_FILE }); }); it('should return the correct database name', () => { @@ -60,7 +63,7 @@ describe('Config Service', () => { expect(configService.get('DATABASE_USERNAME')).toEqual('postgres'); expect(configService.get('DATABASE_PASSWORD')).toEqual('postgres'); expect(configService.get('DATABASE_NAME')).toEqual( - 'heimdallts_vitest_testing_service_db' + 'heimdallts_vitest_testing_service_db', ); expect(configService.get('JWT_SECRET')).toEqual('abc123'); expect(configService.get('NODE_ENV')).toEqual('test'); @@ -75,11 +78,9 @@ describe('Config Service', () => { describe('Tests the get function when environment file is sourced externally', () => { beforeAll(() => { // Mock .env file - mock({ - '.env-loaded-externally': SIMPLE_ENV_MOCK_FILE - }); - // eslint-disable-next-line @typescript-eslint/no-var-requires - dotenv.config({path: '.env-loaded-externally'}); + mock({ '.env-loaded-externally': SIMPLE_ENV_MOCK_FILE }); + + dotenv.config({ path: '.env-loaded-externally' }); }); it('should return the correct database port', () => { @@ -95,45 +96,56 @@ describe('Config Service', () => { describe('When using DATABASE_URL', () => { beforeAll(() => { - mock({ - '.env': DATABASE_URL_MOCK_ENV - }); + mock({ '.env': DATABASE_URL_MOCK_ENV }); }); it('should correctly parse DATABASE_URL into its components', () => { const configService = new ConfigService(); expect(configService.get('DATABASE_HOST')).toEqual( - 'ec2-00-000-11-123.compute-1.amazonaws.com' + 'ec2-00-000-11-123.compute-1.amazonaws.com', ); expect(configService.get('DATABASE_PORT')).toEqual('5432'); expect(configService.get('DATABASE_USERNAME')).toEqual( - 'abcdefghijk123456' + 'abcdefghijk123456', ); expect(configService.get('DATABASE_PASSWORD')).toEqual( - '000011112222333344455556666777778889999aaaabbbbccccddddeeeffff' + '000011112222333344455556666777778889999aaaabbbbccccddddeeeffff', ); expect(configService.get('DATABASE_NAME')).toEqual('database01'); }); }); + describe('When DATABASE_URL carries query parameters', () => { + beforeAll(() => { + mock({ '.env': DATABASE_URL_WITH_QUERY_MOCK_ENV }); + }); + + it('should parse every component and keep the query out of them', () => { + const configService = new ConfigService(); + expect(configService.get('DATABASE_HOST')).toEqual('db.internal.example'); + expect(configService.get('DATABASE_PORT')).toEqual('6432'); + expect(configService.get('DATABASE_USERNAME')).toEqual('queryuser'); + expect(configService.get('DATABASE_PASSWORD')).toEqual('querypass'); + expect(configService.get('DATABASE_NAME')).toEqual('database02'); + }); + }); + describe('Tests for thrown errors', () => { it('should throw an EACCES error', () => { expect.assertions(1); mock({ - '.env': mock.file({ + '.env': file({ content: 'DATABASE_NAME=heimdallts_vitest_testing_service_db', - mode: 0o000 // Set file system permissions to none - }) + mode: 0o000, // Set file system permissions to none + }), }); expect(() => new ConfigService()).toThrowError( - "EACCES, permission denied '.env'" + "EACCES, permission denied '.env'", ); }); it('should throw an error in the get function', () => { - mock({ - '.env': ENV_MOCK_FILE - }); + mock({ '.env': ENV_MOCK_FILE }); const configService = new ConfigService(); vi.spyOn(configService, 'get').mockImplementationOnce(() => { throw new Error('Test error'); @@ -149,4 +161,64 @@ describe('Config Service', () => { expect(configService.get('test')).toBe('value'); }); }); + + // GITLAB_CLIENTSECRET is canonical — it matches GITHUB_CLIENTSECRET / + // GOOGLE_CLIENTSECRET / OKTA_CLIENTSECRET and is the name .env-example and + // the RPM man page have always documented. GITLAB_SECRET is the legacy name + // gitlab.strategy.ts actually read, so both must resolve or every deployment + // configured from either source breaks. + describe('getGitlabClientSecret', () => { + it('should resolve the canonical GITLAB_CLIENTSECRET', () => { + mock({ '.env': GITLAB_CANONICAL_SECRET_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('canonical-secret'); + }); + + it('should resolve the legacy GITLAB_SECRET when the canonical name is unset', () => { + mock({ '.env': GITLAB_LEGACY_SECRET_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('legacy-secret'); + }); + + it('should prefer the canonical name when both are set', () => { + mock({ '.env': GITLAB_BOTH_SECRETS_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('canonical-secret'); + }); + + it('should treat an empty canonical value as unset and fall back to the legacy name', () => { + mock({ '.env': GITLAB_EMPTY_CANONICAL_SECRET_ENV }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toEqual('legacy-secret'); + }); + + it('should return undefined when neither name is set', () => { + mock({ '.env': SIMPLE_ENV_MOCK_FILE }); + const configService = new ConfigService(); + expect(configService.getGitlabClientSecret()).toBe(undefined); + }); + }); +}); + +describe('resolveSslMaterial', () => { + const PEM + = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'; + + it('passes inline PEM material through untouched', () => { + expect(resolveSslMaterial(PEM, 'CA')).toBe(PEM); + }); + + it('reads PEM material from a deployer-specified path', () => { + mock({ '/certs/ca.pem': PEM }); + expect(resolveSslMaterial('/certs/ca.pem', 'CA').toString()).toBe(PEM); + restore(); + }); + + it('throws a labeled error for a missing file', () => { + mock({}); + expect(() => resolveSslMaterial('/certs/missing.pem', 'CA')).toThrowError( + 'SSL CA file does not exist or is unreadable', + ); + restore(); + }); }); diff --git a/apps/backend/src/config/config.service.ts b/apps/backend/src/config/config.service.ts index b56c39f48a..88ca133fd8 100644 --- a/apps/backend/src/config/config.service.ts +++ b/apps/backend/src/config/config.service.ts @@ -1,46 +1,34 @@ -import {SequelizeOptions} from 'sequelize-typescript'; -import AppConfig from '../../config/app_config'; -import {StartupSettingsDto} from './dto/startup-settings.dto'; +import type { SequelizeOptions } from 'sequelize-typescript'; +import AppConfig from '../../config/app-config'; +import { StartupSettingsDto } from './dto/startup-settings.dto'; export class ConfigService { private readonly appConfig: AppConfig; - public defaultGithubBaseURL = 'https://github.com/'; - public defaultGithubAPIURL = 'https://api.github.com/'; - - constructor() { - this.appConfig = new AppConfig(); - } + public defaultGithubAPIURL = 'https://api.github.com/'; + public defaultGithubBaseURL = 'https://github.com/'; public sensitiveKeys = [ - /cookie/i, - /passw(or)?d/i, - /^pw$/, - /^pass$/i, - /secret/i, - /token/i, - /api[-._]?key/i, - /data/i + /cookie/iv, + /passw(?:or)?d/iv, + /^pw$/v, + /^pass$/iv, + /secret/iv, + /token/iv, + /api[\-._]?key/iv, + /data/iv, ]; - isRegistrationAllowed(): boolean { - return this.get('REGISTRATION_DISABLED')?.toLowerCase() !== 'true'; - } - - isLocalLoginAllowed(): boolean { - return this.get('LOCAL_LOGIN_DISABLED')?.toLowerCase() !== 'true'; - } - - isInProductionMode(): boolean { - return this.get('NODE_ENV')?.toLowerCase() === 'production'; + constructor() { + this.appConfig = new AppConfig(); } enabledOauthStrategies() { const enabledOauth: string[] = []; - supportedOauth.forEach((oauthStrategy) => { + for (const oauthStrategy of supportedOauth) { if (this.get(`${oauthStrategy.toUpperCase()}_CLIENTID`)) { enabledOauth.push(oauthStrategy); } - }); + } return enabledOauth; } @@ -55,43 +43,85 @@ export class ConfigService { this.get('CLASSIFICATION_BANNER_TEXT_COLOR') || 'white', enabledOAuth: this.enabledOauthStrategies(), externalUrl: this.getExternalUrl(), + forceTenableFrontend: + this.get('FORCE_TENABLE_FRONTEND')?.toLowerCase() === 'true', + ldap: (this.get('LDAP_ENABLED')?.toLocaleLowerCase() === 'true'), + localLoginEnabled: this.isLocalLoginAllowed(), oidcName: this.get('OIDC_NAME') || '', - ldap: this.get('LDAP_ENABLED')?.toLocaleLowerCase() === 'true' || false, registrationEnabled: this.isRegistrationAllowed(), - localLoginEnabled: this.isLocalLoginAllowed(), + splunkHostUrl: this.getSplunkHostUrl(), tenableHostUrl: this.getTenableHostUrl(), - forceTenableFrontend: - this.get('FORCE_TENABLE_FRONTEND')?.toLowerCase() === 'true', - splunkHostUrl: this.getSplunkHostUrl() }); } + get(key: string): string | undefined { + return this.appConfig.get(key); + } + + getDbConfig(): SequelizeOptions { + return this.appConfig.getDbConfig(); + } + getExternalUrl(): string { return this.appConfig.getExternalUrl(); } + /** + * GitLab's client secret accepts two names. GITLAB_CLIENTSECRET is canonical: + * it matches GITHUB_CLIENTSECRET / GOOGLE_CLIENTSECRET / OKTA_CLIENTSECRET, + * and it is the name apps/backend/.env-example and the RPM man page have + * always documented. GITLAB_SECRET is the legacy name this application + * actually read, so it stays supported — dropping it would break every + * deployment configured from the code rather than the docs. + * + * The canonical name wins when both are set. An empty value counts as unset, + * matching AppConfig.get's own truthiness fallback. + */ + getGitlabClientSecret(): string | undefined { + return this.get('GITLAB_CLIENTSECRET') || this.get('GITLAB_SECRET'); + } + getSplunkHostUrl(): string { return this.appConfig.getSplunkHostUrl(); } + getSSLConfig(): false | Record { + return this.appConfig.getSSLConfig(); + } + getTenableHostUrl(): string { return this.appConfig.getTenableHostUrl(); } - getDbConfig(): SequelizeOptions { - return this.appConfig.getDbConfig(); + getTenableAdditionalHostUrls(): string { + return this.appConfig.getTenableAdditionalHostUrls(); } - getSSLConfig(): false | Record { - return this.appConfig.getSSLConfig(); + // Outbound Tenable connections refuse to land in private, loopback and + // link-local address space by default (heimdall2-86f6.13), which is what stops + // an allowlisted name being pointed at an internal service. A deployment whose + // Security Center genuinely lives on private space opts back in here — an + // explicit operator decision, never the default. + isTenablePrivateAddressAllowed(): boolean { + return ( + this.get('TENABLE_ALLOW_PRIVATE_ADDRESSES')?.toLowerCase() === 'true' + ); } - set(key: string, value: string | undefined): void { - this.appConfig.set(key, value); + isInProductionMode(): boolean { + return this.get('NODE_ENV')?.toLowerCase() === 'production'; } - get(key: string): string | undefined { - return this.appConfig.get(key); + isLocalLoginAllowed(): boolean { + return this.get('LOCAL_LOGIN_DISABLED')?.toLowerCase() !== 'true'; + } + + isRegistrationAllowed(): boolean { + return this.get('REGISTRATION_DISABLED')?.toLowerCase() !== 'true'; + } + + set(key: string, value: string | undefined): void { + this.appConfig.set(key, value); } } export const supportedOauth: string[] = [ @@ -99,5 +129,5 @@ export const supportedOauth: string[] = [ 'gitlab', 'google', 'okta', - 'oidc' + 'oidc', ]; diff --git a/apps/backend/src/config/dto/startup-settings.dto.ts b/apps/backend/src/config/dto/startup-settings.dto.ts index 3af275b85b..93a8fc8c76 100644 --- a/apps/backend/src/config/dto/startup-settings.dto.ts +++ b/apps/backend/src/config/dto/startup-settings.dto.ts @@ -1,4 +1,4 @@ -import {IStartupSettings} from '@heimdall/common/interfaces'; +import type { IStartupSettings } from '@heimdall/common/interfaces'; export class StartupSettingsDto implements IStartupSettings { readonly apiKeysEnabled: boolean; @@ -8,13 +8,13 @@ export class StartupSettingsDto implements IStartupSettings { readonly classificationBannerTextColor: string; readonly enabledOAuth: string[]; readonly externalUrl: string; - readonly oidcName: string; + readonly forceTenableFrontend: boolean; readonly ldap: boolean; - readonly registrationEnabled: boolean; readonly localLoginEnabled: boolean; - readonly tenableHostUrl: string; - readonly forceTenableFrontend: boolean; + readonly oidcName: string; + readonly registrationEnabled: boolean; readonly splunkHostUrl: string; + readonly tenableHostUrl: string; constructor(settings: IStartupSettings) { this.apiKeysEnabled = settings.apiKeysEnabled; diff --git a/apps/backend/src/config/static-paths.ts b/apps/backend/src/config/static-paths.ts new file mode 100644 index 0000000000..6bb8b95277 --- /dev/null +++ b/apps/backend/src/config/static-paths.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; + +/** + * Filesystem roots for the static assets this server mounts. + * + * These are resolved here, once, rather than inline at each mount, because the + * default is anchored on `__dirname` and therefore depends on which runtime is + * executing: under `nest build` __dirname is `apps/backend/dist/src`, but under + * vitest it is `apps/backend/src` — one level shallower. The same four `..` + * segments consequently land INSIDE the repo in production and one level ABOVE + * it under test, where nothing exists. That is why a spec booting AppModule + * silently exercised no static mount at all. + * + * HEIMDALL_STATIC_ROOT overrides the anchor. The pattern is Grafana's + * `static_root_path`: where a server finds its static assets is deployment + * configuration, not something to derive from the location of the code. + * Unset, the resolved paths are byte-identical to the previous inline + * expression, so production behaviour is unchanged. + */ + +// eslint's prefer-module wants import.meta here, but this package compiles to +// CommonJS (nodenext, no "type": "module") where import.meta is a syntax +// error — resolving the static roots stays on __dirname until an ESM +// migration. +const BUILT_LAYOUT_ANCHOR = path.join(__dirname, '..', '..', '..', '..', 'dist'); + + + + +export function staticRoot(): string { + return process.env.HEIMDALL_STATIC_ROOT ?? BUILT_LAYOUT_ANCHOR; +} + +/** The compiled Vue SPA — served as the application itself. */ +export function frontendRoot(): string { + return path.join(staticRoot(), 'frontend'); +} + +/** The built VitePress site — served at /docs for offline and airgapped use. */ +export function documentationRoot(): string { + return path.join(staticRoot(), 'docs'); +} diff --git a/apps/backend/src/crypto/crypto.module.ts b/apps/backend/src/crypto/crypto.module.ts new file mode 100644 index 0000000000..9da602cc94 --- /dev/null +++ b/apps/backend/src/crypto/crypto.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ConfigModule } from '../config/config.module'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { HashWriteGateService } from './hash-write-gate.service'; +import { PasswordService } from './password.service'; + +/** + * ADR-006 §5. PasswordService needs ConfigService, and ConfigModule is NOT + * @Global() in this app, so the import is required — not optional. Exported so + * the call-site cards can inject PasswordService. + * + * §12: HashWriteGateService needs the durable-marker table and the Users + * table (the fresh-install probe), so this module registers both models — + * making CryptoModule self-contained: importing it is all a consumer (or a + * test module) needs. + */ +@Module({ + exports: [HashWriteGateService, PasswordService], + imports: [ + ConfigModule, + SequelizeModule.forFeature([HashMigrationMarker, User]), + ], + providers: [HashWriteGateService, PasswordService], +}) +export class CryptoModule {} diff --git a/apps/backend/src/crypto/fips.spec.ts b/apps/backend/src/crypto/fips.spec.ts new file mode 100644 index 0000000000..9b68e8b490 --- /dev/null +++ b/apps/backend/src/crypto/fips.spec.ts @@ -0,0 +1,107 @@ +import * as nodeCrypto from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { assertFipsMode } from './fips'; +import { PasswordHashError } from './password'; + +// Module-scope assertion patterns (prefer-static-regex). +const REFUSAL_MESSAGE = /FIPS_MODE=true.*getFips\(\).*fips-mode-setup --enable/sv; +const NO_FORCE_FIPS = /Do NOT use.*--force-fips/sv; +const UNSET_WARNING = /FIPS_MODE is not set.*NO FIPS assertion was performed/sv; +const INVALID_VALUE = /FIPS_MODE must be 'true' or 'false'/v; + +// Pass-through wrap so the default-seam tests can steer getFips and so the +// never-calls-setFips AC is mechanically observable. Every other node:crypto +// function goes to the real implementation via the spread. +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getFips: vi.fn(actual.getFips), + setFips: vi.fn(actual.setFips), + }; +}); + +// ADR-006 §10: with --force-fips gone (wrong on RHEL), this assertion is the +// ONLY thing between us and silent non-FIPS operation — the GitLab Workhorse +// failure shape. Injectable getFips/logWarning so both FIPS states are +// testable in non-FIPS CI. +describe('assertFipsMode — §10 startup assertion', () => { + it('FIPS_MODE=true + getFips()===0 throws, naming FIPS_MODE and the RHEL host-FIPS remedy (never --force-fips)', () => { + expect.assertions(2); + expect(() => + assertFipsMode({ fipsMode: 'true', getFips: () => 0 }), + ).toThrow(REFUSAL_MESSAGE); + expect(() => + assertFipsMode({ fipsMode: 'true', getFips: () => 0 }), + ).toThrow(NO_FORCE_FIPS); + }); + + it('FIPS_MODE=true + getFips()===1 passes silently — no throw, no warning', () => { + expect.assertions(1); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: 'true', getFips: () => 1, logWarning }); + expect(logWarning).not.toHaveBeenCalled(); + }); + + it('FIPS_MODE=false is a deliberate operator statement — no throw, no warning, and getFips is never consulted', () => { + expect.assertions(2); + const logWarning = vi.fn(); + const getFips = vi.fn(() => 0); + assertFipsMode({ fipsMode: 'false', getFips, logWarning }); + expect(logWarning).not.toHaveBeenCalled(); + expect(getFips).not.toHaveBeenCalled(); + }); + + it('FIPS_MODE unset logs the prominent no-assertion boot warning and does not throw (§10: silence is how Workhorse-class failures survive)', () => { + expect.assertions(2); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: undefined, getFips: () => 0, logWarning }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toMatch(UNSET_WARNING); + }); + + it('FIPS_MODE empty string behaves as unset — warning, no throw', () => { + expect.assertions(1); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: '', getFips: () => 0, logWarning }); + expect(logWarning).toHaveBeenCalledTimes(1); + }); + + it('an invalid FIPS_MODE value throws at startup (§9: out-of-range config never clamps silently)', () => { + expect.assertions(1); + expect(() => assertFipsMode({ fipsMode: 'yes' })).toThrow( + PasswordHashError, + ); + }); + + it('the invalid-value message names the accepted values', () => { + expect.assertions(1); + expect(() => assertFipsMode({ fipsMode: 'enabled' })).toThrow( + INVALID_VALUE, + ); + }); + + it('the default getFips seam reads crypto.getFips (namespace import) — both outcomes exercised', () => { + expect.assertions(2); + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(0); + expect(() => assertFipsMode({ fipsMode: 'true' })).toThrow( + REFUSAL_MESSAGE, + ); + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(1); + expect(() => assertFipsMode({ fipsMode: 'true' })).not.toThrow(); + }); + + it('NEVER calls crypto.setFips — under --force-fips it is a native CHECK() abort, not a throw (§10)', () => { + expect.assertions(1); + const logWarning = vi.fn(); + assertFipsMode({ fipsMode: 'false', logWarning }); + assertFipsMode({ fipsMode: undefined, logWarning }); + assertFipsMode({ fipsMode: 'true', getFips: () => 1, logWarning }); + try { + assertFipsMode({ fipsMode: 'true', getFips: () => 0, logWarning }); + } catch { + // the refusal throw is the expected behavior under test elsewhere + } + expect(vi.mocked(nodeCrypto.setFips)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/crypto/fips.ts b/apps/backend/src/crypto/fips.ts new file mode 100644 index 0000000000..050789cf6f --- /dev/null +++ b/apps/backend/src/crypto/fips.ts @@ -0,0 +1,73 @@ +import * as nodeCrypto from 'node:crypto'; +import { createLogger, format, transports } from 'winston'; +import { PasswordHashError } from './password'; + +/** + * ADR-006 §10: the FIPS startup assertion. With --force-fips gone (Red Hat's + * Node rejects it — the RHEL model is host FIPS mode -> OpenSSL -> Node), + * this assertion is the ONLY thing between us and silent non-FIPS operation: + * GitLab's Workhorse shipped exactly that failure, fips.Enabled() returning + * false with no error. Exported and injectable because bootstrap() in main.ts + * is not exported and cannot be unit-tested. + * + * NEVER call crypto.setFips() here or anywhere: under --force-fips it + * triggers a native CHECK() that ABORTS the process — it does not throw. + * + * Operator error-family note (§10 — you will hit both): an + * ERR_OSSL_EVP_UNSUPPORTED error is an OpenSSL 3 LEGACY-PROVIDER problem, NOT + * a FIPS denial; "...disabled for FIPS" in an OpenSSL error IS a real FIPS + * denial. Do not conflate them when diagnosing a refused boot. + * + * `node:crypto` is a NAMESPACE import (§5): a destructured getFips compiles + * to a non-writable binding under swc, which would break the injectable seam. + */ + +const fipsLogger = createLogger({ + format: format.printf(info => `[FIPS]: ${String(info.message)}`), + transports: [new transports.Console()], +}); + +export type AssertFipsModeArguments = { + /** Raw FIPS_MODE value; undefined or '' means unset. */ + readonly fipsMode: string | undefined; + /** Injectable FIPS probe; defaults to the real crypto.getFips. */ + readonly getFips?: () => number; + /** Injectable warning sink; defaults to the module's winston logger. */ + readonly logWarning?: (message: string) => void; +}; + +/** + * Throws when FIPS_MODE=true but the OpenSSL provider reports FIPS inactive; + * warns LOUDLY when FIPS_MODE is unset (no assertion performed — §10's + * anti-Workhorse rule); silent for an explicit 'false' and for a satisfied + * 'true'. Invalid values throw per §9 (never clamp silently). + */ +export function assertFipsMode(arguments_: AssertFipsModeArguments): void { + const { + fipsMode, + getFips = nodeCrypto.getFips, + logWarning = (message: string): void => { + fipsLogger.warn({ message }); + }, + } = arguments_; + + if (fipsMode === undefined || fipsMode === '') { + logWarning( + 'FIPS_MODE is not set — NO FIPS assertion was performed at boot. If this host is supposed to run in FIPS mode, set FIPS_MODE=true so a silently non-FIPS OpenSSL provider refuses startup instead of running non-validated crypto (ADR-006 §10).', + ); + return; + } + if (fipsMode !== 'true' && fipsMode !== 'false') { + throw new PasswordHashError( + `FIPS_MODE must be 'true' or 'false' (got '${fipsMode}')`, + ); + } + if (fipsMode === 'false') { + return; + } + if (getFips() !== 1) { + throw new Error( + 'REFUSING TO START: FIPS_MODE=true but the OpenSSL provider reports FIPS is NOT active (getFips() returned 0). Running would silently use non-validated crypto. Remedy on RHEL: enable HOST FIPS mode — fips-mode-setup --enable and reboot — so OpenSSL and Node inherit it (ADR-006 §10). Do NOT use node --force-fips on RHEL: the platform Node rejects it (configure FIPS in OpenSSL instead).', + ); + } +} diff --git a/apps/backend/src/crypto/hash-migration-marker.model.ts b/apps/backend/src/crypto/hash-migration-marker.model.ts new file mode 100644 index 0000000000..74f76f1495 --- /dev/null +++ b/apps/backend/src/crypto/hash-migration-marker.model.ts @@ -0,0 +1,58 @@ +import { + AllowNull, + AutoIncrement, + Column, + CreatedAt, + DataType, + Model, + PrimaryKey, + Table, + UpdatedAt, +} from 'sequelize-typescript'; + +/** + * ADR-006 §12 mechanism 2: the durable marker recording that PBKDF2 writes + * have BEGUN on this database. Planted on the FIRST PBKDF2 write (§12's + * settled planting trigger — never at install or migration time, which would + * record something untrue): the admin bootstrap seeder's write on a fresh + * install, or PasswordService.hash otherwise. Readers: the write-gate + * derivation itself (sticky), the §12 mechanism-3 startup refusal + * (HashWriteGateService.assertMarkerCompatible), and §17's authenticated + * /health detail (e25.20). + * + * markerVersion is a dedicated write-epoch integer (see + * SUPPORTED_HASH_MARKER_VERSION in hash-write-decision.ts for the + * rationale over package.json's semver). + */ +// Decorator stacks below keep sequelize-typescript's REQUIRED order — the +// attribute modifiers first and @Column last (decorators apply bottom-up, so +// @Column must execute before @PrimaryKey/@AllowNull annotate the attribute; +// the library throws "@Column annotation is missing or annotation order is +// wrong" otherwise). perfectionist/sort-decorators wants them alphabetical, +// which the library rejects at runtime — correctness wins. +@Table +export class HashMigrationMarker extends Model { + @CreatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @PrimaryKey + @AutoIncrement + @AllowNull(false) + @Column(DataType.BIGINT) + declare id: string; + + @AllowNull(false) + @Column(DataType.INTEGER) + declare markerVersion: number; + + @AllowNull(false) + @Column(DataType.DATE) + declare pbkdf2WritesBeganAt: Date; + + @UpdatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare updatedAt: Date; +} diff --git a/apps/backend/src/crypto/hash-write-decision.ts b/apps/backend/src/crypto/hash-write-decision.ts new file mode 100644 index 0000000000..ea0bd14722 --- /dev/null +++ b/apps/backend/src/crypto/hash-write-decision.ts @@ -0,0 +1,82 @@ +import { PasswordHashError } from './password'; + +/** + * ADR-006 §12 — the write-gate DECISION, extracted pure so the Nest service + * (hash-write-gate.service.ts) and the CommonJS admin seeder (site 8, which + * runs outside DI and requires the COMPILED dist/src/crypto output) share ONE + * implementation instead of a keep-in-sync copy. Dependency-free by the same + * §5 rule as password.ts. + * + * The write epoch this build understands. Epoch 1 = PBKDF2-PHC credential + * writes (§2). Bump ONLY when stored-credential write semantics change + * incompatibly; the §12 mechanism-3 startup refusal fires when a database's + * marker records a NEWER epoch than this constant. DECISION (card e25.21): a + * dedicated integer, NOT package.json's semver — an RPM Release-only bump + * changes neither write semantics nor this constant; the repo's package + * versions are unreliable comparison subjects (root 0.0.0, backend/frontend + * skew); integers compare without the lexicographic trap semver strings carry + * ('2.13.0' < '2.9.9'). Recorded in the marker table's migration. + */ +export const SUPPORTED_HASH_MARKER_VERSION = 1; + +export type HashWriteDecision = { + readonly enabled: boolean; + readonly reason: string; +}; + +export type HashWriteDecisionInput = { + /** Raw PASSWORD_HASH_WRITE_ENABLED value; undefined or '' means unset. */ + readonly explicitSetting: string | undefined; + /** A durable marker row exists — PBKDF2 writes already began. */ + readonly markerPresent: boolean; + /** The Users table has at least one row. */ + readonly usersPresent: boolean; +}; + +/** + * §9: out-of-range configuration throws at startup, never clamps silently. + */ +export function assertValidHashWriteSetting(raw: string | undefined): void { + if (raw !== undefined && raw !== '' && raw !== 'true' && raw !== 'false') { + throw new PasswordHashError( + `PASSWORD_HASH_WRITE_ENABLED must be 'true' or 'false' (got '${raw}')`, + ); + } +} + +/** + * §12 derivation (settled 2026-08-05): an explicit env value wins; otherwise + * the gate is ON when PBKDF2 writes have already begun on this database + * (marker present — sticky across restarts) or on a fresh install (empty + * Users table — no pre-N peer can exist), and OFF only on an upgrade, where + * a rolling window with pre-N pods is possible. + */ +export function deriveHashWriteState( + input: HashWriteDecisionInput, +): HashWriteDecision { + assertValidHashWriteSetting(input.explicitSetting); + if (input.explicitSetting === 'true') { + return { enabled: true, reason: 'PASSWORD_HASH_WRITE_ENABLED=true' }; + } + if (input.explicitSetting === 'false') { + return { enabled: false, reason: 'PASSWORD_HASH_WRITE_ENABLED=false' }; + } + if (input.markerPresent) { + return { + enabled: true, + reason: + 'durable marker present — PBKDF2 writes already began on this database', + }; + } + if (!input.usersPresent) { + return { + enabled: true, + reason: 'fresh install (empty Users table) — no pre-N peer can exist', + }; + } + return { + enabled: false, + reason: + 'upgrade default — existing users and no marker, so a rolling window with pre-N peers is possible', + }; +} diff --git a/apps/backend/src/crypto/hash-write-gate.service.spec.ts b/apps/backend/src/crypto/hash-write-gate.service.spec.ts new file mode 100644 index 0000000000..9ea26ee49d --- /dev/null +++ b/apps/backend/src/crypto/hash-write-gate.service.spec.ts @@ -0,0 +1,266 @@ +import * as nodeCrypto from 'node:crypto'; +import { KNOWN_GOOD_VECTORS } from '@heimdall/password-hash-vectors'; +import { getModelToken, SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { compare as bcryptCompare } from 'bcryptjs'; +import { Sequelize } from 'sequelize-typescript'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { ConfigService } from '../config/config.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { HashWriteGateService } from './hash-write-gate.service'; +import { verifyPassword } from './password'; +import { PasswordService } from './password.service'; + +const PASSWORD = 'CorrectHorse15!x'; + +// Module-scope assertion patterns (prefer-static-regex). +const BCRYPT_COST_14_PREFIX = /^\$2b\$14\$/v; +const ENV_VALIDATION_MESSAGE = /PASSWORD_HASH_WRITE_ENABLED must be 'true' or 'false'/v; +const FIPS_COHERENCE_MESSAGE = /PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS mode/v; +const REFUSAL_MESSAGE = /REFUSING TO START.*epoch 2.*epoch 1.*[Rr]emedy/sv; + +// Pass-through wrap so the §3 coherence test can steer ONE getFips result — +// real host FIPS state cannot be entered in CI (§10: it is host-level). +// Every other node:crypto function (pbkdf2, randomBytes, timingSafeEqual) +// goes to the real implementation via the spread. +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getFips: vi.fn(actual.getFips) }; +}); + +// ADR-006 §12: the rollout write gate. Real-DB harness (the derivation probes +// the Users table and the durable marker). Services are constructed MANUALLY +// per test — the derivation is boot-scoped and cached per instance, so a fresh +// instance per case is the only way to exercise both derivation outcomes. +describe('HashWriteGateService — §12 rollout write gate', () => { + let databaseService: DatabaseService; + let configService: ConfigService; + let markerModel: typeof HashMigrationMarker; + let userModel: typeof User; + let sequelize: Sequelize; + const priorEnvironment = process.env.PASSWORD_HASH_WRITE_ENABLED; + + function freshGate(): HashWriteGateService { + return new HashWriteGateService(markerModel, userModel, configService); + } + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + DatabaseModule, + SequelizeModule.forFeature([ + HashMigrationMarker, + User, + GroupUser, + Group, + GroupEvaluation, + Evaluation, + EvaluationTag, + ]), + ], + providers: [ConfigService, DatabaseService], + }).compile(); + databaseService = module.get(DatabaseService); + configService = module.get(ConfigService); + markerModel = module.get( + getModelToken(HashMigrationMarker), + ); + userModel = module.get(getModelToken(User)); + sequelize = module.get(Sequelize); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + }); + + afterEach(() => { + if (priorEnvironment === undefined) { + delete process.env.PASSWORD_HASH_WRITE_ENABLED; + } else { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorEnvironment; + } + }); + + it('when PASSWORD_HASH_WRITE_ENABLED=false: the gate reports writesEnabled=false and PasswordService.hash still produces a legacy-readable bcrypt credential', async () => { + expect.assertions(4); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + const gate = freshGate(); + expect(await gate.writesEnabled()).toBe(false); + // §12 scope: a NEW credential written while the gate is off must stay + // readable by a pre-N pod — so hash() falls back to bcrypt, and the + // credential carries rehash debt for after the gate opens. + const passwordService = new PasswordService(configService, gate); + const hash = await passwordService.hash(PASSWORD); + expect(hash).toMatch(BCRYPT_COST_14_PREFIX); + const result = await verifyPassword({ hash, password: PASSWORD }); + expect(result.valid).toBe(true); + expect(result.needsRehash).toBe(true); + }); + + describe('§12 derivation — both ways, per the settled 2026-08-05 decision', () => { + it('env unset + empty Users table → ENABLED (fresh install, no pre-N peer can exist)', async () => { + expect(await freshGate().writesEnabled()).toBe(true); + }); + + it('env unset + existing users + no marker → DISABLED (upgrade default, rolling window possible)', async () => { + await User.create({ + creationMethod: 'local', + email: 'derivation-upgrade@example.com', + encryptedPassword: 'placeholder-never-verified-here', + }); + expect(await freshGate().writesEnabled()).toBe(false); + }); + + it('env unset + existing users + marker present → ENABLED (PBKDF2 writes already began; sticky)', async () => { + await User.create({ + creationMethod: 'local', + email: 'derivation-sticky@example.com', + encryptedPassword: 'placeholder-never-verified-here', + }); + await HashMigrationMarker.create({ + markerVersion: 1, + pbkdf2WritesBeganAt: new Date(), + }); + expect(await freshGate().writesEnabled()).toBe(true); + }); + + it('env true + existing users → ENABLED (explicit env wins over the upgrade default)', async () => { + await User.create({ + creationMethod: 'local', + email: 'derivation-env-wins@example.com', + encryptedPassword: 'placeholder-never-verified-here', + }); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'true'; + expect(await freshGate().writesEnabled()).toBe(true); + }); + + it('an invalid value throws at construction (§9: out-of-range config never clamps silently)', () => { + process.env.PASSWORD_HASH_WRITE_ENABLED = 'yes'; + expect(() => freshGate()).toThrow(ENV_VALIDATION_MESSAGE); + }); + }); + + describe('§12 the REAL fresh-install sequence — migrate, seed, then boot (AC-review round-1 finding)', () => { + it('after the admin seeder runs (its PBKDF2 write plants the marker), the first app boot derives ENABLED', async () => { + expect.assertions(3); + // cmd.sh runs db:migrate -> db:seed:all -> start, so the app's FIRST + // derivation happens with the seeded admin already in Users. Without + // the seeder planting the marker on its own (first) PBKDF2 write, the + // derivation would see users=1/markers=0 and return the upgrade + // default — leaving every fresh containerized install writing bcrypt + // forever. This drives the seeder through a REAL queryInterface. The + // path lives in a const so tsc does not demand declarations for the + // out-of-project CJS file (test/seeders.spec.ts's loading pattern). + const seederPath = '../../seeders/20200514154327-create-administrator.js'; + const seederModule = (await import( + seederPath, + )) as { up: (queryInterface: unknown) => Promise }; + await seederModule.up(sequelize.getQueryInterface()); + expect(await userModel.count()).toBe(1); + expect(await HashMigrationMarker.count()).toBe(1); + expect(await freshGate().writesEnabled()).toBe(true); + }); + }); + + describe('§12 durable marker — planted on the first PBKDF2 write only', () => { + it('the first PBKDF2 hash plants exactly one row {markerVersion: 1, pbkdf2WritesBeganAt}; a second hash does not duplicate it', async () => { + expect.assertions(4); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'true'; + const passwordService = new PasswordService(configService, freshGate()); + expect(await HashMigrationMarker.count()).toBe(0); + await passwordService.hash(PASSWORD); + const rows = await HashMigrationMarker.findAll(); + expect(rows).toHaveLength(1); + expect(rows[0].markerVersion).toBe(1); + await passwordService.hash(PASSWORD); + expect(await HashMigrationMarker.count()).toBe(1); + }); + + it('a bcrypt fallback write (gate off) plants NOTHING — the marker must never record something untrue', async () => { + expect.assertions(2); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + const passwordService = new PasswordService(configService, freshGate()); + const hash = await passwordService.hash(PASSWORD); + expect(hash).toMatch(BCRYPT_COST_14_PREFIX); + expect(await HashMigrationMarker.count()).toBe(0); + }); + }); + + describe('§12 mechanism 3 — startup refusal on a newer marker', () => { + it('refuses to start when the marker records a newer write epoch, naming the remedy', async () => { + expect.assertions(1); + await HashMigrationMarker.create({ + markerVersion: 2, + pbkdf2WritesBeganAt: new Date(), + }); + await expect(freshGate().assertMarkerCompatible()).rejects.toThrow( + REFUSAL_MESSAGE, + ); + }); + + it('starts normally when the marker matches the supported epoch', async () => { + await HashMigrationMarker.create({ + markerVersion: 1, + pbkdf2WritesBeganAt: new Date(), + }); + await expect( + freshGate().assertMarkerCompatible(), + ).resolves.toBeUndefined(); + }); + + it('starts normally when no marker exists (PBKDF2 writes never began)', async () => { + await expect( + freshGate().assertMarkerCompatible(), + ).resolves.toBeUndefined(); + }); + }); + + describe('§3 coherence — the bcrypt fallback is FIPS-gated', () => { + it('gate off + FIPS mode on → hash() refuses rather than generate bcrypt inside the validated boundary (V-222571)', async () => { + expect.assertions(1); + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + const passwordService = new PasswordService(configService, freshGate()); + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(1); + await expect(passwordService.hash(PASSWORD)).rejects.toThrow( + FIPS_COHERENCE_MESSAGE, + ); + }); + }); + + describe('§12(4) graceful degradation — the old verify path against a PBKDF2 hash', () => { + it('bcryptjs.compare returns a clean false (no throw) for every known-good PBKDF2 vector', async () => { + // The rolling-deploy hazard §12(a) describes: a pre-N pod running + // bcryptjs.compare against a row a new pod rehashed. The contract lib's + // vectors stand in for those rows. + expect(KNOWN_GOOD_VECTORS.length).toBeGreaterThan(0); + for (const vector of KNOWN_GOOD_VECTORS) { + await expect( + bcryptCompare(vector.password, vector.hash), + ).resolves.toBe(false); + } + }); + }); +}); diff --git a/apps/backend/src/crypto/hash-write-gate.service.ts b/apps/backend/src/crypto/hash-write-gate.service.ts new file mode 100644 index 0000000000..5a75d904f2 --- /dev/null +++ b/apps/backend/src/crypto/hash-write-gate.service.ts @@ -0,0 +1,132 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { + assertValidHashWriteSetting, + deriveHashWriteState, + HashWriteDecision, + SUPPORTED_HASH_MARKER_VERSION, +} from './hash-write-decision'; + +export { SUPPORTED_HASH_MARKER_VERSION } from './hash-write-decision'; + +/** + * ADR-006 §12: the rollout write gate. One question — may this process write + * PBKDF2 credentials? — answered once per boot, plus the durable marker's + * planting and the mechanism-3 startup refusal. + * + * The gate covers ALL PBKDF2 writes (sites 1, 2, 7, 8 and both rehash + * paths): PasswordService.hash consults it for new-credential writes + * (falling back to bcrypt when off, so a pre-N pod can still read the row), + * and the rehash call sites consult writesEnabled() to skip persistence. + */ +@Injectable() +export class HashWriteGateService { + private derivation?: HashWriteDecision; + + private markerPlanted = false; + public logger = createLogger({ + format: format.printf(info => `[Hash Write Gate]: ${String(info.message)}`), + transports: [new transports.Console()], + }); + + constructor( + @InjectModel(HashMigrationMarker) + private readonly markerModel: typeof HashMigrationMarker, + @InjectModel(User) + private readonly userModel: typeof User, + private readonly configService: ConfigService, + ) { + // §9: out-of-range configuration throws at startup, never clamps. + assertValidHashWriteSetting( + this.configService.get('PASSWORD_HASH_WRITE_ENABLED'), + ); + } + + private async derive(): Promise { + const explicitSetting = this.configService.get( + 'PASSWORD_HASH_WRITE_ENABLED', + ); + if (explicitSetting === 'true' || explicitSetting === 'false') { + // An explicit setting decides alone — no DB probes (the manual test + // constructions with unregistered model classes rely on this). + return deriveHashWriteState({ + explicitSetting, + markerPresent: false, + usersPresent: false, + }); + } + const hasMarker = (await this.markerModel.count()) > 0; + const hasUsers = (await this.userModel.count()) > 0; + return deriveHashWriteState({ + explicitSetting, + markerPresent: hasMarker, + usersPresent: hasUsers, + }); + } + + /** + * §12 mechanism 3 — the downgrade refusal, in the application because RPM + * %pre cannot fire on the downgrades it targets (on downgrade the OLDER + * package's %pre runs, built before the guard existed) and because this + * path also catches the pg_dump-restore hazard. Called from bootstrap() + * before the app starts listening. + */ + async assertMarkerCompatible(): Promise { + const newest = await this.markerModel.max('markerVersion'); + if (typeof newest === 'number' && newest > SUPPORTED_HASH_MARKER_VERSION) { + throw new Error( + `REFUSING TO START: this database records credential write epoch ${newest}, but this build understands only epoch ${SUPPORTED_HASH_MARKER_VERSION} — it was written to by a NEWER Heimdall release, and credentials written under epoch ${newest} would silently fail to verify here. Remedy: reinstall the newer release (or, after an accidental restore, restore a database backup taken under this release). Do not delete the HashMigrationMarkers row to force startup — that trades this loud refusal for silent authentication failures.`, + ); + } + } + + /** + * §12 planting trigger (settled 2026-08-05): the marker is planted on the + * FIRST PBKDF2 write — never at install or migration time, which would + * record something untrue. findOrCreate keyed on the epoch makes planting + * idempotent across pods. A planting failure is loud but must never fail + * the credential write it accompanies: the write itself goes to the same + * database, so a real outage surfaces there with its own error, while the + * marker's protection is only needed on a LATER downgrade. + */ + async plantMarker(): Promise { + if (this.markerPlanted) { + return; + } + try { + await this.markerModel.findOrCreate({ defaults: { pbkdf2WritesBeganAt: new Date() }, where: { markerVersion: SUPPORTED_HASH_MARKER_VERSION } }); + this.markerPlanted = true; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + const message = `failed to plant the §12 hash-migration marker (epoch ${SUPPORTED_HASH_MARKER_VERSION}); downgrade protection is NOT recorded for this write: ${reason}`; + this.logger.error({ message }); + } + } + + /** + * §12 derivation (settled 2026-08-05): an explicit env value wins; + * otherwise the gate is ON when PBKDF2 writes have already begun on this + * database (marker present — sticky across restarts) or on a fresh install + * (empty Users table at first boot — no pre-N peer can exist), and OFF + * only on an upgrade, where a rolling window with pre-N pods is possible. + * + * Boot-scoped: derived once per service instance and cached — the §12 + * rolling-window question is about this process's release, which does not + * change while it runs. + */ + async writesEnabled(): Promise { + if (this.derivation === undefined) { + this.derivation = await this.derive(); + this.logger.info({ + message: `PBKDF2 writes ${ + this.derivation.enabled ? 'ENABLED' : 'DISABLED' + } — ${this.derivation.reason}`, + }); + } + return this.derivation.enabled; + } +} diff --git a/apps/backend/src/crypto/password.service.spec.ts b/apps/backend/src/crypto/password.service.spec.ts new file mode 100644 index 0000000000..042ce3c885 --- /dev/null +++ b/apps/backend/src/crypto/password.service.spec.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigService } from '../config/config.service'; +import { User } from '../users/user.model'; +import { HashMigrationMarker } from './hash-migration-marker.model'; +import { HashWriteGateService } from './hash-write-gate.service'; +import { + configureKdfLimiter, + hashPassword, + kdfLimiterState, + PasswordHashError, +} from './password'; +import { PasswordService } from './password.service'; + +// Hoisted to module scope (prefer-static-regex): assertion patterns for the +// construction-time validation messages. +const ITERATIONS_RANGE = /PASSWORD_HASH_ITERATIONS.*100000.*10000000/v; +const ITERATIONS_NAMED = /PASSWORD_HASH_ITERATIONS/v; +const ALGORITHM_ALLOWLIST = /PASSWORD_HASH_ALGORITHM.*sha256.*sha384.*sha512/v; +const MAX_LENGTH_NAMED = /PASSWORD_MAX_LENGTH.*128/v; +const KDF_CONCURRENCY_NAMED = /PASSWORD_KDF_CONCURRENCY/v; + +// Build a PasswordService whose ConfigService returns the given values. A Map +// (not a Record index) avoids the object-injection sink; spying `get` (not +// casting) keeps the real type — no Gate 3 bypass. +function serviceWith( + values: Record, +): PasswordService { + const lookup = new Map( + Object.entries({ PASSWORD_HASH_WRITE_ENABLED: 'true', ...values }), + ); + const config = new ConfigService(); + vi.spyOn(config, 'get').mockImplementation((key: string) => lookup.get(key)); + // §12: writes explicitly enabled and marker planting stubbed — the gate's + // own behavior is hash-write-gate.service.spec.ts's subject, and this spec + // must never reach for a database. The model classes are passed + // unregistered; with an explicit env value the derivation never queries + // them, and the plantMarker spy (not a cast) keeps the real types. + const gate = new HashWriteGateService(HashMigrationMarker, User, config); + vi.spyOn(gate, 'plantMarker').mockResolvedValue(undefined); + return new PasswordService(config, gate); +} + +describe('PasswordService — §9 config binding + delegation', () => { + beforeEach(() => { + // Reset the module-scope KDF limiter so concurrency-binding assertions + // start from a known state (e25.8's seam). + configureKdfLimiter(); + }); + + describe('construction-time §9 validation — throws, never clamps', () => { + it('throws when PASSWORD_HASH_ITERATIONS is below the floor, naming the variable and range', () => { + expect(() => serviceWith({ PASSWORD_HASH_ITERATIONS: '50000' })).toThrow( + ITERATIONS_RANGE, + ); + }); + + it('throws when PASSWORD_HASH_ITERATIONS exceeds the ceiling', () => { + expect(() => + serviceWith({ PASSWORD_HASH_ITERATIONS: '10000001' }), + ).toThrow(ITERATIONS_NAMED); + }); + + it('throws when PASSWORD_HASH_ALGORITHM is not in the allowlist', () => { + expect(() => serviceWith({ PASSWORD_HASH_ALGORITHM: 'md5' })).toThrow( + ALGORITHM_ALLOWLIST, + ); + }); + + it('throws when PASSWORD_MAX_LENGTH exceeds 128', () => { + expect(() => serviceWith({ PASSWORD_MAX_LENGTH: '256' })).toThrow( + MAX_LENGTH_NAMED, + ); + }); + + it('throws when PASSWORD_KDF_CONCURRENCY is below 1', () => { + expect(() => serviceWith({ PASSWORD_KDF_CONCURRENCY: '0' })).toThrow( + KDF_CONCURRENCY_NAMED, + ); + }); + + it('throws on a non-integer iteration value (never silently coerces)', () => { + expect(() => + serviceWith({ PASSWORD_HASH_ITERATIONS: '6e5' }), + ).toThrow(ITERATIONS_NAMED); + }); + }); + + describe('defaults (§9 table) when nothing is configured', () => { + it('hashes with sha512 / 600000 by default', async () => { + const service = serviceWith({}); + const hash = await service.hash('CorrectHorse15!x'); + expect(hash.startsWith('$pbkdf2-sha512$i=600000$')).toBe(true); + }); + + it('binds PASSWORD_KDF_CONCURRENCY default of 2 to the limiter', async () => { + // Pre-set a DIFFERENT concurrency so this test proves the constructor + // actively rebinds to 2 — not merely that beforeEach left it at 2. If + // the bind were removed, the limiter would stay at 5 and the third probe + // would run ({active:3,queued:0}), failing the assertion. + configureKdfLimiter({ concurrency: 5 }); + serviceWith({}); + const runs = Promise.all([ + hashViaLimiterProbe(), + hashViaLimiterProbe(), + hashViaLimiterProbe(), + ]); + await Promise.resolve(); + expect(kdfLimiterState()).toEqual({ active: 2, queued: 1 }); + await runs; + }); + }); + + describe('configured values are honoured on the hash path', () => { + it('uses PASSWORD_HASH_ALGORITHM and PASSWORD_HASH_ITERATIONS', async () => { + const service = serviceWith({ + PASSWORD_HASH_ALGORITHM: 'sha256', + PASSWORD_HASH_ITERATIONS: '200000', + }); + const hash = await service.hash('CorrectHorse15!x'); + expect(hash.startsWith('$pbkdf2-sha256$i=200000$')).toBe(true); + }); + + it('binds a custom PASSWORD_KDF_CONCURRENCY to the limiter', async () => { + serviceWith({ PASSWORD_KDF_CONCURRENCY: '1' }); + const runs = Promise.all([hashViaLimiterProbe(), hashViaLimiterProbe()]); + await Promise.resolve(); + expect(kdfLimiterState()).toEqual({ active: 1, queued: 1 }); + await runs; + }); + + it('rejects a password longer than the configured PASSWORD_MAX_LENGTH (hash path cap)', async () => { + const service = serviceWith({ PASSWORD_MAX_LENGTH: '64' }); + await expect(service.hash('a'.repeat(65))).rejects.toBeInstanceOf( + PasswordHashError, + ); + await expect(service.hash('Aa1!'.repeat(15))).resolves.toContain( + '$pbkdf2-', + ); // 60 chars, under the cap + }); + }); + + describe('verify path applies NO policy bounds (§9)', () => { + it('verifies a hash made under a DIFFERENT (lower) iteration config', async () => { + // Hash at 200k, then verify through a service configured at 600k — the + // stored parameters govern verification, not the service policy. + const lo = serviceWith({ PASSWORD_HASH_ITERATIONS: '200000' }); + const stored = await lo.hash('CorrectHorse15!x'); + const hi = serviceWith({}); // default 600k + await expect( + hi.verify({ hash: stored, password: 'CorrectHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: true }); + await expect( + hi.verify({ hash: stored, password: 'WrongHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: false }); + }); + + it('verify does not apply PASSWORD_MAX_LENGTH — a password over the configured cap still verifies', async () => { + // A 100-char password: over the configured cap (64) but under the pure + // module's absolute 128 DoS cap, so it was hashable when the cap was + // higher. After the cap drops to 64 the user must STILL verify (§9: + // caps never apply on verify). + const capped = serviceWith({ PASSWORD_MAX_LENGTH: '64' }); + const longPass = 'Aa1!'.repeat(25); // 100 chars + const stored = await hashUncapped(longPass); + await expect( + capped.verify({ hash: stored, password: longPass }), + ).resolves.toEqual({ needsRehash: false, valid: true }); + }); + }); +}); + +function hashUncapped(password: string): Promise { + return hashPassword(password); +} + +// Local helpers kept out of the describe bodies for scoping cleanliness. +// Direct (not dynamic-import) so the acquire runs synchronously and the +// limiter-state probe after one microtask flush is deterministic. +function hashViaLimiterProbe(): Promise { + return hashPassword('CorrectHorse15!x', { iterations: 200_000 }); +} diff --git a/apps/backend/src/crypto/password.service.ts b/apps/backend/src/crypto/password.service.ts new file mode 100644 index 0000000000..47b91259f9 --- /dev/null +++ b/apps/backend/src/crypto/password.service.ts @@ -0,0 +1,184 @@ +import * as nodeCrypto from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { hash as bcryptHashLegacy } from 'bcryptjs'; +import { ConfigService } from '../config/config.service'; +import { HashWriteGateService } from './hash-write-gate.service'; +import { + configureKdfLimiter, + hashPassword, + PasswordHashAlgorithm, + PasswordHashError, + PasswordVerifyResult, + verifyPassword, +} from './password'; + +const DECIMAL_INTEGER = /^\d+$/v; +const ALGORITHMS: readonly PasswordHashAlgorithm[] = [ + 'sha256', + 'sha384', + 'sha512', +]; + +function isAlgorithm(value: string): value is PasswordHashAlgorithm { + return (ALGORITHMS as readonly string[]).includes(value); +} + +/** + * Nest layer over the pure password primitives (ADR-006 §5, §9). Reads the + * §9 configuration through ConfigService and delegates to password.ts. + * + * Only hashing needs configuration — verifyPassword reads its parameters from + * the stored hash, so the verify path passes NO policy bounds (§9's + * contradiction fix: a user hashed under an earlier, lower iteration/length + * config must still verify). All §9 values are validated at construction and + * throw — never a silent clamp. + * + * §9 defaults (must match the ADR table): + * PASSWORD_HASH_ALGORITHM = sha512 (NSS deployments must NOT use sha256 — + * V-222571 makes anything weaker than + * SHA-384 a finding) + * PASSWORD_HASH_ITERATIONS = 600000 + * PASSWORD_MAX_LENGTH = 128 + * PASSWORD_KDF_CONCURRENCY = 2 + */ +@Injectable() +export class PasswordService { + private static readonly ABSOLUTE_MAX_LENGTH = 128; // §6 approved 8–128 range + private static readonly DEFAULT_ALGORITHM: PasswordHashAlgorithm = 'sha512'; + private static readonly DEFAULT_ITERATIONS = 600_000; + private static readonly DEFAULT_KDF_CONCURRENCY = 2; + private static readonly DEFAULT_MAX_LENGTH = 128; + private static readonly MAX_ITERATIONS = 10_000_000; // §6 DoS ceiling + private static readonly MIN_ITERATIONS = 100_000; // §9 hash-path floor + + private readonly algorithm: PasswordHashAlgorithm; + private readonly iterations: number; + private readonly maxLength: number; + + constructor( + private readonly configService: ConfigService, + private readonly hashWriteGate: HashWriteGateService, + ) { + this.algorithm = this.readAlgorithm(); + this.iterations = this.readIntInRange( + 'PASSWORD_HASH_ITERATIONS', + PasswordService.DEFAULT_ITERATIONS, + PasswordService.MIN_ITERATIONS, + PasswordService.MAX_ITERATIONS, + ); + this.maxLength = this.readIntInRange( + 'PASSWORD_MAX_LENGTH', + PasswordService.DEFAULT_MAX_LENGTH, + 1, + PasswordService.ABSOLUTE_MAX_LENGTH, + ); + const concurrency = this.readIntInRange( + 'PASSWORD_KDF_CONCURRENCY', + PasswordService.DEFAULT_KDF_CONCURRENCY, + 1, + Number.MAX_SAFE_INTEGER, + ); + // §11: bind the global KDF limiter's init seam (e25.8). Done once at + // construction, before any hashing runs. + configureKdfLimiter({ concurrency }); + } + + private readAlgorithm(): PasswordHashAlgorithm { + const raw = this.configService.get('PASSWORD_HASH_ALGORITHM'); + if (raw === undefined || raw === '') { + return PasswordService.DEFAULT_ALGORITHM; + } + if (isAlgorithm(raw)) { + return raw; + } + throw new PasswordHashError( + `PASSWORD_HASH_ALGORITHM must be one of sha256, sha384, sha512 (got '${raw}')`, + ); + } + + private readIntInRange( + key: string, + fallback: number, + min: number, + max: number, + ): number { + const raw = this.configService.get(key); + if (raw === undefined || raw === '') { + return fallback; + } + // Decimal integers only — never parseInt/Number coercion (§6 step 4: + // parseInt('6e5') === 6, Number('0x10') === 16). + if (!DECIMAL_INTEGER.test(raw)) { + throw new PasswordHashError( + `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, + ); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new PasswordHashError( + `${key} must be an integer within [${min}, ${max}] (got '${raw}')`, + ); + } + return value; + } + + /** + * Hash a password using the configured algorithm and iterations. Enforces + * the configured PASSWORD_MAX_LENGTH on this (hash) path only; the pure + * function keeps its own absolute 128 cap as defense in depth. + * + * §12 rollout gate: while PBKDF2 writes are DISABLED (rolling-deploy + * window), a NEW credential must still be readable by a pre-N pod, so this + * falls back to bcrypt (cost 14, the historical parameter) and leaves + * rehash debt for after the gate opens. When writes are enabled, the first + * PBKDF2 hash plants the §12 durable marker. + */ + async hash(password: string): Promise { + if (typeof password === 'string' && password.length > this.maxLength) { + throw new PasswordHashError( + `password must be at most ${this.maxLength} characters`, + ); + } + if (!(await this.hashWriteGate.writesEnabled())) { + // §3 / V-222571: bcrypt (pure JS, outside the validated module) must + // never GENERATE a hash while FIPS mode is active. Gate-off + FIPS-on + // is a self-contradictory deployment — §12's phase ordering enables + // FIPS only after cutover, when the gate is necessarily on. + if (nodeCrypto.getFips() === 1) { + throw new PasswordHashError( + 'PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS mode: a bcrypt fallback hash cannot be generated inside the validated boundary (V-222571). Enable PBKDF2 writes or disable FIPS mode.', + ); + } + return bcryptHashLegacy(password, 14); + } + const hashed = await hashPassword(password, { + algorithm: this.algorithm, + iterations: this.iterations, + }); + await this.hashWriteGate.plantMarker(); + return hashed; + } + + /** + * Verify a password against a stored hash. NO policy bounds are applied — + * the stored hash's own parameters govern (§9). getFips defaults to the real + * crypto.getFips inside verifyPassword. + */ + verify(arguments_: { + hash: string; + password: string; + }): Promise { + return verifyPassword(arguments_); + } + + /** + * §12: whether PBKDF2 credential writes are enabled for this process. + * The rehash call sites (sites 4 and 5) consult this to SKIP persistence + * while the gate is off — verifyPassword still reports needsRehash, but a + * rehash written during the rolling window would be unreadable by pre-N + * pods. Exposed here so callers need no direct gate dependency. + */ + writesEnabled(): Promise { + return this.hashWriteGate.writesEnabled(); + } +} diff --git a/apps/backend/src/crypto/password.spec.ts b/apps/backend/src/crypto/password.spec.ts new file mode 100644 index 0000000000..c4b6ca56b4 --- /dev/null +++ b/apps/backend/src/crypto/password.spec.ts @@ -0,0 +1,511 @@ +import * as nodeCrypto from 'node:crypto'; +import { + KNOWN_GOOD_VECTORS, + MALFORMED_CORPUS, +} from '@heimdall/password-hash-vectors'; +import * as bcryptjs from 'bcryptjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + configureKdfLimiter, + hashPassword, + hashPasswordWithSalt, + kdfLimiterState, + KdfOverloadedError, + PasswordHashError, + verifyPassword, +} from './password'; + +// Wrap compare in a pass-through spy so tests can assert INVOCATION and +// NON-invocation of the real module (§5: a return-value assertion alone would +// pass an implementation that calls compare() and discards the result — the +// exact V-222571 finding). vi.mock intercepts the lazy dynamic import in +// password.ts too; the FIPS-off positive control proves the interception is +// live, so the non-invocation assertion cannot pass vacuously. +vi.mock('bcryptjs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, compare: vi.fn(actual.compare) }; +}); + +type Algorithm = 'sha256' | 'sha384' | 'sha512'; + +// Decoded byte width of a no-padding base64 string: floor(len * 6 / 8). +// Arithmetic so the width checks need no Buffer decode. +function b64ByteWidth(field: string): number { + return Math.floor((field.length * 3) / 4); +} + +// PBKDF2 derived-key width = digest width (§2), exhaustive, no dynamic index. +function digestWidth(algorithm: Algorithm): number { + switch (algorithm) { + case 'sha256': { + return 32; + } + case 'sha384': { + return 48; + } + case 'sha512': { + return 64; + } + default: { + throw new Error('unhandled algorithm'); + } + } +} + +// Build a PHC hash with RAW node:crypto, bypassing hashPassword's §9 hashing +// floors — the only way to produce the legacy-parameter hashes (50k, 1000 +// iterations; >128-char passwords) that verifyPassword must still accept. +// Buffer base64, not Uint8Array#toBase64 — that TC39 API is undefined at our +// Node runtime (see toB64 in password.ts). +function rawPhc( + password: string, + salt: Buffer, + iterations: number, + algorithm: Algorithm, +): string { + const key = nodeCrypto.pbkdf2Sync( + password, + salt, + iterations, + digestWidth(algorithm), + algorithm, + ); + const b64 = (buffer: Buffer) => buffer.toString('base64').replaceAll('=', ''); + return `$pbkdf2-${algorithm}$i=${iterations}$${b64(salt)}$${b64(key)}`; +} + +// Decode a vector's salt straight out of its PHC hash so we can inject it — +// the vectors are the implementation-independent ground truth (ADR §14). +function saltOf(hash: string): Buffer { + return Buffer.from(hash.split('$', 4)[3], 'base64'); +} + +describe('hashPasswordWithSalt', () => { + it('reproduces every known-good vector when given the vector salt', async () => { + // THE CONTRACT: hashing a vector's password with its salt/algorithm/ + // iterations must reproduce the vector's hash byte-for-byte. This proves + // the encoder matches the ground truth, not itself. + for (const v of KNOWN_GOOD_VECTORS) { + const produced = await hashPasswordWithSalt(v.password, saltOf(v.hash), { + algorithm: v.algorithm, + iterations: v.iterations, + }); + expect(produced).toBe(v.hash); + } + }); + + it('emits standard base64 with padding stripped in both salt and key', async () => { + const hash = await hashPasswordWithSalt('CorrectHorse15!x', Buffer.alloc(32, 7)); + const parts = hash.split('$'); + expect(parts).toHaveLength(5); + expect(parts[0]).toBe(''); + expect(parts[3]).not.toContain('='); + expect(parts[4]).not.toContain('='); + // Standard alphabet (+/), never base64url (-_). + expect(parts[3].includes('-') || parts[3].includes('_')).toBe(false); + expect(parts[4].includes('-') || parts[4].includes('_')).toBe(false); + }); + + it('keeps every digest output within VARCHAR(255)', async () => { + for (const algorithm of ['sha256', 'sha384', 'sha512'] as const) { + const hash = await hashPasswordWithSalt('CorrectHorse15!x', Buffer.alloc(32, 1), { algorithm }); + expect(hash.length).toBeLessThanOrEqual(255); + // Key width (arithmetic, no decode) must equal the digest width. + expect(b64ByteWidth(hash.split('$', 5)[4])).toBe(digestWidth(algorithm)); + } + }); +}); + +describe('hashPassword (production path)', () => { + it('defaults to sha512 / 600000 / a 32-byte random salt', async () => { + const hash = await hashPassword('CorrectHorse15!x'); + const parts = hash.split('$'); + expect(parts[1]).toBe('pbkdf2-sha512'); + expect(parts[2]).toBe('i=600000'); + expect(b64ByteWidth(parts[3])).toBe(32); + expect(b64ByteWidth(parts[4])).toBe(64); + }); + + it('produces a different salt (and hash) on each call', async () => { + const a = await hashPassword('CorrectHorse15!x'); + const b = await hashPassword('CorrectHorse15!x'); + expect(a).not.toBe(b); + }); + + it('honours algorithm and iteration options', async () => { + const hash = await hashPassword('CorrectHorse15!x', { + algorithm: 'sha256', + iterations: 200_000, + }); + expect(hash.startsWith('$pbkdf2-sha256$i=200000$')).toBe(true); + }); +}); + +describe('validation (§6/§9) — throws typed errors, never clamps', () => { + it('rejects a non-string password', async () => { + // Runtime guard: the seeder is CommonJS JS, so a non-string can arrive. + await expect( + hashPassword(42 as unknown as string), + ).rejects.toBeInstanceOf(PasswordHashError); + }); + + it('rejects an empty password', async () => { + await expect(hashPassword('')).rejects.toBeInstanceOf(PasswordHashError); + }); + + it('rejects a password longer than 128 characters', async () => { + await expect( + hashPassword('a'.repeat(129)), + ).rejects.toBeInstanceOf(PasswordHashError); + // 128 exactly is allowed. + await expect(hashPassword('a'.repeat(128))).resolves.toContain('$pbkdf2-'); + }); + + it('rejects an algorithm outside the strict allowlist', async () => { + await expect( + hashPassword('CorrectHorse15!x', { algorithm: 'md5' as unknown as 'sha512' }), + ).rejects.toBeInstanceOf(PasswordHashError); + }); + + it('rejects iterations below 100000 and above 10000000', async () => { + await expect( + hashPassword('CorrectHorse15!x', { iterations: 99_999 }), + ).rejects.toBeInstanceOf(PasswordHashError); + await expect( + hashPassword('CorrectHorse15!x', { iterations: 10_000_001 }), + ).rejects.toBeInstanceOf(PasswordHashError); + // The boundaries themselves are allowed. + await expect( + hashPassword('CorrectHorse15!x', { iterations: 100_000 }), + ).resolves.toContain('$pbkdf2-'); + await expect( + hashPassword('CorrectHorse15!x', { iterations: 10_000_000 }), + ).resolves.toContain('$pbkdf2-'); + }, 60_000); + + it('rejects a non-integer iteration count', async () => { + await expect( + hashPassword('CorrectHorse15!x', { iterations: 600_000.5 }), + ).rejects.toBeInstanceOf(PasswordHashError); + }); +}); + +describe('verifyPassword — §3 dispatch × FIPS gate', () => { + it('refuses a bcrypt hash under FIPS without ever invoking bcryptjs.compare', async () => { + // §5: the spy IS the point. An implementation that calls compare() and + // discards the result generates a bcrypt hash outside the validated + // module — the V-222571 finding — while passing a return-value check. + vi.mocked(bcryptjs.compare).mockClear(); + const result = await verifyPassword({ + getFips: () => 1, + hash: '$2b$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + password: 'CorrectHorse15!x', + }); + expect(result).toEqual({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + expect(bcryptjs.compare).not.toHaveBeenCalled(); + }); + + it('verifies every known-good vector in BOTH FIPS states with needsRehash:false', async () => { + // §3 row 1: the pbkdf2 path is identical whether FIPS is on or off. + for (const v of KNOWN_GOOD_VECTORS) { + for (const fips of [0, 1]) { + const result = await verifyPassword({ + getFips: () => fips, + hash: v.hash, + password: v.password, + }); + expect(result, `${v.label} fips=${fips}`).toEqual({ + needsRehash: false, + valid: true, + }); + } + } + }); + + it('rejects every vector when the last key character is perturbed', async () => { + // 'A' (0) and 'Q' (16) both have zero trailing bits at every digest + // width, so the perturbed hash stays CANONICAL base64 — it passes the + // §6 step-6 re-encode check and step-7 lengths, reaching the real KDF + // comparison. This pins the timingSafeEqual mismatch path, not an + // earlier malformed-input rejection. + for (const v of KNOWN_GOOD_VECTORS) { + const perturbed + = v.hash.slice(0, -1) + (v.hash.endsWith('A') ? 'Q' : 'A'); + const result = await verifyPassword({ + getFips: () => 0, + hash: perturbed, + password: v.password, + }); + expect(result, `perturbed ${v.label}`).toEqual({ + needsRehash: false, + valid: false, + }); + } + }); +}); + +describe('verifyPassword — §6 validation against the malformed corpus', () => { + it('rejects the ENTIRE corpus of reject/sentinel entries without throwing', async () => { + const rejects = MALFORMED_CORPUS.filter( + entry => entry.expected === 'reject' || entry.expected === 'sentinel', + ); + // Pin the count so a silently shrunk corpus (or a broken filter) fails + // loudly instead of vacuously passing on an empty table. + expect(rejects).toHaveLength(18); + // One FIPS state suffices: getFips is unreachable on every reject path + // (only the bcrypt branch consults it — covered both-states in the + // dispatch tests), and each rejection burns a full-cost constant-work + // dummy, so doubling the loop doubles ~5s of KDF time for zero coverage. + // fips=1 is the deployment-relevant state; the timing and non-string + // tests exercise reject paths at fips=0. + for (const entry of rejects) { + const result = await verifyPassword({ + getFips: () => 1, + // The corpus's one non-string entry deliberately violates the + // signature — §6 step 1 is a runtime guard for the CommonJS seeder. + hash: entry.hash as string, + password: 'CorrectHorse15!x', + }); + expect(result, `corpus trap: ${entry.trap}`).toEqual({ + needsRehash: false, + valid: false, + }); + } + }, 15_000); + + it('rejects a non-string password without throwing', async () => { + const result = await verifyPassword({ + getFips: () => 0, + hash: KNOWN_GOOD_VECTORS[0].hash, + password: 42 as unknown as string, + }); + expect(result).toEqual({ needsRehash: false, valid: false }); + }); +}); + +describe('verifyPassword — constant-work rejection (Risks: timing side-channel)', () => { + it('burns KDF-equivalent work on the unknown-format and FIPS-refuse paths', async () => { + // Timing IS the requirement, so the assertion is temporal: a reject must + // cost at least a quarter of a real default-parameter verification. + // Correct code runs the SAME 600k-iteration KDF on both sides (~1x), so + // the 4x margin cannot flake; pre-fix rejects are ~1000x faster and fail. + const vector = KNOWN_GOOD_VECTORS[0]; // sha512 @ 600k — default params + const realStart = performance.now(); + await verifyPassword({ + getFips: () => 0, + hash: vector.hash, + password: vector.password, + }); + const realDuration = performance.now() - realStart; + + const sentinelStart = performance.now(); + const sentinel = await verifyPassword({ + getFips: () => 0, + hash: 'INVALIDATED-BY-FIPS-CUTOVER-DO-NOT-USE', + password: vector.password, + }); + const sentinelDuration = performance.now() - sentinelStart; + + const refuseStart = performance.now(); + const refused = await verifyPassword({ + getFips: () => 1, + hash: '$2b$14$abcdefghijklmnopqrstuuX0Xz3wF9Yt7q0kz0kz0kz0kz0kz0kz0', + password: vector.password, + }); + const refuseDuration = performance.now() - refuseStart; + + expect(sentinel).toEqual({ needsRehash: false, valid: false }); + expect(refused).toEqual({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + expect(sentinelDuration).toBeGreaterThan(realDuration / 4); + expect(refuseDuration).toBeGreaterThan(realDuration / 4); + }); +}); + +describe('verifyPassword — §9 verify has no policy floor and no length cap', () => { + it('verifies a 50k-iteration hash of a 200-char password — legacy config params must not lock users out', async () => { + // §9: a user hashed under an earlier PASSWORD_HASH_ITERATIONS=50000 (below + // the 100k HASHING floor) must still verify, and PASSWORD_MAX_LENGTH + // applies to hashing only — capping on verify would lock out any user + // whose existing password exceeds it. + const password = 'Aa1!'.repeat(50); // 200 chars — over the 128 hashing cap + const hash = rawPhc(password, Buffer.alloc(24, 9), 50_000, 'sha512'); + const result = await verifyPassword({ getFips: () => 1, hash, password }); + expect(result).toEqual({ needsRehash: false, valid: true }); + }); + + it('accepts the 1000-iteration sanity floor and rejects 999', async () => { + const atFloor = rawPhc('CorrectHorse15!x', Buffer.alloc(16, 3), 1000, 'sha256'); + await expect( + verifyPassword({ getFips: () => 0, hash: atFloor, password: 'CorrectHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: true }); + + const belowFloor = rawPhc('CorrectHorse15!x', Buffer.alloc(16, 3), 999, 'sha256'); + await expect( + verifyPassword({ getFips: () => 0, hash: belowFloor, password: 'CorrectHorse15!x' }), + ).resolves.toEqual({ needsRehash: false, valid: false }); + }); +}); + +describe('verifyPassword — §3 bcrypt path (FIPS off)', () => { + it('verifies a real bcrypt hash via bcryptjs.compare with needsRehash:valid — the positive control for the spy', async () => { + // This test proves the vi.mock interception is LIVE: the same spy the + // FIPS-refusal test asserts was NOT called must observe the call here. + // Without this control, a spy that failed to intercept would make the + // non-invocation assertion pass vacuously. + const hash = await bcryptjs.hash('CorrectHorse15!x', 4); + vi.mocked(bcryptjs.compare).mockClear(); + const match = await verifyPassword({ + getFips: () => 0, + hash, + password: 'CorrectHorse15!x', + }); + expect(match).toEqual({ needsRehash: true, valid: true }); + expect(bcryptjs.compare).toHaveBeenCalledTimes(1); + expect(bcryptjs.compare).toHaveBeenCalledWith('CorrectHorse15!x', hash); + + const mismatch = await verifyPassword({ + getFips: () => 0, + hash, + password: 'WrongHorse15!x', + }); + expect(mismatch).toEqual({ needsRehash: false, valid: false }); + }); + + it('rejects a bcrypt-prefixed but malformed hash without throwing — a corrupted row must fail auth, not 500', async () => { + // bcryptjs v3 compare, probed live: length !== 60 resolves false, but a + // 60-char hash whose salt section uses a non-bcrypt alphabet REJECTS the + // promise ("Illegal salt length: 0 != 16") — verifyPassword must convert + // that into the reject result (the "never throws on malformed input" + // contract covers the §3 bcrypt row too). + const shortMalformed = await verifyPassword({ + getFips: () => 0, + hash: '$2b$zz$not-a-valid-bcrypt-hash', + password: 'CorrectHorse15!x', + }); + expect(shortMalformed).toEqual({ needsRehash: false, valid: false }); + + const rejectingMalformed = await verifyPassword({ + getFips: () => 0, + hash: `$2b$10$${'!'.repeat(53)}`, // 60 chars — compare() rejects on this + password: 'CorrectHorse15!x', + }); + expect(rejectingMalformed).toEqual({ needsRehash: false, valid: false }); + }); + + it('dispatches all three corpus bcrypt prefixes: compare invoked when FIPS off, refused un-invoked when FIPS on', async () => { + const bcryptEntries = MALFORMED_CORPUS.filter( + entry => entry.expected === 'bcrypt', + ); + expect(bcryptEntries).toHaveLength(3); // $2a$ / $2b$ / $2y$ + for (const entry of bcryptEntries) { + // FIPS off — §3 dispatches to bcryptjs; garbage checksum → false, no throw. + vi.mocked(bcryptjs.compare).mockClear(); + const offResult = await verifyPassword({ + getFips: () => 0, + hash: entry.hash as string, + password: 'CorrectHorse15!x', + }); + expect(offResult, `${entry.trap} fips=0`).toEqual({ + needsRehash: false, + valid: false, + }); + expect(bcryptjs.compare, `${entry.trap} fips=0`).toHaveBeenCalledTimes(1); + + // FIPS on — §3 refuses without ever invoking bcryptjs. + vi.mocked(bcryptjs.compare).mockClear(); + const onResult = await verifyPassword({ + getFips: () => 1, + hash: entry.hash as string, + password: 'CorrectHorse15!x', + }); + expect(onResult, `${entry.trap} fips=1`).toEqual({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + expect(bcryptjs.compare, `${entry.trap} fips=1`).not.toHaveBeenCalled(); + } + }); +}); + +describe('KDF concurrency limiter (§11) — global semaphore inside password.ts', () => { + afterEach(() => { + // Restore defaults so limiter config never leaks across tests. + configureKdfLimiter(); + }); + + it('with concurrency 2, a third concurrent hashPassword queues and does not dispatch until a slot frees', async () => { + configureKdfLimiter({ concurrency: 2 }); + // 200k iterations ≈ tens of ms — long enough that all three overlap and + // the state probe below runs before ANY of them completes. + const inFlight = [ + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + ]; + // One microtask flush: the first two acquired slots synchronously, the + // third must be QUEUED — not dispatched to pbkdf2. + await Promise.resolve(); + expect(kdfLimiterState()).toEqual({ active: 2, queued: 1 }); + + const hashes = await Promise.all(inFlight); + for (const hash of hashes) { + expect(hash.startsWith('$pbkdf2-sha512$i=200000$')).toBe(true); + } + // All slots released after settlement — no leaked accounting. + expect(kdfLimiterState()).toEqual({ active: 0, queued: 0 }); + }); + + it('rejects with the typed KdfOverloadedError when the bounded queue is full', async () => { + configureKdfLimiter({ concurrency: 1, maxQueue: 1 }); + const first = hashPassword('CorrectHorse15!x', { iterations: 200_000 }); + const second = hashPassword('CorrectHorse15!x', { iterations: 200_000 }); + // 1 active + 1 queued — the third must reject, typed, without ever + // dispatching KDF work. + await expect( + hashPassword('CorrectHorse15!x', { iterations: 200_000 }), + ).rejects.toBeInstanceOf(KdfOverloadedError); + await expect(first).resolves.toContain('$pbkdf2-'); + await expect(second).resolves.toContain('$pbkdf2-'); + expect(kdfLimiterState()).toEqual({ active: 0, queued: 0 }); + }); + + it('covers the verify path too — a queued verifyPassword completes correctly', async () => { + configureKdfLimiter({ concurrency: 1 }); + const vector = KNOWN_GOOD_VECTORS[0]; + // Occupy the single slot, then verify — the verify must queue, then run. + const occupant = hashPassword('CorrectHorse15!x', { iterations: 200_000 }); + const verified = verifyPassword({ + getFips: () => 0, + hash: vector.hash, + password: vector.password, + }); + await Promise.resolve(); + expect(kdfLimiterState().queued).toBe(1); + await expect(verified).resolves.toEqual({ + needsRehash: false, + valid: true, + }); + await occupant; + }); + + it('rejects invalid limiter configuration with a typed error, never clamps', () => { + expect(() => configureKdfLimiter({ concurrency: 0 })).toThrow( + PasswordHashError, + ); + expect(() => configureKdfLimiter({ maxQueue: 0 })).toThrow( + PasswordHashError, + ); + expect(() => + configureKdfLimiter({ concurrency: 1.5 }), + ).toThrow(PasswordHashError); + }); +}); diff --git a/apps/backend/src/crypto/password.ts b/apps/backend/src/crypto/password.ts new file mode 100644 index 0000000000..31dc6f3aad --- /dev/null +++ b/apps/backend/src/crypto/password.ts @@ -0,0 +1,448 @@ +/** + * FIPS-validated password hashing — the pure primitive (ADR-006 §1, §2, §5, §6). + * + * DEPENDENCY-FREE by hard constraint: the only TOP-LEVEL import is + * `node:crypto`. The admin bootstrap seeder (ADR §4 site 8) requires this + * module via a bare `require()` of the COMPILED output, outside Nest DI and + * the ConfigService — so it must pull in nothing else, and the inferred build + * layout must not shift. Do NOT add imports of Nest, config, a logger, or the + * vectors package (the vectors are a TEST-only dependency, imported by the + * spec, never here). ONE disclosed exception: verifyPassword's FIPS-off + * bcrypt fallback lazily `await import()`s bcryptjs at call time — never + * loaded under FIPS, never loaded on the hash path the seeder uses. + * + * `node:crypto` is a NAMESPACE import, never destructured: swc compiles a + * destructured `import {getFips}` to a non-writable binding, which blocks the + * injectable `getFips` seam that verifyPassword (e25.7) needs. + * + * Public API is `hashPassword(password, options?)` — it always generates a + * fresh 32-byte random salt. `hashPasswordWithSalt` is the deterministic + * variant used by tests and vector tooling to reproduce known-good vectors; + * production code must never pass a caller-controlled salt. + */ +import * as crypto from 'node:crypto'; + +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export type PasswordHashOptions = { + /** HMAC digest. Default 'sha512'. */ + algorithm?: PasswordHashAlgorithm; + /** PBKDF2 iterations. Default 600000. Must be within [100000, 10000000]. */ + iterations?: number; +}; + +/** Result of {@link verifyPassword} (§5). `requiresReset` appears ONLY on the + * FIPS-refuse path: a bcrypt credential encountered while FIPS is on. */ +export type PasswordVerifyResult = { + needsRehash: boolean; + requiresReset?: boolean; + valid: boolean; +}; + +/** + * Thrown when the bounded KDF queue is full (§11). Server-side signal only: + * the auth layer maps this to its generic failure — never a distinct + * client-visible error (Risks — enumeration oracle). + */ +export class KdfOverloadedError extends Error { + constructor(message: string) { + super(message); + this.name = 'KdfOverloadedError'; + } +} + +/** Thrown for any invalid input — never a silent clamp (§6, §9). */ +export class PasswordHashError extends Error { + constructor(message: string) { + super(message); + this.name = 'PasswordHashError'; + } +} + +const DEFAULT_ALGORITHM: PasswordHashAlgorithm = 'sha512'; +const DEFAULT_ITERATIONS = 600_000; +const SALT_BYTES = 32; +const MAX_PASSWORD_LENGTH = 128; // §6 DoS cap + approved 8–128 range +const MIN_ITERATIONS = 100_000; // §9 hash-path floor (NOT enforced on verify) +const MAX_ITERATIONS = 10_000_000; // §6 DoS ceiling +const ALLOWED_ALGORITHMS = new Set([ + 'sha256', + 'sha384', + 'sha512', +]); + +/** + * Hash a password with a fresh 32-byte random salt, producing a PHC string + * `$pbkdf2-$i=$$` (§1, §2). Defaults: sha512, + * 600000 iterations. Throws {@link PasswordHashError} on invalid input. + */ +export function hashPassword( + password: string, + options?: PasswordHashOptions, +): Promise { + return hashPasswordWithSalt(password, crypto.randomBytes(SALT_BYTES), options); +} + +/** + * Deterministic hash — the caller supplies the salt. Tests and vector tooling + * only; production code uses {@link hashPassword}. Reusing a salt across + * passwords in production would be a critical weakness. + */ +export async function hashPasswordWithSalt( + password: string, + salt: Buffer, + options?: PasswordHashOptions, +): Promise { + const algorithm = options?.algorithm ?? DEFAULT_ALGORITHM; + const iterations = options?.iterations ?? DEFAULT_ITERATIONS; + validate(password, algorithm, iterations); + const key = await pbkdf2( + password, + salt, + iterations, + digestWidth(algorithm), + algorithm, + ); + return `$pbkdf2-${algorithm}$i=${iterations}$${toB64(salt)}$${toB64(key)}`; +} + +const BCRYPT_PREFIX = /^\$2[aby]\$/v; // §3: exactly $2a$/$2b$/$2y$ + +/** + * §6 step 3: STRICT allowlist over the FULL identifier. Never prefix-match — + * crypto.pbkdf2 accepts 'md5' and 'sha1', so `$pbkdf2-sha*$` would verify a + * downgraded digest, and allowlisting only the digest admits + * `$pbkdf2-sha512-md5$` via naive splitting. + */ +const PBKDF2_IDENTIFIERS = new Map([ + ['pbkdf2-sha256', 'sha256'], + ['pbkdf2-sha384', 'sha384'], + ['pbkdf2-sha512', 'sha512'], +]); + +/** + * §6 step 4: iterations by regex ONLY — parseInt('6e5') is 6 (a 100,000× + * downgrade that looks well-formed), parseInt('600000abc') is 600000, + * Number('0x10000') is 65536. Nine digits max, so Number() on the capture + * is always a safe integer. + */ +const ITERATIONS_FIELD = /^i=(?[1-9]\d{0,8})$/v; + +/** + * §9: floors and caps apply to HASHING only, never verification — a user + * hashed under an earlier PASSWORD_HASH_ITERATIONS=50000 must still verify. + * Verify enforces only the DoS upper bound (MAX_ITERATIONS) plus this sanity + * floor of 1000, the module's own documented minimum. + */ +const VERIFY_MIN_ITERATIONS = 1000; +const MIN_SALT_BYTES = 16; // §6 step 7 + +/** Constant input for the timing-mitigation dummy — its output is discarded. */ +const DUMMY_SALT = Buffer.alloc(SALT_BYTES); + +/** + * §11 KDF concurrency limiter — a hand-rolled counting semaphore (zero deps; + * the module must stay dependency-free for the seeder's bare require). It + * wraps the module's single internal pbkdf2 dispatcher, so hashPassword, + * verifyPassword, AND the constant-work dummy are all globally capped — + * "600k is only safe if UV_THREADPOOL_SIZE is raised AND a global KDF + * concurrency limit lands." + * + * Defaults per §11's starvation measurements: concurrency 2 leaves ≥2 of + * libuv's default 4 threads for fs/dns; queue 100 bounds the flood so thread + * starvation cannot become memory exhaustion. This is DELIBERATE module-scope + * shared state (the limit is global by design); configureKdfLimiter is the + * init/test seam. + */ +const DEFAULT_KDF_CONCURRENCY = 2; +const DEFAULT_KDF_MAX_QUEUE = 100; + +type KdfLimiter = { + active: number; + concurrency: number; + maxQueue: number; + queue: (() => void)[]; +}; + +const kdfLimiter: KdfLimiter = { + active: 0, + concurrency: DEFAULT_KDF_CONCURRENCY, + maxQueue: DEFAULT_KDF_MAX_QUEUE, + queue: [], +}; + +/** + * Init/test seam: the service card binds PASSWORD_KDF_CONCURRENCY here at + * construction; tests use it to configure and reset. Call ONLY at boot or + * between settled operations — it zeroes the accounting, so reconfiguring + * with KDFs in flight would corrupt the slot count. Out-of-range values + * THROW (§9) — never a silent clamp. + */ +export function configureKdfLimiter(options?: { + concurrency?: number; + maxQueue?: number; +}): void { + const concurrency = options?.concurrency ?? DEFAULT_KDF_CONCURRENCY; + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new PasswordHashError( + 'PASSWORD_KDF_CONCURRENCY must be an integer >= 1', + ); + } + const maxQueue = options?.maxQueue ?? DEFAULT_KDF_MAX_QUEUE; + if (!Number.isSafeInteger(maxQueue) || maxQueue < 1) { + throw new PasswordHashError('KDF maxQueue must be an integer >= 1'); + } + kdfLimiter.concurrency = concurrency; + kdfLimiter.maxQueue = maxQueue; + kdfLimiter.active = 0; + kdfLimiter.queue = []; +} + +/** Observable semaphore state — the test seam the limiter ACs require. */ +export function kdfLimiterState(): { active: number; queued: number } { + return { active: kdfLimiter.active, queued: kdfLimiter.queue.length }; +} + +/** + * Verify a submitted password against a stored hash, dispatching on stored + * format AND FIPS state (§3). Never throws on malformed input — rejects via + * the result object. `getFips` is injectable (§5) so both FIPS states are + * testable in non-FIPS CI; the default is the real `crypto.getFips`. + */ +export async function verifyPassword(arguments_: { + getFips?: () => number; + hash: string; + password: string; +}): Promise { + const { hash, password } = arguments_; + if (typeof hash !== 'string' || typeof password !== 'string') { + // §6 step 1 — the seeder path is CommonJS JS, so a non-string can arrive. + return rejectWithConstantWork(password); + } + const getFips = arguments_.getFips ?? crypto.getFips; + if (BCRYPT_PREFIX.test(hash)) { + if (getFips() === 1) { + // §3: refuse — do NOT invoke bcryptjs. bcryptjs.compare() generates a + // bcrypt hash in pure JS outside the validated module; under a FIPS + // deployment that is the V-222571 finding itself. + return rejectWithConstantWork(password, true); + } + // Lazy import so FIPS deployments never load unapproved-crypto code and + // the seeder's bare require() of the compiled module stays free of it — + // this branch is the ONLY exception to the header's node:crypto-only + // rule, and it never executes under FIPS or on the hash path. + const { compare } = await import('bcryptjs'); + try { + const isValid = await compare(password, hash); + // needsRehash: valid — a rehash requires the correct plaintext (§3). + return { needsRehash: isValid, valid: isValid }; + } catch { + // compare() rejects on a bcrypt-prefixed hash with an unparseable + // rounds/salt section (verified against bcryptjs 3.0.3) — a corrupted + // stored credential. Classification: unverifiable-hash → auth failure, + // never a thrown 500. This module is dependency-free by the header's + // constraint, so logging the corruption belongs to the service layer + // (rehash audit card). Constant work keeps the fast parse failure from + // advertising that the stored hash is corrupt rather than mismatched. + return rejectWithConstantWork(password); + } + } + return verifyPbkdf2(hash, password); +} + +function acquireKdfSlot(): Promise { + if (kdfLimiter.active < kdfLimiter.concurrency) { + kdfLimiter.active += 1; + return Promise.resolve(); + } + if (kdfLimiter.queue.length >= kdfLimiter.maxQueue) { + return Promise.reject( + new KdfOverloadedError( + `KDF queue full (${kdfLimiter.maxQueue} pending) — server overloaded`, + ), + ); + } + return new Promise((resolve) => { + kdfLimiter.queue.push(() => { + kdfLimiter.active += 1; + resolve(); + }); + }); +} + +/** PBKDF2 derived-key width = digest width (§2). Exhaustive over the union. */ +function digestWidth(algorithm: PasswordHashAlgorithm): number { + switch (algorithm) { + case 'sha256': { + return 32; + } + case 'sha384': { + return 48; + } + case 'sha512': { + return 64; + } + default: { + throw new PasswordHashError('unhandled algorithm'); + } + } +} + +/** The module's ONLY pbkdf2 dispatcher — every KDF passes the §11 limiter. */ +async function pbkdf2( + password: string, + salt: Buffer, + iterations: number, + keylen: number, + digest: PasswordHashAlgorithm, +): Promise { + await acquireKdfSlot(); + try { + return await new Promise((resolve, reject) => { + crypto.pbkdf2( + password, + salt, + iterations, + keylen, + digest, + (error, key) => { + if (error) { + reject(error); + return; + } + resolve(key); + }, + ); + }); + } finally { + releaseKdfSlot(); + } +} + +/** + * Risks (timing side-channel): burn one KDF-equivalent of work at DEFAULT + * parameters on every reject that did not run the real KDF, so no rejection — + * unknown format, the cutover sentinel, any §6 step failure, or the FIPS + * refuse path — is distinguishable by timing from a failed verification. + * Rejections after the real KDF (§6 step 8) already paid full cost. + */ +async function rejectWithConstantWork( + password: unknown, + shouldRequireReset?: boolean, +): Promise { + await pbkdf2( + typeof password === 'string' ? password : '', + DUMMY_SALT, + DEFAULT_ITERATIONS, + digestWidth(DEFAULT_ALGORITHM), + DEFAULT_ALGORITHM, + ); + return shouldRequireReset === true + ? { needsRehash: false, requiresReset: true, valid: false } + : { needsRehash: false, valid: false }; +} + +function releaseKdfSlot(): void { + kdfLimiter.active -= 1; + const next = kdfLimiter.queue.shift(); + if (next !== undefined) { + next(); + } +} + +/** + * Standard base64 (not base64url), padding stripped — §2. Uses Buffer, not + * Uint8Array#toBase64: that TC39 API is undefined at the Node runtime + * (verified), so eslint's prefer-uint8array-base64 is a config-ahead-of-runtime + * false preference here — migrate when Node ships the API. + */ +function toB64(buffer: Buffer): string { + let out = buffer.toString('base64'); + while (out.endsWith('=')) { + out = out.slice(0, -1); + } + return out; +} + +function validate( + password: unknown, + algorithm: PasswordHashAlgorithm, + iterations: number, +): asserts password is string { + if (typeof password !== 'string') { + throw new PasswordHashError('password must be a string'); + } + if (password.length === 0) { + throw new PasswordHashError('password must not be empty'); + } + if (password.length > MAX_PASSWORD_LENGTH) { + throw new PasswordHashError( + `password must be at most ${MAX_PASSWORD_LENGTH} characters`, + ); + } + if (!ALLOWED_ALGORITHMS.has(algorithm)) { + throw new PasswordHashError(`unsupported algorithm: ${algorithm}`); + } + if (!Number.isSafeInteger(iterations)) { + throw new PasswordHashError('iterations must be an integer'); + } + if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS) { + throw new PasswordHashError( + `iterations must be within [${MIN_ITERATIONS}, ${MAX_ITERATIONS}]`, + ); + } +} + +/** The §6 validation sequence, in order, against a candidate PHC string. */ +async function verifyPbkdf2( + hash: string, + password: string, +): Promise { + // §6 steps 1–2: ''.split('$') is [''], so the parts[0] === '' check alone + // passes for the empty string — the exact-field-count check catches it. + // These are AND, not alternatives. + const parts = hash.split('$'); + if (parts.length !== 5 || parts[0] !== '') { + return rejectWithConstantWork(password); + } + const algorithm = PBKDF2_IDENTIFIERS.get(parts[1]); // step 3 + if (algorithm === undefined) { + return rejectWithConstantWork(password); + } + const iterationsMatch = ITERATIONS_FIELD.exec(parts[2]); // step 4 + if (iterationsMatch === null) { + return rejectWithConstantWork(password); + } + const iterations = Number(iterationsMatch.groups?.iterations); + // Step 5: the upper bound is the DoS guard — Node permits 2³¹−1, roughly + // 8.6 minutes of one libuv thread per verification. (isSafeInteger also + // rejects the cannot-happen NaN if the named group were ever absent.) + if ( + !Number.isSafeInteger(iterations) + || iterations < VERIFY_MIN_ITERATIONS + || iterations > MAX_ITERATIONS + ) { + return rejectWithConstantWork(password); + } + // Step 6: Buffer.from(str, 'base64') is lenient — 'AA@@AA' and 'A A A A' + // decode to the same bytes as 'AAAA'. Re-encode and compare (padding + // stripped both sides) to reject non-canonical fields. (Buffer, not + // Uint8Array.fromBase64 — undefined at our Node runtime; see toB64.) + const salt = Buffer.from(parts[3], 'base64'); + const key = Buffer.from(parts[4], 'base64'); + if (toB64(salt) !== parts[3] || toB64(key) !== parts[4]) { + return rejectWithConstantWork(password); + } + // Step 7: BEFORE pbkdf2 — keylen=0 throws an untyped error, and a sha512 + // hash carrying a 32-byte key would otherwise silently verify a + // downgraded artifact. + if (key.length !== digestWidth(algorithm) || salt.length < MIN_SALT_BYTES) { + return rejectWithConstantWork(password); + } + const derived = await pbkdf2(password, salt, iterations, key.length, algorithm); + // Step 8: timingSafeEqual THROWS on length mismatch — guard first. + if (derived.length !== key.length) { + return { needsRehash: false, valid: false }; + } + return { needsRehash: false, valid: crypto.timingSafeEqual(derived, key) }; +} diff --git a/apps/backend/src/database/database.module.ts b/apps/backend/src/database/database.module.ts index 507dc3829b..2ba3b68b87 100644 --- a/apps/backend/src/database/database.module.ts +++ b/apps/backend/src/database/database.module.ts @@ -1,27 +1,23 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import winston from 'winston'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; -import {DatabaseService} from './database.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { createLogger, format, transports } from 'winston'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { DatabaseService } from './database.service'; const line = '________________________________________________\n'; -const logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.colorize({ - all: true - }), - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), - winston.format.errors({stack: true}), - winston.format.align(), - winston.format.printf( - (info) => - `${line}[${info.timestamp}] Query(${info.queryType}): ${info.message}` - ) - ) +const logger = createLogger({ + format: format.combine( + format.colorize({ all: true }), + format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), + format.errors({ stack: true }), + format.align(), + format.printf( + info => + `${line}[${String(info.timestamp)}] Query(${String(info.queryType)}): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); const localConfigService = new ConfigService(); @@ -30,40 +26,36 @@ function getSynchronize(configService: ConfigService): boolean { const nodeEnvironment = configService.get('NODE_ENV'); if (nodeEnvironment === undefined) { throw new TypeError('NODE_ENV is not set and must be provided.'); - } else { - return nodeEnvironment === 'test' ? false : true; } -} - -function sanitize(fields: string[], values?: string[]): string[] { - return ( - values?.map((value, index) => { - if ( - localConfigService.sensitiveKeys.some((regex) => - regex.test(fields[index + 1]) - ) - ) { - return 'REDACTED'; - } else { - return value; - } - }) || [] - ); + return nodeEnvironment === 'test' ? false : true; } function logQuery( sql: string, - connection: {fields: string[]; bind: string[]; type: string} + connection: { bind: string[]; fields: string[]; type: string }, ) { logger.info({ message: `${sql} [${sanitize(connection.fields, connection.bind).join( - ', ' + ', ', )}]`, - queryType: connection.type + queryType: connection.type, }); } +function sanitize(fields: string[], values?: string[]): string[] { + return ( + values?.map((value, index) => { + return localConfigService.sensitiveKeys.some(regex => + regex.test(fields[index + 1]), + ) + ? 'REDACTED' + : value; + }) || [] + ); +} + @Module({ + exports: [DatabaseService], imports: [ SequelizeModule.forRootAsync({ imports: [ConfigModule], @@ -71,28 +63,27 @@ function logQuery( useFactory: (configService: ConfigService) => ({ ...configService.getDbConfig(), autoLoadModels: true, - synchronize: getSynchronize(configService), logging: (sql, connection) => { logQuery( sql, // Connection is incorrectly typed as a number connection as unknown as { - fields: string[]; bind: string[]; + fields: string[]; type: string; - } + }, ); }, pool: { + acquire: 30_000, + idle: 10_000, max: 5, min: 0, - acquire: 30000, - idle: 10000 - } - }) - }) + }, + synchronize: getSynchronize(configService), + }), + }), ], providers: [DatabaseService], - exports: [DatabaseService] }) export class DatabaseModule {} diff --git a/apps/backend/src/database/database.service.spec.ts b/apps/backend/src/database/database.service.spec.ts index 11e7a3c41a..fdbaba0685 100644 --- a/apps/backend/src/database/database.service.spec.ts +++ b/apps/backend/src/database/database.service.spec.ts @@ -1,8 +1,8 @@ -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, describe, expect, it} from 'vitest'; -import {DatabaseModule} from './database.module'; -import {DatabaseService} from './database.service'; -import {DeltaArgs} from './interfaces/delta-args.interface'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DatabaseModule } from './database.module'; +import { DatabaseService } from './database.service'; +import type { DeltaArgs as DeltaArguments } from './interfaces/delta-args.interface'; describe('DatabaseService', () => { let databaseService: DatabaseService; @@ -10,7 +10,7 @@ describe('DatabaseService', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [DatabaseModule], - providers: [DatabaseService] + providers: [DatabaseService], }).compile(); databaseService = module.get(DatabaseService); @@ -22,9 +22,9 @@ describe('DatabaseService', () => { }); describe('getDelta', () => { - it('returns the correct value when no items are given', async () => { - const source: DeltaArgs[] = []; - const updated: DeltaArgs[] = []; + it('returns the correct value when no items are given', () => { + const source: DeltaArguments[] = []; + const updated: DeltaArguments[] = []; const delta = databaseService.getDelta(source, updated); expect(delta.added.length).toEqual(0); @@ -32,9 +32,9 @@ describe('DatabaseService', () => { expect(delta.deleted.length).toEqual(0); }); - it('returns the correct value when an item is added', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}]; - const updated = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + it('returns the correct value when an item is added', () => { + const source = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const updated = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -43,9 +43,9 @@ describe('DatabaseService', () => { expect(delta.deleted.length).toEqual(0); }); - it('returns the correct value when an item is changed', async () => { - const source = [{id: 1, prop: 1}]; - const updated = [{id: 1, prop: 2}]; + it('returns the correct value when an item is changed', () => { + const source = [{ id: 1, prop: 1 }]; + const updated = [{ id: 1, prop: 2 }]; const delta = databaseService.getDelta(source, updated); @@ -56,9 +56,9 @@ describe('DatabaseService', () => { expect(delta.changed[0].prop).toEqual(updated[0].prop); }); - it('returns the correct value when an item is deleted', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; - const updated = [{id: 1}, {id: 2}, {id: 4}]; + it('returns the correct value when an item is deleted', () => { + const source = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const updated = [{ id: 1 }, { id: 2 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -67,9 +67,9 @@ describe('DatabaseService', () => { expect(delta.deleted.length).toEqual(1); }); - it('returns the correct value when all items are added', async () => { - const source: DeltaArgs[] = []; - const updated: DeltaArgs[] = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + it('returns the correct value when all items are added', () => { + const source: DeltaArguments[] = []; + const updated: DeltaArguments[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -78,9 +78,9 @@ describe('DatabaseService', () => { expect(delta.deleted.length).toEqual(0); }); - it('returns the correct value when all items are changed', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; - const updated = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + it('returns the correct value when all items are changed', () => { + const source = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const updated = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; const delta = databaseService.getDelta(source, updated); @@ -89,9 +89,9 @@ describe('DatabaseService', () => { expect(delta.deleted.length).toEqual(0); }); - it('returns the correct value when all items are deleted', async () => { - const source = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; - const updated: DeltaArgs[] = []; + it('returns the correct value when all items are deleted', () => { + const source = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const updated: DeltaArguments[] = []; const delta = databaseService.getDelta(source, updated); diff --git a/apps/backend/src/database/database.service.ts b/apps/backend/src/database/database.service.ts index e5a6ab41e6..f6eb2f5e92 100644 --- a/apps/backend/src/database/database.service.ts +++ b/apps/backend/src/database/database.service.ts @@ -1,52 +1,49 @@ -import {Injectable} from '@nestjs/common'; -import {Sequelize} from 'sequelize-typescript'; -import {DeltaArgs} from './interfaces/delta-args.interface'; -import {IDelta} from './interfaces/delta.interface'; +import { Injectable } from '@nestjs/common'; +import { Sequelize } from 'sequelize-typescript'; +import { DeltaArgs as DeltaArguments } from './interfaces/delta-args.interface'; +import { IDelta } from './interfaces/delta.interface'; @Injectable() export class DatabaseService { constructor(readonly sequelize: Sequelize) {} - async closeConnection(): Promise { - await this.sequelize.close(); + async cleanAll(): Promise { + await this.sequelize.truncate({ cascade: true, restartIdentity: true }); } - async cleanAll(): Promise { - await this.sequelize.truncate({cascade: true, restartIdentity: true}); + async closeConnection(): Promise { + await this.sequelize.close(); } - getDelta( - source: Array, - updated: Array + getDelta( + source: T[], + updated: T[], ): IDelta { if (source === undefined || updated === undefined) { return { added: [], changed: [], - deleted: [] + deleted: [], }; } const added = updated.filter( - (updatedItem) => - source.find((sourceItem) => sourceItem.id === updatedItem.id) === - undefined + updatedItem => + source.every(sourceItem => sourceItem.id !== updatedItem.id), ); const changed = updated.filter( - (updatedItem) => - source.find((sourceItem) => sourceItem.id === updatedItem.id) !== - undefined + updatedItem => + source.some(sourceItem => sourceItem.id === updatedItem.id), ); const deleted = source.filter( - (sourceItem) => - updated.find((updatedItem) => updatedItem.id === sourceItem.id) === - undefined + sourceItem => + updated.every(updatedItem => updatedItem.id !== sourceItem.id), ); return { added: added, changed: changed, - deleted: deleted + deleted: deleted, }; } } diff --git a/apps/backend/src/database/interfaces/delta-args.interface.ts b/apps/backend/src/database/interfaces/delta-args.interface.ts index 078b718373..692f20e1e9 100644 --- a/apps/backend/src/database/interfaces/delta-args.interface.ts +++ b/apps/backend/src/database/interfaces/delta-args.interface.ts @@ -1,3 +1 @@ -export interface DeltaArgs { - id: number; -} +export type DeltaArgs = { id: number }; diff --git a/apps/backend/src/database/interfaces/delta.interface.ts b/apps/backend/src/database/interfaces/delta.interface.ts index 020a0f92ba..590cae50e9 100644 --- a/apps/backend/src/database/interfaces/delta.interface.ts +++ b/apps/backend/src/database/interfaces/delta.interface.ts @@ -1,5 +1,5 @@ -export interface IDelta { - added: Array; - changed: Array; - deleted: Array; -} +export type IDelta = { + added: T[]; + changed: T[]; + deleted: T[]; +}; diff --git a/apps/backend/src/docs/documentation-mount.spec.ts b/apps/backend/src/docs/documentation-mount.spec.ts new file mode 100644 index 0000000000..9823f20afc --- /dev/null +++ b/apps/backend/src/docs/documentation-mount.spec.ts @@ -0,0 +1,131 @@ +import { staticRootFixture } from './static-root.fixture'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { INestApplication } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { documentationRoot, frontendRoot } from '../config/static-paths'; +import { AppModule } from '../app.module'; + +// Markers standing in for the two real builds. The mount under test routes by +// PATH, so the bodies only need to be distinguishable — using fixtures instead +// of the real output keeps this spec hermetic: no dependence on the repo +// layout, and no dependence on anyone having run a build first. +const DOCS_MARKER = ''; +const NESTED_DOCS_MARKER = ''; +const SPA_MARKER = ''; +// A docs/ directory colliding inside the SPA build — see the ordering test. +const SPA_COLLISION_MARKER = ''; + +describe('in-app documentation mount', () => { + let app: INestApplication; + let baseUrl: string; + + beforeAll(async () => { + // A self-contained static root: /frontend and /docs, exactly the + // layout the built server serves from. + mkdirSync(path.join(staticRootFixture, 'frontend', 'docs'), { + recursive: true, + }); + mkdirSync(path.join(staticRootFixture, 'docs', 'getting-started'), { + recursive: true, + }); + writeFileSync( + path.join(staticRootFixture, 'frontend', 'index.html'), + SPA_MARKER, + ); + writeFileSync( + path.join(staticRootFixture, 'frontend', 'docs', 'index.html'), + SPA_COLLISION_MARKER, + ); + writeFileSync(path.join(staticRootFixture, 'docs', 'index.html'), DOCS_MARKER); + writeFileSync( + path.join(staticRootFixture, 'docs', 'getting-started', 'index.html'), + NESTED_DOCS_MARKER, + ); + + // NestFactory.create, not Test.createTestingModule().createNestApplication(): + // this is exactly how main.ts boots, and static mounting is sensitive to it. + app = await NestFactory.create(AppModule); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address(); + if (address === null || typeof address !== 'object') { + throw new TypeError('expected the test server to bind a TCP port'); + } + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + afterAll(async () => { + await app.close(); + rmSync(staticRootFixture, { force: true, recursive: true }); + }); + + it('resolves both static roots from HEIMDALL_STATIC_ROOT', () => { + // Guards the seam the assertions below depend on. If this passes while + // they fail, the paths are right and the mount itself is at fault. + expect(frontendRoot()).toBe(path.join(staticRootFixture, 'frontend')); + expect(documentationRoot()).toBe(path.join(staticRootFixture, 'docs')); + }); + + it('serves the documentation at /docs/, not the SPA', async () => { + const response = await fetch(`${baseUrl}/docs/`); + + expect(response.status).toBe(200); + // Asserts the BODY. The SPA mount answers unmatched routes with its own + // index.html, so a status-only check would pass while serving the wrong + // thing entirely. + expect(await response.text()).toContain(DOCS_MARKER); + }); + + it('serves a nested documentation page', async () => { + const response = await fetch(`${baseUrl}/docs/getting-started/`); + + expect(response.status).toBe(200); + expect(await response.text()).toContain(NESTED_DOCS_MARKER); + }); + + it('returns a REAL 404 for an unknown documentation path', async () => { + // Not the docs home (soft 404 from a render fallback) and not the SPA + // shell (the catch-all swallowing the path). Both would be 200. + const response = await fetch(`${baseUrl}/docs/no-such-page`); + const body = await response.text(); + + expect(response.status).toBe(404); + expect(body).not.toContain(DOCS_MARKER); + expect(body).not.toContain(SPA_MARKER); + }); + + it('still serves the SPA at the application root', async () => { + const response = await fetch(`${baseUrl}/`); + + expect(response.status).toBe(200); + expect(await response.text()).toContain(SPA_MARKER); + }); + + it('leaves the SPA catch-all intact for unknown application routes', async () => { + // The SPA is a client-routed app: unknown NON-docs paths must still get + // the shell so the router can handle them. + const response = await fetch(`${baseUrl}/results/some-client-route`); + + expect(response.status).toBe(200); + expect(await response.text()).toContain(SPA_MARKER); + }); + + it('prefers the docs mount over a colliding path inside the SPA build', async () => { + // If the frontend build ever emits its own docs/ directory, the entry + // registered FIRST wins. This is what makes docs-first load-bearing rather + // than decorative — without it, ordering could be swapped unnoticed. + const response = await fetch(`${baseUrl}/docs/`); + + expect(await response.text()).not.toContain(SPA_COLLISION_MARKER); + }); + + it('leaves application routes unaffected', async () => { + const response = await fetch(`${baseUrl}/health`); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ status: 'ok' }); + }); +}); diff --git a/apps/backend/src/docs/static-root.fixture.ts b/apps/backend/src/docs/static-root.fixture.ts new file mode 100644 index 0000000000..e450870890 --- /dev/null +++ b/apps/backend/src/docs/static-root.fixture.ts @@ -0,0 +1,21 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +/** + * Side-effect fixture for documentation-mount.spec.ts — MUST stay its first + * import. app.module resolves the static roots inside its @Module decorator + * at import time, so the override has to exist before that module evaluates. + * Module evaluation order follows import declaration order in ESM and CJS + * alike, which makes first-import the one placement no build pipeline can + * reorder. (vi.hoisted cannot do this job here: the swc plugin emits CJS + * requires ahead of hoisted blocks. And a dynamic import('../app.module.js') + * — the previous approach — resolved to the stale nest-build copy under + * dist/ whenever another spec had already run in the same fork.) + */ +export const staticRootFixture = mkdtempSync( + path.join(tmpdir(), 'heimdall-static-'), +); + +process.env.HEIMDALL_STATIC_ROOT = staticRootFixture; + diff --git a/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts b/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts index e1a7cadaf3..3f8031db3f 100644 --- a/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts +++ b/apps/backend/src/evaluation-tags/dto/create-evaluation-tag.dto.ts @@ -1,5 +1,5 @@ -import {ICreateEvaluationTag} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { ICreateEvaluationTag } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class CreateEvaluationTagDto implements ICreateEvaluationTag { @IsNotEmpty() diff --git a/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts b/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts index 98c9e1c234..bddd57e976 100644 --- a/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts +++ b/apps/backend/src/evaluation-tags/dto/delete-evaluation-tag.dto.ts @@ -1,5 +1,5 @@ -import {IDeleteEvaluationTag} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsNumberString, IsString, Min} from 'class-validator'; +import { IDeleteEvaluationTag } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsNumberString, IsString, Min } from 'class-validator'; export class DeleteEvaluationTagDto implements IDeleteEvaluationTag { @IsNotEmpty() diff --git a/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts b/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts index b10bf9a39c..673a4855f7 100644 --- a/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts +++ b/apps/backend/src/evaluation-tags/dto/evaluation-tag.dto.ts @@ -1,12 +1,12 @@ -import {IEvaluationTag} from '@heimdall/common/interfaces'; -import {EvaluationTag} from '../evaluation-tag.model'; +import type { IEvaluationTag } from '@heimdall/common/interfaces'; +import type { EvaluationTag } from '../evaluation-tag.model'; export class EvaluationTagDto implements IEvaluationTag { - readonly id: string; - readonly value: string; - readonly evaluationId: string; readonly createdAt: Date; + readonly evaluationId: string; + readonly id: string; readonly updatedAt: Date; + readonly value: string; constructor(evaluationTag: EvaluationTag) { this.id = evaluationTag.id; diff --git a/apps/backend/src/evaluation-tags/evaluation-tag.model.ts b/apps/backend/src/evaluation-tags/evaluation-tag.model.ts index 5ba6f0fb86..67af37f501 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tag.model.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tag.model.ts @@ -7,12 +7,23 @@ import { ForeignKey, Model, PrimaryKey, - Table + Table, } from 'sequelize-typescript'; -import {Evaluation} from '../evaluations/evaluation.model'; +import { Evaluation } from '../evaluations/evaluation.model'; @Table export class EvaluationTag extends Model { + @AllowNull(false) + @Column + declare createdAt: Date; + + @BelongsTo(() => Evaluation) + declare evaluation: Evaluation; + + @ForeignKey(() => Evaluation) + @Column(DataType.BIGINT) + declare evaluationId: string; + @PrimaryKey @AutoIncrement @AllowNull(false) @@ -21,20 +32,9 @@ export class EvaluationTag extends Model { @AllowNull(false) @Column - declare value: string; - - @AllowNull(false) - @Column - declare createdAt: Date; + declare updatedAt: Date; @AllowNull(false) @Column - declare updatedAt: Date; - - @ForeignKey(() => Evaluation) - @Column(DataType.BIGINT) - declare evaluationId: string; - - @BelongsTo(() => Evaluation) - declare evaluation: Evaluation; + declare value: string; } diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts b/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts index 07d944c015..6db6253340 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.controller.spec.ts @@ -1,29 +1,31 @@ -import {ForbiddenError} from '@casl/ability'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {CREATE_EVALUATION_TAG_DTO} from '../../test/constants/evaluation-tags-test.constant'; -import {EVALUATION_1} from '../../test/constants/evaluations-test.constant'; -import {PRIVATE_GROUP} from '../../test/constants/groups-test.constant'; +import { ForbiddenError } from '@casl/ability'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { CREATE_EVALUATION_TAG_DTO } from '../../test/constants/evaluation-tags-test.constant'; +import { EVALUATION_1 } from '../../test/constants/evaluations-test.constant'; +import { PRIVATE_GROUP } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, } from '../../test/constants/users-test.constant'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigModule} from '../config/config.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {EvaluationTag} from './evaluation-tag.model'; -import {EvaluationTagsController} from './evaluation-tags.controller'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigModule } from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { EvaluationTag } from './evaluation-tag.model'; +import { EvaluationTagsController } from './evaluation-tags.controller'; +import { EvaluationTagsService } from './evaluation-tags.service'; describe('EvaluationTagsController', () => { let evaluationTagsController: EvaluationTagsController; @@ -40,6 +42,7 @@ describe('EvaluationTagsController', () => { controllers: [EvaluationTagsController], imports: [ ConfigModule, + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Evaluation, @@ -47,8 +50,8 @@ describe('EvaluationTagsController', () => { User, GroupEvaluation, Group, - GroupUser - ]) + GroupUser, + ]), ], providers: [ AuthzService, @@ -56,15 +59,15 @@ describe('EvaluationTagsController', () => { EvaluationTagsService, UsersService, EvaluationsService, - GroupsService - ] + GroupsService, + ], }).compile(); evaluationTagsController = module.get( - EvaluationTagsController + EvaluationTagsController, ); evaluationTagsService = module.get( - EvaluationTagsService + EvaluationTagsService, ); evaluationsService = module.get(EvaluationsService); usersService = module.get(UsersService); @@ -87,63 +90,57 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); - const foundEvaluationTags = await evaluationTagsController.index({ - user: user - }); + const foundEvaluationTags = await evaluationTagsController.index({ user: user }); expect(foundEvaluationTags.length).toEqual(1); expect(foundEvaluationTags[0].value).toEqual( - CREATE_EVALUATION_TAG_DTO.value + CREATE_EVALUATION_TAG_DTO.value, ); }); it('should return EvaluationTags a User has group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); await groupsService.addEvaluationToGroup(group, evaluation); await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); - const foundEvaluationTags = await evaluationTagsController.index({ - user: user - }); + const foundEvaluationTags = await evaluationTagsController.index({ user: user }); expect(foundEvaluationTags.length).toEqual(1); expect(foundEvaluationTags[0].value).toEqual( - CREATE_EVALUATION_TAG_DTO.value + CREATE_EVALUATION_TAG_DTO.value, ); }); it('should not return EvaluationTags associated with an Evaluation a User not authorized to view', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); - const foundEvaluationTags = await evaluationTagsController.index({ - user: user - }); + const foundEvaluationTags = await evaluationTagsController.index({ user: user }); expect(foundEvaluationTags.length).toEqual(0); }); }); @@ -153,38 +150,38 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const foundTag = await evaluationTagsController.findById( evaluationTag.id, - {user: user} + { user: user }, ); expect(foundTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); }); it('should return an EvaluationTags a User has group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); await groupsService.addEvaluationToGroup(group, evaluation); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const foundEvaluationTag = await evaluationTagsController.findById( evaluationTag.id, - {user: user} + { user: user }, ); expect(foundEvaluationTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); @@ -194,20 +191,20 @@ describe('EvaluationTagsController', () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); await expect( - evaluationTagsController.findById(evaluationTag.id, {user: user}) + evaluationTagsController.findById(evaluationTag.id, { user: user }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -217,24 +214,24 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTag = await evaluationTagsController.create( evaluation.id, CREATE_EVALUATION_TAG_DTO, - {user: user} + { user: user }, ); expect(evaluationTag).toBeDefined(); }); it('should create EvaluationTags on an Evaluation a User has Group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); @@ -242,7 +239,7 @@ describe('EvaluationTagsController', () => { const evaluationTag = await evaluationTagsController.create( evaluation.id, CREATE_EVALUATION_TAG_DTO, - {user: user} + { user: user }, ); expect(evaluationTag).toBeDefined(); @@ -252,20 +249,20 @@ describe('EvaluationTagsController', () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await expect( evaluationTagsController.create( evaluation.id, CREATE_EVALUATION_TAG_DTO, - {user: user} - ) + { user: user }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -275,38 +272,38 @@ describe('EvaluationTagsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const removedTag = await evaluationTagsController.remove( evaluationTag.id, - {user: user} + { user: user }, ); expect(removedTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); }); it('should remove EvaluationTags on an Evaluation a User has Group ownership of', async () => { const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const group = await groupsService.create(PRIVATE_GROUP); await groupsService.addUserToGroup(group, user, 'owner'); await groupsService.addEvaluationToGroup(group, evaluation); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); const removedTag = await evaluationTagsController.remove( evaluationTag.id, - {user: user} + { user: user }, ); expect(removedTag.value).toEqual(CREATE_EVALUATION_TAG_DTO.value); @@ -316,20 +313,20 @@ describe('EvaluationTagsController', () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); await expect( - evaluationTagsController.remove(evaluationTag.id, {user: user}) + evaluationTagsController.remove(evaluationTag.id, { user: user }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts b/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts index 73f94f7234..27ccd6fa93 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Body, Controller, @@ -8,17 +8,17 @@ import { Post, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {CreateEvaluationTagDto} from './dto/create-evaluation-tag.dto'; -import {EvaluationTagDto} from './dto/evaluation-tag.dto'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { CreateEvaluationTagDto } from './dto/create-evaluation-tag.dto'; +import { EvaluationTagDto } from './dto/evaluation-tag.dto'; +import { EvaluationTagsService } from './evaluation-tags.service'; @Controller('evaluation-tags') @UseGuards(JwtAuthGuard) @@ -27,65 +27,65 @@ export class EvaluationTagsController { constructor( private readonly evaluationTagsService: EvaluationTagsService, private readonly evaluationsService: EvaluationsService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} - @Get() - async index(@Request() request: {user: User}): Promise { + @Post(':evaluationId') + async create( + @Param('evaluationId') evaluationId: string, + @Body() createEvaluationTagDto: CreateEvaluationTagDto, + @Request() request: { user: User }, + ): Promise { const abac = this.authz.abac.createForUser(request.user); - let evaluationTags = await this.evaluationTagsService.findAll(); - evaluationTags = evaluationTags.filter((evaluationTag) => - abac.can(Action.Read, evaluationTag.evaluation) - ); - return evaluationTags.map( - (evaluationTag) => new EvaluationTagDto(evaluationTag) + const evaluation = await this.evaluationsService.findById(evaluationId); + // Use Action.Update here because any authenticated user can create an evaluation + // and we wouldn't want anyone to be able to add any tag to any evaluation. + ForbiddenError.from(abac).throwUnlessCan(Action.Update, evaluation); + + return new EvaluationTagDto( + await this.evaluationTagsService.create( + evaluationId, + createEvaluationTagDto, + ), ); } @Get(':id') async findById( @Param('id') id: string, - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); const evaluationTag = await this.evaluationTagsService.findById(id); ForbiddenError.from(abac).throwUnlessCan( Action.Read, - evaluationTag.evaluation + evaluationTag.evaluation, ); return new EvaluationTagDto(evaluationTag); } - @Post(':evaluationId') - async create( - @Param('evaluationId') evaluationId: string, - @Body() createEvaluationTagDto: CreateEvaluationTagDto, - @Request() request: {user: User} - ): Promise { + @Get() + async index(@Request() request: { user: User }): Promise { const abac = this.authz.abac.createForUser(request.user); - const evaluation = await this.evaluationsService.findById(evaluationId); - // Use Action.Update here because any authenticated user can create an evaluation - // and we wouldn't want anyone to be able to add any tag to any evaluation. - ForbiddenError.from(abac).throwUnlessCan(Action.Update, evaluation); - - return new EvaluationTagDto( - await this.evaluationTagsService.create( - evaluationId, - createEvaluationTagDto - ) + let evaluationTags = await this.evaluationTagsService.findAll(); + evaluationTags = evaluationTags.filter(evaluationTag => + abac.can(Action.Read, evaluationTag.evaluation), + ); + return evaluationTags.map( + evaluationTag => new EvaluationTagDto(evaluationTag), ); } @Delete(':id') async remove( @Param('id') id: string, - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); const evaluationTag = await this.evaluationTagsService.findById(id); ForbiddenError.from(abac).throwUnlessCan( Action.Delete, - evaluationTag.evaluation + evaluationTag.evaluation, ); return new EvaluationTagDto(await this.evaluationTagsService.remove(id)); } diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.module.ts b/apps/backend/src/evaluation-tags/evaluation-tags.module.ts index 6cad8fe0c0..9acc93e97b 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.module.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.module.ts @@ -1,23 +1,23 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ConfigModule} from '../config/config.module'; -import {DatabaseModule} from '../database/database.module'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {EvaluationTag} from './evaluation-tag.model'; -import {EvaluationTagsController} from './evaluation-tags.controller'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ConfigModule } from '../config/config.module'; +import { DatabaseModule } from '../database/database.module'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { EvaluationTag } from './evaluation-tag.model'; +import { EvaluationTagsController } from './evaluation-tags.controller'; +import { EvaluationTagsService } from './evaluation-tags.service'; @Module({ + controllers: [EvaluationTagsController], + exports: [EvaluationTagsService], imports: [ SequelizeModule.forFeature([Evaluation, Group, User, EvaluationTag]), ConfigModule, - DatabaseModule + DatabaseModule, ], providers: [EvaluationsService, EvaluationTagsService], - controllers: [EvaluationTagsController], - exports: [EvaluationTagsService] }) export class EvaluationTagsModule {} diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts b/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts index 32d7a680c8..66d0b93d08 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.service.spec.ts @@ -1,29 +1,29 @@ -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { CREATE_EVALUATION_TAG_DTO, - CREATE_EVALUATION_TAG_DTO_MISSING_VALUE + CREATE_EVALUATION_TAG_DTO_MISSING_VALUE, } from '../../test/constants/evaluation-tags-test.constant'; -import {EVALUATION_1} from '../../test/constants/evaluations-test.constant'; -import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; +import { EVALUATION_1 } from '../../test/constants/evaluations-test.constant'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - USERS_SERVICE_MOCK + USERS_SERVICE_MOCK, } from '../../test/constants/users-test.constant'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationDto} from '../evaluations/dto/evaluation.dto'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {EvaluationTag} from './evaluation-tag.model'; -import {EvaluationTagsService} from './evaluation-tags.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationDto } from '../evaluations/dto/evaluation.dto'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { EvaluationTag } from './evaluation-tag.model'; +import { EvaluationTagsService } from './evaluation-tags.service'; describe('EvaluationTagsService', () => { let evaluationTagsService: EvaluationTagsService; @@ -42,20 +42,20 @@ describe('EvaluationTagsService', () => { User, GroupEvaluation, Group, - GroupUser - ]) + GroupUser, + ]), ], providers: [ DatabaseService, EvaluationTagsService, EvaluationsService, - {provide: UsersService, useValue: USERS_SERVICE_MOCK}, - {provide: GroupsService, useValue: GROUPS_SERVICE_MOCK} - ] + { provide: UsersService, useValue: USERS_SERVICE_MOCK }, + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], }).compile(); evaluationTagsService = module.get( - EvaluationTagsService + EvaluationTagsService, ); evaluationsService = module.get(EvaluationsService); databaseService = module.get(DatabaseService); @@ -74,8 +74,8 @@ describe('EvaluationTagsService', () => { await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id - }) + userId: user.id, + }), ); }); @@ -83,7 +83,7 @@ describe('EvaluationTagsService', () => { it('should create a valid EvaluationTag', async () => { const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); expect(evaluationTag.id).toBeDefined(); expect(evaluationTag.evaluationId).toEqual(evaluation.id); @@ -98,10 +98,10 @@ describe('EvaluationTagsService', () => { await expect( evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO_MISSING_VALUE - ) + CREATE_EVALUATION_TAG_DTO_MISSING_VALUE, + ), ).rejects.toThrow( - 'notNull Violation: EvaluationTag.value cannot be null' + 'notNull Violation: EvaluationTag.value cannot be null', ); }); }); @@ -116,14 +116,14 @@ describe('EvaluationTagsService', () => { // One existing tag await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); foundEvaluationTags = await evaluationTagsService.findAll(); expect(foundEvaluationTags.length).toEqual(1); // Multiple existing tags await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); foundEvaluationTags = await evaluationTagsService.findAll(); expect(foundEvaluationTags.length).toBeGreaterThan(1); @@ -134,15 +134,15 @@ describe('EvaluationTagsService', () => { it('should remove an existing tag', async () => { const evaluationTag = await evaluationTagsService.create( evaluation.id, - CREATE_EVALUATION_TAG_DTO + CREATE_EVALUATION_TAG_DTO, ); expect(evaluationTag).toBeDefined(); const removedEvaluationTag = await evaluationTagsService.remove( - evaluationTag.id + evaluationTag.id, ); expect(removedEvaluationTag.value).toEqual(evaluationTag.value); const foundEvaluationTag = await EvaluationTag.findByPk( - evaluationTag.id + evaluationTag.id, ); expect(foundEvaluationTag).toEqual(null); }); diff --git a/apps/backend/src/evaluation-tags/evaluation-tags.service.ts b/apps/backend/src/evaluation-tags/evaluation-tags.service.ts index 704c77ec53..e7a359461f 100644 --- a/apps/backend/src/evaluation-tags/evaluation-tags.service.ts +++ b/apps/backend/src/evaluation-tags/evaluation-tags.service.ts @@ -1,95 +1,94 @@ -import {Injectable, NotFoundException} from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions} from 'sequelize'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {CreateEvaluationTagDto} from './dto/create-evaluation-tag.dto'; -import {EvaluationTag} from './evaluation-tag.model'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions } from 'sequelize'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { CreateEvaluationTagDto } from './dto/create-evaluation-tag.dto'; +import { EvaluationTag } from './evaluation-tag.model'; @Injectable() export class EvaluationTagsService { constructor( @InjectModel(EvaluationTag) - private readonly evaluationTagModel: typeof EvaluationTag + private readonly evaluationTagModel: typeof EvaluationTag, ) {} + async count(): Promise { + return this.evaluationTagModel.count(); + } + + async create( + evaluationId: string, + createEvaluationTagDto: CreateEvaluationTagDto, + ): Promise { + const evaluationTag = new EvaluationTag(); + evaluationTag.value = createEvaluationTagDto.value; + evaluationTag.evaluationId = evaluationId; + return evaluationTag.save(); + } + async findAll(): Promise { return this.evaluationTagModel.findAll({ include: [ { - model: Evaluation, include: [ { + include: [User], model: Group, - include: [User] - } - ] - } - ] + }, + ], + model: Evaluation, + }, + ], }); } - async count(): Promise { - return this.evaluationTagModel.count(); - } - async findById(id: string): Promise { return this.findByPkBang(id, { include: [ { - model: Evaluation, include: [ { + include: [User], model: Group, - include: [User] - } - ] - } - ] + }, + ], + model: Evaluation, + }, + ], }); } - async create( - evaluationId: string, - createEvaluationTagDto: CreateEvaluationTagDto + async findByPkBang( + identifier: Buffer | number | string | undefined, + options: Pick, ): Promise { - const evaluationTag = new EvaluationTag(); - evaluationTag.value = createEvaluationTagDto.value; - evaluationTag.evaluationId = evaluationId; - return evaluationTag.save(); + const evaluationTag = await this.evaluationTagModel.findByPk( + identifier, + options, + ); + if (evaluationTag === null) { + throw new NotFoundException('EvaluationTag with given id not found'); + } + return evaluationTag; } async remove(id: string): Promise { const evaluationTag = await this.findByPkBang(id, { include: [ { - model: Evaluation, include: [ { + include: [User], model: Group, - include: [User] - } - ] - } - ] + }, + ], + model: Evaluation, + }, + ], }); await evaluationTag.destroy(); return evaluationTag; } - - async findByPkBang( - identifier: string | number | Buffer | undefined, - options: Pick - ): Promise { - const evaluationTag = await this.evaluationTagModel.findByPk( - identifier, - options - ); - if (evaluationTag === null) { - throw new NotFoundException('EvaluationTag with given id not found'); - } else { - return evaluationTag; - } - } } diff --git a/apps/backend/src/evaluations/dto/create-evaluation.dto.ts b/apps/backend/src/evaluations/dto/create-evaluation.dto.ts index 74ae80909c..0b6c41791c 100644 --- a/apps/backend/src/evaluations/dto/create-evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/create-evaluation.dto.ts @@ -1,27 +1,27 @@ -import {ICreateEvaluation} from '@heimdall/common/interfaces'; +import { ICreateEvaluation } from '@heimdall/common/interfaces'; import { IsArray, IsBoolean, IsNotEmpty, IsOptional, - IsString + IsString, } from 'class-validator'; -import {CreateEvaluationTagDto} from '../../evaluation-tags/dto/create-evaluation-tag.dto'; +import { CreateEvaluationTagDto } from '../../evaluation-tags/dto/create-evaluation-tag.dto'; export class CreateEvaluationDto implements ICreateEvaluation { - @IsNotEmpty() - @IsString() - readonly filename!: string; - - @IsNotEmpty() - @IsBoolean() - readonly public!: boolean; - @IsOptional() @IsArray() readonly evaluationTags: CreateEvaluationTagDto[] | undefined; + @IsNotEmpty() + @IsString() + readonly filename!: string; + @IsOptional() @IsArray() readonly groups: string[] | undefined; + + @IsNotEmpty() + @IsBoolean() + readonly public!: boolean; } diff --git a/apps/backend/src/evaluations/dto/evaluation.dto.ts b/apps/backend/src/evaluations/dto/evaluation.dto.ts index 43933354f7..22616f6d5f 100644 --- a/apps/backend/src/evaluations/dto/evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/evaluation.dto.ts @@ -1,48 +1,47 @@ -import {IEvaluation} from '@heimdall/common/interfaces'; -import {EvaluationTagDto} from '../../evaluation-tags/dto/evaluation-tag.dto'; -import {GroupDto} from '../../groups/dto/group.dto'; -import {Group} from '../../groups/group.model'; -import {Evaluation} from '../evaluation.model'; +import type { IEvaluation } from '@heimdall/common/interfaces'; +import { EvaluationTagDto } from '../../evaluation-tags/dto/evaluation-tag.dto'; +import { GroupDto } from '../../groups/dto/group.dto'; +import type { Group } from '../../groups/group.model'; +import type { Evaluation } from '../evaluation.model'; + +export type IEvaluationResponse = { + evaluations: EvaluationDto[]; + totalCount: number; +}; export class EvaluationDto implements IEvaluation { - readonly id: string; - filename: string; + readonly createdAt: Date; readonly data?: Record; + readonly editable: boolean; readonly evaluationTags: EvaluationTagDto[]; - readonly groups: GroupDto[]; - readonly userId?: string; + filename: string; readonly groupId?: string; + readonly groups: GroupDto[]; + readonly id: string; readonly public: boolean; - readonly createdAt: Date; - readonly updatedAt: Date; - readonly editable: boolean; readonly shareURL?: string; + readonly updatedAt: Date; + readonly userId?: string; constructor( evaluation: Evaluation, editable = false, - shareURL: string | undefined = undefined + shareURL?: string, ) { this.id = evaluation.id; this.filename = evaluation.filename; this.data = evaluation.data; - if ( - evaluation.evaluationTags === null || - evaluation.evaluationTags === undefined - ) { - this.evaluationTags = []; - } else { - this.evaluationTags = evaluation.evaluationTags.map( - (tag) => new EvaluationTagDto(tag) + this.evaluationTags = evaluation.evaluationTags === null + || evaluation.evaluationTags === undefined + ? [] + : evaluation.evaluationTags.map( + tag => new EvaluationTagDto(tag), ); - } - if (evaluation.groups === null || evaluation.groups === undefined) { - this.groups = []; - } else { - this.groups = evaluation.groups.map( - (group) => new GroupDto(group as Group) + this.groups = evaluation.groups === null || evaluation.groups === undefined + ? [] + : evaluation.groups.map( + group => new GroupDto(group as Group), ); - } this.userId = evaluation.userId; this.groupId = evaluation.groupId; this.public = evaluation.public; @@ -52,8 +51,3 @@ export class EvaluationDto implements IEvaluation { this.shareURL = shareURL; } } - -export interface IEvaluationResponse { - evaluations: EvaluationDto[]; - totalCount: number; -} diff --git a/apps/backend/src/evaluations/dto/update-evaluation.dto.ts b/apps/backend/src/evaluations/dto/update-evaluation.dto.ts index 19d691e711..9edd795d01 100644 --- a/apps/backend/src/evaluations/dto/update-evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/update-evaluation.dto.ts @@ -1,15 +1,15 @@ -import {IUpdateEvaluation} from '@heimdall/common/interfaces'; -import {IsBoolean, IsObject, IsOptional, IsString} from 'class-validator'; +import { IUpdateEvaluation } from '@heimdall/common/interfaces'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; export class UpdateEvaluationDto implements IUpdateEvaluation { - @IsOptional() - @IsString() - readonly filename: string | undefined; - @IsOptional() @IsObject() readonly data: Record | undefined; + @IsOptional() + @IsString() + readonly filename: string | undefined; + @IsOptional() @IsBoolean() readonly public: boolean | undefined; diff --git a/apps/backend/src/evaluations/evaluation.model.ts b/apps/backend/src/evaluations/evaluation.model.ts index 80d2e4a1a8..e40616601a 100644 --- a/apps/backend/src/evaluations/evaluation.model.ts +++ b/apps/backend/src/evaluations/evaluation.model.ts @@ -12,60 +12,58 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; @Table export class Evaluation extends Model { - @PrimaryKey - @AutoIncrement - @AllowNull(false) - @Column(DataType.BIGINT) - declare id: string; - + @CreatedAt @AllowNull(false) @Column - declare filename: string; + declare createdAt: Date; @AllowNull(false) @Column(DataType.JSON) declare data: Record; - @AllowNull(false) - @Default(false) - @Column(DataType.BOOLEAN) - declare public: boolean; + @HasMany(() => EvaluationTag) + declare evaluationTags: EvaluationTag[]; - @ForeignKey(() => User) - @Column(DataType.BIGINT) - declare userId: string; + @AllowNull(false) + @Column + declare filename: string; @ForeignKey(() => Group) @Column(DataType.BIGINT) declare groupId: string; - @BelongsTo(() => User, { - constraints: false - }) - declare user: User; + @BelongsToMany(() => Group, () => GroupEvaluation) + declare groups: (Group & { GroupEvaluation: GroupEvaluation })[]; - @CreatedAt + @PrimaryKey + @AutoIncrement @AllowNull(false) - @Column - declare createdAt: Date; + @Column(DataType.BIGINT) + declare id: string; + + @AllowNull(false) + @Default(false) + @Column(DataType.BOOLEAN) + declare public: boolean; @UpdatedAt @AllowNull(false) @Column declare updatedAt: Date; - @HasMany(() => EvaluationTag) - declare evaluationTags: EvaluationTag[]; + @BelongsTo(() => User, { constraints: false }) + declare user: User; - @BelongsToMany(() => Group, () => GroupEvaluation) - declare groups: Array; + @ForeignKey(() => User) + @Column(DataType.BIGINT) + declare userId: string; } diff --git a/apps/backend/src/evaluations/evaluations.controller.spec.ts b/apps/backend/src/evaluations/evaluations.controller.spec.ts index 839ef9e29d..2688fd7548 100644 --- a/apps/backend/src/evaluations/evaluations.controller.spec.ts +++ b/apps/backend/src/evaluations/evaluations.controller.spec.ts @@ -1,8 +1,12 @@ +import type { AddressInfo } from 'node:net'; +import {Readable} from 'node:stream'; import {ForbiddenError} from '@casl/ability'; -import {NotFoundException} from '@nestjs/common'; +import type { ExecutionContext, INestApplication } from '@nestjs/common'; +import {BadRequestException, NotFoundException} from '@nestjs/common'; import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import type { TestingModule } from '@nestjs/testing'; +import {Test} from '@nestjs/testing'; +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; import { CREATE_EVALUATION_DTO_WITHOUT_TAGS, EVALUATION_1, @@ -20,6 +24,7 @@ import { } from '../../test/constants/users-test.constant'; import {AuthzService} from '../authz/authz.service'; import {ConfigService} from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; import {DatabaseModule} from '../database/database.module'; import {DatabaseService} from '../database/database.service'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -27,6 +32,8 @@ import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; import {GroupUser} from '../group-users/group-user.model'; import {Group} from '../groups/group.model'; import {GroupsService} from '../groups/groups.service'; +import { APIKeyOrJwtAuthGuard } from '../guards/api-key-or-jwt-auth.guard'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import {User} from '../users/user.model'; import {UsersService} from '../users/users.service'; import {EvaluationDto} from './dto/evaluation.dto'; @@ -34,16 +41,26 @@ import {Evaluation} from './evaluation.model'; import {EvaluationsController} from './evaluations.controller'; import {EvaluationsService} from './evaluations.service'; -// This allows basic testing of the evaluations controller -// interface without having to construct a full File object -const mockFile: Express.Multer.File = { - originalname: 'abc.json', - buffer: Buffer.from('{}') -}; -const secondMockFile: Express.Multer.File = { - originalname: 'cda.json', - buffer: Buffer.from('{}') -}; +// A complete Multer File — the controller only reads originalname/buffer, +// but a partial literal is a TS2740 under tsc --noEmit (the transpile-only +// test runner never type checks, so the gap was invisible until then). +function buildMockFile(originalname: string): Express.Multer.File { + const buffer = Buffer.from('{}'); + return { + buffer, + destination: '', + encoding: '7bit', + fieldname: 'data', + filename: originalname, + mimetype: 'application/json', + originalname, + path: '', + size: buffer.length, + stream: Readable.from(buffer) + }; +} +const mockFile = buildMockFile('abc.json'); +const secondMockFile = buildMockFile('cda.json'); describe('EvaluationsController', () => { let evaluationsController: EvaluationsController; @@ -59,6 +76,7 @@ describe('EvaluationsController', () => { module = await Test.createTestingModule({ controllers: [EvaluationsController], imports: [ + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ EvaluationTag, @@ -253,7 +271,7 @@ describe('EvaluationsController', () => { ); expect(evaluation).toBeDefined(); if (Array.isArray(evaluation)) { - throw new Error( + throw new TypeError( 'Returned evaluation for one file upload should not be an array' ); } @@ -270,7 +288,7 @@ describe('EvaluationsController', () => { ); expect(evaluation).toBeDefined(); if (Array.isArray(evaluation)) { - throw new Error( + throw new TypeError( 'Returned evaluation for one file upload should not be an array' ); } @@ -285,7 +303,7 @@ describe('EvaluationsController', () => { ); expect(evaluations).toBeDefined(); if (!Array.isArray(evaluations)) { - throw new Error( + throw new TypeError( 'Returned evaluation for multiple file upload should be an array' ); } @@ -295,6 +313,47 @@ describe('EvaluationsController', () => { // Creating an evaluation should return a DTO without data. expect(evaluations[0].data).not.toBeDefined(); }); + + // These two pin the awaited group attach on BOTH create paths. They induce + // the failure deliberately: a discarded promise is indistinguishable from an + // awaited one until the attach REJECTS. Without the await the rejection + // becomes an unhandled rejection AFTER create has already resolved, so the + // caller is told the upload succeeded. The route-resolution tests elsewhere + // in this file pass either way and do not pin this. + it('surfaces a failed group attach instead of reporting success (user path)', async () => { + const group = await groupsService.create(PRIVATE_GROUP); + await groupsService.addUserToGroup(group, user, 'owner'); + const attach = vi + .spyOn(groupsService, 'addEvaluationToGroup') + .mockRejectedValue(new Error('group attach exploded')); + + await expect( + evaluationsController.create( + {...CREATE_EVALUATION_DTO_WITHOUT_TAGS, groups: [group.id]}, + [mockFile], + {user: user} + ) + ).rejects.toThrow('group attach exploded'); + + attach.mockRestore(); + }); + + it('surfaces a failed group attach on the group-upload path', async () => { + const group = await groupsService.create(PRIVATE_GROUP); + const attach = vi + .spyOn(groupsService, 'addEvaluationToGroup') + .mockRejectedValue(new Error('group attach exploded')); + + await expect( + evaluationsController.create( + CREATE_EVALUATION_DTO_WITHOUT_TAGS, + [mockFile], + {user: group} + ) + ).rejects.toThrow(BadRequestException); + + attach.mockRestore(); + }); }); describe('update', () => { @@ -453,3 +512,135 @@ describe('EvaluationsController', () => { }); }); }); + +// Route resolution is a ROUTER concern, not a handler concern: calling +// evaluationsController.findAll() directly succeeds no matter what order the +// decorators are declared in, so the unit tests above cannot detect route +// shadowing. These tests boot the real Nest application and issue real HTTP +// requests so the actual registered routing table is what gets exercised. +// Node's built-in fetch is used deliberately — supertest is not a dependency +// of this repo and adding one to reach a routing assertion is not warranted. +describe('EvaluationsController route resolution', () => { + let app: INestApplication; + let baseUrl: string; + let module: TestingModule; + let databaseService: DatabaseService; + let usersService: UsersService; + let evaluationsService: EvaluationsService; + // The overridden guards read this at request time, so each test's freshly + // created user is the one ABAC sees. + let currentUser: User; + + const allowWithCurrentUser = { + canActivate: (context: ExecutionContext): boolean => { + context.switchToHttp().getRequest<{ user: User }>().user = currentUser; + return true; + }, + }; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [EvaluationsController], + imports: [ + CryptoModule, + DatabaseModule, + SequelizeModule.forFeature([ + EvaluationTag, + Evaluation, + User, + GroupEvaluation, + GroupUser, + Group, + ]), + ], + providers: [ + AuthzService, + ConfigService, + DatabaseService, + UsersService, + EvaluationsService, + GroupsService, + ], + }) + // Auth is not what these tests are about; the routing table is. + .overrideGuard(APIKeyOrJwtAuthGuard) + .useValue(allowWithCurrentUser) + .overrideGuard(JwtAuthGuard) + .useValue(allowWithCurrentUser) + .compile(); + + databaseService = module.get(DatabaseService); + usersService = module.get(UsersService); + evaluationsService = module.get(EvaluationsService); + + app = module.createNestApplication(); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address() as AddressInfo; + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + afterAll(async () => { + // Order matters: app.close() tears down the Nest app INCLUDING its + // Sequelize connection, so the cleanup query has to run first or it hits + // "ConnectionManager.getConnection was called after the connection manager + // was closed". app.close() also makes a separate closeConnection() call + // redundant. + await databaseService.cleanAll(); + await app.close(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + // ABAC needs a real model instance, and create() returns a DTO — so the + // row is re-read rather than cast. + const createdUser = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const persisted = await User.findByPk(createdUser.id); + if (persisted === null) { + throw new TypeError('test user was not persisted'); + } + currentUser = persisted; + }); + + it('routes GET /evaluations/e2e to findAll, not to the :id handler', async () => { + expect.assertions(2); + const response = await fetch(`${baseUrl}/evaluations/e2e`); + + // If ':id' is declared first, Nest dispatches this to findById, which + // hands the literal string "e2e" to Postgres as a bigint and 500s with + // 'invalid input syntax for type bigint'. + expect(response.status).toBe(200); + // findAll returns a LIST; findById returns a single object. + expect(Array.isArray(await response.json())).toBe(true); + }); + + it('still routes GET /evaluations/:id to findById for a real numeric id', async () => { + expect.assertions(2); + const created = await evaluationsService.create({ + ...EVALUATION_1, + data: mockFile, + userId: currentUser.id + }); + + const response = await fetch(`${baseUrl}/evaluations/${created.id}`); + + expect(response.status).toBe(200); + const body = (await response.json()) as {id: string}; + expect(body.id).toBe(created.id); + }); + + it('still routes GET /evaluations/:id/groups to groupsForEvaluation', async () => { + expect.assertions(2); + const created = await evaluationsService.create({ + ...EVALUATION_1, + data: mockFile, + userId: currentUser.id + }); + + const response = await fetch(`${baseUrl}/evaluations/${created.id}/groups`); + + expect(response.status).toBe(200); + expect(Array.isArray(await response.json())).toBe(true); + }); +}); diff --git a/apps/backend/src/evaluations/evaluations.controller.ts b/apps/backend/src/evaluations/evaluations.controller.ts index d044d06218..ba20f99471 100644 --- a/apps/backend/src/evaluations/evaluations.controller.ts +++ b/apps/backend/src/evaluations/evaluations.controller.ts @@ -44,6 +44,27 @@ export class EvaluationsController { private readonly authz: AuthzService ) {} + // NestJS matches routes in declaration order, so every LITERAL path must be + // declared before the parameterised ':id' route. Declared after it, '/e2e' + // was captured by findById and reached Postgres as a bigint id + // ("invalid input syntax for type bigint: e2e" -> HTTP 500), making this + // endpoint unreachable. Guarded by a routing test in the controller spec. + @UseGuards(APIKeyOrJwtAuthGuard) + @Get('e2e') + async findAll(@Request() request: {user: User}): Promise { + const abac = this.authz.abac.createForUser(request.user); + let evaluations = await this.evaluationsService.findAll(); + + evaluations = evaluations.filter((evaluation) => + abac.can(Action.Read, evaluation) + ); + + return evaluations.map( + (evaluation) => + new EvaluationDto(evaluation, abac.can(Action.Update, evaluation)) + ); + } + @UseGuards(APIKeyOrJwtAuthGuard) @Get(':id') async findById( @@ -72,22 +93,6 @@ export class EvaluationsController { return evaluationGroups.map((group) => new GroupDto(group)); } - @UseGuards(APIKeyOrJwtAuthGuard) - @Get('e2e') - async findAll(@Request() request: {user: User}): Promise { - const abac = this.authz.abac.createForUser(request.user); - let evaluations = await this.evaluationsService.findAll(); - - evaluations = evaluations.filter((evaluation) => - abac.can(Action.Read, evaluation) - ); - - return evaluations.map( - (evaluation) => - new EvaluationDto(evaluation, abac.can(Action.Update, evaluation)) - ); - } - @UseGuards(APIKeyOrJwtAuthGuard) @Get() async findAndCountAll( @@ -95,31 +100,24 @@ export class EvaluationsController { @Request() request: {user: User} ): Promise { const abac = this.authz.abac.createForUser(request.user); - let evaluations: Evaluation[] = []; - let totalItems = 0; - if (params.useClause) { - const response = await this.evaluationsService.getEvaluationsWithClause( - params, - request.user.email, - request.user.role - ); - evaluations = response.evaluations; - totalItems = response.totalItems; - } else { - const response = await this.evaluationsService.getAllEvaluations( - params, - request.user.email, - request.user.role - ); - evaluations = response.evaluations; - totalItems = response.totalItems; - } + const response = params.useClause + ? await this.evaluationsService.getEvaluationsWithClause( + params, + request.user.email, + request.user.role + ) + : await this.evaluationsService.getAllEvaluations( + params, + request.user.email, + request.user.role + ); + const totalItems = response.totalItems; // Perform an policy-based access control (AKA Attribute-based access control) // Show public evaluations, evaluations that belong to a group the logged-in user // belongs too, or those created by logged-in user. - evaluations = evaluations.filter((evaluation: Subject) => + const evaluations = response.evaluations.filter((evaluation: Subject) => abac.can(Action.Read, evaluation) ); @@ -151,10 +149,12 @@ export class EvaluationsController { serializedDta = {originalResultsData: file.buffer.toString('utf8')}; } + let createdDto: EvaluationDto; // If the "user" is a group, we'll add the evaluation to the group, and ignore any other groups if (request.user instanceof Group) { - const evaluation = await this.evaluationsService - .create({ + let evaluation: Evaluation; + try { + evaluation = await this.evaluationsService.create({ // Only respect custom file names for single file uploads filename: data.length > 1 @@ -164,21 +164,17 @@ export class EvaluationsController { public: createEvaluationDto.public, data: serializedDta, groupId: request.user.id - }) - .then(async (evaluation) => { - const group = await this.groupsService.findByPkBang( - request.user.id - ); - this.groupsService.addEvaluationToGroup(group, evaluation); - return evaluation; - }) - .catch((err) => { - throw new BadRequestException(err.message); }); - - const createdDto = new EvaluationDto(evaluation, true); - - return _.omit(createdDto, 'data'); + const group = await this.groupsService.findByPkBang(request.user.id); + // Awaited: dropping this promise meant a failed group attach could + // only surface as an unhandled rejection, after the response had + // already reported success. + await this.groupsService.addEvaluationToGroup(group, evaluation); + } catch (error) { + throw new BadRequestException((error as Error).message); + } + + createdDto = new EvaluationDto(evaluation, true); } else { let groups: Group[] = createEvaluationDto.groups ? await this.groupsService.findByIds(createEvaluationDto.groups) @@ -188,31 +184,29 @@ export class EvaluationsController { groups = groups.filter((group) => { return abac.can(Action.AddEvaluation, group); }); - const evaluation = await this.evaluationsService - .create({ - // Only respect custom file names for single file uploads - filename: - data.length > 1 - ? file.originalname - : createEvaluationDto.filename, // lgtm [js/type-confusion-through-parameter-tampering] - evaluationTags: createEvaluationDto.evaluationTags || [], - public: createEvaluationDto.public, - data: serializedDta, - userId: request.user.id // Do not include userId on the DTO so we can set it automatically to the uploader's id. - }) - .then((createdEvaluation) => { - groups.forEach((group) => - this.groupsService.addEvaluationToGroup(group, createdEvaluation) - ); - return createdEvaluation; - }); - const createdDto: EvaluationDto = new EvaluationDto( + const evaluation = await this.evaluationsService.create({ + // Only respect custom file names for single file uploads + filename: + data.length > 1 ? file.originalname : createEvaluationDto.filename, // lgtm [js/type-confusion-through-parameter-tampering] + evaluationTags: createEvaluationDto.evaluationTags || [], + public: createEvaluationDto.public, + data: serializedDta, + userId: request.user.id // Do not include userId on the DTO so we can set it automatically to the uploader's id. + }); + // Awaited: forEach discarded each promise, so the response could report + // success before the evaluation had joined its groups. + await Promise.all( + groups.map((group) => + this.groupsService.addEvaluationToGroup(group, evaluation) + ) + ); + createdDto = new EvaluationDto( evaluation, true, `${this.configService.getExternalUrl()}/results/${evaluation.id}` ); - return _.omit(createdDto, 'data'); } + return _.omit(createdDto, 'data'); }); if (uploadedFiles.length === 1) { return uploadedFiles[0]; diff --git a/apps/backend/src/evaluations/evaluations.module.ts b/apps/backend/src/evaluations/evaluations.module.ts index 5a59332dd2..234fd4dd37 100644 --- a/apps/backend/src/evaluations/evaluations.module.ts +++ b/apps/backend/src/evaluations/evaluations.module.ts @@ -1,18 +1,21 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ConfigModule} from '../config/config.module'; -import {DatabaseModule} from '../database/database.module'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {Evaluation} from './evaluation.model'; -import {EvaluationsController} from './evaluations.controller'; -import {EvaluationsService} from './evaluations.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ConfigModule } from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { Evaluation } from './evaluation.model'; +import { EvaluationsController } from './evaluations.controller'; +import { EvaluationsService } from './evaluations.service'; @Module({ + controllers: [EvaluationsController], + exports: [EvaluationsService], imports: [ SequelizeModule.forFeature([ Evaluation, @@ -20,13 +23,12 @@ import {EvaluationsService} from './evaluations.service'; User, Group, GroupUser, - GroupEvaluation + GroupEvaluation, ]), ConfigModule, - DatabaseModule + CryptoModule, + DatabaseModule, ], providers: [EvaluationsService, UsersService, GroupsService], - controllers: [EvaluationsController], - exports: [EvaluationsService] }) export class EvaluationsModule {} diff --git a/apps/backend/src/evaluations/evaluations.service.spec.ts b/apps/backend/src/evaluations/evaluations.service.spec.ts index c69b3f0710..f6538071eb 100644 --- a/apps/backend/src/evaluations/evaluations.service.spec.ts +++ b/apps/backend/src/evaluations/evaluations.service.spec.ts @@ -1,32 +1,33 @@ -import {NotFoundException} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { NotFoundException } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { CREATE_EVALUATION_DTO_WITHOUT_FILENAME, CREATE_EVALUATION_DTO_WITHOUT_TAGS, EVALUATION_WITH_TAGS_1, UPDATE_EVALUATION, UPDATE_EVALUATION_DATA_ONLY, - UPDATE_EVALUATION_FILENAME_ONLY + UPDATE_EVALUATION_FILENAME_ONLY, } from '../../test/constants/evaluations-test.constant'; -import {GROUP_1} from '../../test/constants/groups-test.constant'; -import {CREATE_USER_DTO_TEST_OBJ} from '../../test/constants/users-test.constant'; -import {ConfigService} from '../config/config.service'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTagsModule} from '../evaluation-tags/evaluation-tags.module'; -import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {UserDto} from '../users/dto/user.dto'; -import {UsersModule} from '../users/users.module'; -import {UsersService} from '../users/users.service'; -import {EvaluationDto} from './dto/evaluation.dto'; -import {Evaluation} from './evaluation.model'; -import {EvaluationsService} from './evaluations.service'; +import { GROUP_1 } from '../../test/constants/groups-test.constant'; +import { CREATE_USER_DTO_TEST_OBJ } from '../../test/constants/users-test.constant'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTagsModule } from '../evaluation-tags/evaluation-tags.module'; +import { EvaluationTagsService } from '../evaluation-tags/evaluation-tags.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { UserDto } from '../users/dto/user.dto'; +import { UsersModule } from '../users/users.module'; +import { UsersService } from '../users/users.service'; +import { EvaluationDto } from './dto/evaluation.dto'; +import { Evaluation } from './evaluation.model'; +import { EvaluationsService } from './evaluations.service'; describe('EvaluationsService', () => { let evaluationsService: EvaluationsService; @@ -39,29 +40,30 @@ describe('EvaluationsService', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [ + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Evaluation, GroupUser, Group, - GroupEvaluation + GroupEvaluation, ]), EvaluationTagsModule, - UsersModule + UsersModule, ], providers: [ ConfigService, EvaluationsService, DatabaseService, UsersService, - GroupsService - ] + GroupsService, + ], }).compile(); databaseService = module.get(DatabaseService); evaluationsService = module.get(EvaluationsService); evaluationTagsService = module.get( - EvaluationTagsService + EvaluationTagsService, ); usersService = module.get(UsersService); groupsService = module.get(GroupsService); @@ -85,12 +87,12 @@ describe('EvaluationsService', () => { await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); evaluationsDtoArray = await evaluationsService.findAll(); expect(evaluationsDtoArray.length).toEqual(2); @@ -100,7 +102,7 @@ describe('EvaluationsService', () => { await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const evaluations = await evaluationsService.findAll(); @@ -113,7 +115,7 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); let evaluations = await evaluationsService.findAll(); @@ -138,18 +140,18 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const foundEvaluation = await evaluationsService.findById(evaluation.id); expect(new EvaluationDto(evaluation)).toEqual( - new EvaluationDto(foundEvaluation) + new EvaluationDto(foundEvaluation), ); }); it('should throw an error if an evaluation does not exist', async () => { expect.assertions(1); await expect(evaluationsService.findById('-1')).rejects.toThrow( - NotFoundException + NotFoundException, ); }); }); @@ -159,7 +161,7 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); expect(evaluation.id).toBeDefined(); expect(evaluation.updatedAt).toBeDefined(); @@ -172,12 +174,12 @@ describe('EvaluationsService', () => { if (EVALUATION_WITH_TAGS_1.evaluationTags === undefined) { throw new TypeError( - 'Evaluation fixture does not have any associated tags.' + 'Evaluation fixture does not have any associated tags.', ); } expect(evaluation.evaluationTags?.[0].value).toEqual( - EVALUATION_WITH_TAGS_1.evaluationTags[0].value + EVALUATION_WITH_TAGS_1.evaluationTags[0].value, ); }); @@ -185,17 +187,18 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...CREATE_EVALUATION_DTO_WITHOUT_TAGS, data: {}, - userId: user.id + userId: user.id, }); expect(evaluation.id).toBeDefined(); expect(evaluation.updatedAt).toBeDefined(); expect(evaluation.createdAt).toBeDefined(); expect(evaluation.data).toEqual({}); expect(evaluation.filename).toEqual( - CREATE_EVALUATION_DTO_WITHOUT_TAGS.filename + CREATE_EVALUATION_DTO_WITHOUT_TAGS.filename, ); expect(evaluation.evaluationTags).not.toBeDefined(); - expect((await evaluationTagsService.findAll()).length).toBe(0); + const allTags = await evaluationTagsService.findAll(); + expect(allTags.length).toBe(0); }); it('should throw an error when missing the filename field', async () => { @@ -204,10 +207,10 @@ describe('EvaluationsService', () => { evaluationsService.create({ ...CREATE_EVALUATION_DTO_WITHOUT_FILENAME, data: {}, - userId: user.id - }) + userId: user.id, + }), ).rejects.toThrow( - 'notNull Violation: Evaluation.filename cannot be null' + 'notNull Violation: Evaluation.filename cannot be null', ); }); }); @@ -216,7 +219,7 @@ describe('EvaluationsService', () => { it('should throw an error if an evaluation does not exist', async () => { expect.assertions(1); await expect( - evaluationsService.update('-1', UPDATE_EVALUATION) + evaluationsService.update('-1', UPDATE_EVALUATION), ).rejects.toThrow(NotFoundException); }); @@ -224,11 +227,11 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const updatedEvaluation = await evaluationsService.update( evaluation.id, - UPDATE_EVALUATION + UPDATE_EVALUATION, ); expect(updatedEvaluation.id).toEqual(evaluation.id); expect(updatedEvaluation.createdAt).toEqual(evaluation.createdAt); @@ -241,17 +244,17 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const updatedEvaluation = await evaluationsService.update( evaluation.id, - UPDATE_EVALUATION_DATA_ONLY + UPDATE_EVALUATION_DATA_ONLY, ); expect(updatedEvaluation.id).toEqual(evaluation.id); expect(updatedEvaluation.createdAt).toEqual(evaluation.createdAt); expect(updatedEvaluation.updatedAt).not.toEqual(evaluation.updatedAt); expect(updatedEvaluation.evaluationTags.length).toEqual( - evaluation.evaluationTags.length + evaluation.evaluationTags.length, ); expect(updatedEvaluation.data).not.toEqual(evaluation.data); expect(updatedEvaluation.filename).toEqual(evaluation.filename); @@ -261,18 +264,18 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const updatedEvaluation = await evaluationsService.update( evaluation.id, - UPDATE_EVALUATION_FILENAME_ONLY + UPDATE_EVALUATION_FILENAME_ONLY, ); expect(updatedEvaluation.id).toEqual(evaluation.id); expect(updatedEvaluation.createdAt).toEqual(evaluation.createdAt); expect(updatedEvaluation.updatedAt).not.toEqual(evaluation.updatedAt); expect(updatedEvaluation.evaluationTags.length).toEqual( - evaluation.evaluationTags.length + evaluation.evaluationTags.length, ); expect(updatedEvaluation.data).toEqual(evaluation.data); expect(updatedEvaluation.filename).not.toEqual(evaluation.filename); @@ -284,24 +287,24 @@ describe('EvaluationsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); const removedEvaluation = await evaluationsService.remove(evaluation.id); const foundEvaluationTags = await evaluationTagsService.findAll(); expect(foundEvaluationTags.length).toEqual(0); expect(new EvaluationDto(removedEvaluation)).toEqual( - new EvaluationDto(evaluation) + new EvaluationDto(evaluation), ); await expect( - evaluationsService.findById(removedEvaluation.id) + evaluationsService.findById(removedEvaluation.id), ).rejects.toThrow(NotFoundException); }); it('should throw an error when the evaluation does not exist', async () => { expect.assertions(1); await expect(evaluationsService.findById('-1')).rejects.toThrow( - NotFoundException + NotFoundException, ); }); }); diff --git a/apps/backend/src/evaluations/evaluations.service.ts b/apps/backend/src/evaluations/evaluations.service.ts index 7102ed8080..0eb4d0c600 100644 --- a/apps/backend/src/evaluations/evaluations.service.ts +++ b/apps/backend/src/evaluations/evaluations.service.ts @@ -1,44 +1,45 @@ -import {IEvalPaginationParams} from '@heimdall/common/interfaces'; -import {Injectable, NotFoundException} from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions, Op, WhereOptions, Sequelize} from 'sequelize'; -import {DatabaseService} from '../database/database.service'; -import {CreateEvaluationTagDto} from '../evaluation-tags/dto/create-evaluation-tag.dto'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; -import {UpdateEvaluationDto} from './dto/update-evaluation.dto'; -import {Evaluation} from './evaluation.model'; - -interface EvaluationsResponse { - totalItems: number; +import { IEvalPaginationParams } from '@heimdall/common/interfaces'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions, Op, Sequelize, WhereOptions } from 'sequelize'; +import { DatabaseService } from '../database/database.service'; +import { CreateEvaluationTagDto } from '../evaluation-tags/dto/create-evaluation-tag.dto'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { UpdateEvaluationDto } from './dto/update-evaluation.dto'; +import { Evaluation } from './evaluation.model'; + +type EvaluationsResponse = { evaluations: Evaluation[]; -} + totalItems: number; +}; -interface WhereClauseParams { - searchFields: string[]; - operator: string; +type WhereClauseParameters = { + action: string; email: string; + operator: string; role: string; - action: string; -} + searchFields: string[]; +}; @Injectable() export class EvaluationsService { + /* + NOTE: Hack to overcome the inability to retrieve the desire + number of evaluation (see note 1 above). Pad the + requested number of records by an estimated number of + group members (20 per group). + */ + totalGroupMembers = 20; + constructor( @InjectModel(Evaluation) private readonly evaluationModel: typeof Evaluation, - private readonly databaseService: DatabaseService + private readonly databaseService: DatabaseService, ) {} - async findAll(): Promise { - return this.evaluationModel.findAll({ - attributes: {exclude: ['data']}, - include: [EvaluationTag, User, {model: Group, include: [User]}] - }); - } - /* - NOTES: These notes are about the getAllEvaluations() and the + NOTES: These notes are about the getAllEvaluations() and the getEvaluationsWithClause() methods 1: The sequelize model is using eager loading, at the SQL level, this is a @@ -58,7 +59,7 @@ export class EvaluationsService { 2: TypeScript is not able to infer OrderItem[]. - The 'order' option in sequelize is defined as type OrderItem like: + The 'order' option in sequelize is defined as type OrderItem like: string | fn | col | literal | [string | col | fn | literal, string] | [Model | { model: Model, as: string }, string, string] | [Model, Model, string, string] @@ -76,7 +77,7 @@ export class EvaluationsService { Using the findAll and calling specific queries to determine the total records. - 4: Using ORDER BY on top-level and nested columns, for that reason we need + 4: Using ORDER BY on top-level and nested columns, for that reason we need to reference nested columns by utilizing the '$nested.column$' syntax. For that reason the params.order array can have 2 or 3 indices as listed bellow. @@ -88,139 +89,208 @@ export class EvaluationsService { */ - /* - NOTE: Hack to overcome the inability to retrieve the desire - number of evaluation (see note 1 above). Pad the - requested number of records by an estimated number of - group members (20 per group). - */ - totalGroupMembers = 20; + async count(): Promise { + return this.evaluationModel.count(); + } + + async create(evaluation: { + data: unknown; + evaluationTags: CreateEvaluationTagDto[] | undefined; + filename: string; + groupId?: string; + public: boolean; + userId?: string; + }): Promise { + return Evaluation.create( + { ...evaluation }, + { include: [EvaluationTag] }, + ); + } + + async evaluationCount(userEmail: string, role: string): Promise { + return role === 'admin' + ? this.evaluationModel.count() + : this.evaluationModel.count({ + col: 'id', + distinct: true, + include: [User, { include: [User], model: Group }], + where: { + [Op.or]: [ + { public: { [Op.eq]: 'true' } }, + { '$user.email$': { [Op.like]: userEmail } }, + { + [Op.and]: { + '$groups->users.id$': { + [Op.eq]: Sequelize.literal( + `(SELECT id FROM "Users" WHERE "email" LIKE '${userEmail}')`, + ), + }, + }, + }, + ], + }, + }); + } + + async findAll(): Promise { + return this.evaluationModel.findAll({ + attributes: { exclude: ['data'] }, + include: [EvaluationTag, User, { include: [User], model: Group }], + }); + } + + async findById(id: string): Promise { + return this.findByPkBang(id, { include: [EvaluationTag, User, Group, { include: [User], model: Group }] }); + } + + async findByPkBang( + identifier: Buffer | number | string | undefined, + options: Pick, + ): Promise { + const evaluation = await this.evaluationModel.findByPk( + identifier, + options, + ); + if (evaluation === null) { + throw new NotFoundException('Evaluation with given id not found'); + } + return evaluation; + } async getAllEvaluations( - params: IEvalPaginationParams, + parameters: IEvalPaginationParams, email: string, - role: string + role: string, ): Promise { const queryResponse: EvaluationsResponse = { + evaluations: [], totalItems: 0, - evaluations: [] }; const whereClause = this.getWhereClauseAll(role, email); - await this.evaluationModel - .findAll({ - attributes: {exclude: ['data']}, - include: [EvaluationTag, User, {model: Group, include: [User]}], - offset: params.offset, - limit: Number(params.limit) * this.totalGroupMembers, - order: - params.order.length === 2 - ? [[params.order[0], params.order[1]]] - : [[params.order[0], params.order[1], params.order[2]]], - subQuery: false, // enable where clause to reference attributes from the included models - where: whereClause - }) - .then(async (data) => { - const totalItems = await this.evaluationCount(email, role); - - const totalPages = Math.ceil(totalItems / params.limit); - const totalReturned = Number(params.offset) + Number(params.limit); - const onPage = Math.ceil( - totalReturned / 100 / (Number(params.limit) / 100) - ); - if (onPage == totalPages) { - const returnCnt = totalItems - Number(params.offset); - // Return from the back of the array - queryResponse.evaluations = data.slice(-returnCnt); - } else { - queryResponse.evaluations = data.slice(0, params.limit); - } - queryResponse.totalItems = totalItems; - }); + const data = await this.evaluationModel.findAll({ + attributes: { exclude: ['data'] }, + include: [EvaluationTag, User, { include: [User], model: Group }], + limit: Number(parameters.limit) * this.totalGroupMembers, + offset: parameters.offset, + order: + parameters.order.length === 2 + ? [[parameters.order[0], parameters.order[1]]] + : [[parameters.order[0], parameters.order[1], parameters.order[2]]], + subQuery: false, // enable where clause to reference attributes from the included models + where: whereClause, + }); + const totalItems = await this.evaluationCount(email, role); + + const totalPages = Math.ceil(totalItems / parameters.limit); + const totalReturned = Number(parameters.offset) + Number(parameters.limit); + const onPage = Math.ceil( + totalReturned / 100 / (Number(parameters.limit) / 100), + ); + if (onPage == totalPages) { + const returnCnt = totalItems - Number(parameters.offset); + // Return from the back of the array + queryResponse.evaluations = data.slice(-returnCnt); + } else { + queryResponse.evaluations = data.slice(0, parameters.limit); + } + queryResponse.totalItems = totalItems; return queryResponse; } + async getEvaluationIdsForTagName(tagValue: string): Promise { + const evaluationTags = await EvaluationTag.findAll({ + attributes: ['evaluationId'], + raw: true, + where: { value: { [Op.iRegexp]: tagValue } }, + }); + return evaluationTags.map(tag => tag.evaluationId); + } + async getEvaluationsWithClause( - params: IEvalPaginationParams, + parameters: IEvalPaginationParams, email: string, - role: string + role: string, ): Promise { const queryResponse: EvaluationsResponse = { + evaluations: [], totalItems: 0, - evaluations: [] }; - const whereClauseParams: WhereClauseParams = { - searchFields: - params.searchFields === undefined ? [''] : params.searchFields, - operator: params.operator === undefined ? 'OR' : params.operator, + const whereClauseParameters: WhereClauseParameters = { + action: 'search', email: email, + operator: parameters.operator === undefined ? 'OR' : parameters.operator, role: role, - action: 'search' + searchFields: + parameters.searchFields === undefined ? [''] : parameters.searchFields, }; const whereClause = await this.getWhereClauseSearch( - whereClauseParams.searchFields, - whereClauseParams.operator, - whereClauseParams.email, - whereClauseParams.role, - whereClauseParams.action + whereClauseParameters.searchFields, + whereClauseParameters.operator, + whereClauseParameters.email, + whereClauseParameters.role, + whereClauseParameters.action, ); - await this.evaluationModel - .findAll({ - attributes: {exclude: ['data']}, - include: [EvaluationTag, User, {model: Group, include: [User]}], - offset: params.offset, - limit: Number(params.limit) * this.totalGroupMembers, - order: - params.order.length === 2 - ? [[params.order[0], params.order[1]]] - : [[params.order[0], params.order[1], params.order[2]]], - subQuery: false, - where: whereClause - }) - .then(async (data) => { - const totalItems = await this.searchItemsCount(whereClauseParams); - - const totalPages = Math.ceil(totalItems / params.limit); - const totalReturned = Number(params.offset) + Number(params.limit); - const onPage = Math.ceil( - totalReturned / 100 / (Number(params.limit) / 100) - ); - if (onPage === totalPages) { - const returnCnt = totalItems - Number(params.offset); - // Return from the back of the array - queryResponse.evaluations = data.slice(-returnCnt); - } else { - queryResponse.evaluations = data.slice(0, params.limit); - } - queryResponse.totalItems = totalItems; - }); + const data = await this.evaluationModel.findAll({ + attributes: { exclude: ['data'] }, + include: [EvaluationTag, User, { include: [User], model: Group }], + limit: Number(parameters.limit) * this.totalGroupMembers, + offset: parameters.offset, + order: + parameters.order.length === 2 + ? [[parameters.order[0], parameters.order[1]]] + : [[parameters.order[0], parameters.order[1], parameters.order[2]]], + subQuery: false, + where: whereClause, + }); + const totalItems = await this.searchItemsCount(whereClauseParameters); + + const totalPages = Math.ceil(totalItems / parameters.limit); + const totalReturned = Number(parameters.offset) + Number(parameters.limit); + const onPage = Math.ceil( + totalReturned / 100 / (Number(parameters.limit) / 100), + ); + if (onPage === totalPages) { + const returnCnt = totalItems - Number(parameters.offset); + // Return from the back of the array + queryResponse.evaluations = data.slice(-returnCnt); + } else { + queryResponse.evaluations = data.slice(0, parameters.limit); + } + queryResponse.totalItems = totalItems; return queryResponse; } getWhereClauseAll(role: string, email: string): WhereOptions { const whereClause = this.getWhereClauseBaseCriteria(role, email); - return {[Op.or]: whereClause}; + return { [Op.or]: whereClause }; } - getWhereClauseBaseCriteria(role: string, email: string): WhereOptions { - const baseCriteria = []; - baseCriteria.push({public: {[Op.eq]: 'true'}}); + getWhereClauseBaseCriteria(role: string, email: string): WhereOptions[] { + // Explicitly typed: the criteria are heterogeneous (a `public` match, an + // `$user.email$` match, an [Op.and] group), so inference from the first + // element would lock the array to that one shape and reject the rest. + // Both callers consume the result as the operand of [Op.or]/[Op.and], + // so the array — not a single WhereOptions — is the honest return type. + const baseCriteria: WhereOptions[] = [{ public: { [Op.eq]: 'true' } }]; if (role === 'admin') { - baseCriteria.push({public: {[Op.eq]: 'false'}}); + baseCriteria.push({ public: { [Op.eq]: 'false' } }); } else { - baseCriteria.push({'$user.email$': {[Op.like]: `${email}`}}); - baseCriteria.push({ - [Op.and]: { - '$groups->users.id$': { - [Op.eq]: Sequelize.literal( - `(SELECT id FROM "Users" WHERE "email" LIKE '${email}')` - ) - } - } - }); + baseCriteria.push( + { '$user.email$': { [Op.like]: email } }, + { + [Op.and]: { + '$groups->users.id$': { + [Op.eq]: Sequelize.literal( + `(SELECT id FROM "Users" WHERE "email" LIKE '${email}')`, + ), + }, + }, + }, + ); } return baseCriteria; } @@ -230,175 +300,88 @@ export class EvaluationsService { operation: string, email: string, role: string, - action: string + action: string, ): Promise { const searchFields = []; const baseCriteria = this.getWhereClauseBaseCriteria(role, email); if (fields[0] !== '()') { - searchFields.push({filename: {[Op.iRegexp]: `${fields[0]}`}}); + searchFields.push({ filename: { [Op.iRegexp]: fields[0] } }); } if (fields[1] !== '()') { - searchFields.push({'$groups.name$': {[Op.iRegexp]: `${fields[1]}`}}); + searchFields.push({ '$groups.name$': { [Op.iRegexp]: fields[1] } }); } if (fields[2] !== '()') { if (action === 'count') { - searchFields.push({ - '$evaluationTags.value$': {[Op.iRegexp]: `${fields[2]}`} - }); + searchFields.push({ '$evaluationTags.value$': { [Op.iRegexp]: fields[2] } }); } else { const evaluationIds = await this.getEvaluationIdsForTagName(fields[2]); searchFields.push({ [Op.or]: [ - {id: {[Op.in]: evaluationIds}}, - {'$evaluationTags.value$': {[Op.iRegexp]: `${fields[2]}`}} - ] + { id: { [Op.in]: evaluationIds } }, + { '$evaluationTags.value$': { [Op.iRegexp]: fields[2] } }, + ], }); } } if (operation === 'AND') { // Expected outcome: an OR baseCriteria AND an AND searchFields - return {[Op.or]: baseCriteria, [Op.and]: searchFields}; - } else { - // Expected outcome: an OR baseCriteria AND an OR searchFields - return { - [Op.and]: [{[Op.or]: baseCriteria}, {[Op.and]: {[Op.or]: searchFields}}] - }; + return { [Op.and]: searchFields, [Op.or]: baseCriteria }; } + // Expected outcome: an OR baseCriteria AND an OR searchFields + return { [Op.and]: [{ [Op.or]: baseCriteria }, { [Op.and]: { [Op.or]: searchFields } }] }; } - async getEvaluationIdsForTagName(tagValue: string): Promise { - let evaluationIds: string[] = []; - await EvaluationTag.findAll({ - attributes: ['evaluationId'], - where: {value: {[Op.iRegexp]: tagValue}}, - raw: true - }).then(async (evalIds) => { - evaluationIds = evalIds.map((evalIds) => evalIds.evaluationId); + async groups(id: string): Promise { + const evaluation = await this.findByPkBang(id, { + include: { include: [User], model: Group }, }); - return evaluationIds; + return evaluation.groups; } - async evaluationCount(userEmail: string, role: string): Promise { - if (role === 'admin') { - return this.evaluationModel.count(); - } else { - return this.evaluationModel.count({ - include: [User, {model: Group, include: [User]}], - where: { - [Op.or]: [ - {public: {[Op.eq]: 'true'}}, - {'$user.email$': {[Op.like]: `${userEmail}`}}, - { - [Op.and]: { - '$groups->users.id$': { - [Op.eq]: Sequelize.literal( - `(SELECT id FROM "Users" WHERE "email" LIKE '${userEmail}')` - ) - } - } - } - ] - }, - distinct: true, - col: 'id' - }); - } + async remove(id: string): Promise { + const evaluation = await this.findByPkBang(id, { include: [EvaluationTag] }); + await this.databaseService.sequelize.transaction(async (transaction) => { + if (evaluation.evaluationTags !== null) { + // Promise.all, not a bare await of the ARRAY (a no-op): the tag + // destroys must settle before this callback returns, or the + // transaction can commit while they are still in flight. + await Promise.all( + evaluation.evaluationTags.map((evaluationTag) => + evaluationTag.destroy({ transaction }), + ), + ); + } + return evaluation.destroy({ transaction }); + }); + return evaluation; } async searchItemsCount( - whereClauseParams: WhereClauseParams + whereClauseParameters: WhereClauseParameters, ): Promise { const whereClause = await this.getWhereClauseSearch( - whereClauseParams.searchFields, - whereClauseParams.operator, - whereClauseParams.email, - whereClauseParams.role, - 'count' + whereClauseParameters.searchFields, + whereClauseParameters.operator, + whereClauseParameters.email, + whereClauseParameters.role, + 'count', ); return this.evaluationModel.count({ - include: [EvaluationTag, User, {model: Group, include: [User]}], - where: whereClause, + col: 'id', distinct: true, - col: 'id' + include: [EvaluationTag, User, { include: [User], model: Group }], + where: whereClause, }); } - async count(): Promise { - return this.evaluationModel.count(); - } - - async create(evaluation: { - filename: string; - evaluationTags: CreateEvaluationTagDto[] | undefined; - public: boolean; - data: unknown; - userId?: string; - groupId?: string; - }): Promise { - return Evaluation.create( - { - ...evaluation - }, - { - include: [EvaluationTag] - } - ); - } - async update( id: string, - updateEvaluationDto: UpdateEvaluationDto + updateEvaluationDto: UpdateEvaluationDto, ): Promise { - const evaluation = await this.findByPkBang(id, { - include: [EvaluationTag] - }); + const evaluation = await this.findByPkBang(id, { include: [EvaluationTag] }); return evaluation.update(updateEvaluationDto); } - - async remove(id: string): Promise { - const evaluation = await this.findByPkBang(id, { - include: [EvaluationTag] - }); - await this.databaseService.sequelize.transaction(async (transaction) => { - if (evaluation.evaluationTags !== null) { - await Promise.all([ - evaluation.evaluationTags.map(async (evaluationTag) => { - await evaluationTag.destroy({transaction}); - }) - ]); - } - return evaluation.destroy({transaction}); - }); - return evaluation; - } - - async findById(id: string): Promise { - return this.findByPkBang(id, { - include: [EvaluationTag, User, Group, {model: Group, include: [User]}] - }); - } - - async groups(id: string): Promise { - return ( - await this.findByPkBang(id, {include: {model: Group, include: [User]}}) - ).groups; - } - - async findByPkBang( - identifier: string | number | Buffer | undefined, - options: Pick - ): Promise { - const evaluation = await this.evaluationModel.findByPk( - identifier, - options - ); - if (evaluation === null) { - throw new NotFoundException('Evaluation with given id not found'); - } else { - return evaluation; - } - } } diff --git a/apps/backend/src/filters/authentication-exception.filter.ts b/apps/backend/src/filters/authentication-exception.filter.ts index deaff398cf..805066e305 100644 --- a/apps/backend/src/filters/authentication-exception.filter.ts +++ b/apps/backend/src/filters/authentication-exception.filter.ts @@ -1,46 +1,42 @@ -import {ArgumentsHost, Catch, ExceptionFilter} from '@nestjs/common'; +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; import _ from 'lodash'; -import winston from 'winston'; -import {ConfigService} from '../config/config.service'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; @Catch(Error) export class AuthenticationExceptionFilter implements ExceptionFilter { - configService = new ConfigService(); - private readonly line = '_______________________________________________\n'; + + configService = new ConfigService(); public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: this.loggingTimeFormat - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Authentication Exception Filter): ${info.message}` - ) - ) + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: this.loggingTimeFormat }), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (Authentication Exception Filter): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); catch(exception: Error, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const request = ctx.getRequest(); - const response = ctx.getResponse(); - const errInfo = { - message: exception.message, - stack: exception.stack, + const context_ = host.switchToHttp(); + const request = context_.getRequest(); + const response = context_.getResponse(); + const errorInfo = { authInfo: _.get(request, 'authInfo'), + headers: request.headers, + message: exception.message, query: request.query, - headers: request.headers + stack: exception.stack, }; this.logger.warn( - `Authentication Error\n${JSON.stringify(errInfo, null, 2)}` + `Authentication Error\n${JSON.stringify(errorInfo, null, 2)}`, ); - const authError = - `${_.has(request, 'authInfo.message') ? _.get(request, 'authInfo.message') : ''}\n${exception.message}`.trim(); - response.cookie('authenticationError', authError, { - secure: this.configService.isInProductionMode() - }); + const authError + = `${_.has(request, 'authInfo.message') ? _.get(request, 'authInfo.message') : ''}\n${exception.message}`.trim(); + response.cookie('authenticationError', authError, { secure: this.configService.isInProductionMode() }); response.redirect(302, '/'); } } diff --git a/apps/backend/src/filters/unique-constraint-error.filter.ts b/apps/backend/src/filters/unique-constraint-error.filter.ts index 5d22b4c05f..376eddc52a 100644 --- a/apps/backend/src/filters/unique-constraint-error.filter.ts +++ b/apps/backend/src/filters/unique-constraint-error.filter.ts @@ -2,31 +2,28 @@ import { ArgumentsHost, Catch, ExceptionFilter, - HttpStatus + HttpStatus, } from '@nestjs/common'; -import {Response} from 'express'; -import {UniqueConstraintError, ValidationErrorItem} from 'sequelize'; +import { Response } from 'express'; +import { UniqueConstraintError, ValidationErrorItem } from 'sequelize'; @Catch(UniqueConstraintError) export class UniqueConstraintErrorFilter implements ExceptionFilter { + buildMessage(errors: ValidationErrorItem[]): string[] { + const builtErrors: string[] = Array.from(errors, error => error.message); + return builtErrors; + } + catch(exception: UniqueConstraintError, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); + const context_ = host.switchToHttp(); + const response = context_.getResponse(); const status = HttpStatus.INTERNAL_SERVER_ERROR; const message = this.buildMessage(exception.errors); response.status(status).json({ - statusCode: status, error: 'Internal Server Error', - message: message - }); - } - - buildMessage(errors: ValidationErrorItem[]): string[] { - const builtErrors: string[] = []; - errors.forEach((error) => { - builtErrors.push(error.message); + message: message, + statusCode: status, }); - return builtErrors; } } diff --git a/apps/backend/src/group-evaluations/group-evaluation.model.ts b/apps/backend/src/group-evaluations/group-evaluation.model.ts index 13a0ea695c..7deb6d7267 100644 --- a/apps/backend/src/group-evaluations/group-evaluation.model.ts +++ b/apps/backend/src/group-evaluations/group-evaluation.model.ts @@ -8,31 +8,31 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {Group} from '../groups/group.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { Group } from '../groups/group.model'; @Table export class GroupEvaluation extends Model { - @PrimaryKey - @AutoIncrement + @CreatedAt @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @ForeignKey(() => Evaluation) @Column(DataType.BIGINT) - declare id: string; + declare evaluationId: string; @ForeignKey(() => Group) @Column(DataType.BIGINT) declare groupId: string; - @ForeignKey(() => Evaluation) - @Column(DataType.BIGINT) - declare evaluationId: string; - - @CreatedAt + @PrimaryKey + @AutoIncrement @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; + @Column(DataType.BIGINT) + declare id: string; @UpdatedAt @AllowNull(false) diff --git a/apps/backend/src/group-evaluations/group-evaluations.module.ts b/apps/backend/src/group-evaluations/group-evaluations.module.ts index 87c9f4d339..4c1de1663e 100644 --- a/apps/backend/src/group-evaluations/group-evaluations.module.ts +++ b/apps/backend/src/group-evaluations/group-evaluations.module.ts @@ -1,8 +1,6 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {GroupEvaluation} from './group-evaluation.model'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { GroupEvaluation } from './group-evaluation.model'; -@Module({ - imports: [SequelizeModule.forFeature([GroupEvaluation])] -}) +@Module({ imports: [SequelizeModule.forFeature([GroupEvaluation])] }) export class GroupEvaluationsModule {} diff --git a/apps/backend/src/group-users/group-user.model.ts b/apps/backend/src/group-users/group-user.model.ts index b40ad84552..d9e55f599e 100644 --- a/apps/backend/src/group-users/group-user.model.ts +++ b/apps/backend/src/group-users/group-user.model.ts @@ -9,13 +9,22 @@ import { Model, PrimaryKey, Table, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Group} from '../groups/group.model'; -import {User} from '../users/user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; @Table export class GroupUser extends Model { + @CreatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @ForeignKey(() => Group) + @Column(DataType.BIGINT) + declare groupId: string; + @PrimaryKey @AutoIncrement @AllowNull(false) @@ -27,21 +36,12 @@ export class GroupUser extends Model { @Column(DataType.STRING) declare role: string; - @ForeignKey(() => Group) - @Column(DataType.BIGINT) - declare groupId: string; - - @ForeignKey(() => User) - @Column(DataType.BIGINT) - declare userId: string; - - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; - @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; + + @ForeignKey(() => User) + @Column(DataType.BIGINT) + declare userId: string; } diff --git a/apps/backend/src/group-users/group-users.module.ts b/apps/backend/src/group-users/group-users.module.ts index 7ac18b3314..f102ce30c2 100644 --- a/apps/backend/src/group-users/group-users.module.ts +++ b/apps/backend/src/group-users/group-users.module.ts @@ -1,8 +1,6 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {GroupUser} from './group-user.model'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { GroupUser } from './group-user.model'; -@Module({ - imports: [SequelizeModule.forFeature([GroupUser])] -}) +@Module({ imports: [SequelizeModule.forFeature([GroupUser])] }) export class GroupUsersModule {} diff --git a/apps/backend/src/groups/dto/add-user-to-group.dto.ts b/apps/backend/src/groups/dto/add-user-to-group.dto.ts index faed37c9e1..8e763721f2 100644 --- a/apps/backend/src/groups/dto/add-user-to-group.dto.ts +++ b/apps/backend/src/groups/dto/add-user-to-group.dto.ts @@ -1,12 +1,12 @@ -import {IAddUserToGroup} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IAddUserToGroup } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class AddUserToGroupDto implements IAddUserToGroup { @IsNotEmpty() @IsString() - readonly userId!: string; + readonly groupRole!: string; @IsNotEmpty() @IsString() - readonly groupRole!: string; + readonly userId!: string; } diff --git a/apps/backend/src/groups/dto/create-group.dto.ts b/apps/backend/src/groups/dto/create-group.dto.ts index 849913c2ad..b31c11abc3 100644 --- a/apps/backend/src/groups/dto/create-group.dto.ts +++ b/apps/backend/src/groups/dto/create-group.dto.ts @@ -1,7 +1,11 @@ -import {ICreateGroup} from '@heimdall/common/interfaces'; -import {IsBoolean, IsNotEmpty, IsOptional, IsString} from 'class-validator'; +import { ICreateGroup } from '@heimdall/common/interfaces'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateGroupDto implements ICreateGroup { + @IsOptional() + @IsString() + readonly desc!: string; + @IsNotEmpty() @IsString() readonly name!: string; @@ -9,8 +13,4 @@ export class CreateGroupDto implements ICreateGroup { @IsOptional() @IsBoolean() readonly public!: boolean; - - @IsOptional() - @IsString() - readonly desc!: string; } diff --git a/apps/backend/src/groups/dto/evaluation-group.dto.ts b/apps/backend/src/groups/dto/evaluation-group.dto.ts index 7e8eda23cd..573f9d433f 100644 --- a/apps/backend/src/groups/dto/evaluation-group.dto.ts +++ b/apps/backend/src/groups/dto/evaluation-group.dto.ts @@ -1,5 +1,5 @@ -import {IEvaluationGroup} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IEvaluationGroup } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class EvaluationGroupDto implements IEvaluationGroup { @IsNotEmpty() diff --git a/apps/backend/src/groups/dto/group.dto.ts b/apps/backend/src/groups/dto/group.dto.ts index cec4662bca..f129498f1d 100644 --- a/apps/backend/src/groups/dto/group.dto.ts +++ b/apps/backend/src/groups/dto/group.dto.ts @@ -1,29 +1,29 @@ -import {IGroup} from '@heimdall/common/interfaces'; -import {GroupUser} from '../../group-users/group-user.model'; -import {SlimUserDto} from '../../users/dto/slim-user.dto'; -import {Group} from '../group.model'; +import type { IGroup } from '@heimdall/common/interfaces'; +import type { GroupUser } from '../../group-users/group-user.model'; +import { SlimUserDto } from '../../users/dto/slim-user.dto'; +import type { Group } from '../group.model'; export class GroupDto implements IGroup { + readonly createdAt: Date; + readonly desc: string; readonly id: string; readonly name: string; readonly public: boolean; readonly role?: string; - readonly users: SlimUserDto[]; - readonly desc: string; - readonly createdAt: Date; readonly updatedAt: Date; + readonly users: SlimUserDto[]; - constructor(group: Group & {GroupUser?: GroupUser}, role?: string) { + constructor(group: Group & { GroupUser?: GroupUser }, role?: string) { this.id = group.id; this.name = group.name; this.role = role || group?.GroupUser?.role; this.public = group.public; - this.users = - group.users === undefined + this.users + = group.users === undefined ? [] : group.users.map((user) => { - return new SlimUserDto(user, user.GroupUser.role); - }); + return new SlimUserDto(user, user.GroupUser.role); + }); this.desc = group.desc; this.createdAt = group.createdAt; this.updatedAt = group.updatedAt; diff --git a/apps/backend/src/groups/dto/remove-user-from-group.dto.ts b/apps/backend/src/groups/dto/remove-user-from-group.dto.ts index 3894399cf3..6a2c833354 100644 --- a/apps/backend/src/groups/dto/remove-user-from-group.dto.ts +++ b/apps/backend/src/groups/dto/remove-user-from-group.dto.ts @@ -1,5 +1,5 @@ -import {IRemoveUserFromGroup} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IRemoveUserFromGroup } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class RemoveUserFromGroupDto implements IRemoveUserFromGroup { @IsNotEmpty() diff --git a/apps/backend/src/groups/dto/update-group-user.dto.ts b/apps/backend/src/groups/dto/update-group-user.dto.ts index 666cc58de9..79c317f2bd 100644 --- a/apps/backend/src/groups/dto/update-group-user.dto.ts +++ b/apps/backend/src/groups/dto/update-group-user.dto.ts @@ -1,12 +1,12 @@ -import {IUpdateGroupUser} from '@heimdall/common/interfaces'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IUpdateGroupUser } from '@heimdall/common/interfaces'; +import { IsNotEmpty, IsString } from 'class-validator'; export class UpdateGroupUserRoleDto implements IUpdateGroupUser { @IsNotEmpty() @IsString() - readonly userId!: string; + readonly groupRole!: string; @IsNotEmpty() @IsString() - readonly groupRole!: string; + readonly userId!: string; } diff --git a/apps/backend/src/groups/group.model.ts b/apps/backend/src/groups/group.model.ts index 3e17603943..a874b779fb 100644 --- a/apps/backend/src/groups/group.model.ts +++ b/apps/backend/src/groups/group.model.ts @@ -10,15 +10,28 @@ import { PrimaryKey, Table, Unique, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {User} from '../users/user.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { User } from '../users/user.model'; @Table export class Group extends Model { + @CreatedAt + @AllowNull(false) + @Column(DataType.DATE) + declare createdAt: Date; + + @AllowNull(false) + @Default('') + @Column(DataType.TEXT) + declare desc: string; + + @BelongsToMany(() => Evaluation, () => GroupEvaluation) + declare evaluations: (Evaluation & { GroupEvaluation: GroupEvaluation })[]; + @PrimaryKey @AutoIncrement @AllowNull(false) @@ -35,24 +48,11 @@ export class Group extends Model { @Column(DataType.BOOLEAN) declare public: boolean; - @AllowNull(false) - @Default('') - @Column(DataType.TEXT) - declare desc: string; - - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; - @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; @BelongsToMany(() => User, () => GroupUser) - declare users: Array; - - @BelongsToMany(() => Evaluation, () => GroupEvaluation) - declare evaluations: Array; + declare users: (User & { GroupUser: GroupUser })[]; } diff --git a/apps/backend/src/groups/groups.controller.spec.ts b/apps/backend/src/groups/groups.controller.spec.ts index c208f9ce04..ffb226d4de 100644 --- a/apps/backend/src/groups/groups.controller.spec.ts +++ b/apps/backend/src/groups/groups.controller.spec.ts @@ -1,32 +1,37 @@ -import {ForbiddenError} from '@casl/ability'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {EVALUATION_1} from '../../test/constants/evaluations-test.constant'; +import type { AddressInfo } from 'node:net'; +import { ForbiddenError } from '@casl/ability'; +import type { ExecutionContext, INestApplication } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { EVALUATION_1 } from '../../test/constants/evaluations-test.constant'; import { GROUP_1, PRIVATE_GROUP, - UPDATE_GROUP + UPDATE_GROUP, } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, } from '../../test/constants/users-test.constant'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigModule} from '../config/config.module'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {SlimUserDto} from '../users/dto/slim-user.dto'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {Group} from './group.model'; -import {GroupsController} from './groups.controller'; -import {GroupsService} from './groups.service'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigModule } from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { SlimUserDto } from '../users/dto/slim-user.dto'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { Group } from './group.model'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; describe('GroupsController', () => { let groupsController: GroupsController; @@ -43,6 +48,7 @@ describe('GroupsController', () => { controllers: [GroupsController], imports: [ ConfigModule, + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Group, @@ -50,16 +56,16 @@ describe('GroupsController', () => { GroupEvaluation, Evaluation, EvaluationTag, - User - ]) + User, + ]), ], providers: [ AuthzService, DatabaseService, GroupsService, UsersService, - EvaluationsService - ] + EvaluationsService, + ], }).compile(); groupsService = module.get(GroupsService); @@ -83,8 +89,8 @@ describe('GroupsController', () => { expect.assertions(3); const response = await groupsController.create( - {user: basicUser}, - PRIVATE_GROUP + { user: basicUser }, + PRIVATE_GROUP, ); const group = await groupsService.findByPkBang(response.id); expect(response.name).toEqual(PRIVATE_GROUP.name); @@ -104,7 +110,7 @@ describe('GroupsController', () => { it('findAll should only return public groups and groups the user is explicitly added to', async () => { expect.assertions(1); - const groups = await groupsController.findAll({user: basicUser}); + const groups = await groupsController.findAll({ user: basicUser }); expect(groups.length).toEqual(1); }); @@ -113,17 +119,18 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); - const groups = await groupsController.findAll({user: basicUser}); + const groups = await groupsController.findAll({ user: basicUser }); expect(groups.length).toEqual(2); }); it('findForUser should return groups the user is a member of', async () => { expect.assertions(1); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); - const publicGroups = (await groupsService.findAll()).filter( - (group) => group.public && group.id !== privateGroup.id + const allGroups = await groupsService.findAll(); + const publicGroups = allGroups.filter( + group => group.public && group.id !== privateGroup.id, ); - const groups = await groupsController.findForUser({user: basicUser}); + const groups = await groupsController.findForUser({ user: basicUser }); expect(groups.length).toEqual(1 + publicGroups.length); }); @@ -131,10 +138,10 @@ describe('GroupsController', () => { const otherUser = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); await groupsService.addUserToGroup(privateGroup, otherUser, 'member'); - const groups = await groupsController.findForUser({user: basicUser}); + const groups = await groupsController.findForUser({ user: basicUser }); expect(groups[0].users).toContainEqual( - new SlimUserDto(otherUser, 'member') + new SlimUserDto(otherUser, 'member'), ); }); }); @@ -154,9 +161,9 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); const response = await groupsController.update( - {user: owner}, + { user: owner }, privateGroup.id, - UPDATE_GROUP + UPDATE_GROUP, ); expect(response.id).toEqual(privateGroup.id); expect(response.name).toEqual(UPDATE_GROUP.name); @@ -172,20 +179,20 @@ describe('GroupsController', () => { await expect( groupsController.update( - {user: basicUser}, + { user: basicUser }, privateGroup.id, - UPDATE_GROUP - ) + UPDATE_GROUP, + ), ).rejects.toBeInstanceOf(ForbiddenError); await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); await expect( groupsController.update( - {user: basicUser}, + { user: basicUser }, privateGroup.id, - UPDATE_GROUP - ) + UPDATE_GROUP, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -197,8 +204,8 @@ describe('GroupsController', () => { await groupsController.addUserToGroup( privateGroup.id, - {user: owner}, - {userId: basicUser.id, groupRole: 'member'} + { user: owner }, + { groupRole: 'member', userId: basicUser.id }, ); const groupMembers = await privateGroup.$get('users'); @@ -213,9 +220,9 @@ describe('GroupsController', () => { await expect( groupsController.addUserToGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id, groupRole: 'member'} - ) + { user: basicUser }, + { groupRole: 'member', userId: user.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -224,14 +231,14 @@ describe('GroupsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: basicUser.id + userId: basicUser.id, }); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); await groupsController.addEvaluationToGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} + { user: basicUser }, + { id: evaluation.id }, ); const groupEvaluations = await privateGroup.$get('evaluations'); @@ -243,36 +250,36 @@ describe('GroupsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: basicUser.id + userId: basicUser.id, }); await expect( groupsController.addEvaluationToGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} - ) + { user: basicUser }, + { id: evaluation.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); it('should stop members from adding an evaluation they do not have access to', async () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); await expect( groupsController.addEvaluationToGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} - ) + { user: basicUser }, + { id: evaluation.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -290,8 +297,8 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); const response = await groupsController.remove( - {user: owner}, - privateGroup.id + { user: owner }, + privateGroup.id, ); expect(response.id).toEqual(privateGroup.id); expect(response.name).toEqual(privateGroup.name); @@ -303,13 +310,13 @@ describe('GroupsController', () => { expect.assertions(2); await expect( - groupsController.remove({user: basicUser}, privateGroup.id) + groupsController.remove({ user: basicUser }, privateGroup.id), ).rejects.toBeInstanceOf(ForbiddenError); await groupsService.addUserToGroup(privateGroup, basicUser, 'user'); await expect( - groupsController.remove({user: basicUser}, privateGroup.id) + groupsController.remove({ user: basicUser }, privateGroup.id), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -318,37 +325,39 @@ describe('GroupsController', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: basicUser.id + userId: basicUser.id, }); await groupsService.addEvaluationToGroup(privateGroup, evaluation); await groupsService.addUserToGroup(privateGroup, basicUser, 'member'); - expect((await privateGroup.$get('evaluations')).length).toEqual(1); + const evaluationsBeforeRemove = await privateGroup.$get('evaluations'); + expect(evaluationsBeforeRemove.length).toEqual(1); await groupsController.removeEvaluationFromGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} + { user: basicUser }, + { id: evaluation.id }, ); - expect((await privateGroup.$get('evaluations')).length).toEqual(0); + const evaluationsAfterRemove = await privateGroup.$get('evaluations'); + expect(evaluationsAfterRemove.length).toEqual(0); }); it('should prevent non-members from removing an evaluation', async () => { expect.assertions(1); const evaluationOwner = await usersService.create( - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, ); const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: evaluationOwner.id + userId: evaluationOwner.id, }); await groupsService.addEvaluationToGroup(privateGroup, evaluation); await expect( groupsController.removeEvaluationFromGroup( privateGroup.id, - {user: basicUser}, - {id: evaluation.id} - ) + { user: basicUser }, + { id: evaluation.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -357,13 +366,15 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'owner'); const user = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); await groupsService.addUserToGroup(privateGroup, user, 'member'); - expect((await privateGroup.$get('users')).length).toEqual(2); + const usersBeforeRemove = await privateGroup.$get('users'); + expect(usersBeforeRemove.length).toEqual(2); await groupsController.removeUserFromGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id} + { user: basicUser }, + { userId: user.id }, ); - expect((await privateGroup.$get('users')).length).toEqual(1); + const usersAfterRemove = await privateGroup.$get('users'); + expect(usersAfterRemove.length).toEqual(1); }); it('should allow owners to remove owners', async () => { @@ -371,13 +382,15 @@ describe('GroupsController', () => { await groupsService.addUserToGroup(privateGroup, basicUser, 'owner'); const user = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); await groupsService.addUserToGroup(privateGroup, user, 'owner'); - expect((await privateGroup.$get('users')).length).toEqual(2); + const usersBeforeRemove = await privateGroup.$get('users'); + expect(usersBeforeRemove.length).toEqual(2); await groupsController.removeUserFromGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id} + { user: basicUser }, + { userId: user.id }, ); - expect((await privateGroup.$get('users')).length).toEqual(1); + const usersAfterRemove = await privateGroup.$get('users'); + expect(usersAfterRemove.length).toEqual(1); }); it('should prevent non-owners from removing members', async () => { @@ -388,10 +401,121 @@ describe('GroupsController', () => { await expect( groupsController.removeUserFromGroup( privateGroup.id, - {user: basicUser}, - {userId: user.id} - ) + { user: basicUser }, + { userId: user.id }, + ), ).rejects.toBeInstanceOf(ForbiddenError); }); }); }); + +// Route resolution is a ROUTER concern, not a handler concern: calling +// groupsController.findForUser() directly succeeds no matter what order the +// decorators are declared in, so the unit tests above cannot detect route +// shadowing. These tests boot the real Nest application and issue real HTTP +// requests so the actual registered routing table is what gets exercised. +// Node's built-in fetch is used deliberately — supertest is not a dependency +// of this repo and adding one to reach a routing assertion is not warranted. +describe('GroupsController route resolution', () => { + let app: INestApplication; + let baseUrl: string; + let module: TestingModule; + let databaseService: DatabaseService; + let usersService: UsersService; + let groupsService: GroupsService; + // The overridden guard reads this at request time, so each test's freshly + // created user is the one ABAC sees. + let currentUser: User; + + const allowWithCurrentUser = { + canActivate: (context: ExecutionContext): boolean => { + context.switchToHttp().getRequest<{ user: User }>().user = currentUser; + return true; + }, + }; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [GroupsController], + imports: [ + ConfigModule, + CryptoModule, + DatabaseModule, + SequelizeModule.forFeature([ + Group, + GroupUser, + GroupEvaluation, + Evaluation, + EvaluationTag, + User, + ]), + ], + providers: [ + AuthzService, + DatabaseService, + GroupsService, + UsersService, + EvaluationsService, + ], + }) + // Auth is not what these tests are about; the routing table is. + .overrideGuard(JwtAuthGuard) + .useValue(allowWithCurrentUser) + .compile(); + + databaseService = module.get(DatabaseService); + usersService = module.get(UsersService); + groupsService = module.get(GroupsService); + + app = module.createNestApplication(); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address() as AddressInfo; + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + afterAll(async () => { + // Order matters: app.close() tears down the Nest app INCLUDING its + // Sequelize connection, so the cleanup query has to run first. + await databaseService.cleanAll(); + await app.close(); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + // ABAC needs a real model instance, and create() returns a DTO — so the + // row is re-read rather than cast. + const createdUser = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const persisted = await User.findByPk(createdUser.id); + if (persisted === null) { + throw new TypeError('test user was not persisted'); + } + currentUser = persisted; + }); + + it('routes GET /groups/my to findForUser, not to the :id handler', async () => { + expect.assertions(2); + const response = await fetch(`${baseUrl}/groups/my`); + + // If ':id' is declared first, Nest dispatches this to findById, which + // hands the literal string "my" to Postgres as a bigint and 500s with + // 'invalid input syntax for type bigint'. That is the exact regression + // ESLint's class-member sorting introduced in 3bdd1f146 — and it broke + // GUI login outright, because the login handler calls /groups/my. + expect(response.status).toBe(200); + // findForUser returns a LIST; findById returns a single object. + expect(Array.isArray(await response.json())).toBe(true); + }); + + it('still routes GET /groups/:id to findById for a real numeric id', async () => { + expect.assertions(2); + const created = await groupsService.create(GROUP_1); + + const response = await fetch(`${baseUrl}/groups/${created.id}`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { id: string }; + expect(body.id).toBe(created.id); + }); +}); diff --git a/apps/backend/src/groups/groups.controller.ts b/apps/backend/src/groups/groups.controller.ts index b070b40ee2..9c7f9cdce9 100644 --- a/apps/backend/src/groups/groups.controller.ts +++ b/apps/backend/src/groups/groups.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Body, Controller, @@ -9,23 +9,23 @@ import { Put, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupUser} from '../group-users/group-user.model'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {AddUserToGroupDto} from './dto/add-user-to-group.dto'; -import {CreateGroupDto} from './dto/create-group.dto'; -import {EvaluationGroupDto} from './dto/evaluation-group.dto'; -import {GroupDto} from './dto/group.dto'; -import {RemoveUserFromGroupDto} from './dto/remove-user-from-group.dto'; -import {UpdateGroupUserRoleDto} from './dto/update-group-user.dto'; -import {GroupsService} from './groups.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupUser } from '../group-users/group-user.model'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { AddUserToGroupDto } from './dto/add-user-to-group.dto'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { EvaluationGroupDto } from './dto/evaluation-group.dto'; +import { GroupDto } from './dto/group.dto'; +import { RemoveUserFromGroupDto } from './dto/remove-user-from-group.dto'; +import { UpdateGroupUserRoleDto } from './dto/update-group-user.dto'; +import { GroupsService } from './groups.service'; @Controller('groups') @UseGuards(JwtAuthGuard) @@ -35,151 +35,170 @@ export class GroupsController { private readonly groupsService: GroupsService, private readonly usersService: UsersService, private readonly evaluationsService: EvaluationsService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} - @Get() - async findAll(@Request() request: {user: User}): Promise { + @Post('/:id/evaluation') + async addEvaluationToGroup( + @Param('id') id: string, + @Request() request: { user: User }, + @Body() evaluationGroupDto: EvaluationGroupDto, + ): Promise { const abac = this.authz.abac.createForUser(request.user); - - let groups = await this.groupsService.findAll(); - groups = groups.filter((group) => abac.can(Action.Read, group)); - - return groups.map((group) => new GroupDto(group)); - } - - @Get('/my') - async findForUser(@Request() request: {user: User}): Promise { - const groups = await request.user.$get('groups', {include: [User]}); - const groupIds = groups.map((g) => g.id); - const publicGroups = (await this.groupsService.findAll()).filter( - (group) => group.public && !groupIds.includes(group.id) + const group = await this.groupsService.findByPkBang(id); + // Group Permissions + ForbiddenError.from(abac).throwUnlessCan(Action.AddEvaluation, group); + const evaluationToAdd = await this.evaluationsService.findById( + evaluationGroupDto.id, ); - return groups - .map((group) => new GroupDto(group)) - .concat(publicGroups.map((group) => new GroupDto(group))); - } - - @Post() - async create( - @Request() request: {user: User}, - @Body() createGroupDto: CreateGroupDto - ): Promise { - const group = await this.groupsService.create(createGroupDto); - await this.groupsService.addUserToGroup(group, request.user, 'owner'); - return new GroupDto(group, 'owner'); + // Evaluation Permissions + ForbiddenError.from(abac).throwUnlessCan(Action.Read, evaluationToAdd); + await this.groupsService.addEvaluationToGroup(group, evaluationToAdd); + return new GroupDto(group); } @Post('/:id/user') async addUserToGroup( @Param('id') id: string, - @Request() request: {user: User}, - @Body() addUserToGroupDto: AddUserToGroupDto + @Request() request: { user: User }, + @Body() addUserToGroupDto: AddUserToGroupDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); const userToAdd = await this.usersService.findById( - addUserToGroupDto.userId + addUserToGroupDto.userId, ); await this.groupsService.addUserToGroup( group, userToAdd, - addUserToGroupDto.groupRole + addUserToGroupDto.groupRole, ); return new GroupDto(group); } - @Delete('/:id/user') - async removeUserFromGroup( - @Param('id') id: string, - @Request() request: {user: User}, - @Body() removeUserFromGroupDto: RemoveUserFromGroupDto + @Post() + async create( + @Request() request: { user: User }, + @Body() createGroupDto: CreateGroupDto, ): Promise { - const group = await this.groupsService.findByPkBang(id); - if (request.user.role !== 'admin') { - const abac = this.authz.abac.createForUser(request.user); - ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); - } - const userToRemove = await this.usersService.findById( - removeUserFromGroupDto.userId - ); - return new GroupDto( - await this.groupsService.removeUserFromGroup(group, userToRemove) + const group = await this.groupsService.create(createGroupDto); + await this.groupsService.addUserToGroup(group, request.user, 'owner'); + return new GroupDto(group, 'owner'); + } + + @Get() + async findAll(@Request() request: { user: User }): Promise { + const abac = this.authz.abac.createForUser(request.user); + + let groups = await this.groupsService.findAll(); + groups = groups.filter(group => abac.can(Action.Read, group)); + + return groups.map(group => new GroupDto(group)); + } + + // DECLARATION ORDER IS SEMANTIC HERE — DO NOT SORT THIS CLASS BY MEMBER NAME. + // Nest registers routes in the order their handlers are declared, so this + // literal '/my' route MUST stay above the parameterized ':id' route below. + // Alphabetizing the class puts findById first, which makes every + // GET /groups/my resolve to findById('my') and 500 on + // `invalid input syntax for type bigint: "my"` — that regression shipped in + // 3bdd1f146 and broke GUI login outright, because the login handler calls + // this endpoint. Pinned by the 'GroupsController route resolution' tests. + @Get('/my') + async findForUser(@Request() request: { user: User }): Promise { + const groups = await request.user.$get('groups', { include: [User] }); + const groupIds = new Set(groups.map(g => g.id)); + const allGroups = await this.groupsService.findAll(); + const publicGroups = allGroups.filter( + group => group.public && !groupIds.has(group.id), ); + return [...groups + .map(group => new GroupDto(group)), ...publicGroups.map(group => new GroupDto(group))]; } - @Post('/:id/evaluation') - async addEvaluationToGroup( + @Get(':id') + async findById( + @Request() request: { user: User }, @Param('id') id: string, - @Request() request: {user: User}, - @Body() evaluationGroupDto: EvaluationGroupDto ): Promise { const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); - // Group Permissions - ForbiddenError.from(abac).throwUnlessCan(Action.AddEvaluation, group); - const evaluationToAdd = await this.evaluationsService.findById( - evaluationGroupDto.id - ); - // Evaluation Permissions - ForbiddenError.from(abac).throwUnlessCan(Action.Read, evaluationToAdd); - await this.groupsService.addEvaluationToGroup(group, evaluationToAdd); - return new GroupDto(group); + ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); + + return new GroupDto(group, 'owner'); + } + + @Delete(':id') + async remove( + @Request() request: { user: User }, + @Param('id') id: string, + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + const groupToDelete = await this.groupsService.findByPkBang(id); + ForbiddenError.from(abac).throwUnlessCan(Action.Delete, groupToDelete); + return new GroupDto(await this.groupsService.remove(groupToDelete)); } @Delete('/:id/evaluation') async removeEvaluationFromGroup( @Param('id') id: string, - @Request() request: {user: User}, - @Body() evaluationGroupDto: EvaluationGroupDto + @Request() request: { user: User }, + @Body() evaluationGroupDto: EvaluationGroupDto, ): Promise { // This must perform validation checks to ensure the user performing the action has permission to remove evaluations from a group. const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.RemoveEvaluation, group); const evaluationToRemove = await this.evaluationsService.findById( - evaluationGroupDto.id + evaluationGroupDto.id, ); return new GroupDto( await this.groupsService.removeEvaluationFromGroup( group, - evaluationToRemove - ) + evaluationToRemove, + ), ); } - @Get(':id') - async findById( - @Request() request: {user: User}, - @Param('id') id: string + @Delete('/:id/user') + async removeUserFromGroup( + @Param('id') id: string, + @Request() request: { user: User }, + @Body() removeUserFromGroupDto: RemoveUserFromGroupDto, ): Promise { - const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); - ForbiddenError.from(abac).throwUnlessCan(Action.Read, group); - - return new GroupDto(group, 'owner'); + if (request.user.role !== 'admin') { + const abac = this.authz.abac.createForUser(request.user); + ForbiddenError.from(abac).throwUnlessCan(Action.Update, group); + } + const userToRemove = await this.usersService.findById( + removeUserFromGroupDto.userId, + ); + return new GroupDto( + await this.groupsService.removeUserFromGroup(group, userToRemove), + ); } @Put(':id') async update( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() updateGroup: CreateGroupDto + @Body() updateGroup: CreateGroupDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const groupToUpdate = await this.groupsService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.Update, groupToUpdate); return new GroupDto( - await this.groupsService.update(groupToUpdate, updateGroup) + await this.groupsService.update(groupToUpdate, updateGroup), ); } @Put(':id/updateGroupUserRole') async updateGroupUserRole( - @Request() request: {user: User}, + @Request() request: { user: User }, @Param('id') id: string, - @Body() updateGroupUser: UpdateGroupUserRoleDto + @Body() updateGroupUser: UpdateGroupUserRoleDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const group = await this.groupsService.findByPkBang(id); @@ -187,15 +206,4 @@ export class GroupsController { return this.groupsService.updateGroupUserRole(group, updateGroupUser); } - - @Delete(':id') - async remove( - @Request() request: {user: User}, - @Param('id') id: string - ): Promise { - const abac = this.authz.abac.createForUser(request.user); - const groupToDelete = await this.groupsService.findByPkBang(id); - ForbiddenError.from(abac).throwUnlessCan(Action.Delete, groupToDelete); - return new GroupDto(await this.groupsService.remove(groupToDelete)); - } } diff --git a/apps/backend/src/groups/groups.module.ts b/apps/backend/src/groups/groups.module.ts index 18f3269a7c..8e77dc1a42 100644 --- a/apps/backend/src/groups/groups.module.ts +++ b/apps/backend/src/groups/groups.module.ts @@ -1,16 +1,18 @@ -import {forwardRef, Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ApiKeyModule} from '../apikeys/apikeys.module'; -import {AuthzModule} from '../authz/authz.module'; -import {ConfigModule} from '../config/config.module'; -import {EvaluationTagsModule} from '../evaluation-tags/evaluation-tags.module'; -import {EvaluationsModule} from '../evaluations/evaluations.module'; -import {UsersModule} from '../users/users.module'; -import {Group} from './group.model'; -import {GroupsController} from './groups.controller'; -import {GroupsService} from './groups.service'; +import { forwardRef, Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ApiKeyModule } from '../apikeys/apikeys.module'; +import { AuthzModule } from '../authz/authz.module'; +import { ConfigModule } from '../config/config.module'; +import { EvaluationTagsModule } from '../evaluation-tags/evaluation-tags.module'; +import { EvaluationsModule } from '../evaluations/evaluations.module'; +import { UsersModule } from '../users/users.module'; +import { Group } from './group.model'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; @Module({ + controllers: [GroupsController], + exports: [GroupsService], imports: [ SequelizeModule.forFeature([Group]), ApiKeyModule, @@ -18,10 +20,8 @@ import {GroupsService} from './groups.service'; ConfigModule, forwardRef(() => UsersModule), EvaluationsModule, - EvaluationTagsModule + EvaluationTagsModule, ], providers: [GroupsService], - controllers: [GroupsController], - exports: [GroupsService] }) export class GroupsModule {} diff --git a/apps/backend/src/groups/groups.service.spec.ts b/apps/backend/src/groups/groups.service.spec.ts index 74f4f47bf1..e9d56772ad 100644 --- a/apps/backend/src/groups/groups.service.spec.ts +++ b/apps/backend/src/groups/groups.service.spec.ts @@ -1,31 +1,32 @@ -import {ForbiddenException, NotFoundException} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { EVALUATION_1, - EVALUATION_WITH_TAGS_1 + EVALUATION_WITH_TAGS_1, } from '../../test/constants/evaluations-test.constant'; -import {GROUP_1} from '../../test/constants/groups-test.constant'; +import { GROUP_1 } from '../../test/constants/groups-test.constant'; import { CREATE_USER_DTO_TEST_OBJ, - CREATE_USER_DTO_TEST_OBJ_2 + CREATE_USER_DTO_TEST_OBJ_2, } from '../../test/constants/users-test.constant'; -import {ConfigService} from '../config/config.service'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTagDto} from '../evaluation-tags/dto/evaluation-tag.dto'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupEvaluationsModule} from '../group-evaluations/group-evaluations.module'; -import {GroupUser} from '../group-users/group-user.model'; -import {GroupUsersModule} from '../group-users/group-users.module'; -import {UserDto} from '../users/dto/user.dto'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {Group} from './group.model'; -import {GroupsService} from './groups.service'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTagDto } from '../evaluation-tags/dto/evaluation-tag.dto'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupEvaluationsModule } from '../group-evaluations/group-evaluations.module'; +import { GroupUser } from '../group-users/group-user.model'; +import { GroupUsersModule } from '../group-users/group-users.module'; +import { UserDto } from '../users/dto/user.dto'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { Group } from './group.model'; +import { GroupsService } from './groups.service'; describe('GroupsService', () => { let groupsService: GroupsService; @@ -36,24 +37,25 @@ describe('GroupsService', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [ + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ Group, GroupUser, Evaluation, EvaluationTag, - User + User, ]), GroupEvaluationsModule, - GroupUsersModule + GroupUsersModule, ], providers: [ ConfigService, GroupsService, DatabaseService, UsersService, - EvaluationsService - ] + EvaluationsService, + ], }).compile(); groupsService = module.get(GroupsService); @@ -82,7 +84,7 @@ describe('GroupsService', () => { it('should throw a not found exception when the given id is not found', async () => { expect.assertions(1); await expect(groupsService.findByPkBang('0')).rejects.toBeInstanceOf( - NotFoundException + NotFoundException, ); }); @@ -90,7 +92,7 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const user = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); await groupsService.addUserToGroup(group, user, 'owner'); const foundGroup = await groupsService.findByPkBang(group.id); @@ -103,7 +105,7 @@ describe('GroupsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); await groupsService.addEvaluationToGroup(group, evaluation); const foundGroup = await groupsService.findByPkBang(group.id); @@ -116,7 +118,7 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const user = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); await groupsService.addUserToGroup(group, user, 'owner'); const groupUsers = await group.$get('users'); @@ -132,7 +134,7 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const groupOwner = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); const groupMember = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); await groupsService.addUserToGroup(group, groupOwner, 'owner'); @@ -150,11 +152,11 @@ describe('GroupsService', () => { const group = await groupsService.create(GROUP_1); await usersService.create(CREATE_USER_DTO_TEST_OBJ); const groupOwner = await usersService.findByEmail( - CREATE_USER_DTO_TEST_OBJ.email + CREATE_USER_DTO_TEST_OBJ.email, ); await groupsService.addUserToGroup(group, groupOwner, 'owner'); await expect( - groupsService.removeUserFromGroup(group, groupOwner) + groupsService.removeUserFromGroup(group, groupOwner), ).rejects.toBeInstanceOf(ForbiddenException); }); }); @@ -166,17 +168,15 @@ describe('GroupsService', () => { const evaluation = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); await groupsService.addEvaluationToGroup(group, evaluation); - const groupEvaluations = await group.$get('evaluations', { - include: [{model: EvaluationTag}] - }); + const groupEvaluations = await group.$get('evaluations', { include: [{ model: EvaluationTag }] }); expect(groupEvaluations).toHaveLength(1); expect(groupEvaluations[0].filename).toEqual(evaluation.filename); expect(groupEvaluations[0].data).toEqual(evaluation.data); expect( - new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]) + new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]), ).toEqual(new EvaluationTagDto(evaluation.evaluationTags[0])); }); }); @@ -188,25 +188,23 @@ describe('GroupsService', () => { const evaluationOne = await evaluationsService.create({ ...EVALUATION_1, data: {}, - userId: user.id + userId: user.id, }); const evaluationTwo = await evaluationsService.create({ ...EVALUATION_WITH_TAGS_1, data: {}, - userId: user.id + userId: user.id, }); await groupsService.addEvaluationToGroup(group, evaluationOne); await groupsService.addEvaluationToGroup(group, evaluationTwo); expect(await group.$get('evaluations')).toHaveLength(2); await groupsService.removeEvaluationFromGroup(group, evaluationOne); - const groupEvaluations = await group.$get('evaluations', { - include: [{model: EvaluationTag}] - }); + const groupEvaluations = await group.$get('evaluations', { include: [{ model: EvaluationTag }] }); expect(groupEvaluations).toHaveLength(1); expect(groupEvaluations[0].filename).toEqual(evaluationTwo.filename); expect(groupEvaluations[0].data).toEqual(evaluationTwo.data); expect( - new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]) + new EvaluationTagDto(groupEvaluations[0].evaluationTags[0]), ).toEqual(new EvaluationTagDto(evaluationTwo.evaluationTags[0])); }); }); diff --git a/apps/backend/src/groups/groups.service.ts b/apps/backend/src/groups/groups.service.ts index cf314de079..5abec5c513 100644 --- a/apps/backend/src/groups/groups.service.ts +++ b/apps/backend/src/groups/groups.service.ts @@ -1,189 +1,144 @@ import { ForbiddenException, Injectable, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions, Op} from 'sequelize'; -import winston from 'winston'; -import AppConfig from '../../config/app_config'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {User} from '../users/user.model'; -import {CreateGroupDto} from './dto/create-group.dto'; -import {UpdateGroupUserRoleDto} from './dto/update-group-user.dto'; -import {Group} from './group.model'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions, Op } from 'sequelize'; +import { createLogger, format, transports } from 'winston'; +import AppConfig from '../../config/app-config'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { User } from '../users/user.model'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupUserRoleDto } from './dto/update-group-user.dto'; +import { Group } from './group.model'; @Injectable() export class GroupsService { private readonly line = '_______________________________________________\n'; - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Group Service): ${info.message}` - ) - ) + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (Group Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); + constructor( @InjectModel(Group) private readonly groupModel: typeof Group, @InjectModel(User) - private readonly userModel: typeof User + private readonly userModel: typeof User, ) {} - async findAll(): Promise { - return this.groupModel.findAll({include: 'users'}); + async addEvaluationToGroup( + group: Group, + evaluation: Evaluation, + ): Promise { + await group.$add('evaluation', evaluation, { through: { createdAt: new Date(), updatedAt: new Date() } }); } - async count(): Promise { - return this.groupModel.count(); + async addUserToGroup(group: Group, user: User, role: string): Promise { + await group.$add('user', user, { through: { createdAt: new Date(), role: role, updatedAt: new Date() } }); } - async findOneBang(options?: FindOptions): Promise { - const group = await this.groupModel.findOne(options); - if (group === null) { - throw new NotFoundException('Group with given name not found'); - } else { - return group; - } + async count(): Promise { + return this.groupModel.count(); } - // This method is used to find groups by group name, - // primarily to sync user roles from an external provider - async findByName(name: string): Promise { - return this.findOneBang({ - where: { - name - } + async create(createGroupDto: CreateGroupDto): Promise { + const sameNamedGroups = await this.groupModel.findAll({ + where: { name: createGroupDto.name }, }); - } - - async findByPkBang(id: string): Promise { - // Users must be included for determining permissions on the group. - // Other assocations should be called by their ID separately and not eagerly loaded. - const group = await this.groupModel.findByPk(id, {include: 'users'}); - if (group === null) { - throw new NotFoundException('Group with given id not found'); - } else { - return group; + if (sameNamedGroups.length > 0) { + throw new ForbiddenException( + 'Duplicate key detected. The names of groups must be unique.', + ); } - } - async findByIds(id: string[]): Promise { - return this.groupModel.findAll({ - where: {id: {[Op.in]: id}}, - include: 'users' - }); - } - - async addUserToGroup(group: Group, user: User, role: string): Promise { - await group.$add('user', user, { - through: {role: role, createdAt: new Date(), updatedAt: new Date()} - }); + const group = new Group(createGroupDto as any); + return group.save(); } async ensureGroupHasOwner( group: Group, - user: User | GroupUser + user: GroupUser | User, ): Promise { - const owners = (await group.$get('users')).filter( - (userOnGroup) => userOnGroup.GroupUser.role === 'owner' + const groupUsers = await group.$get('users'); + const owners = groupUsers.filter( + userOnGroup => userOnGroup.GroupUser.role === 'owner', ); // If there are no more owners, set an admin to owner if ( - (owners.length < 2 && - owners.some( - (owner) => owner.id === ('userId' in user ? user.userId : user.id) - )) || - owners.length === 0 + (owners.length < 2 + && owners.some( + owner => owner.id === ('userId' in user ? user.userId : user.id), + )) + || owners.length === 0 ) { const appConfig = new AppConfig(); // If default admin is not found, use admin with lowest ID - const admin = - (await this.userModel.findOne({ - where: {role: 'admin', email: appConfig.getDefaultAdmin()} - })) || - (await this.userModel.findOne({ - where: {role: 'admin'}, - order: [['id', 'ASC']] - })); - if (admin !== null) { + const admin + = (await this.userModel.findOne({ where: { email: appConfig.getDefaultAdmin(), role: 'admin' } })) + || (await this.userModel.findOne({ + order: [['id', 'ASC']], + where: { role: 'admin' }, + })); + if (admin === null) { + // No admin found in system + throw new ForbiddenException('No admin to be promoted'); + } else { // If admin is in the group, promote it. If not, add as owner const adminId = admin.id; - const adminInGroup = (await group.$get('users')).find( - (userOnGroup) => userOnGroup.id === adminId + const usersInGroup = await group.$get('users'); + const adminInGroup = usersInGroup.find( + userOnGroup => userOnGroup.id === adminId, ); - adminInGroup - ? await adminInGroup.GroupUser.update({role: 'owner'}) - : await this.addUserToGroup(group, admin, 'owner'); - } else { - // No admin found in system - throw new ForbiddenException('No admin to be promoted'); + if (adminInGroup) { + await adminInGroup.GroupUser.update({ role: 'owner' }); + } else { + await this.addUserToGroup(group, admin, 'owner'); + } } } } - async updateGroupUserRole( - group: Group, - updateGroupUser: UpdateGroupUserRoleDto - ): Promise { - const groupUser = await GroupUser.findOne({ - where: {groupId: group.id, userId: updateGroupUser.userId} - }); - if (groupUser) { - await this.ensureGroupHasOwner(group, groupUser); - } - return groupUser?.update({role: updateGroupUser.groupRole}); - } - - async removeUserFromGroup(group: Group, user: User): Promise { - await this.ensureGroupHasOwner(group, user); - return group.$remove('user', user); + async findAll(): Promise { + return this.groupModel.findAll({ include: 'users' }); } - async addEvaluationToGroup( - group: Group, - evaluation: Evaluation - ): Promise { - await group.$add('evaluation', evaluation, { - through: {createdAt: new Date(), updatedAt: new Date()} + async findByIds(id: string[]): Promise { + return this.groupModel.findAll({ + include: 'users', + where: { id: { [Op.in]: id } }, }); } - async removeEvaluationFromGroup( - group: Group, - evaluation: Evaluation - ): Promise { - return group.$remove('evaluation', evaluation); + // This method is used to find groups by group name, + // primarily to sync user roles from an external provider + async findByName(name: string): Promise { + return this.findOneBang({ where: { name } }); } - async create(createGroupDto: CreateGroupDto): Promise { - if ( - (await this.groupModel.findAll({where: {name: createGroupDto.name}})) - .length > 0 - ) { - throw new ForbiddenException( - 'Duplicate key detected. The names of groups must be unique.' - ); + async findByPkBang(id: string): Promise { + // Users must be included for determining permissions on the group. + // Other assocations should be called by their ID separately and not eagerly loaded. + const group = await this.groupModel.findByPk(id, { include: 'users' }); + if (group === null) { + throw new NotFoundException('Group with given id not found'); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const group = new Group(createGroupDto as any); - return group.save(); + return group; } - async update(groupToUpdate: Group, groupDto: CreateGroupDto): Promise { - if ( - (await this.groupModel.findAll({where: {name: groupDto.name}})).length > 1 - ) { - throw new ForbiddenException( - 'Duplicate key detected. The names of groups must be unique.' - ); + async findOneBang(options?: FindOptions): Promise { + const group = await this.groupModel.findOne(options); + if (group === null) { + throw new NotFoundException('Group with given name not found'); } - return groupToUpdate.update(groupDto); + return group; } async remove(groupToDelete: Group): Promise { @@ -192,22 +147,34 @@ export class GroupsService { return groupToDelete; } + async removeEvaluationFromGroup( + group: Group, + evaluation: Evaluation, + ): Promise { + return group.$remove('evaluation', evaluation); + } + + async removeUserFromGroup(group: Group, user: User): Promise { + await this.ensureGroupHasOwner(group, user); + return group.$remove('user', user); + } + // This method ensures that the passed in user is in all of the // passed in groups, as long as the group already exists. // It will additionally remove the user from any groups not in the list. // Called from oidc.strategy.ts, if OIDC_EXTERNAL_GROUPS is enabled async syncUserGroups(user: User, groups: string[]) { - const currentGroups = await user.$get('groups', {include: [User]}); + const currentGroups = await user.$get('groups', { include: [User] }); const groupsToLeave = currentGroups.filter( - (group) => !groups.includes(group.name) + group => !groups.includes(group.name), ); // Remove user from any groups that they should not be in for (const groupToLeave of groupsToLeave) { try { await this.removeUserFromGroup(groupToLeave, user); - } catch (err) { - this.logger.warn(`Failed to remove user from group: ${err}`); + } catch (error) { + this.logger.warn(`Failed to remove user from group: ${String(error)}`); } } @@ -218,11 +185,11 @@ export class GroupsService { try { const existingGroup = await this.findByName(group); existingGroups.push(existingGroup); - } catch (err) { - if (err instanceof NotFoundException) { + } catch (error) { + if (error instanceof NotFoundException) { this.logger.info('External group does not exist locally, skipping..'); } else { - this.logger.warn(err); + this.logger.warn(error); } } } @@ -231,19 +198,42 @@ export class GroupsService { await Promise.all( existingGroups .filter( - (existingGroup) => - !currentGroups.some((group) => group.name === existingGroup.name) - ) - .map((existingGroup) => - this.addUserToGroup(existingGroup, user, 'member') + existingGroup => + currentGroups.every(group => group.name !== existingGroup.name), ) + .map(existingGroup => + this.addUserToGroup(existingGroup, user, 'member'), + ), ); // Ensure we didn't leave any dangling groups await Promise.all( groupsToLeave.map(async (group) => { await this.ensureGroupHasOwner(group, user); - }) + }), ); } + + async update(groupToUpdate: Group, groupDto: CreateGroupDto): Promise { + const sameNamedGroups = await this.groupModel.findAll({ + where: { name: groupDto.name }, + }); + if (sameNamedGroups.length > 1) { + throw new ForbiddenException( + 'Duplicate key detected. The names of groups must be unique.', + ); + } + return groupToUpdate.update(groupDto); + } + + async updateGroupUserRole( + group: Group, + updateGroupUser: UpdateGroupUserRoleDto, + ): Promise { + const groupUser = await GroupUser.findOne({ where: { groupId: group.id, userId: updateGroupUser.userId } }); + if (groupUser) { + await this.ensureGroupHasOwner(group, groupUser); + } + return groupUser?.update({ role: updateGroupUser.groupRole }); + } } diff --git a/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts b/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts index 400f406256..5997443fd3 100644 --- a/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts +++ b/apps/backend/src/guards/api-key-or-jwt-auth.guard.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class APIKeyOrJwtAuthGuard extends AuthGuard(['jwt', 'apikey']) {} diff --git a/apps/backend/src/guards/api-keys-enabled.guard.ts b/apps/backend/src/guards/api-keys-enabled.guard.ts index 8fdb76064e..a449c13505 100644 --- a/apps/backend/src/guards/api-keys-enabled.guard.ts +++ b/apps/backend/src/guards/api-keys-enabled.guard.ts @@ -1,6 +1,6 @@ -import {CanActivate, ExecutionContext, Injectable} from '@nestjs/common'; -import {Observable} from 'rxjs'; -import {ConfigService} from '../config/config.service'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../config/config.service'; @Injectable() export class APIKeysEnabled implements CanActivate { @@ -8,9 +8,10 @@ export class APIKeysEnabled implements CanActivate { constructor(configService: ConfigService) { this.configService = configService; } + canActivate( - _context: ExecutionContext - ): boolean | Promise | Observable { + _context: ExecutionContext, + ): boolean | Observable | Promise { return Boolean(this.configService.get('API_KEY_SECRET')); } } diff --git a/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts b/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts index 3ded7a2014..ee0aa72687 100644 --- a/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts +++ b/apps/backend/src/guards/implicit-allow-jwt-auth.guard.ts @@ -1,11 +1,11 @@ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class ImplicitAllowJwtAuthGuard extends AuthGuard('jwt') { // All these are typed as any within passport - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - handleRequest(_err: any, user: any, _info: any): any { + + handleRequest(_error: any, user: any, _info: any): any { return user; } } diff --git a/apps/backend/src/guards/jwt-auth.guard.ts b/apps/backend/src/guards/jwt-auth.guard.ts index 5440f30669..2155290ede 100644 --- a/apps/backend/src/guards/jwt-auth.guard.ts +++ b/apps/backend/src/guards/jwt-auth.guard.ts @@ -1,5 +1,5 @@ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/backend/src/guards/local-auth.guard.ts b/apps/backend/src/guards/local-auth.guard.ts index 72c5876689..ccf962b679 100644 --- a/apps/backend/src/guards/local-auth.guard.ts +++ b/apps/backend/src/guards/local-auth.guard.ts @@ -1,5 +1,5 @@ -import {Injectable} from '@nestjs/common'; -import {AuthGuard} from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/apps/backend/src/guards/test.guard.ts b/apps/backend/src/guards/test.guard.ts index c4ccd446b2..017ff9e3c3 100644 --- a/apps/backend/src/guards/test.guard.ts +++ b/apps/backend/src/guards/test.guard.ts @@ -1,13 +1,13 @@ -import {CanActivate, Injectable} from '@nestjs/common'; +import { CanActivate, Injectable } from '@nestjs/common'; @Injectable() export class TestGuard implements CanActivate { - async canActivate(): Promise { + canActivate(): boolean { const environment = process.env.NODE_ENV; return ( - environment !== undefined && - ['development', 'test'].includes(environment) && - process.env.CYPRESS_TESTING === 'true' + environment !== undefined + && ['development', 'test'].includes(environment) + && process.env.CYPRESS_TESTING === 'true' ); } } diff --git a/apps/backend/src/health/dto/health.dto.ts b/apps/backend/src/health/dto/health.dto.ts new file mode 100644 index 0000000000..60ac53d3f3 --- /dev/null +++ b/apps/backend/src/health/dto/health.dto.ts @@ -0,0 +1,33 @@ +import type { + IHealth, + IHealthDetails, + IHealthTableCounts, +} from '@heimdall/common/interfaces'; + +export class HealthDetailsDto implements IHealthDetails { + readonly bcryptRemaining: IHealthTableCounts; + readonly fips: boolean; + readonly fipsModeAsserted: boolean; + readonly oldestUnmigratedLogin: null | string; + readonly passwordHashWriteEnabled: boolean; + readonly pbkdf2Migrated: IHealthTableCounts; + + constructor(details: IHealthDetails) { + this.bcryptRemaining = details.bcryptRemaining; + this.fips = details.fips; + this.fipsModeAsserted = details.fipsModeAsserted; + this.oldestUnmigratedLogin = details.oldestUnmigratedLogin; + this.passwordHashWriteEnabled = details.passwordHashWriteEnabled; + this.pbkdf2Migrated = details.pbkdf2Migrated; + } +} + +export class HealthDto implements IHealth { + readonly status: string; + readonly version: string; + + constructor(health: IHealth) { + this.status = health.status; + this.version = health.version; + } +} diff --git a/apps/backend/src/health/health.controller.spec.ts b/apps/backend/src/health/health.controller.spec.ts new file mode 100644 index 0000000000..84bb456634 --- /dev/null +++ b/apps/backend/src/health/health.controller.spec.ts @@ -0,0 +1,180 @@ +import type { INestApplication } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { + HealthCheckError, + SequelizeHealthIndicator, + TerminusModule, +} from '@nestjs/terminus'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { version as backendVersion } from '../../package.json'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { HealthService } from '../health/health.service'; +import { User } from '../users/user.model'; +import { HealthController } from './health.controller'; + +// §17 disclosure boundary: none of these may ever appear on a probe surface. +const MIGRATION_STATE_PATTERN = /bcrypt|fips|passwordHashWriteEnabled|pbkdf2/v; + +describe('HealthController Unit Tests', () => { + let app: INestApplication; + let baseUrl: string; + let healthController: HealthController; + let configService: ConfigService; + let databaseService: DatabaseService; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [HealthController], + imports: [ + ConfigModule, + CryptoModule, + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + Evaluation, + EvaluationTag, + Group, + GroupEvaluation, + GroupUser, + User, + ]), + TerminusModule, + ], + providers: [DatabaseService, HealthService], + }).compile(); + + healthController = module.get(HealthController); + configService = module.get(ConfigService); + databaseService = module.get(DatabaseService); + + app = module.createNestApplication(); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address(); + if (address === null || typeof address !== 'object') { + throw new TypeError('expected the test server to bind a TCP port'); + } + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + configService.set('FIPS_MODE', undefined); + }); + + afterAll(async () => { + // Order matters: app.close() tears down the Nest app INCLUDING its + // Sequelize connection, so the cleanup query has to run first. + await databaseService.cleanAll(); + await app.close(); + }); + + describe('GET /health (unauthenticated liveness)', () => { + it('returns {status, version} ONLY — no fips, write-gate, or count fields (ADR-006 §17 disclosure boundary)', () => { + expect(healthController.getHealth()).toEqual({ + status: 'ok', + version: backendVersion, + }); + }); + + it('serves the liveness shape over HTTP with NO Authorization header (unauthenticated surface)', async () => { + const response = await fetch(`${baseUrl}/health`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: 'ok', + version: backendVersion, + }); + }); + }); + + describe('GET /health/ready (unauthenticated Terminus readiness probe)', () => { + it('returns the standard Terminus envelope with the database up, with NO Authorization header', async () => { + const response = await fetch(`${baseUrl}/health/ready`); + expect(response.status).toBe(200); + const body: unknown = await response.json(); + expect(body).toEqual({ + details: { database: { status: 'up' } }, + error: {}, + info: { database: { status: 'up' } }, + status: 'ok', + }); + // §17 disclosure boundary, asserted explicitly per the AC: no + // migration state anywhere in the probe response (the exact toEqual + // above already pins the key set; this sweeps nested values too). + expect(JSON.stringify(body)).not.toMatch(MIGRATION_STATE_PATTERN); + }); + + it('sends Cache-Control: no-cache, no-store, must-revalidate — probe responses must never be cached (@HealthCheck)', async () => { + const response = await fetch(`${baseUrl}/health/ready`); + expect(response.headers.get('cache-control')).toBe( + 'no-cache, no-store, must-revalidate', + ); + }); + + it('returns 503 with the Terminus error envelope when the DB check fails (same handler path, failing indicator)', async () => { + // The real controller, HealthCheckService, and 503 mapping — only the + // indicator (the piece that talks to the DB) is substituted with one + // that fails the way a dead connection does. Deliberately NO + // DatabaseModule here: a second Sequelize registration rebinds the + // shared model classes and poisons the main module's cleanup, and the + // ready route never touches HealthService. + const failingModule = await Test.createTestingModule({ + controllers: [HealthController], + imports: [ConfigModule, TerminusModule], + providers: [ + { provide: HealthService, useValue: {} }, + ], + }) + .overrideProvider(SequelizeHealthIndicator) + .useValue({ + pingCheck: () => { + throw new HealthCheckError('sequelize ping failed', { database: { status: 'down' } }); + }, + }) + .compile(); + const failingApp = failingModule.createNestApplication(); + await failingApp.init(); + await failingApp.listen(0); + const failingAddress = failingApp.getHttpServer().address(); + if (failingAddress === null || typeof failingAddress !== 'object') { + throw new TypeError('expected the failing test server to bind a port'); + } + + try { + const response = await fetch( + `http://127.0.0.1:${String(failingAddress.port)}/health/ready`, + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + details: { database: { status: 'down' } }, + error: { database: { status: 'down' } }, + info: {}, + status: 'error', + }); + } finally { + await failingApp.close(); + } + }); + }); +}); diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts new file mode 100644 index 0000000000..7b868c38c9 --- /dev/null +++ b/apps/backend/src/health/health.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, UseInterceptors } from '@nestjs/common'; +import type { HealthCheckResult } from '@nestjs/terminus'; +import { + HealthCheck, + HealthCheckService, + SequelizeHealthIndicator, +} from '@nestjs/terminus'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { HealthDto } from './dto/health.dto'; +import { HealthService } from './health.service'; + +/** + * ADR-006 §17 (ratified policy, 2026-08-10): this controller carries ONLY + * the probe-safe surface. GET /health is the UNAUTHENTICATED liveness check + * ({status, version}, no dependency checks — a DB outage must never restart + * app pods). GET /health/ready is the UNAUTHENTICATED Terminus readiness + * probe — a constant-cost DB ping for container/k8s/systemd probe use. + * + * The admin migration report lives at /admin/migration-status + * (AdminController) — it is NOT a health check, must never be probed (its + * counts are full table scans), and never returns on this surface (Risks — + * disclosure). + */ +@Controller('health') +@UseInterceptors(LoggingInterceptor) +export class HealthController { + constructor( + private readonly healthCheckService: HealthCheckService, + private readonly healthService: HealthService, + private readonly sequelizeIndicator: SequelizeHealthIndicator, + ) {} + + @Get('ready') + @HealthCheck() + checkReadiness(): Promise { + return this.healthCheckService.check([ + () => this.sequelizeIndicator.pingCheck('database'), + ]); + } + + @Get() + getHealth(): HealthDto { + return this.healthService.getHealth(); + } +} diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts new file mode 100644 index 0000000000..7bf46f2fc9 --- /dev/null +++ b/apps/backend/src/health/health.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { ConfigModule } from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +/** + * ADR-006 §17. ConfigModule feeds FIPS_MODE; CryptoModule feeds the write + * gate. The Sequelize connection (raw §17 count queries and the Terminus + * ping) is provided by the root DatabaseModule registration. TerminusModule + * supplies the /health/ready probe machinery — probes stay constant-cost and + * never touch the §17 scans. + */ +@Module({ + controllers: [HealthController], + exports: [HealthService], + imports: [ConfigModule, CryptoModule, TerminusModule], + providers: [HealthService], +}) +export class HealthModule {} diff --git a/apps/backend/src/health/health.service.spec.ts b/apps/backend/src/health/health.service.spec.ts new file mode 100644 index 0000000000..f2657f079a --- /dev/null +++ b/apps/backend/src/health/health.service.spec.ts @@ -0,0 +1,193 @@ +import * as nodeCrypto from 'node:crypto'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { HashMigrationMarker } from '../crypto/hash-migration-marker.model'; +import { HashWriteGateService } from '../crypto/hash-write-gate.service'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { User } from '../users/user.model'; +import { HealthService } from './health.service'; + +// Pass-through mock: every crypto member stays real; getFips gains a +// mockable seam (vi.spyOn on ESM builtin namespaces is not configurable — +// the fips.spec.ts pattern). +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getFips: vi.fn(actual.getFips) }; +}); + +// Prefix-shaped literals for the §17 count queries — never verified as +// credentials, only matched against LIKE '$2%' / '$pbkdf2-%'. +const BCRYPT_SHAPED_HASH + = '$2b$14$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const PBKDF2_SHAPED_HASH = '$pbkdf2-sha512$i=600000$c2FsdHNhbHQ$aGFzaGhhc2g'; +const DAY_MS = 24 * 60 * 60 * 1000; +const THREE_DAYS_INTERVAL_PREFIX = /^3 days/v; + +describe('HealthService Unit Tests', () => { + let healthService: HealthService; + let configService: ConfigService; + let databaseService: DatabaseService; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule, + CryptoModule, + DatabaseModule, + SequelizeModule.forFeature([ + ApiKey, + Evaluation, + EvaluationTag, + Group, + GroupEvaluation, + GroupUser, + User, + ]), + ], + providers: [DatabaseService, HealthService], + }).compile(); + + healthService = module.get(HealthService); + configService = module.get(ConfigService); + databaseService = module.get(DatabaseService); + }); + + beforeEach(async () => { + await databaseService.cleanAll(); + configService.set('FIPS_MODE', undefined); + }); + + afterAll(async () => { + await databaseService.cleanAll(); + await databaseService.closeConnection(); + }); + + describe('getDetails', () => { + it('returns zero counts, null oldestUnmigratedLogin, and the exact §17 shape on empty tables', async () => { + expect(await healthService.getDetails()).toEqual({ + bcryptRemaining: { apiKeys: 0, users: 0 }, + fips: false, + fipsModeAsserted: false, + oldestUnmigratedLogin: null, + passwordHashWriteEnabled: true, + pbkdf2Migrated: { apiKeys: 0, users: 0 }, + }); + }); + + it('splits counts by hash prefix over BOTH tables and reports the OLDEST unmigrated login (§17 FILTER shape)', async () => { + const bcryptUserOld = await User.create({ + creationMethod: 'local', + email: 'bcrypt-old@example.com', + encryptedPassword: BCRYPT_SHAPED_HASH, + lastLogin: new Date(Date.now() - 3 * DAY_MS), + }); + await User.create({ + creationMethod: 'local', + email: 'bcrypt-recent@example.com', + encryptedPassword: BCRYPT_SHAPED_HASH, + lastLogin: new Date(Date.now() - 1 * DAY_MS), + }); + await User.create({ + creationMethod: 'local', + email: 'pbkdf2-older-login@example.com', + encryptedPassword: PBKDF2_SHAPED_HASH, + // Older than every bcrypt login — must NOT win: the age() aggregate + // is FILTERed to unmigrated ('$2%') rows only. + lastLogin: new Date(Date.now() - 5 * DAY_MS), + }); + await ApiKey.create({ + apiKey: BCRYPT_SHAPED_HASH, + name: 'legacy key', + userId: bcryptUserOld.id, + }); + await ApiKey.create({ + apiKey: PBKDF2_SHAPED_HASH, + name: 'migrated key', + userId: bcryptUserOld.id, + }); + + expect(await healthService.getDetails()).toEqual({ + bcryptRemaining: { apiKeys: 1, users: 2 }, + fips: false, + fipsModeAsserted: false, + oldestUnmigratedLogin: expect.stringMatching( + THREE_DAYS_INTERVAL_PREFIX, + ), + passwordHashWriteEnabled: true, + pbkdf2Migrated: { apiKeys: 1, users: 1 }, + }); + }); + + it('reports null oldestUnmigratedLogin when no unmigrated user has ever logged in', async () => { + await User.create({ + creationMethod: 'local', + email: 'bcrypt-never-logged-in@example.com', + encryptedPassword: BCRYPT_SHAPED_HASH, + }); + await User.create({ + creationMethod: 'local', + email: 'pbkdf2-logged-in@example.com', + encryptedPassword: PBKDF2_SHAPED_HASH, + lastLogin: new Date(Date.now() - 2 * DAY_MS), + }); + + const details = await healthService.getDetails(); + expect(details.oldestUnmigratedLogin).toBeNull(); + expect(details.bcryptRemaining).toEqual({ apiKeys: 0, users: 1 }); + }); + + it('reports fipsModeAsserted=true for FIPS_MODE=true while fips still reflects the real provider probe', async () => { + configService.set('FIPS_MODE', 'true'); + const details = await healthService.getDetails(); + expect(details.fipsModeAsserted).toBe(true); + // Non-FIPS test host: the OpenSSL probe is independent of the setting. + expect(details.fips).toBe(false); + }); + + it('reports fips=true when the OpenSSL provider probe is active (getFips()===1)', async () => { + vi.mocked(nodeCrypto.getFips).mockReturnValueOnce(1); + const details = await healthService.getDetails(); + expect(details.fips).toBe(true); + }); + + it('reports passwordHashWriteEnabled=false when the write gate derives OFF (explicit env)', async () => { + const priorSetting = process.env.PASSWORD_HASH_WRITE_ENABLED; + process.env.PASSWORD_HASH_WRITE_ENABLED = 'false'; + try { + // Fresh instances: the module singletons cached the suite-wide + // gate-ON derivation. The explicit-env path never touches the + // injected models (hash-write-gate contract). + const gatedOffService = new HealthService( + configService, + new HashWriteGateService(HashMigrationMarker, User, configService), + databaseService.sequelize, + ); + const details = await gatedOffService.getDetails(); + expect(details.passwordHashWriteEnabled).toBe(false); + } finally { + process.env.PASSWORD_HASH_WRITE_ENABLED = priorSetting; + } + }); + }); +}); diff --git a/apps/backend/src/health/health.service.ts b/apps/backend/src/health/health.service.ts new file mode 100644 index 0000000000..cd26392b9e --- /dev/null +++ b/apps/backend/src/health/health.service.ts @@ -0,0 +1,95 @@ +import * as nodeCrypto from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { QueryTypes } from 'sequelize'; +import { Sequelize } from 'sequelize-typescript'; +import { version as backendVersion } from '../../package.json'; +import { ConfigService } from '../config/config.service'; +import { HashWriteGateService } from '../crypto/hash-write-gate.service'; +import { HealthDetailsDto, HealthDto } from './dto/health.dto'; + +/** + * ADR-006 §17: the split health surface. The liveness half returns + * {status, version} ONLY — migration state (fips, write gate, hash counts) is + * a disclosure decision the Risks table forbids on any unauthenticated + * surface, and it lives behind auth on /health/details instead. + * + * The version is the backend's own package.json version — the backend analog + * of the frontend's build-time PACKAGE_VERSION (vue.config.js reads the same + * field from its package.json). resolveJsonModule emits the file into dist/, + * so the compiled require resolves at runtime. + * + * The count queries are §17's single-scan FILTER shape over BOTH credential + * tables — the prior draft's Users-only query is the documented mistake + * (bcrypt_remaining could read 0 while every ApiKeys row was still $2b$). + * Each call runs the full scans: no caching, and never wire these into a + * readiness probe (§17 — self-inflicted outage). + */ + +const USERS_HASH_COUNTS_SQL = ` +SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%')::int AS "bcryptRemaining", + count(*) FILTER (WHERE "encryptedPassword" LIKE '$pbkdf2-%')::int AS "pbkdf2Migrated", + (max(age(now(), "lastLogin")) + FILTER (WHERE "encryptedPassword" LIKE '$2%'))::text AS "oldestUnmigratedLogin" +FROM "Users"`; + +const API_KEYS_HASH_COUNTS_SQL = ` +SELECT count(*) FILTER (WHERE "apiKey" LIKE '$2%')::int AS "bcryptRemaining", + count(*) FILTER (WHERE "apiKey" LIKE '$pbkdf2-%')::int AS "pbkdf2Migrated" +FROM "ApiKeys"`; + +type ApiKeysHashCountsRow = { + readonly bcryptRemaining: number; + readonly pbkdf2Migrated: number; +}; + +type UsersHashCountsRow = { + readonly bcryptRemaining: number; + readonly oldestUnmigratedLogin: null | string; + readonly pbkdf2Migrated: number; +}; + +@Injectable() +export class HealthService { + constructor( + private readonly configService: ConfigService, + private readonly hashWriteGate: HashWriteGateService, + private readonly sequelize: Sequelize, + ) {} + + async getDetails(): Promise { + const userCounts = await this.sequelize.query( + USERS_HASH_COUNTS_SQL, + { plain: true, type: QueryTypes.SELECT }, + ); + const apiKeyCounts = await this.sequelize.query( + API_KEYS_HASH_COUNTS_SQL, + { plain: true, type: QueryTypes.SELECT }, + ); + if (userCounts === null || apiKeyCounts === null) { + // A single-row aggregate cannot return an empty set; a null here means + // the query itself broke and must never read as "zero remaining". + throw new Error('hash-count aggregate returned no row'); + } + return new HealthDetailsDto({ + bcryptRemaining: { + apiKeys: apiKeyCounts.bcryptRemaining, + users: userCounts.bcryptRemaining, + }, + fips: nodeCrypto.getFips() === 1, + // Exact-match semantics shared with assertFipsMode (§10): only the + // literal 'true' asserts, anything else reports unasserted here and is + // warned about or refused at boot. + fipsModeAsserted: this.configService.get('FIPS_MODE') === 'true', + oldestUnmigratedLogin: userCounts.oldestUnmigratedLogin, + passwordHashWriteEnabled: await this.hashWriteGate.writesEnabled(), + pbkdf2Migrated: { + apiKeys: apiKeyCounts.pbkdf2Migrated, + users: userCounts.pbkdf2Migrated, + }, + }); + } + + getHealth(): HealthDto { + return new HealthDto({ status: 'ok', version: backendVersion }); + } +} diff --git a/apps/backend/src/interceptors/create-evaluation-interceptor.ts b/apps/backend/src/interceptors/create-evaluation-interceptor.ts index 9e638bf5ab..04cbc94fe3 100644 --- a/apps/backend/src/interceptors/create-evaluation-interceptor.ts +++ b/apps/backend/src/interceptors/create-evaluation-interceptor.ts @@ -1,13 +1,13 @@ -import {ICreateEvaluation} from '@heimdall/common/interfaces'; +import { ICreateEvaluation } from '@heimdall/common/interfaces'; import { CallHandler, ExecutionContext, Injectable, - NestInterceptor + NestInterceptor, } from '@nestjs/common'; -import {Observable} from 'rxjs'; -import {CreateEvaluationTagDto} from '../evaluation-tags/dto/create-evaluation-tag.dto'; -import {GroupsService} from '../groups/groups.service'; +import { Observable } from 'rxjs'; +import { CreateEvaluationTagDto } from '../evaluation-tags/dto/create-evaluation-tag.dto'; +import { GroupsService } from '../groups/groups.service'; @Injectable() export class CreateEvaluationInterceptor implements NestInterceptor { @@ -18,25 +18,21 @@ export class CreateEvaluationInterceptor implements NestInterceptor { public intercept( _context: ExecutionContext, - next: CallHandler + next: CallHandler, ): Observable { // changing request const request = _context.switchToHttp().getRequest(); if (request.body.public) { request.body.public = [true, 'true'].includes(request.body.public); } - if ( - request.body.evaluationTags !== undefined && - request.body.evaluationTags !== '' - ) { - request.body.evaluationTags = request.body.evaluationTags + request.body.evaluationTags = request.body.evaluationTags !== undefined + && request.body.evaluationTags !== '' + ? request.body.evaluationTags .split(',') .map( - (evaluationTag: string) => new CreateEvaluationTagDto(evaluationTag) - ); - } else { - request.body.evaluationTags = []; - } + (evaluationTag: string) => new CreateEvaluationTagDto(evaluationTag), + ) + : []; if (request.body.groups !== undefined) { request.body.groups = request.body.groups.split(','); } diff --git a/apps/backend/src/interceptors/logging.interceptor.spec.ts b/apps/backend/src/interceptors/logging.interceptor.spec.ts new file mode 100644 index 0000000000..ba4d9e6e6a --- /dev/null +++ b/apps/backend/src/interceptors/logging.interceptor.spec.ts @@ -0,0 +1,90 @@ +import type { Request } from 'express'; +import { Test } from '@nestjs/testing'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { LoggingInterceptor } from './logging.interceptor'; + +const asRequest = (headers: Record, ip: string): Request => + ({ headers, ip }) as unknown as Request; + +describe('LoggingInterceptor', () => { + let interceptor: LoggingInterceptor; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ConfigModule], + }).compile(); + // The interceptor's redaction behavior is driven by the REAL + // ConfigService.sensitiveKeys patterns — mocking them would test nothing. + interceptor = new LoggingInterceptor(module.get(ConfigService)); + }); + + describe('redact', () => { + it('redacts values whose keys match the sensitive patterns', () => { + const result = interceptor.redact({ + filename: 'kept', + apiKey: 'hunter2', + password: 'hunter2', + token: 'hunter2', + }); + + expect(result).toEqual({ + filename: 'kept', + apiKey: '[REDACTED]', + password: '[REDACTED]', + token: '[REDACTED]', + }); + }); + + it('is shallow: nested sensitive keys are NOT redacted', () => { + // Pins the current contract so a future "fix" that deep-redacts (or a + // refactor that silently stops at depth 0) is a visible decision. + const result = interceptor.redact({ + nested: { password: 'hunter2' }, + }); + + expect(result).toEqual({ nested: { password: 'hunter2' } }); + }); + + it('returns undefined for non-object bodies', () => { + expect(interceptor.redact(undefined)).toBeUndefined(); + expect( + interceptor.redact('password=x' as unknown as Record), + ).toBeUndefined(); + }); + + it('never mutates the request body it was given', () => { + const body = { password: 'hunter2' }; + + interceptor.redact(body); + + expect(body.password).toBe('hunter2'); + }); + + it('handles an own __proto__ key from parsed JSON without polluting', () => { + // JSON.parse creates __proto__ as an OWN data property, bypassing the + // setter — exactly what an express.json request body can contain. + const body = JSON.parse('{"__proto__": {"polluted": true}, "a": 1}'); + + const result = interceptor.redact(body); + + expect(({} as Record).polluted).toBeUndefined(); + expect(result).toMatchObject({ a: 1 }); + }); + }); + + describe('getRealIP', () => { + it('reports proxy chain when x-forwarded-for is present', () => { + const request = asRequest({ 'x-forwarded-for': '10.0.0.7' }, '127.0.0.1'); + + expect(interceptor.getRealIP(request)).toBe('10.0.0.7 -> 127.0.0.1'); + }); + + it('falls back to the socket ip without proxy headers', () => { + const request = asRequest({ referer: 'https://example.org' }, '127.0.0.1'); + + expect(interceptor.getRealIP(request)).toBe('127.0.0.1'); + }); + }); +}); diff --git a/apps/backend/src/interceptors/logging.interceptor.ts b/apps/backend/src/interceptors/logging.interceptor.ts index 76f0326787..2dfe3dcc4d 100644 --- a/apps/backend/src/interceptors/logging.interceptor.ts +++ b/apps/backend/src/interceptors/logging.interceptor.ts @@ -2,96 +2,103 @@ import { CallHandler, ExecutionContext, Injectable, - NestInterceptor + NestInterceptor, } from '@nestjs/common'; -import {Request} from 'express'; +import { Request } from 'express'; import _ from 'lodash'; -import {Observable} from 'rxjs'; -import winston from 'winston'; -import {ConfigService} from '../config/config.service'; -import {SlimUserDto} from '../users/dto/slim-user.dto'; -import {UserDto} from '../users/dto/user.dto'; -import {User} from '../users/user.model'; +import { Observable } from 'rxjs'; +import { createLogger, format, transports } from 'winston'; +import { ConfigService } from '../config/config.service'; +import { SlimUserDto } from '../users/dto/slim-user.dto'; +import { UserDto } from '../users/dto/user.dto'; +import { User } from '../users/user.model'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly configService: ConfigService; private readonly line = '___________________________________________\n'; + public logger = createLogger({ + format: format.combine( + format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), + format.printf( + info => + `${this.line}[${String([info.timestamp])}] (Interceptor): ${String(info.ip)} ${String( + info.referer + )} ${String(info.userAgent)} ${String(info.user)} ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], + }); + constructor(configService: ConfigService) { this.configService = configService; } - public logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: 'MMM-DD-YYYY HH:mm:ss Z' - }), - winston.format.printf( - (info) => - `${this.line}[${[info.timestamp]}] (Interceptor): ${info.ip} ${ - info.referer - } ${info.userAgent} ${info.user} ${info.message}` - ) - ) - }); + + getRealIP(request: Request): string | unknown { + const forwarded = Object.entries(request.headers).find( + ([header]) => + header.toLowerCase() === 'x-forwarded-for' + || header.toLowerCase() === 'x-real-ip', + ); + if (!forwarded) { + return request.ip; + } + // Node models repeated headers as string[]; a comma join is the HTTP + // semantics for that case and keeps the template expression a string. + const [, value] = forwarded; + const proxyIP = Array.isArray(value) ? value.join(', ') : value; + return `${proxyIP} -> ${request.ip}`; + } intercept(context: ExecutionContext, next: CallHandler): Observable { - const request: Request & {user?: User} = context + const request: Request & { user?: User } = context .switchToHttp() .getRequest(); const method = request.method; const endpoint = request.originalUrl; - const callingUser: User | undefined = request.user; + const callingUser: undefined | User = request.user; const calledMethod = context.getHandler().name; - const requestParams = JSON.stringify(this.redact(request.body)); - const referer = request.headers['referer']; + const requestParameters = JSON.stringify(this.redact(request.body)); + const referer = request.headers.referer; const userAgent = request.headers['user-agent']; this.logger.info({ ip: this.getRealIP(request), - user: this.userToString(callingUser), + message: `${_.startCase( + calledMethod, + )} (${method}) ${requestParameters} ${endpoint}`, referer: referer, + user: this.userToString(callingUser), userAgent: userAgent, - message: `${_.startCase( - calledMethod - )} (${method}) ${requestParams} ${endpoint}` }); return next.handle(); } - userToString(user?: User | UserDto | SlimUserDto): string { - if (user) { - return `User`; + redact(object?: Record): Record | undefined { + if (!_.isObject(object)) { + return undefined; } - return `User`; + return this.redactObject(structuredClone(object)); } - getRealIP(request: Request): string | unknown { - const realIP = Object.keys(request.headers).find( - (header) => - header.toLowerCase() === 'x-forwarded-for' || - header.toLowerCase() === 'x-real-ip' + redactObject(object: Record): Record { + // Rebuilt rather than mutated in place: no computed-key write exists for + // an attacker-shaped key to reach. Entries preserves own enumerable keys + // only, so an own __proto__ from JSON.parse rides through as data — it + // can never hit the prototype setter here. + return Object.fromEntries( + Object.entries(object).map(([key, value]) => + this.configService.sensitiveKeys.some(regex => regex.test(key)) + ? [key, '[REDACTED]'] + : [key, value], + ), ); - if (realIP) { - return `${request.headers[realIP]} -> ${request.ip}`; - } else { - return request.ip; - } } - redact(obj?: Record): Record | undefined { - if (!_.isObject(obj)) { - return undefined; + userToString(user?: SlimUserDto | User | UserDto): string { + if (user) { + return `User`; } - return this.redactObject(structuredClone(obj)); - } - - redactObject(obj: Record): Record { - Object.keys(obj).forEach((key) => { - if (this.configService.sensitiveKeys.some((regex) => regex.test(key))) { - obj[key] = '[REDACTED]'; - } - }); - return obj; + return 'User'; } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 54768d316b..d7de40d7b3 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,50 +1,69 @@ -import {ValidationPipe} from '@nestjs/common'; -import {NestFactory} from '@nestjs/core'; -import {NestExpressApplication} from '@nestjs/platform-express'; -import {json} from 'express'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import postgresSessionStore from 'connect-pg-simple'; +import { json } from 'express'; import rateLimit from 'express-rate-limit'; -import helmet from 'helmet'; +import session from 'express-session'; +import helmet, { contentSecurityPolicy } from 'helmet'; import multer from 'multer'; -import winston from 'winston'; -import passport = require('passport'); -import postgresSessionStore = require('connect-pg-simple'); -import session = require('express-session'); -import {AppModule} from './app.module'; -import {ConfigService} from './config/config.service'; -import {generateDefault} from './token/token.providers'; +import passport from 'passport'; +import { createLogger, format, transports } from 'winston'; +import { AppModule } from './app.module'; +import { ConfigService } from './config/config.service'; +import { assertFipsMode } from './crypto/fips'; +import { HashWriteGateService } from './crypto/hash-write-gate.service'; +import { generateDefault } from './token/token.providers'; const line = '_______________________________________________\n'; const loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; -const logger = winston.createLogger({ - transports: [new winston.transports.Console()], - format: winston.format.combine( - winston.format.timestamp({ - format: loggingTimeFormat - }), - winston.format.printf( - (info) => `${line}[${[info.timestamp]}] (Authn Service): ${info.message}` - ) - ) +const logger = createLogger({ + format: format.combine( + format.timestamp({ format: loggingTimeFormat }), + format.printf( + info => + `${line}[${String([info.timestamp])}] (Authn Service): ${String(info.message)}`, + ), + ), + transports: [new transports.Console()], }); async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); + // ADR-006 §10: assert host FIPS state before anything else — logic lives in + // the testable module; this call site stays one line. + assertFipsMode({ fipsMode: configService.get('FIPS_MODE') }); + // ADR-006 §12 mechanism 3: refuse to start against a database whose + // credential write epoch is newer than this build understands (a downgrade + // or a pg_dump restore from a newer system) — enforced here in the + // application because RPM %pre cannot fire on the downgrades it targets, + // and a container path has no scriptlet at all. The thrown error crashes + // bootstrap loudly with the operator remedy in the message. + await app.get(HashWriteGateService).assertMarkerCompatible(); app.set('query parser', 'extended'); app.enableShutdownHooks(); app.use(helmet()); app.use( - helmet.contentSecurityPolicy({ + contentSecurityPolicy({ directives: { // These are the defaults from helmet, except upgrade-insecure-requests // is removed since it causes issues for users trying to run over http // https://github.com/mitre/heimdall2/issues/787 // This whole block can be changed back to - // ...helmet.contentSecurityPolicy.getDefaultDirectives() + // ...contentSecurityPolicy.getDefaultDirectives() // If heimdall begins providing users with an easy way to generate a SSL // certificate as part of deployment. 'base-uri': ["'self'"], 'block-all-mixed-content': [], + // This is the only setting that is different from the defaults. + 'connect-src': [ + "'self'", + 'https://api.github.com', + 'https://sts.amazonaws.com', + configService.getTenableHostUrl(), + configService.getSplunkHostUrl(), + ].filter(Boolean), 'default-src': ["'self'"], 'font-src': ["'self'", 'https:', 'data:'], 'frame-ancestors': ["'self'"], @@ -53,45 +72,39 @@ async function bootstrap() { 'script-src': ["'self'"], 'script-src-attr': ["'none'"], 'style-src': ["'self'", 'https:', "'unsafe-inline'"], - // This is the only setting that is different from the defaults. - 'connect-src': [ - "'self'", - 'https://api.github.com', - 'https://sts.amazonaws.com', - configService.getTenableHostUrl(), - configService.getSplunkHostUrl() - ].filter((source) => source) - } - }) + }, + }), ); - app.use(json({limit: '50mb'})); + app.use(json({ limit: '50mb' })); app.use(passport.initialize()); // Sessions was previously set to only be used for oauth callbacks // but now is used for Tenable authentication as well. if ( - configService.enabledOauthStrategies().length || - configService.getTenableHostUrl().length + configService.enabledOauthStrategies().length > 0 + || configService.getTenableHostUrl().length > 0 ) { + const PostgresSessionStore = postgresSessionStore(session); + const sessionStore = new PostgresSessionStore({ + conObject: { + ...configService.getDbConfig(), + /* The pg conObject takes mostly the same parameters as Sequelize, except the ssl options, + those are equal to the dialectOptions passed to sequelize */ + ssl: configService.getSSLConfig(), + }, + tableName: 'session', + }); app.use( session({ - secret: generateDefault(), - store: new (postgresSessionStore(session))({ - conObject: { - ...configService.getDbConfig(), - /* The pg conObject takes mostly the same parameters as Sequelize, except the ssl options, - those are equal to the dialectOptions passed to sequelize */ - ssl: configService.getSSLConfig() - }, - tableName: 'session' - }), - proxy: configService.isInProductionMode() ? true : undefined, cookie: { maxAge: 60 * 60 * 1000, // 1 hour - secure: configService.isInProductionMode() + secure: configService.isInProductionMode(), }, + proxy: configService.isInProductionMode() ? true : undefined, + resave: false, saveUninitialized: false, - resave: false - }) + secret: generateDefault(), + store: sessionStore, + }), ); if (configService.isInProductionMode()) { app.getHttpAdapter().getInstance().set('trust proxy', true); @@ -101,39 +114,43 @@ async function bootstrap() { app.use( '/authn/login', rateLimit({ - windowMs: 60 * 1000, max: 20, message: { - status: 429, + error: 'Ratelimited', message: 'Too Many Requests', - error: 'Ratelimited' - } - }) + status: 429, + }, + windowMs: 60 * 1000, + }), ); // Allow for file uploads up to 50 mb multer({ limits: { fieldSize: - parseInt(configService.get('MAX_FILE_UPLOAD_SIZE') || '50') * - 1024 * - 1024 - } + parseInt(configService.get('MAX_FILE_UPLOAD_SIZE') || '50') + * 1024 + * 1024, + }, }); app.useGlobalPipes( new ValidationPipe({ transform: true, - whitelist: true - }) + whitelist: true, + }), ); - //eslint-disable-next-line @typescript-eslint/no-explicit-any - app.use((req: any, res: any, next: any) => { - logger.debug('Url:', req.url); - logger.debug('Session:', JSON.stringify(req.session, null, 2)); + app.use((request: any, _res: any, next: any) => { + logger.debug('Url:', request.url); + logger.debug('Session:', JSON.stringify(request.session, null, 2)); next(); }); await app.listen(configService.get('PORT') || 3000); } -bootstrap(); +bootstrap().catch((error) => { + // A failed boot must exit nonzero and say why, not surface as an + // unhandled rejection. + console.error(error); + process.exit(1); +}); diff --git a/apps/backend/src/pipes/password-change.pipe.spec.ts b/apps/backend/src/pipes/password-change.pipe.spec.ts index 6496def112..c48dd22ac6 100644 --- a/apps/backend/src/pipes/password-change.pipe.spec.ts +++ b/apps/backend/src/pipes/password-change.pipe.spec.ts @@ -1,12 +1,12 @@ -import {BadRequestException} from '@nestjs/common'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UPDATE_USER_DTO_TEST_OBJ, UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, - UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD } from '../../test/constants/users-test.constant'; -import {PasswordChangePipe} from './password-change.pipe'; +import { PasswordChangePipe } from './password-change.pipe'; describe('PasswordChangePipe', () => { let passwordChangePipe: PasswordChangePipe; @@ -23,61 +23,61 @@ describe('PasswordChangePipe', () => { describe('classesChanged Helper Function', () => { it('should pass', () => { expect( - passwordChangePipe.classesChanged('Totally$Different199', 'Letmein123@') + passwordChangePipe.classesChanged('Totally$Different199', 'Letmein123@'), ).toBeTruthy(); }); it('should fail because both passwords have the same uppercase letter(s) in the same order', () => { expect( - passwordChangePipe.classesChanged('abc$LghE17', 'LEtmein123') + passwordChangePipe.classesChanged('abc$LghE17', 'LEtmein123'), ).toBeFalsy(); }); it('should pass because both passwords have the same uppercase letter(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('abc$EghL17', 'LEtmein123') + passwordChangePipe.classesChanged('abc$EghL17', 'LEtmein123'), ).toBeTruthy(); }); it('should fail because both passwords have the same lowercase letter(s) in the same order', () => { expect( - passwordChangePipe.classesChanged('ABCDe$PQRSt', 'LetMEIN123') + passwordChangePipe.classesChanged('ABCDe$PQRSt', 'LetMEIN123'), ).toBeFalsy(); }); it('should pass because both passwords have the same lowercase letter(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('ABCDt$PQRSe', 'LetMEIN123') + passwordChangePipe.classesChanged('ABCDt$PQRSe', 'LetMEIN123'), ).toBeTruthy(); }); it('should fail because both passwords have the same number(s) in the same order', () => { expect( - passwordChangePipe.classesChanged('ab0c$DEF7', '0ABCdef7') + passwordChangePipe.classesChanged('ab0c$DEF7', '0ABCdef7'), ).toBeFalsy(); }); it('should pass because both passwords have the same number(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('ab7c$4DEF0', '0ABCdef7') + passwordChangePipe.classesChanged('ab7c$4DEF0', '0ABCdef7'), ).toBeTruthy(); }); it('should pass because both passwords have the same special character(s) but in a different order', () => { expect( - passwordChangePipe.classesChanged('ab$c D1EF&', '&ABCdef7$') + passwordChangePipe.classesChanged('ab$c D1EF&', '&ABCdef7$'), ).toBeTruthy(); }); it('should fail because both passwords have the same special character(s) but in the same order', () => { expect( - passwordChangePipe.classesChanged('&abc D1EF$', '&ABCdef7$') + passwordChangePipe.classesChanged('&abc D1EF$', '&ABCdef7$'), ).toBeFalsy(); }); it('should fail because both passwords are the same', () => { expect( - passwordChangePipe.classesChanged('Letmein123$', 'Letmein123$') + passwordChangePipe.classesChanged('Letmein123$', 'Letmein123$'), ).toBeFalsy(); }); }); @@ -90,14 +90,14 @@ describe('PasswordChangePipe', () => { it('should return the same UpdateUserDto', () => { expect( passwordChangePipe.transform( - UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD - ) + UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + ), ).toEqual(UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD); }); it('should return UpdateUserDto if password fields are null', () => { expect( - passwordChangePipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS) + passwordChangePipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS), ).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS); }); @@ -105,8 +105,8 @@ describe('PasswordChangePipe', () => { it('should should pass when the currentPassword is not provided and a valid new password is provided', () => { expect( passwordChangePipe.transform( - UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD - ) + UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, + ), ).toEqual(UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD); }); }); @@ -115,12 +115,12 @@ describe('PasswordChangePipe', () => { describe('Test Invalid Password Changes', () => { it('should throw a BadRequestException', () => { expect(() => - passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ) + passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ), ).toThrowError(BadRequestException); expect(() => - passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ) + passwordChangePipe.transform(UPDATE_USER_DTO_TEST_OBJ), ).toThrowError( - 'A minimum of four character classes must be changed when updating a password. A minimum of eight of the total number of characters must be changed when updating a password.' + 'A minimum of four character classes must be changed when updating a password. A minimum of eight of the total number of characters must be changed when updating a password.', ); }); }); diff --git a/apps/backend/src/pipes/password-change.pipe.ts b/apps/backend/src/pipes/password-change.pipe.ts index c3577b111a..f9e03a3160 100644 --- a/apps/backend/src/pipes/password-change.pipe.ts +++ b/apps/backend/src/pipes/password-change.pipe.ts @@ -1,48 +1,45 @@ -import {BadRequestException, Injectable, PipeTransform} from '@nestjs/common'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; import levenshtein from 'js-levenshtein'; +// Safe to share: matchAll iterates an internal clone and never advances the +// source regex's lastIndex. +const CHARACTER_CLASS_MATCHERS = [/[a-z]/gv, /[A-Z]/gv, /\d/g, /[^\s\w]/g]; + @Injectable() export class PasswordChangePipe implements PipeTransform { + classesChanged(future: string, current: string): boolean { + for (const validator of CHARACTER_CLASS_MATCHERS) { + const currentMatch = [...current.matchAll(validator)]; + const futureMatch = [...future.matchAll(validator)]; + if (JSON.stringify(currentMatch) === JSON.stringify(futureMatch)) { + return false; + } + } + return true; + } + transform(value: { currentPassword?: string; password: string | undefined; passwordConfirmation: string | undefined; }): Record { if ( - (!value.password && !value.passwordConfirmation) || - !value.currentPassword + (!value.password && !value.passwordConfirmation) + || !value.currentPassword ) { return value; - } else if ( - typeof value.password == 'string' && - typeof value.currentPassword == 'string' && - levenshtein(value.password, value.currentPassword) > 8 && - this.classesChanged(value.password, value.currentPassword) + } + if ( + typeof value.password == 'string' + && typeof value.currentPassword == 'string' + && levenshtein(value.password, value.currentPassword) > 8 + && this.classesChanged(value.password, value.currentPassword) ) { return value; - } else { - throw new BadRequestException( - 'A minimum of four character classes must be changed when updating a password.' + - ' A minimum of eight of the total number of characters must be changed when updating a password.' - ); - } - } - - classesChanged(future: string, current: string): boolean { - const validators = [ - RegExp('[a-z]', 'g'), - RegExp('[A-Z]', 'g'), - RegExp('[0-9]', 'g'), - RegExp(/[^\w\s]/, 'g') - ]; - - for (const validator of validators) { - const currentMatch = [...current.matchAll(validator)]; - const futureMatch = [...future.matchAll(validator)]; - if (JSON.stringify(currentMatch) === JSON.stringify(futureMatch)) { - return false; - } } - return true; + throw new BadRequestException( + 'A minimum of four character classes must be changed when updating a password.' + + ' A minimum of eight of the total number of characters must be changed when updating a password.', + ); } } diff --git a/apps/backend/src/pipes/password-complexity.pipe.spec.ts b/apps/backend/src/pipes/password-complexity.pipe.spec.ts index 3c2bd9a785..b0e4412c76 100644 --- a/apps/backend/src/pipes/password-complexity.pipe.spec.ts +++ b/apps/backend/src/pipes/password-complexity.pipe.spec.ts @@ -1,16 +1,16 @@ -import {validators} from '@heimdall/password-complexity'; -import {BadRequestException} from '@nestjs/common'; -import {beforeEach, describe, expect, it} from 'vitest'; +import { validators } from '@heimdall/password-complexity'; +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it } from 'vitest'; import { CREATE_USER_DTO_TEST_OBJ, CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, UPDATE_USER_DTO_TEST_OBJ, UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD, - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, } from '../../test/constants/users-test.constant'; import { PasswordComplexityPipe, - validatePassword + validatePassword, } from './password-complexity.pipe'; describe('PasswordComplexityPipe', () => { @@ -31,7 +31,7 @@ describe('PasswordComplexityPipe', () => { }); it('should pass because the password has more than 15 characters', () => { expect(validatePassword('NotAShortPassword')).not.toContain( - validators[0].name + validators[0].name, ); }); }); @@ -39,31 +39,31 @@ describe('PasswordComplexityPipe', () => { describe('hasClasses', () => { it('should fail because the password does not contain a special character', () => { expect(validatePassword('Testpasswordwithoutspecialchar7')).toContain( - validators[1].name + validators[1].name, ); }); it('should fail because the password does not contain a number', () => { expect(validatePassword('Testpasswordwithoutanumber')).toContain( - validators[1].name + validators[1].name, ); }); it('should fail because the password does not contain an uppercase letter', () => { expect(validatePassword('testpasswordwithoutuppercase7$')).toContain( - validators[1].name + validators[1].name, ); }); it('should fail because the password does not contain a lowercase letter', () => { expect(validatePassword('TESTPASSWORDWITHOUTLOWERCASE7$')).toContain( - validators[1].name + validators[1].name, ); }); it('should pass because the password has all character classes and is at least 15 characters', () => { expect(validatePassword('Atestpassword7$')).not.toContain( - validators[1].name + validators[1].name, ); }); }); @@ -93,7 +93,7 @@ describe('PasswordComplexityPipe', () => { expect(validatePassword('1078')).toContain(validators[2].name); }); - it('should fail because there is more than 3 consecutive repeating numbers in the password', () => { + it('should fail because there is more than 3 consecutive repeating special characters in the password', () => { expect(validatePassword('$$$$')).toContain(validators[2].name); }); @@ -107,7 +107,7 @@ describe('PasswordComplexityPipe', () => { it('should pass because the password meets all the minimum requirements', () => { expect(validatePassword('aaaBBB111$$$')).not.toContain( - validators[2].name + validators[2].name, ); }); }); @@ -117,21 +117,21 @@ describe('PasswordComplexityPipe', () => { describe('Test Valid Password', () => { it('should return the same CreateUserDto', () => { expect( - passwordComplexityPipe.transform(CREATE_USER_DTO_TEST_OBJ) + passwordComplexityPipe.transform(CREATE_USER_DTO_TEST_OBJ), ).toEqual(CREATE_USER_DTO_TEST_OBJ); }); it('should return the same UpdateUserDto', () => { expect( - passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_OBJ) + passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_OBJ), ).toEqual(UPDATE_USER_DTO_TEST_OBJ); }); it('should return UpdateUserDto if password fields are null', () => { expect( passwordComplexityPipe.transform( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS - ) + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, + ), ).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS); }); }); @@ -141,22 +141,22 @@ describe('PasswordComplexityPipe', () => { it('should throw a BadRequestException for CreateUserDto with missing password', () => { expect(() => passwordComplexityPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD - ) + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + ), ).toThrowError(BadRequestException); expect(() => passwordComplexityPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD - ) + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + ), ).toThrowError('Password must be of type string'); }); it('should throw a BadRequestException for UpdateUserDto with missing password', () => { expect(() => - passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD) + passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD), ).toThrowError(BadRequestException); expect(() => - passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD) + passwordComplexityPipe.transform(UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD), ).toThrowError('Password must be of type string'); }); }); diff --git a/apps/backend/src/pipes/password-complexity.pipe.ts b/apps/backend/src/pipes/password-complexity.pipe.ts index 8d010ae11c..f456f408ef 100644 --- a/apps/backend/src/pipes/password-complexity.pipe.ts +++ b/apps/backend/src/pipes/password-complexity.pipe.ts @@ -1,14 +1,12 @@ -import {validators} from '@heimdall/password-complexity'; -import {BadRequestException, Injectable, PipeTransform} from '@nestjs/common'; +import { validators } from '@heimdall/password-complexity'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; export function validatePassword(password?: string): string[] { - if (typeof password !== 'string') { - return ['Password must be of type string']; - } else { - return validators - .filter((validator) => !validator.check(password)) - .map((validator) => validator.name); - } + return typeof password === 'string' + ? validators + .filter(validator => !validator.check(password)) + .map(validator => validator.name) + : ['Password must be of type string']; } @Injectable() @@ -21,14 +19,13 @@ export class PasswordComplexityPipe implements PipeTransform { return value; } if ( - typeof value.password === 'string' && - validatePassword(value.password).length === 0 + typeof value.password === 'string' + && validatePassword(value.password).length === 0 ) { return value; - } else { - throw new BadRequestException( - validatePassword(value.password).join(', ') - ); } + throw new BadRequestException( + validatePassword(value.password).join(', '), + ); } } diff --git a/apps/backend/src/pipes/passwords-match.pipe.spec.ts b/apps/backend/src/pipes/passwords-match.pipe.spec.ts index fd5f5f8766..a5ba70e5f6 100644 --- a/apps/backend/src/pipes/passwords-match.pipe.spec.ts +++ b/apps/backend/src/pipes/passwords-match.pipe.spec.ts @@ -1,12 +1,12 @@ -import {BadRequestException} from '@nestjs/common'; -import {beforeEach, describe, expect, it} from 'vitest'; +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it } from 'vitest'; import { CREATE_USER_DTO_TEST_OBJ, CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS, UPDATE_USER_DTO_TEST_OBJ, - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, } from '../../test/constants/users-test.constant'; -import {PasswordsMatchPipe} from './passwords-match.pipe'; +import { PasswordsMatchPipe } from './passwords-match.pipe'; describe('PasswordsMatchPipe', () => { let passwordsMatchPipe: PasswordsMatchPipe; @@ -23,19 +23,19 @@ describe('PasswordsMatchPipe', () => { describe('Test Matching Passwords', () => { it('should return the same CreateUserDto', () => { expect(passwordsMatchPipe.transform(CREATE_USER_DTO_TEST_OBJ)).toEqual( - CREATE_USER_DTO_TEST_OBJ + CREATE_USER_DTO_TEST_OBJ, ); }); it('should return the same UpdateUserDto', () => { expect(passwordsMatchPipe.transform(UPDATE_USER_DTO_TEST_OBJ)).toEqual( - UPDATE_USER_DTO_TEST_OBJ + UPDATE_USER_DTO_TEST_OBJ, ); }); it('should return UpdateUserDto if password fields are null', () => { expect( - passwordsMatchPipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS) + passwordsMatchPipe.transform(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS), ).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS); }); }); @@ -45,13 +45,13 @@ describe('PasswordsMatchPipe', () => { it('should throw a Bad Request Exception', () => { expect(() => passwordsMatchPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS - ) + CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS, + ), ).toThrowError(BadRequestException); expect(() => passwordsMatchPipe.transform( - CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS - ) + CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS, + ), ).toThrowError('Passwords do not match'); }); }); diff --git a/apps/backend/src/pipes/passwords-match.pipe.ts b/apps/backend/src/pipes/passwords-match.pipe.ts index 68eced9982..4521cbfbbf 100644 --- a/apps/backend/src/pipes/passwords-match.pipe.ts +++ b/apps/backend/src/pipes/passwords-match.pipe.ts @@ -1,4 +1,4 @@ -import {BadRequestException, Injectable, PipeTransform} from '@nestjs/common'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; @Injectable() export class PasswordsMatchPipe implements PipeTransform { @@ -8,16 +8,15 @@ export class PasswordsMatchPipe implements PipeTransform { passwordConfirmation: string | undefined; }): Record { if ( - value.currentPassword != null && - value.password == null && - value.passwordConfirmation == null + value.currentPassword != null + && value.password == null + && value.passwordConfirmation == null ) { return value; } if (value.password === value.passwordConfirmation) { return value; - } else { - throw new BadRequestException('Passwords do not match'); } + throw new BadRequestException('Passwords do not match'); } } diff --git a/apps/backend/src/statistics/dto/statistics.dto.ts b/apps/backend/src/statistics/dto/statistics.dto.ts index ea602fbfe7..6cc52bdcd3 100644 --- a/apps/backend/src/statistics/dto/statistics.dto.ts +++ b/apps/backend/src/statistics/dto/statistics.dto.ts @@ -1,11 +1,11 @@ -import {IStatistics} from '@heimdall/common/interfaces'; +import type { IStatistics } from '@heimdall/common/interfaces'; export class StatisticsDTO implements IStatistics { readonly apiKeyCount: number; - readonly userCount: number; readonly evaluationCount: number; readonly evaluationTagCount: number; readonly groupCount: number; + readonly userCount: number; constructor(statistics: StatisticsDTO) { this.apiKeyCount = statistics.apiKeyCount; diff --git a/apps/backend/src/statistics/statistics.controller.ts b/apps/backend/src/statistics/statistics.controller.ts index e5d67dfbbd..0b13edf547 100644 --- a/apps/backend/src/statistics/statistics.controller.ts +++ b/apps/backend/src/statistics/statistics.controller.ts @@ -1,31 +1,31 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Controller, Get, Request, UseGuards, - UseInterceptors + UseInterceptors, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {User} from '../users/user.model'; -import {StatisticsDTO} from './dto/statistics.dto'; -import {StatisticsService} from './statistics.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { User } from '../users/user.model'; +import { StatisticsDTO } from './dto/statistics.dto'; +import { StatisticsService } from './statistics.service'; @Controller('statistics') @UseInterceptors(LoggingInterceptor) export class StatisticsController { constructor( private readonly statisticsService: StatisticsService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} @Get() @UseGuards(JwtAuthGuard) async getHeimdallStatistics( - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); ForbiddenError.from(abac).throwUnlessCan(Action.ViewStatistics, User); diff --git a/apps/backend/src/statistics/statistics.module.ts b/apps/backend/src/statistics/statistics.module.ts index 6e92e8fca9..c83fe92213 100644 --- a/apps/backend/src/statistics/statistics.module.ts +++ b/apps/backend/src/statistics/statistics.module.ts @@ -1,31 +1,34 @@ -import {Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {ApiKey} from '../apikeys/apikey.model'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {User} from '../users/user.model'; -import {UsersService} from '../users/users.service'; -import {StatisticsController} from './statistics.controller'; -import {StatisticsService} from './statistics.service'; +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ApiKey } from '../apikeys/apikey.model'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { EvaluationTagsService } from '../evaluation-tags/evaluation-tags.service'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { User } from '../users/user.model'; +import { UsersService } from '../users/users.service'; +import { StatisticsController } from './statistics.controller'; +import { StatisticsService } from './statistics.service'; @Module({ + controllers: [StatisticsController], imports: [ SequelizeModule.forFeature([ ApiKey, Evaluation, EvaluationTag, User, - Group + Group, ]), - ConfigModule + ConfigModule, + CryptoModule, ], providers: [ StatisticsService, @@ -35,8 +38,7 @@ import {StatisticsService} from './statistics.service'; EvaluationsService, EvaluationTagsService, UsersService, - GroupsService + GroupsService, ], - controllers: [StatisticsController] }) export class StatisticsModule {} diff --git a/apps/backend/src/statistics/statistics.service.ts b/apps/backend/src/statistics/statistics.service.ts index 1038aab98d..d4c42dbdd9 100644 --- a/apps/backend/src/statistics/statistics.service.ts +++ b/apps/backend/src/statistics/statistics.service.ts @@ -1,10 +1,10 @@ -import {Injectable} from '@nestjs/common'; -import {ApiKeyService} from '../apikeys/apikey.service'; -import {EvaluationTagsService} from '../evaluation-tags/evaluation-tags.service'; -import {EvaluationsService} from '../evaluations/evaluations.service'; -import {GroupsService} from '../groups/groups.service'; -import {UsersService} from '../users/users.service'; -import {StatisticsDTO} from './dto/statistics.dto'; +import { Injectable } from '@nestjs/common'; +import { ApiKeyService } from '../apikeys/apikey.service'; +import { EvaluationTagsService } from '../evaluation-tags/evaluation-tags.service'; +import { EvaluationsService } from '../evaluations/evaluations.service'; +import { GroupsService } from '../groups/groups.service'; +import { UsersService } from '../users/users.service'; +import { StatisticsDTO } from './dto/statistics.dto'; @Injectable() export class StatisticsService { @@ -13,16 +13,16 @@ export class StatisticsService { private readonly evaluationsService: EvaluationsService, private readonly evaluationTagsService: EvaluationTagsService, private readonly groupsService: GroupsService, - private readonly usersService: UsersService + private readonly usersService: UsersService, ) {} async getHeimdallStatistics(): Promise { return new StatisticsDTO({ apiKeyCount: await this.apiKeyService.count(), - userCount: await this.usersService.count(), evaluationCount: await this.evaluationsService.count(), evaluationTagCount: await this.evaluationTagsService.count(), - groupCount: await this.groupsService.count() + groupCount: await this.groupsService.count(), + userCount: await this.usersService.count(), }); } } diff --git a/apps/backend/src/tenable/tenable-filtering-agent.spec.ts b/apps/backend/src/tenable/tenable-filtering-agent.spec.ts new file mode 100644 index 0000000000..a7fd96c6f1 --- /dev/null +++ b/apps/backend/src/tenable/tenable-filtering-agent.spec.ts @@ -0,0 +1,340 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { INestApplication } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import axios from 'axios'; +import type { Request } from 'express'; +import session from 'express-session'; +import { sign } from 'jsonwebtoken'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { JwtStrategy } from '../authn/jwt.strategy'; +import { ConfigService } from '../config/config.service'; +import { UsersService } from '../users/users.service'; +import { + createTenableAgents, + isBlockedAddress, +} from './tenable-filtering-agent'; +import { TenableController } from './tenable.controller'; +import { TenableService } from './tenable.service'; + +// heimdall2-86f6.13 — the address filter, the THIRD of three SSRF controls. +// +// heimdall2-86f6.6 validates the NAME. The socket connects to whatever that +// name RESOLVES to at connect time, so an attacker who can point a permitted +// name at an internal address defeats the allowlist without ever violating it. +// +// THE CONTROL RUNS INSIDE THE CONNECTION'S OWN DNS LOOKUP — the community +// pattern (azu/request-filtering-agent), which Node supports via the documented +// `lookup` option on socket.connect. Validating there means the address checked +// IS the address connected to, so there is no window between the check and the +// connection for the answer to change. +// +// THE SEAM NEEDS NO DNS MOCK. `localhost` is a NAME that resolves through the +// ordinary resolver to a loopback address, so it exercises resolve-then-connect +// end to end with no external DNS dependency and no vi.mock. The hit counter on +// the target server is what discriminates: either the socket arrived or it did +// not. A literal `127.0.0.1` host exercises the other branch, where no DNS +// lookup happens at all. + +const TEST_JWT_SECRET = 'tenable-filter-spec-jwt-secret'; +const TEST_USER = { + email: 'tenable-filter-spec@example.com', + id: '1', + jwtSecret: 'tenable-filter-spec-user-secret', + role: 'user', +}; + +// Served by the target if anything ever reaches it. +const LOOT = 'INTERNAL-SERVICE-REACHED'; + +describe('isBlockedAddress', () => { + // One assertion per range, because a classifier that happens to catch + // 127.0.0.1 tells you nothing about whether it catches fe80::/10. + it.each([ + ['10.0.0.5', 'IPv4 private 10/8'], + ['172.16.0.5', 'IPv4 private 172.16/12'], + ['192.168.1.5', 'IPv4 private 192.168/16'], + ['127.0.0.1', 'IPv4 loopback 127/8'], + ['169.254.1.1', 'IPv4 link-local 169.254/16'], + ['169.254.169.254', 'the cloud metadata address'], + ['::1', 'IPv6 loopback'], + ['fe80::1', 'IPv6 link-local fe80::/10'], + ['fc00::1', 'IPv6 unique-local fc00::/7'], + ])('blocks %s (%s)', (address) => { + expect(isBlockedAddress(address)).toBe(true); + }); + + it('blocks the IPv4-mapped IPv6 form of a blocked address', () => { + expect.assertions(2); + + // The half that gets forgotten: a check reasoning only about IPv6 notation + // lets ::ffff:169.254.169.254 through to the metadata service. + expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true); + expect(isBlockedAddress('::ffff:127.0.0.1')).toBe(true); + }); + + it('allows an ordinary routable address', () => { + expect.assertions(2); + + // The control must not refuse everything. Without this, a classifier that + // returns true unconditionally would pass every test above. + expect(isBlockedAddress('93.184.216.34')).toBe(false); + expect(isBlockedAddress('2606:2800:220:1:248:1893:25c8:1946')).toBe(false); + }); + + it('refuses a value that is not an IP address at all', () => { + expect.assertions(1); + + // Fail closed: something that cannot be classified is not proven safe. + expect(isBlockedAddress('not-an-address')).toBe(true); + }); +}); + +describe('the filtering agent', () => { + let hits: number; + let target: http.Server; + let targetPort: number; + + beforeAll(async () => { + hits = 0; + target = http.createServer((_request, response) => { + hits += 1; + response.writeHead(200, { 'Content-Type': 'text/plain' }); + response.end(LOOT); + }); + await new Promise((resolve) => { + target.listen(0, '127.0.0.1', () => { + targetPort = (target.address() as AddressInfo).port; + resolve(); + }); + }); + }); + + afterAll(async () => { + await new Promise((resolve) => { + target.close(() => { + resolve(); + }); + }); + }); + + beforeEach(() => { + hits = 0; + }); + + it('never connects to a NAME that resolves to a loopback address', async () => { + expect.assertions(2); + const { httpAgent, httpsAgent } = createTenableAgents({ + allowPrivateAddresses: false, + }); + + // `localhost` is a name, not a literal, so this goes through DNS resolution + // exactly as an attacker-controlled rebinding host would. + await expect( + axios.get(`http://localhost:${String(targetPort)}/`, { + httpAgent, + httpsAgent, + }), + ).rejects.toThrow(); + + // The load-bearing assertion: the socket never arrived. + expect(hits).toBe(0); + }); + + it('never connects to a literal blocked IP, where no DNS lookup happens', async () => { + expect.assertions(2); + const { httpAgent, httpsAgent } = createTenableAgents({ + allowPrivateAddresses: false, + }); + + await expect( + axios.get(`http://127.0.0.1:${String(targetPort)}/`, { + httpAgent, + httpsAgent, + }), + ).rejects.toThrow(); + + expect(hits).toBe(0); + }); + + it('connects when the operator has opted in to private addresses', async () => { + expect.assertions(2); + const { httpAgent, httpsAgent } = createTenableAgents({ + allowPrivateAddresses: true, + }); + + // Proves two things at once: the opt-out works, and the refusals above are + // caused by the filter rather than by anything else in this harness. + const response = await axios.get( + `http://127.0.0.1:${String(targetPort)}/`, + { httpAgent, httpsAgent }, + ); + + expect(response.data).toBe(LOOT); + expect(hits).toBe(1); + }); +}); + +describe('the Tenable endpoints refuse a permitted host that resolves into blocked space', () => { + let app: INestApplication; + let appUrl: string; + let hits: number; + let module: TestingModule; + let target: http.Server; + let targetOrigin: string; + + beforeAll(async () => { + hits = 0; + target = http.createServer((_request, response) => { + hits += 1; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ response: { username: 'looted' } })); + }); + await new Promise((resolve) => { + target.listen(0, '127.0.0.1', () => { + const { port } = target.address() as AddressInfo; + // A NAME on the allowlist that resolves to loopback — the rebinding + // shape. The allowlist will say yes; the address filter must say no. + targetOrigin = `http://localhost:${String(port)}`; + resolve(); + }); + }); + + module = await Test.createTestingModule({ + controllers: [TenableController], + imports: [PassportModule], + providers: [ + TenableService, + JwtStrategy, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'JWT_SECRET') return TEST_JWT_SECRET; + return; + }, + getTenableAdditionalHostUrls: () => '', + // Configured, therefore allowlisted. This card is about what + // happens AFTER the name check has already passed. + getTenableHostUrl: () => targetOrigin, + isTenablePrivateAddressAllowed: () => false, + }, + }, + { + provide: UsersService, + useValue: { findById: () => Promise.resolve(TEST_USER) }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + app.use( + session({ + resave: false, + saveUninitialized: false, + secret: 'tenable-filter-spec-session-secret', + }), + ); + await app.init(); + await app.listen(0); + appUrl = `http://127.0.0.1:${String((app.getHttpServer().address() as AddressInfo).port)}`; + }); + + afterAll(async () => { + await app.close(); + await new Promise((resolve) => { + target.close(() => { + resolve(); + }); + }); + }); + + beforeEach(() => { + hits = 0; + }); + + it('refuses the login probe and never reaches the internal service', async () => { + expect.assertions(3); + const token = sign( + { email: TEST_USER.email, role: TEST_USER.role, sub: TEST_USER.id }, + TEST_JWT_SECRET + TEST_USER.jwtSecret, + { expiresIn: '1h' }, + ); + + const response = await fetch(`${appUrl}/api/tenable/login`, { + body: JSON.stringify({ + accesskey: 'irrelevant', + host_url: targetOrigin, + secretkey: 'irrelevant', + }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const body = (await response.json()) as { code?: string }; + + // THE VULNERABILITY, demonstrated: today the allowlist passes on the name + // and the server connects to the loopback service anyway. + expect(hits).toBe(0); + expect(body.code).toBe('UPSTREAM_ADDRESS_REFUSED'); + // Distinguishable from HOST_NOT_ALLOWED and UPSTREAM_REDIRECT_REFUSED. + expect(response.status).toBe(502); + }); + + it('refuses the proxy path too, which builds its own axios instance', async () => { + expect.assertions(2); + // Asserted separately from the login probe on purpose: the proxy configures + // its own axios instance in tenable.service.ts, so filtering one does not + // filter the other. + const service = new TenableService({ + isTenablePrivateAddressAllowed: () => false, + } as unknown as ConfigService); + const request = { + body: {}, + get: () => 'application/json', + method: 'GET', + originalUrl: '/api/tenable/rest/scanResult', + query: {}, + } as unknown as Request; + + await expect( + service.proxyRequest(request, { + accesskey: 'irrelevant', + host_url: targetOrigin, + secretkey: 'irrelevant', + }), + ).rejects.toThrow(); + + expect(hits).toBe(0); + }); + + it('does not disclose the resolved address to the caller', async () => { + expect.assertions(1); + const token = sign( + { email: TEST_USER.email, role: TEST_USER.role, sub: TEST_USER.id }, + TEST_JWT_SECRET + TEST_USER.jwtSecret, + { expiresIn: '1h' }, + ); + + const response = await fetch(`${appUrl}/api/tenable/login`, { + body: JSON.stringify({ + accesskey: 'irrelevant', + host_url: targetOrigin, + secretkey: 'irrelevant', + }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const body = await response.text(); + + // Reflecting what a name resolved to turns this endpoint into a DNS oracle. + expect(body).not.toContain('127.0.0.1'); + }); +}); diff --git a/apps/backend/src/tenable/tenable-filtering-agent.ts b/apps/backend/src/tenable/tenable-filtering-agent.ts new file mode 100644 index 0000000000..c7cee3cf13 --- /dev/null +++ b/apps/backend/src/tenable/tenable-filtering-agent.ts @@ -0,0 +1,194 @@ +import { lookup as systemLookup } from 'node:dns'; +import type { ClientRequestArgs } from 'node:http'; +import { Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; +import type { LookupFunction } from 'node:net'; +import { BlockList, isIPv4, isIPv6, Socket } from 'node:net'; +import type { Duplex } from 'node:stream'; + +// The THIRD of three independent controls against server-side request forgery +// on the Tenable proxy. This one governs where a connection may LAND. +// +// heimdall2-86f6.6 validates the destination NAME, and heimdall2-86f6.12 stops +// the response redirecting the request elsewhere. Neither can say anything about +// what a permitted name RESOLVES to: an attacker who can point an allowlisted +// name at an internal address defeats the allowlist without ever violating it. +// +// WHY THE CHECK LIVES IN THE CONNECTION'S OWN LOOKUP, and not in a resolve-then- +// request step before it. Resolving separately and then handing the NAME to the +// HTTP client means the client resolves again when it connects, and the second +// answer can differ from the one that was validated — the DNS rebinding window. +// Node lets a connection supply its own resolver (`lookup` on socket.connect, +// documented as "Custom lookup function. Default: dns.lookup()"), so validating +// inside that callback makes the address that was checked the same address the +// socket uses. This is the pattern azu/request-filtering-agent implements by +// overriding Agent#createConnection, read from its source rather than recalled. +// +// RESIDUAL EXPOSURE, stated precisely rather than as a generic caveat: there is +// no second resolution to disagree with the first, so the classic rebinding +// window is closed for these agents. What remains is that a name may resolve to +// a permitted address on one connection and a blocked one on the next — each +// connection is judged on its own resolution, which is the correct behaviour, +// not a gap. Connection reuse (keep-alive) means a socket validated once stays +// open; that is bounded by the agent's socket lifetime, and is the reason +// `keepAlive` is left at its default rather than being turned on here. + +export const ADDRESS_REFUSED_CODE = 'UPSTREAM_ADDRESS_REFUSED'; + +// Ranges an outbound Tenable connection must never land in. `BlockList` parses +// addresses properly and understands CIDR, so no dotted-quad regex or hand- +// rolled mask arithmetic appears anywhere in this module. +function buildBlockedRanges(): BlockList { + const blocked = new BlockList(); + blocked.addSubnet('0.0.0.0', 8, 'ipv4'); // "this network", reaches localhost on some stacks + blocked.addSubnet('10.0.0.0', 8, 'ipv4'); // RFC 1918 private + blocked.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback + blocked.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local, contains 169.254.169.254 + blocked.addSubnet('172.16.0.0', 12, 'ipv4'); // RFC 1918 private + blocked.addSubnet('192.168.0.0', 16, 'ipv4'); // RFC 1918 private + blocked.addAddress('::1', 'ipv6'); // loopback + blocked.addSubnet('fc00::', 7, 'ipv6'); // unique-local + blocked.addSubnet('fe80::', 10, 'ipv6'); // link-local + return blocked; +} + +const BLOCKED = buildBlockedRanges(); + +/** + * Classify a single resolved address. + * + * Fails CLOSED: a value that cannot be parsed as an address is treated as + * blocked, because "unclassifiable" is not the same as "proven safe". + */ +export function isBlockedAddress(address: string): boolean { + if (isIPv4(address)) { + return BLOCKED.check(address, 'ipv4'); + } + if (isIPv6(address)) { + // BlockList maps IPv4-mapped IPv6 (::ffff:a.b.c.d) onto the IPv4 rules + // above, so the mapped form of a blocked address is blocked too. + return BLOCKED.check(address, 'ipv6'); + } + return true; +} + +function refusal(): NodeJS.ErrnoException { + // Deliberately does NOT name the address. It is attacker-influenced, and + // reflecting it would make this endpoint a DNS oracle — the same reasoning as + // the allowlist's rejection reasons in tenable-host-allowlist.ts. + const error: NodeJS.ErrnoException = new Error( + 'The Tenable host resolves to an address this server is not permitted to contact', + ); + error.code = ADDRESS_REFUSED_CODE; + return error; +} + +/** + * A `lookup` implementation that resolves normally and then refuses to hand + * back any address in a blocked range. Callers see a failed connection, never a + * connection to somewhere they did not intend. + * + * Typed as node's own LookupFunction so the shape is the platform's, not ours. + */ +function makeFilteringLookup(allowPrivateAddresses: boolean): LookupFunction { + return (hostname, options, callback) => { + systemLookup(hostname, options, (error, address, family) => { + if (error) { + callback(error, address, family); + return; + } + if (allowPrivateAddresses) { + callback(null, address, family); + return; + } + // `all: true` yields an array of entries; the single-address form yields + // a string. Both shapes must be checked, or one of them is unguarded. + const candidates = Array.isArray(address) + ? address.map((entry) => entry.address) + : [address]; + if (candidates.some((candidate) => isBlockedAddress(candidate))) { + callback(refusal(), address, family); + return; + } + callback(null, address, family); + }); + }; +} + +function erroringSocket(error: NodeJS.ErrnoException): Socket { + const socket = new Socket(); + // Deferred so the caller can attach its 'error' handler first, which + // http.ClientRequest does synchronously after createConnection returns. A + // microtask is late enough for that and is the repo's preferred primitive. + queueMicrotask(() => { + socket.destroy(error); + }); + return socket; +} + +function blockedLiteralHost( + host: string | undefined, + allowPrivateAddresses: boolean, +): boolean { + if (allowPrivateAddresses || host === undefined) { + return false; + } + // A host given as a literal address never reaches the resolver, so the + // `lookup` hook above would never see it. This is the other branch. + if (!isIPv4(host) && !isIPv6(host)) { + return false; + } + return isBlockedAddress(host); +} + +export class TenableFilteringHttpAgent extends HttpAgent { + constructor(private readonly allowPrivateAddresses: boolean) { + super(); + } + + createConnection( + options: ClientRequestArgs, + callback?: (error: Error | null, stream: Duplex) => void, + ): Duplex | null | undefined { + if (blockedLiteralHost(options.host, this.allowPrivateAddresses)) { + return erroringSocket(refusal()); + } + return super.createConnection( + { ...options, lookup: makeFilteringLookup(this.allowPrivateAddresses) }, + callback, + ); + } +} + +export class TenableFilteringHttpsAgent extends HttpsAgent { + constructor(private readonly allowPrivateAddresses: boolean) { + super(); + } + + createConnection( + options: ClientRequestArgs, + callback?: (error: Error | null, stream: Duplex) => void, + ): Duplex | null | undefined { + if (blockedLiteralHost(options.host, this.allowPrivateAddresses)) { + return erroringSocket(refusal()); + } + return super.createConnection( + { ...options, lookup: makeFilteringLookup(this.allowPrivateAddresses) }, + callback, + ); + } +} + +/** + * Both agents, because axios selects between httpAgent and httpsAgent by the + * target's protocol — supplying only one silently leaves the other scheme + * unfiltered. + */ +export function createTenableAgents(options: { + allowPrivateAddresses: boolean; +}): { httpAgent: HttpAgent; httpsAgent: HttpsAgent } { + return { + httpAgent: new TenableFilteringHttpAgent(options.allowPrivateAddresses), + httpsAgent: new TenableFilteringHttpsAgent(options.allowPrivateAddresses), + }; +} diff --git a/apps/backend/src/tenable/tenable-host-allowlist.spec.ts b/apps/backend/src/tenable/tenable-host-allowlist.spec.ts new file mode 100644 index 0000000000..f10320977e --- /dev/null +++ b/apps/backend/src/tenable/tenable-host-allowlist.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + buildAllowedOrigins, + checkTenableHost, +} from './tenable-host-allowlist'; + +const CONFIGURED = 'https://tenable.example.com'; + +describe('buildAllowedOrigins', () => { + it('normalises the configured host to an origin', () => { + expect.assertions(1); + + expect(buildAllowedOrigins(CONFIGURED, '')).toStrictEqual([ + 'https://tenable.example.com', + ]); + }); + + it('accepts additional hosts separated by commas or whitespace', () => { + expect.assertions(1); + + expect( + buildAllowedOrigins(CONFIGURED, 'https://second.example.com https://third.example.com,https://fourth.example.com'), + ).toStrictEqual([ + 'https://tenable.example.com', + 'https://second.example.com', + 'https://third.example.com', + 'https://fourth.example.com', + ]); + }); + + it('drops an unparseable additional host rather than throwing', () => { + expect.assertions(1); + + // One malformed extra entry must not take the whole integration offline. + expect( + buildAllowedOrigins(CONFIGURED, 'not a url,https://second.example.com'), + ).toStrictEqual([ + 'https://tenable.example.com', + 'https://second.example.com', + ]); + }); + + it('is empty when nothing is configured', () => { + expect.assertions(1); + + expect(buildAllowedOrigins('', '')).toStrictEqual([]); + }); + + it('refuses to build an entry from a non-http scheme', () => { + expect.assertions(2); + + // The protocol restriction has to be asserted HERE, not on the request + // side: an `ftp://` or `file://` REQUEST is already rejected by the origin + // comparison, so a request-side test passes with or without the protocol + // check and proves nothing. Mutation testing caught exactly that — the + // request-side test below survived deleting this restriction. What the + // restriction genuinely prevents is a non-http scheme becoming a usable + // ALLOWLIST ENTRY. + expect(buildAllowedOrigins('ftp://tenable.example.com', '')).toStrictEqual( + [], + ); + expect(buildAllowedOrigins('file:///etc/passwd', '')).toStrictEqual([]); + }); +}); + +describe('checkTenableHost', () => { + const allowed = buildAllowedOrigins(CONFIGURED, ''); + + it('permits the configured host', () => { + expect.assertions(1); + + expect(checkTenableHost(CONFIGURED, allowed)).toStrictEqual({ + kind: 'allowed', + origin: 'https://tenable.example.com', + }); + }); + + it('permits the same host with the default port made explicit', () => { + expect.assertions(1); + + // AuthStep.vue appends `:443` client-side, so the server receives this form + // while the operator configured the bare host. They are the same origin. + expect( + checkTenableHost('https://tenable.example.com:443/', allowed), + ).toStrictEqual({ kind: 'allowed', origin: 'https://tenable.example.com' }); + }); + + it('rejects a host that merely has the allowed host as a prefix', () => { + expect.assertions(2); + + const decision = checkTenableHost( + 'https://tenable.example.com.attacker.test', + allowed, + ); + + // The suffix attack a substring or startsWith comparison would admit. + expect(decision.kind).toBe('rejected'); + expect(decision).not.toHaveProperty('origin'); + }); + + it('rejects a different host entirely', () => { + expect.assertions(1); + + expect( + checkTenableHost('http://169.254.169.254/latest/meta-data/', allowed) + .kind, + ).toBe('rejected'); + }); + + it('rejects a non-http protocol even when the host matches', () => { + expect.assertions(1); + + expect( + checkTenableHost('file://tenable.example.com/etc/passwd', allowed).kind, + ).toBe('rejected'); + }); + + it('rejects a value that cannot be parsed as a URL', () => { + expect.assertions(1); + + expect(checkTenableHost('tenable.example.com', allowed).kind).toBe( + 'rejected', + ); + }); + + it('refuses everything when no host is configured', () => { + expect.assertions(2); + + const decision = checkTenableHost(CONFIGURED, []); + + // An empty allowlist means refuse, never "allow anything". + expect(decision.kind).toBe('rejected'); + expect(decision).toStrictEqual({ + kind: 'rejected', + reason: 'No Tenable host is configured on this server', + }); + }); + + it('does not echo the rejected host back in the reason', () => { + expect.assertions(1); + + const decision = checkTenableHost('https://attacker.test/probe', allowed); + + // Reflecting the input invites using this endpoint as a probe oracle. + expect( + 'reason' in decision ? decision.reason : '', + ).not.toContain('attacker.test'); + }); +}); diff --git a/apps/backend/src/tenable/tenable-host-allowlist.ts b/apps/backend/src/tenable/tenable-host-allowlist.ts new file mode 100644 index 0000000000..6132fbd495 --- /dev/null +++ b/apps/backend/src/tenable/tenable-host-allowlist.ts @@ -0,0 +1,112 @@ +// The FIRST of three independent controls against server-side request forgery +// on the Tenable proxy. This one governs where a request may be SENT, by name. +// +// It is NOT sufficient alone. A permitted name still resolves to whatever its +// DNS says at request time, and a permitted host can still answer 302 and move +// the request somewhere else. Those are heimdall2-86f6.13 and heimdall2-86f6.12. +// +// Comparison is on WHATWG URL origins rather than raw strings, which OWASP's +// SSRF Prevention Cheat Sheet requires: a substring or prefix comparison admits +// `https://tenable.example.com.attacker.test` against an allowlist containing +// `https://tenable.example.com`. Using `origin` also normalises the two forms +// the server actually receives — AuthStep.vue appends `https://` and `:443` +// client-side, and `origin` drops a default port, lowercases the host, and +// discards any path, so `https://host` and `https://host:443/` compare equal. + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); + +// Operators supply additional hosts as one delimited string, because +// app-config.ts reads every setting as a single string (see getTenableHostUrl). +const ENTRY_SEPARATOR = /[\s,]+/v; + +// Discriminated on a STRING literal rather than a boolean `allowed` flag. This +// repo sets neither `strict` nor `strictNullChecks`, and without them TypeScript +// will not narrow a union through truthiness — `if (!decision.allowed)` compiles +// under vitest (transpile-only) and then fails `nest build` on `decision.reason`. +// A string tag narrows correctly regardless of that setting, and unlike the +// `decision.allowed === false` form it needs no comparison against a boolean +// literal, so nothing here is one "simplification" away from breaking the build. +export type HostDecision = + | { kind: 'allowed'; origin: string } + | { kind: 'rejected'; reason: string }; + +/** + * Normalise one configured or requested host to its origin. + * Returns undefined when the value is absent, unparseable, or uses a protocol + * that is not http/https — all three are "not usable as an allowlist entry". + */ +function toOrigin(value: string): string | undefined { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + // Not a parseable absolute URL. Deliberately not "fixed up" by prepending a + // scheme: guessing what an operator or caller meant is how a validator ends + // up admitting something neither of them intended. + return undefined; + } + return ALLOWED_PROTOCOLS.has(parsed.protocol) ? parsed.origin : undefined; +} + +/** + * Build the set of permitted origins from the configured Tenable host plus any + * additional hosts. Unparseable entries are dropped rather than throwing: one + * malformed extra host must not take the whole integration offline, and the + * empty-allowlist case is itself a refusal (see checkTenableHost). + */ +export function buildAllowedOrigins( + configuredHost: string, + additionalHosts: string, +): string[] { + const candidates = [configuredHost, ...additionalHosts.split(ENTRY_SEPARATOR)]; + const origins = new Set(); + for (const candidate of candidates) { + const origin = toOrigin(candidate); + if (origin !== undefined) { + origins.add(origin); + } + } + return [...origins]; +} + +/** + * Decide whether a requested host may be contacted. + * + * Callers MUST act on a rejected decision before any outbound request — + * rejecting after the request has already been made still performs the forgery + * and still returns the upstream response to the caller. + */ +export function checkTenableHost( + requestedHost: string, + allowedOrigins: readonly string[], +): HostDecision { + if (allowedOrigins.length === 0) { + // No configured host means there is nothing legitimate to talk to, so the + // safe reading is "refuse", never "allow anything". + return { + kind: 'rejected', + reason: 'No Tenable host is configured on this server', + }; + } + const origin = toOrigin(requestedHost); + if (origin === undefined) { + return { + kind: 'rejected', + reason: 'The Tenable host must be a valid http or https URL', + }; + } + if (!allowedOrigins.includes(origin)) { + // The rejected origin is deliberately NOT echoed back: the caller already + // knows what they sent, and reflecting it invites using this endpoint as a + // probe oracle. + return { + kind: 'rejected', + reason: 'The requested Tenable host is not permitted by this server', + }; + } + return { kind: 'allowed', origin }; +} diff --git a/apps/backend/src/tenable/tenable-no-redirect.spec.ts b/apps/backend/src/tenable/tenable-no-redirect.spec.ts new file mode 100644 index 0000000000..54b54d80de --- /dev/null +++ b/apps/backend/src/tenable/tenable-no-redirect.spec.ts @@ -0,0 +1,239 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { INestApplication } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import type { Request } from 'express'; +import session from 'express-session'; +import { sign } from 'jsonwebtoken'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { JwtStrategy } from '../authn/jwt.strategy'; +import { ConfigService } from '../config/config.service'; +import { UsersService } from '../users/users.service'; +import { TenableController } from './tenable.controller'; +import { TenableService } from './tenable.service'; + +// heimdall2-86f6.12 — the redirect control, the SECOND of three independent +// SSRF controls on the Tenable proxy. +// +// The name allowlist (heimdall2-86f6.6) governs where a request may be SENT. It +// says nothing about where the RESPONSE may send it next: axios follows +// redirects by default, so an allowlisted host answering +// `302 Location: http://169.254.169.254/...` walks the server straight past the +// allowlist that just approved it. That is the bypass this spec pins. +// +// THE SEAM IS TWO REAL LOCAL SERVERS, not a mock and not an inspection of the +// axios config object. This contract is about what the HTTP client DOES. A test +// asserting that `maxRedirects: 0` appears in a config literal is a FORM check — +// it passes whether or not the behaviour holds, and it would keep passing if the +// option were later overridden, renamed by an axios major, or shadowed by a +// per-request config. Counting hits on the redirect TARGET cannot pass for the +// wrong reason: either the second server was contacted or it was not. + +// The body the redirect target serves. If this string ever reaches a caller, the +// redirect was followed and the control has failed. +const LOOT = 'REDIRECT-TARGET-REACHED'; + +const TEST_JWT_SECRET = 'tenable-redirect-spec-jwt-secret'; +const TEST_USER = { + email: 'tenable-redirect-spec@example.com', + id: '1', + jwtSecret: 'tenable-redirect-spec-user-secret', + role: 'user', +}; + +function listen(server: http.Server): Promise { + return new Promise((resolve) => { + // Port 0 = ephemeral, matching the controller spec, so this never collides + // with a dev server or with a parallel test file. + server.listen(0, '127.0.0.1', () => { + const address = server.address() as AddressInfo; + resolve(`http://127.0.0.1:${String(address.port)}`); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); +} + +describe('outbound Tenable requests do not follow redirects', () => { + let app: INestApplication; + let appUrl: string; + let module: TestingModule; + let redirector: http.Server; + let redirectorUrl: string; + let target: http.Server; + let targetHits: number; + let targetUrl: string; + + beforeAll(async () => { + // The redirect TARGET — stands in for the address an attacker would steer + // the server toward. It records every request it receives. + targetHits = 0; + target = http.createServer((_request, response) => { + targetHits += 1; + response.writeHead(200, { 'Content-Type': 'text/plain' }); + response.end(LOOT); + }); + targetUrl = await listen(target); + + // The allowlisted host. It is permitted by name, and it answers every + // request with a redirect elsewhere. + redirector = http.createServer((_request, response) => { + response.writeHead(302, { Location: `${targetUrl}/looted` }); + response.end(); + }); + redirectorUrl = await listen(redirector); + + module = await Test.createTestingModule({ + controllers: [TenableController], + imports: [PassportModule], + providers: [ + TenableService, + JwtStrategy, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'JWT_SECRET') return TEST_JWT_SECRET; + return; + }, + getTenableAdditionalHostUrls: () => '', + // The redirector is ON the allowlist. That is the point: this card + // is about the control that still has to hold once the name check + // has already said yes. + getTenableHostUrl: () => redirectorUrl, + // DELIBERATE OPT-OUT, not an accident. Both servers in this spec + // are on 127.0.0.1, which the address filter (heimdall2-86f6.13) + // refuses by default. This spec measures REDIRECT behaviour, so it + // opts out of address filtering to keep the two controls + // independently testable — the address filter has its own spec. + isTenablePrivateAddressAllowed: () => true, + }, + }, + { + provide: UsersService, + useValue: { findById: () => Promise.resolve(TEST_USER) }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + app.use( + session({ + resave: false, + saveUninitialized: false, + secret: 'tenable-redirect-spec-session-secret', + }), + ); + await app.init(); + await app.listen(0); + const address = app.getHttpServer().address() as AddressInfo; + appUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + afterAll(async () => { + await app.close(); + await close(redirector); + await close(target); + }); + + beforeEach(() => { + targetHits = 0; + }); + + it('does not follow a 302 from the login probe', async () => { + expect.assertions(3); + const token = sign( + { email: TEST_USER.email, role: TEST_USER.role, sub: TEST_USER.id }, + TEST_JWT_SECRET + TEST_USER.jwtSecret, + { expiresIn: '1h' }, + ); + + const response = await fetch(`${appUrl}/api/tenable/login`, { + body: JSON.stringify({ + accesskey: 'irrelevant', + host_url: redirectorUrl, + secretkey: 'irrelevant', + }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const body = await response.text(); + + // THE LOAD-BEARING ASSERTION. The redirect target was never contacted, so + // no outbound request left for an address the allowlist never approved. + expect(targetHits).toBe(0); + // And its body never reached the caller. Asserting only the hit count would + // miss a future path that fetches the target through some other route. + expect(body).not.toContain(LOOT); + // AC: the refusal is a HANDLED outcome. Not a 500 (an unhandled throw) and + // not a 302 (which would make our own API answer a redirect it invented). + expect(response.status).toBe(502); + }); + + it('names the refused redirect rather than reporting a generic proxy error', async () => { + expect.assertions(1); + const token = sign( + { email: TEST_USER.email, role: TEST_USER.role, sub: TEST_USER.id }, + TEST_JWT_SECRET + TEST_USER.jwtSecret, + { expiresIn: '1h' }, + ); + + const response = await fetch(`${appUrl}/api/tenable/login`, { + body: JSON.stringify({ + accesskey: 'irrelevant', + host_url: redirectorUrl, + secretkey: 'irrelevant', + }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const body = (await response.json()) as { code?: string }; + + // A distinct code, because "the upstream tried to redirect us and we + // refused" is operationally different from "the upstream is unreachable". + expect(body.code).toBe('UPSTREAM_REDIRECT_REFUSED'); + }); + + it('does not follow a 302 from the proxy path', async () => { + expect.assertions(2); + // Same deliberate opt-out as the module stub above: this test is about + // redirects, and its servers are on loopback. + const service = new TenableService({ + isTenablePrivateAddressAllowed: () => true, + } as unknown as ConfigService); + // The proxy builds its own axios instance, entirely separately from the + // login probe. Fixing one does not fix the other, so this is asserted + // against the service directly rather than inferred from the test above. + const request = { + body: {}, + get: () => 'application/json', + method: 'GET', + originalUrl: '/api/tenable/rest/scanResult', + query: {}, + } as unknown as Request; + + await expect( + service.proxyRequest(request, { + accesskey: 'irrelevant', + host_url: redirectorUrl, + secretkey: 'irrelevant', + }), + ).rejects.toThrow(); + + expect(targetHits).toBe(0); + }); +}); diff --git a/apps/backend/src/tenable/tenable.controller.spec.ts b/apps/backend/src/tenable/tenable.controller.spec.ts new file mode 100644 index 0000000000..cadca8ab0d --- /dev/null +++ b/apps/backend/src/tenable/tenable.controller.spec.ts @@ -0,0 +1,180 @@ +import type { AddressInfo } from 'node:net'; +import type { INestApplication } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import session from 'express-session'; +import { sign } from 'jsonwebtoken'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { JwtStrategy } from '../authn/jwt.strategy'; +import { ConfigService } from '../config/config.service'; +import { UsersService } from '../users/users.service'; +import { TenableController } from './tenable.controller'; +import { TenableService } from './tenable.service'; + +// The strategy resolves its signing key as JWT_SECRET + the user's own +// jwtSecret, so both halves are pinned here and the token below is signed with +// the same concatenation. Stubs rather than the real ConfigService/UsersService: +// this spec is about the guard chain, and a stub keeps it off the database +// without weakening what is under test — the REAL JwtAuthGuard and the REAL +// JwtStrategy are both registered. +const TEST_JWT_SECRET = 'tenable-spec-jwt-secret'; +const TEST_USER = { + email: 'tenable-spec@example.com', + id: '1', + jwtSecret: 'tenable-spec-user-secret', + role: 'user', +}; + +// The handler's own answer when it runs without Tenable credentials in the +// session. Its presence in a response body proves the request reached the +// handler — which is exactly what the guard must prevent. +const HANDLER_SESSION_REJECTION = 'Not authenticated with Tenable'; + +// The only host this deployment is configured to talk to. +const TEST_TENABLE_HOST = 'https://tenable.example.com'; + +// The handler's ENOTFOUND branch (tenable.controller.ts). A `.invalid` host is +// reserved by RFC 2606 and never resolves, so this code appears if and only if +// control reached axios and a request was actually attempted. Its ABSENCE is +// the evidence that the allowlist rejected the host first — asserting the +// status alone cannot tell the two rejections apart, because both answer 400. +const HANDLER_DNS_FAILURE = 'INVALID_HOST_URL'; + +describe('TenableController authentication', () => { + let app: INestApplication; + let baseUrl: string; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [TenableController], + imports: [PassportModule], + providers: [ + TenableService, + JwtStrategy, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'JWT_SECRET') return TEST_JWT_SECRET; + if (key === 'TENABLE_HOST_URL') return TEST_TENABLE_HOST; + return; + }, + getTenableAdditionalHostUrls: () => '', + getTenableHostUrl: () => TEST_TENABLE_HOST, + }, + }, + { + provide: UsersService, + useValue: { findById: () => Promise.resolve(TEST_USER) }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + // Production installs express-session (main.ts) whenever a Tenable host is + // configured, and the proxy handler reads request.session. Without it the + // handler throws a TypeError and answers 500, which would make the + // "did the handler run?" assertion below pass for the wrong reason. + app.use( + session({ + resave: false, + saveUninitialized: false, + secret: 'tenable-spec-session-secret', + }), + ); + await app.init(); + // Port 0 = ephemeral, so this never collides with a dev server. + await app.listen(0); + const address = app.getHttpServer().address() as AddressInfo; + baseUrl = `http://127.0.0.1:${String(address.port)}`; + }); + + afterAll(async () => { + await app.close(); + }); + + it('rejects POST /api/tenable/login when no Authorization header is sent', async () => { + expect.assertions(1); + + const response = await fetch(`${baseUrl}/api/tenable/login`, { + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + // Unguarded, this reaches the handler and answers 400 'Missing + // credentials' — the endpoint accepts an arbitrary host_url from any + // caller and returns what that host said, which is the defect. + expect(response.status).toBe(401); + }); + + it('rejects the catch-all proxy at the guard rather than at the handler session check', async () => { + expect.assertions(2); + + const response = await fetch(`${baseUrl}/api/tenable/scanResult`); + const body = await response.text(); + + expect(response.status).toBe(401); + // Both the guard and the handler answer 401, so status alone cannot tell + // them apart. The handler's body names the Tenable session; the guard's + // does not. Asserting only the status would pass against the bug. + expect(body).not.toContain(HANDLER_SESSION_REJECTION); + }); + + it('lets an authenticated request through to the handler', async () => { + expect.assertions(2); + const token = sign( + { email: TEST_USER.email, role: TEST_USER.role, sub: TEST_USER.id }, + TEST_JWT_SECRET + TEST_USER.jwtSecret, + { expiresIn: '1h' }, + ); + + const response = await fetch(`${baseUrl}/api/tenable/login`, { + body: JSON.stringify({}), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const body = (await response.json()) as { message?: string }; + + // 400 from the handler, not 401 from the guard: the guard admitted the + // request and the feature still works for a signed-in user. + expect(response.status).toBe(400); + expect(body.message).toBe('Missing credentials'); + }); + + it('rejects a host absent from the allowlist before any request is made', async () => { + expect.assertions(2); + const token = sign( + { email: TEST_USER.email, role: TEST_USER.role, sub: TEST_USER.id }, + TEST_JWT_SECRET + TEST_USER.jwtSecret, + { expiresIn: '1h' }, + ); + + const response = await fetch(`${baseUrl}/api/tenable/login`, { + body: JSON.stringify({ + accesskey: 'irrelevant', + host_url: 'https://attacker.invalid', + secretkey: 'irrelevant', + }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + const body = await response.text(); + + expect(response.status).toBe(400); + // The load-bearing assertion. Both the allowlist rejection and the + // handler's DNS failure answer 400, so the status cannot discriminate. If + // this string is present the server attempted the outbound request, which + // is the forgery itself — rejecting AFTER the request would still be a + // vulnerability. + expect(body).not.toContain(HANDLER_DNS_FAILURE); + }); +}); diff --git a/apps/backend/src/tenable/tenable.controller.ts b/apps/backend/src/tenable/tenable.controller.ts index 153fd5df38..23a4cc2cd7 100644 --- a/apps/backend/src/tenable/tenable.controller.ts +++ b/apps/backend/src/tenable/tenable.controller.ts @@ -1,37 +1,97 @@ import { - Controller, - Req, - Res, - Post, + All, Body, + Controller, HttpException, HttpStatus, - All + Post, + Req, + Res, + UseGuards, } from '@nestjs/common'; -import {TenableService} from './tenable.service'; import axios from 'axios'; -import {Request, Response} from 'express'; +import { Request, Response } from 'express'; +import { ConfigService } from '../config/config.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { + ADDRESS_REFUSED_CODE, + createTenableAgents, +} from './tenable-filtering-agent'; +import { + buildAllowedOrigins, + checkTenableHost, +} from './tenable-host-allowlist'; +import { TenableService } from './tenable.service'; -// Extend express-session types to include 'tenable' +const TRAILING_SLASH = /\/$/v; + +// Extend express-session types to include 'tenable'. +// This MUST stay an `interface`: module augmentation works by declaration +// merging, and only interfaces merge. Written as `type SessionData = {...}` it +// declares a second, conflicting SessionData instead of extending the one +// express-session exports — TS2300 duplicate identifier, and every +// `session.tenable` access then fails with TS2339. There is no `type` form of +// this fix, so the rule is disabled for this declaration only. declare module 'express-session' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface SessionData { tenable?: { - host_url: string; accesskey: string; + host_url: string; secretkey: string; }; } } -const TENABLE_CSP_NOT_SET = - "Cannot set properties of undefined (setting 'tenable')"; +const TENABLE_CSP_NOT_SET + = "Cannot set properties of undefined (setting 'tenable')"; + +// The SECOND of three SSRF controls (heimdall2-86f6.12). Both outbound paths +// set `maxRedirects: 0`, and axios then settles a 3xx as an ERROR rather than +// following it, because its default validateStatus accepts 2xx only. That +// arrives in the catch blocks below as an ordinary AxiosError carrying the 3xx +// response — indistinguishable from any other upstream failure unless it is +// classified explicitly. +// +// Classifying it is not cosmetic. Left to the default branch, `status: +// error.response?.status` would re-emit the UPSTREAM's 302 as this API's own +// status code: Heimdall would answer a redirect it never authored, on behalf of +// a host it just refused to follow. +const REDIRECT_STATUS_MIN = 300; +const REDIRECT_STATUS_MAX = 400; +// The message deliberately does not name the redirect target. It is supplied by +// the upstream host, and reflecting it turns this endpoint into a probe oracle — +// the same reasoning as the allowlist's rejection reasons (heimdall2-86f6.6). +const REFUSED_REDIRECT_MESSAGE + = 'The Tenable host attempted to redirect the request, which is not permitted'; + +function isRefusedRedirect(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return false; + } + const status = error.response?.status; + return ( + status !== undefined + && status >= REDIRECT_STATUS_MIN + && status < REDIRECT_STATUS_MAX + ); +} // NestJS controller that handles Tenable authentication and proxying requests to Tenable // It allows users to log in with their Tenable credentials and then proxies all subsequent requests // to the Tenable API, handling authentication via session storage. +// Guarded at the CLASS level, not per route: @All('*splat') below means any +// future route on this controller is reachable the moment it is declared, and a +// per-route guard list would silently miss it. Both handlers accept a caller- +// supplied Tenable host and return what that host answered, so an unauthenticated +// request here is an outbound-request primitive with a readable response. @Controller('api/tenable') +@UseGuards(JwtAuthGuard) export class TenableController { - constructor(private readonly tenableService: TenableService) {} + constructor( + private readonly tenableService: TenableService, + private readonly configService: ConfigService, + ) {} @Post('login') /** @@ -43,123 +103,198 @@ export class TenableController { * @throws {HttpException} If any credentials are missing or if authentication fails. */ async login( - @Req() req: Request, - @Body() body: {host_url: string; accesskey: string; secretkey: string} + @Req() request: Request, + @Body() body: { accesskey: string; host_url: string; secretkey: string }, ) { - const {host_url, accesskey, secretkey} = body; + const { accesskey, host_url, secretkey } = body; if (!host_url || !accesskey || !secretkey) { throw new HttpException('Missing credentials', HttpStatus.BAD_REQUEST); } + // BEFORE any outbound request. Rejecting afterwards would still have + // performed the forgery and still have returned the upstream response to + // the caller, so the ordering here is the control, not a formality. + const decision = checkTenableHost( + host_url, + buildAllowedOrigins( + this.configService.getTenableHostUrl(), + this.configService.getTenableAdditionalHostUrls(), + ), + ); + // Narrowed on the string tag, never on truthiness: this repo does not enable + // strictNullChecks, so `if (!decision.allowed)` would compile under vitest + // (transpile-only) and then fail `nest build` on `decision.reason`. See the + // HostDecision declaration for why the tag is a string rather than a boolean. + if (decision.kind === 'rejected') { + throw new HttpException( + { + code: 'HOST_NOT_ALLOWED', + message: decision.reason, + status: HttpStatus.BAD_REQUEST, + }, + HttpStatus.BAD_REQUEST, + ); + } + try { // This helps prevent double slashes in the resulting URL if host_url ends with a slash. - const fullUrl = `${host_url.replace(/\/$/, '')}/rest/currentUser`; + const fullUrl = `${host_url.replace(TRAILING_SLASH, '')}/rest/currentUser`; + // BOTH agents: axios picks between them by the target's protocol, so + // supplying only one would leave the other scheme unfiltered. The agents + // refuse to connect to private/loopback/link-local addresses, which is + // what stops this allowlisted NAME being pointed at an internal service + // (heimdall2-86f6.13). + const { httpAgent, httpsAgent } = createTenableAgents({ + allowPrivateAddresses: + this.configService.isTenablePrivateAddressAllowed(), + }); const result = await axios.get(fullUrl, { - headers: { - 'x-apikey': `accesskey=${accesskey}; secretkey=${secretkey}` - } + headers: { 'x-apikey': `accesskey=${accesskey}; secretkey=${secretkey}` }, + httpAgent, + httpsAgent, + // See REFUSED_REDIRECT_* above: the allowlist approved this host by + // NAME, and following its redirect would land the request somewhere it + // never approved. Configured here as well as in tenable.service.ts — + // two axios configurations, two fixes. + maxRedirects: 0, }); // Assign the Tenable credentials to the session - req.session.tenable = {host_url, accesskey, secretkey}; + request.session.tenable = { accesskey, host_url, secretkey }; // Return the authenticated user data // Note: result.data is already a plain object, no need to convert it. - return {success: true, user: result.data}; // Return plain object - } catch (err) { - if (axios.isAxiosError(err)) { - if (err.message.includes(TENABLE_CSP_NOT_SET)) { - throw new HttpException( - { - status: HttpStatus.NOT_FOUND, - message: 'Tenable CSP not set', - code: 'ERR_NETWORK' // custom application error code (optional) - }, - HttpStatus.NOT_FOUND - ); - } else if (err.response?.status === HttpStatus.UNAUTHORIZED) { - throw new HttpException( - { - status: HttpStatus.UNAUTHORIZED, - message: 'Invalid Tenable credentials', - code: 'INVALID_CREDENTIALS' // custom application error code (optional) - }, - HttpStatus.UNAUTHORIZED - ); - } else if (err.code === 'ECONNREFUSED') { + return { success: true, user: result.data }; // Return plain object + } catch (error) { + if (axios.isAxiosError(error)) { + // Before every other classification: a refused redirect is not a + // transport failure and must not fall through to the default branch, + // which would answer with the upstream's own 3xx status. + // The filtering agent refuses before any bytes are exchanged, so this + // is a transport-level error carrying our own code rather than an HTTP + // response. Classified first, for the same reason as the redirect case: + // the default branch would report it as a generic proxy failure and + // hide the fact that a permitted name resolved somewhere it must not. + if (error.code === ADDRESS_REFUSED_CODE) { throw new HttpException( { + code: ADDRESS_REFUSED_CODE, + message: error.message, status: HttpStatus.BAD_GATEWAY, - message: 'Tenable server is unreachable', - code: 'SERVER_UNREACHABLE' // custom app code }, - HttpStatus.BAD_GATEWAY + HttpStatus.BAD_GATEWAY, ); - } else if (err.code === 'ENOTFOUND') { - throw new HttpException( - { - status: HttpStatus.BAD_REQUEST, - message: - 'Unable to resolve Tenable host URL to an IP address (possible DNS resolution on the hosting platform).', - code: 'INVALID_HOST_URL' // custom app code - }, - HttpStatus.BAD_REQUEST - ); - } else if (err.code === 'ETIMEDOUT') { + } + if (isRefusedRedirect(error)) { throw new HttpException( { - status: HttpStatus.REQUEST_TIMEOUT, - message: 'Tenable server took too long to respond', - code: 'CONNECTION_TIMEOUT' // custom application error code (optional) + code: 'UPSTREAM_REDIRECT_REFUSED', + message: REFUSED_REDIRECT_MESSAGE, + status: HttpStatus.BAD_GATEWAY, }, - HttpStatus.REQUEST_TIMEOUT + HttpStatus.BAD_GATEWAY, ); - } else if (err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { + } + if (error.message.includes(TENABLE_CSP_NOT_SET)) { throw new HttpException( { - status: HttpStatus.BAD_GATEWAY, - message: - 'SSL certificate verification failed while connecting to Tenable ' + - `(${host_url}). This may be due to an untrusted or incomplete TLS ` + - 'certificate chain.', - code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + code: 'ERR_NETWORK', // custom application error code (optional) + message: 'Tenable CSP not set', + status: HttpStatus.NOT_FOUND, }, - HttpStatus.BAD_GATEWAY + HttpStatus.NOT_FOUND, ); - } else { + } + if (error.response?.status === HttpStatus.UNAUTHORIZED) { throw new HttpException( { - status: err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, - message: - err.response?.data?.message || - `Unexpected error connecting to Tenable ${host_url}`, - code: 'TENABLE_PROXY_ERROR' // Optional custom app code + code: 'INVALID_CREDENTIALS', // custom application error code (optional) + message: 'Invalid Tenable credentials', + status: HttpStatus.UNAUTHORIZED, }, - err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR + HttpStatus.UNAUTHORIZED, ); } - } else if (err instanceof Error) { + switch (error.code) { + case 'ECONNREFUSED': { + throw new HttpException( + { + code: 'SERVER_UNREACHABLE', // custom app code + message: 'Tenable server is unreachable', + status: HttpStatus.BAD_GATEWAY, + }, + HttpStatus.BAD_GATEWAY, + ); + } + case 'ENOTFOUND': { + throw new HttpException( + { + code: 'INVALID_HOST_URL', // custom app code + message: + 'Unable to resolve Tenable host URL to an IP address (possible DNS resolution on the hosting platform).', + status: HttpStatus.BAD_REQUEST, + }, + HttpStatus.BAD_REQUEST, + ); + } + case 'ETIMEDOUT': { + throw new HttpException( + { + code: 'CONNECTION_TIMEOUT', // custom application error code (optional) + message: 'Tenable server took too long to respond', + status: HttpStatus.REQUEST_TIMEOUT, + }, + HttpStatus.REQUEST_TIMEOUT, + ); + } + case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': { + throw new HttpException( + { + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + message: + 'SSL certificate verification failed while connecting to Tenable ' + + `(${host_url}). This may be due to an untrusted or incomplete TLS ` + + 'certificate chain.', + status: HttpStatus.BAD_GATEWAY, + }, + HttpStatus.BAD_GATEWAY, + ); + } + default: { + throw new HttpException( + { + code: 'TENABLE_PROXY_ERROR', // Optional custom app code + message: + error.response?.data?.message + || `Unexpected error connecting to Tenable ${host_url}`, + status: error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + }, + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + if (error instanceof Error) { throw new HttpException( { - status: HttpStatus.INTERNAL_SERVER_ERROR, + code: 'TENABLE_PROXY_ERROR', message: - err.message || - `Unexpected error connecting to Tenable ${host_url}`, - code: 'TENABLE_PROXY_ERROR' - }, - HttpStatus.INTERNAL_SERVER_ERROR - ); - } else { - throw new HttpException( - { + error.message + || `Unexpected error connecting to Tenable ${host_url}`, status: HttpStatus.INTERNAL_SERVER_ERROR, - message: `Unexpected error connecting to Tenable ${host_url}: ${JSON.stringify(err, null, 2)}`, - code: 'TENABLE_PROXY_ERROR' }, - HttpStatus.INTERNAL_SERVER_ERROR + HttpStatus.INTERNAL_SERVER_ERROR, ); } + throw new HttpException( + { + code: 'TENABLE_PROXY_ERROR', + message: `Unexpected error connecting to Tenable ${host_url}: ${JSON.stringify(error, null, 2)}`, + status: HttpStatus.INTERNAL_SERVER_ERROR, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } @@ -175,60 +310,89 @@ export class TenableController { * @throws 404 if user session content is not available. * @throws 500 or the proxied error status if the proxy request fails. */ - async proxy(@Req() req: Request, @Res() res: Response) { + async proxy(@Req() request: Request, @Res() res: Response) { try { - const creds = req.session.tenable; + const creds = request.session.tenable; // If credentials are missing, user is not authenticated, send 401 Unauthorized. if (!creds) { - return res.status(401).json({error: 'Not authenticated with Tenable'}); + return res.status(401).json({ error: 'Not authenticated with Tenable' }); } // Forward the incoming request to the Tenable API using stored credentials. // Respond to the client with the status and data from Tenable's response or // handle any errors that occur during the proxy request. - const result = await this.tenableService.proxyRequest(req, creds); + const result = await this.tenableService.proxyRequest(request, creds); res.status(result.status).send(result.data); - } catch (err) { - const cspMsg = TENABLE_CSP_NOT_SET.replace('set', 'read').replace( + } catch (error) { + const cspMessage = TENABLE_CSP_NOT_SET.replace('set', 'read').replace( 'setting', - 'reading' + 'reading', ); - if (axios.isAxiosError(err)) { - if (err.message.includes(cspMsg)) { + if (axios.isAxiosError(error)) { + // Same classification as the login probe, and needed here for a second + // reason: this branch forwards `error.response?.status` and the upstream + // BODY straight to the caller, so an unclassified 3xx would answer a + // redirect this API never authored. + // The filtering agent refuses before any bytes are exchanged, so this + // is a transport-level error carrying our own code rather than an HTTP + // response. Classified first, for the same reason as the redirect case: + // the default branch would report it as a generic proxy failure and + // hide the fact that a permitted name resolved somewhere it must not. + if (error.code === ADDRESS_REFUSED_CODE) { throw new HttpException( { - status: HttpStatus.NOT_FOUND, + code: ADDRESS_REFUSED_CODE, + message: error.message, + status: HttpStatus.BAD_GATEWAY, + }, + HttpStatus.BAD_GATEWAY, + ); + } + if (isRefusedRedirect(error)) { + throw new HttpException( + { + code: 'UPSTREAM_REDIRECT_REFUSED', + message: REFUSED_REDIRECT_MESSAGE, + status: HttpStatus.BAD_GATEWAY, + }, + HttpStatus.BAD_GATEWAY, + ); + } + if (error.message.includes(cspMessage)) { + throw new HttpException( + { + code: 'ERR_NETWORK', // custom application error code (optional) message: 'Tenable CSP not set', - code: 'ERR_NETWORK' // custom application error code (optional) + status: HttpStatus.NOT_FOUND, }, - HttpStatus.NOT_FOUND + HttpStatus.NOT_FOUND, ); } else { - const status = - err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; - const message = err.response?.data || 'Proxy error'; + const status + = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const message = error.response?.data || 'Proxy error'; res.status(status).send(message); } - } else if (err instanceof Error) { - if (err.message.includes(cspMsg)) { + } else if (error instanceof Error) { + if (error.message.includes(cspMessage)) { throw new HttpException( { - status: HttpStatus.NOT_FOUND, + code: 'ERR_NETWORK', message: 'Tenable CSP not set', - code: 'ERR_NETWORK' + status: HttpStatus.NOT_FOUND, }, - HttpStatus.NOT_FOUND + HttpStatus.NOT_FOUND, ); } else { const status = HttpStatus.INTERNAL_SERVER_ERROR; - const message = err.message || 'Proxy error'; + const message = error.message || 'Proxy error'; res.status(status).send(message); } } else { const status = HttpStatus.INTERNAL_SERVER_ERROR; - const message = `Proxy error: ${JSON.stringify(err, null, 2)}`; + const message = `Proxy error: ${JSON.stringify(error, null, 2)}`; res.status(status).send(message); } } diff --git a/apps/backend/src/tenable/tenable.module.ts b/apps/backend/src/tenable/tenable.module.ts index 528a3d6d8d..998c37871a 100644 --- a/apps/backend/src/tenable/tenable.module.ts +++ b/apps/backend/src/tenable/tenable.module.ts @@ -1,6 +1,7 @@ -import {Module} from '@nestjs/common'; -import {TenableController} from './tenable.controller'; -import {TenableService} from './tenable.service'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '../config/config.module'; +import { TenableController } from './tenable.controller'; +import { TenableService } from './tenable.service'; // NestJS module definition for the Tenable proxy feature. // Registers the controller and service needed for routing Tenable requests. @@ -8,7 +9,12 @@ import {TenableService} from './tenable.service'; @Module({ // Handles HTTP requests related to Tenable controllers: [TenableController], + // ConfigModule is NOT @Global, so every module needing ConfigService must + // import it. The controller reads the host allowlist from configuration, and + // omitting this import fails at BOOT while unit specs stay green — they + // provide ConfigService directly and never exercise module wiring. + imports: [ConfigModule], // Provides logic for proxying and interacting with Tenable API - providers: [TenableService] + providers: [TenableService], }) export class TenableModule {} diff --git a/apps/backend/src/tenable/tenable.service.ts b/apps/backend/src/tenable/tenable.service.ts index caeb4dc7d3..a108e38f3a 100644 --- a/apps/backend/src/tenable/tenable.service.ts +++ b/apps/backend/src/tenable/tenable.service.ts @@ -1,39 +1,64 @@ -import {Injectable} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import axios from 'axios'; -import {Request} from 'express'; +import { Request } from 'express'; +import { ConfigService } from '../config/config.service'; +import { createTenableAgents } from './tenable-filtering-agent'; -interface TenableCredentials { - host_url: string; +type TenableCredentials = { accesskey: string; + host_url: string; secretkey: string; -} +}; // NestJS service that performs proxied requests to Tenable using credentials stored in the session @Injectable() export class TenableService { - async proxyRequest(req: Request, creds: TenableCredentials) { + constructor(private readonly configService: ConfigService) {} + + async proxyRequest(request: Request, creds: TenableCredentials) { + // Both agents, because axios selects by the target's protocol. Configured + // here as well as on the login probe in tenable.controller.ts — two axios + // configurations, and filtering one does not filter the other. + const { httpAgent, httpsAgent } = createTenableAgents({ + allowPrivateAddresses: this.configService.isTenablePrivateAddressAllowed(), + }); const axiosInstance = axios.create({ baseURL: creds.host_url, headers: { + 'Content-Type': request.get('content-type') || 'application/json', 'x-apikey': `accesskey=${creds.accesskey}; secretkey=${creds.secretkey}`, - 'Content-Type': req.get('content-type') || 'application/json' - } + }, + // The SECOND of three SSRF controls (heimdall2-86f6.12). The allowlist + // decides where a request may be SENT; it cannot decide where the + // RESPONSE sends it next. axios follows up to 21 redirects by default, so + // without this an allowlisted host answering `302 Location: + // http://169.254.169.254/...` moves the request somewhere the allowlist + // never approved — and this path forwards the result to the caller. + // 0 means follow none; axios then settles the 3xx as an error, because + // its default validateStatus accepts 2xx only. + // + // This instance is configured SEPARATELY from the login probe in + // tenable.controller.ts. They are two axios configurations and fixing one + // does not fix the other. + maxRedirects: 0, + httpAgent, + httpsAgent, }); - const method = req.method; - const url = req.originalUrl.replace('/api/tenable', ''); - const data = req.body; - const params = req.query; + const method = request.method; + const url = request.originalUrl.replace('/api/tenable', ''); + const data = request.body; + const parameters = request.query; return axiosInstance({ - method, - url, data, - params, + method, + params: parameters, responseType: - method === 'POST' && req.get('content-type')?.includes('zip') + method === 'POST' && request.get('content-type')?.includes('zip') ? 'arraybuffer' - : 'json' + : 'json', + url, }); } } diff --git a/apps/backend/src/token/token.module.ts b/apps/backend/src/token/token.module.ts index e246cf2631..b7b4fe2ec8 100644 --- a/apps/backend/src/token/token.module.ts +++ b/apps/backend/src/token/token.module.ts @@ -1,9 +1,9 @@ -import {Module} from '@nestjs/common'; -import {JwtModule} from '@nestjs/jwt'; -import {tokenProviders} from './token.providers'; +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { tokenProviders } from './token.providers'; @Module({ + exports: [JwtModule], imports: [...tokenProviders], - exports: [JwtModule] }) export class TokenModule {} diff --git a/apps/backend/src/token/token.providers.ts b/apps/backend/src/token/token.providers.ts index 7a79cefaa4..e628e51dcd 100644 --- a/apps/backend/src/token/token.providers.ts +++ b/apps/backend/src/token/token.providers.ts @@ -1,8 +1,8 @@ -import {JwtModule} from '@nestjs/jwt'; import * as crypto from 'crypto'; +import { JwtModule } from '@nestjs/jwt'; import ms from 'ms'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; export function generateDefault(): string { return crypto.randomBytes(64).toString('hex'); @@ -13,13 +13,11 @@ export function limitJWTTime(time: string, logLimit: boolean) { const maxDays = ms('2d'); // limit to two days if (timeMs > maxDays) { if (logLimit) { - // eslint-disable-next-line no-console console.log('JWT Expire time has been limited to two days maximum.'); } return maxDays; - } else { - return timeMs; } + return timeMs; } export const tokenProviders = [ @@ -31,9 +29,9 @@ export const tokenProviders = [ signOptions: { expiresIn: limitJWTTime( configService.get('JWT_EXPIRE_TIME') || '60s', - true - ) - } - }) - }) + true, + ), + }, + }), + }), ]; diff --git a/apps/backend/src/users/dto/create-user.dto.ts b/apps/backend/src/users/dto/create-user.dto.ts index 9a75a0d463..08df0c1111 100644 --- a/apps/backend/src/users/dto/create-user.dto.ts +++ b/apps/backend/src/users/dto/create-user.dto.ts @@ -1,18 +1,15 @@ -import {ICreateUser} from '@heimdall/common/interfaces'; -import {IsEmail, IsIn, IsNotEmpty, IsOptional, IsString} from 'class-validator'; +import { ICreateUser } from '@heimdall/common/interfaces'; +import { IsEmail, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateUserDto implements ICreateUser { - @IsEmail() - @IsNotEmpty() - readonly email!: string; - @IsNotEmpty() @IsString() - readonly password!: string; + @IsIn(['local', 'ldap', 'github', 'gitlab', 'google', 'okta', 'ldap']) + readonly creationMethod!: string; + @IsEmail() @IsNotEmpty() - @IsString() - readonly passwordConfirmation!: string; + readonly email!: string; @IsOptional() @IsString() @@ -26,17 +23,20 @@ export class CreateUserDto implements ICreateUser { @IsString() readonly organization: string | undefined; - @IsOptional() + @IsNotEmpty() @IsString() - readonly title: string | undefined; + readonly password!: string; + + @IsNotEmpty() + @IsString() + readonly passwordConfirmation!: string; @IsNotEmpty() @IsString() @IsIn(['user']) readonly role!: string; - @IsNotEmpty() + @IsOptional() @IsString() - @IsIn(['local', 'ldap', 'github', 'gitlab', 'google', 'okta', 'ldap']) - readonly creationMethod!: string; + readonly title: string | undefined; } diff --git a/apps/backend/src/users/dto/delete-user.dto.ts b/apps/backend/src/users/dto/delete-user.dto.ts index dfb5055008..00ccc2c82f 100644 --- a/apps/backend/src/users/dto/delete-user.dto.ts +++ b/apps/backend/src/users/dto/delete-user.dto.ts @@ -1,5 +1,5 @@ -import {IDeleteUser} from '@heimdall/common/interfaces'; -import {IsOptional, IsString, MinLength} from 'class-validator'; +import { IDeleteUser } from '@heimdall/common/interfaces'; +import { IsOptional, IsString, MinLength } from 'class-validator'; export class DeleteUserDto implements IDeleteUser { @IsOptional() diff --git a/apps/backend/src/users/dto/slim-user.dto.ts b/apps/backend/src/users/dto/slim-user.dto.ts index 136d7b85c3..e4daf784b1 100644 --- a/apps/backend/src/users/dto/slim-user.dto.ts +++ b/apps/backend/src/users/dto/slim-user.dto.ts @@ -1,31 +1,31 @@ -import {ISlimUser} from '@heimdall/common/interfaces'; -import {IsOptional, IsString} from 'class-validator'; -import {User} from '../user.model'; +import { ISlimUser } from '@heimdall/common/interfaces'; +import { IsOptional, IsString } from 'class-validator'; +import { User } from '../user.model'; export class SlimUserDto implements ISlimUser { - @IsString() - readonly id: string; - @IsString() readonly email: string; @IsOptional() @IsString() - readonly title?: string; + readonly firstName?: string; @IsOptional() @IsString() readonly groupRole?: string; - @IsOptional() @IsString() - readonly firstName?: string; + readonly id: string; @IsOptional() @IsString() readonly lastName?: string; - constructor(user: User, groupRole: string | undefined = undefined) { + @IsOptional() + @IsString() + readonly title?: string; + + constructor(user: User, groupRole?: string) { this.id = user.id; this.email = user.email; this.title = user.title; diff --git a/apps/backend/src/users/dto/update-user.dto.ts b/apps/backend/src/users/dto/update-user.dto.ts index 00cbd5aad4..4e94c2cd14 100644 --- a/apps/backend/src/users/dto/update-user.dto.ts +++ b/apps/backend/src/users/dto/update-user.dto.ts @@ -1,7 +1,11 @@ -import {IUpdateUser} from '@heimdall/common/interfaces'; -import {IsBoolean, IsEmail, IsIn, IsOptional, IsString} from 'class-validator'; +import { IUpdateUser } from '@heimdall/common/interfaces'; +import { IsBoolean, IsEmail, IsIn, IsOptional, IsString } from 'class-validator'; export class UpdateUserDto implements IUpdateUser { + @IsOptional() + @IsString() + readonly currentPassword?: string; + @IsEmail() @IsOptional() readonly email: string | undefined; @@ -11,21 +15,16 @@ export class UpdateUserDto implements IUpdateUser { readonly firstName!: string | undefined; @IsOptional() - @IsString() - readonly lastName!: string | undefined; - - @IsOptional() - @IsString() - readonly organization!: string | undefined; + @IsBoolean() + readonly forcePasswordChange: boolean | undefined; @IsOptional() @IsString() - readonly title!: string | undefined; + readonly lastName!: string | undefined; @IsOptional() @IsString() - @IsIn(['user', 'admin']) - readonly role: string | undefined; + readonly organization!: string | undefined; @IsOptional() @IsString() @@ -36,10 +35,11 @@ export class UpdateUserDto implements IUpdateUser { readonly passwordConfirmation: string | undefined; @IsOptional() - @IsBoolean() - readonly forcePasswordChange: boolean | undefined; + @IsString() + @IsIn(['user', 'admin']) + readonly role: string | undefined; @IsOptional() @IsString() - readonly currentPassword?: string; + readonly title!: string | undefined; } diff --git a/apps/backend/src/users/dto/user.dto.ts b/apps/backend/src/users/dto/user.dto.ts index cb9247f7f3..7538f2c864 100644 --- a/apps/backend/src/users/dto/user.dto.ts +++ b/apps/backend/src/users/dto/user.dto.ts @@ -1,18 +1,18 @@ -import {IUser} from '@heimdall/common/interfaces'; -import {User} from '../user.model'; +import type { IUser } from '@heimdall/common/interfaces'; +import type { User } from '../user.model'; export class UserDto implements IUser { - id: string; + readonly createdAt: Date; + readonly creationMethod: string; readonly email: string; readonly firstName: string | undefined; + id: string; + readonly lastLogin: Date | undefined; readonly lastName: string | undefined; - readonly title: string | undefined; - readonly role: string; - readonly organization: string | undefined; readonly loginCount: number; - readonly lastLogin: Date | undefined; - readonly creationMethod: string; - readonly createdAt: Date; + readonly organization: string | undefined; + readonly role: string; + readonly title: string | undefined; readonly updatedAt: Date; constructor(user: User) { diff --git a/apps/backend/src/users/user.model.ts b/apps/backend/src/users/user.model.ts index 5a94eba76e..581e5f24f1 100644 --- a/apps/backend/src/users/user.model.ts +++ b/apps/backend/src/users/user.model.ts @@ -11,18 +11,21 @@ import { PrimaryKey, Table, Unique, - UpdatedAt + UpdatedAt, } from 'sequelize-typescript'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; @Table export class User extends Model { - @PrimaryKey - @AutoIncrement + @CreatedAt @AllowNull(false) - @Column(DataType.BIGINT) - declare id: string; + @Column(DataType.DATE) + declare createdAt: Date; + + @AllowNull(false) + @Column(DataType.STRING) + declare creationMethod: string; @Unique @IsEmail @@ -30,39 +33,48 @@ export class User extends Model { @Column(DataType.STRING) declare email: string; - @AllowNull(true) + @AllowNull(false) @Column(DataType.STRING) - declare firstName: string | undefined; + declare encryptedPassword: string; @AllowNull(true) @Column(DataType.STRING) - declare lastName: string | undefined; + declare firstName: string | undefined; @AllowNull(true) - @Column(DataType.STRING) - declare organization: string | undefined; + @Column(DataType.BOOLEAN) + declare forcePasswordChange: boolean | undefined; - @AllowNull(true) - @Column(DataType.STRING) - declare title: string | undefined; + @BelongsToMany(() => Group, () => GroupUser) + declare groups: (Group & { GroupUser: GroupUser })[]; + @PrimaryKey + @AutoIncrement @AllowNull(false) - @Column(DataType.STRING) - declare encryptedPassword: string; + @Column(DataType.BIGINT) + declare id: string; @AllowNull(true) - @Column(DataType.BOOLEAN) - declare forcePasswordChange: boolean | undefined; + @Column(DataType.STRING) + declare jwtSecret: string; @AllowNull(true) @Column(DataType.DATE) declare lastLogin: Date | undefined; + @AllowNull(true) + @Column(DataType.STRING) + declare lastName: string | undefined; + @AllowNull(false) @Default(0) @Column(DataType.BIGINT) declare loginCount: number; + @AllowNull(true) + @Column(DataType.STRING) + declare organization: string | undefined; + @AllowNull(true) @Column(DataType.DATE) declare passwordChangedAt: Date | undefined; @@ -72,24 +84,12 @@ export class User extends Model { @Column(DataType.STRING) declare role: string; - @AllowNull(false) - @Column(DataType.STRING) - declare creationMethod: string; - @AllowNull(true) @Column(DataType.STRING) - declare jwtSecret: string; - - @CreatedAt - @AllowNull(false) - @Column(DataType.DATE) - declare createdAt: Date; + declare title: string | undefined; @UpdatedAt @AllowNull(false) @Column(DataType.DATE) declare updatedAt: Date; - - @BelongsToMany(() => Group, () => GroupUser) - declare groups: Array; } diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 3a2b730d62..7c63eb9f16 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -1,14 +1,15 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { BadRequestException, ForbiddenException, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test, TestingModule} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {ValidationError} from 'sequelize'; -import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; +import { SequelizeModule } from '@nestjs/sequelize'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { ValidationError } from 'sequelize'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_ADMIN_DTO, CREATE_USER_DTO_TEST_OBJ, @@ -20,23 +21,24 @@ import { DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, ID, UPDATE_USER_DTO_TEST_OBJ, - UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD + UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD, } from '../../test/constants/users-test.constant'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigModule} from '../config/config.module'; -import {ConfigService} from '../config/config.service'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {UserDto} from './dto/user.dto'; -import {User} from './user.model'; -import {UsersController} from './users.controller'; -import {UsersService} from './users.service'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { UserDto } from './dto/user.dto'; +import { User } from './user.model'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; // Test suite for the UsersController describe('UsersController Unit Tests', () => { @@ -54,6 +56,7 @@ describe('UsersController Unit Tests', () => { controllers: [UsersController], imports: [ ConfigModule, + CryptoModule, DatabaseModule, SequelizeModule.forFeature([ User, @@ -61,15 +64,15 @@ describe('UsersController Unit Tests', () => { Group, GroupEvaluation, Evaluation, - EvaluationTag - ]) + EvaluationTag, + ]), ], providers: [ AuthzService, DatabaseService, UsersService, - {provide: GroupsService, useValue: GROUPS_SERVICE_MOCK} - ] + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], }).compile(); usersService = module.get(UsersService); @@ -97,7 +100,7 @@ describe('UsersController Unit Tests', () => { expect.assertions(1); expect( - await usersController.findUserById(basicUser.id, {user: basicUser}) + await usersController.findUserById(basicUser.id, { user: basicUser }), ).toEqual(new UserDto(await usersService.findById(basicUser.id))); }); @@ -105,9 +108,7 @@ describe('UsersController Unit Tests', () => { it('should test findById with invalid ID', async () => { expect.assertions(1); - await expect(async () => { - await usersController.findUserById(ID, {user: basicUser}); - }).rejects.toThrow(NotFoundException); + await expect(usersController.findUserById(ID, { user: basicUser })).rejects.toThrow(NotFoundException); }); }); @@ -115,12 +116,9 @@ describe('UsersController Unit Tests', () => { // Tests the findAll function with valid ID (basic positive test) it('should list all users for an admin', async () => { expect.assertions(1); - const serviceFoundUsers = (await usersService.adminFindAllUsers()).map( - (user) => new UserDto(user) - ); - const controllerFoundUsers = await usersController.adminFindAllUsers({ - user: adminUser - }); + const allUsers = await usersService.adminFindAllUsers(); + const serviceFoundUsers = allUsers.map(user => new UserDto(user)); + const controllerFoundUsers = await usersController.adminFindAllUsers({ user: adminUser }); // In the case of admin, they should be equal becuase admin can see all expect(controllerFoundUsers).toEqual(serviceFoundUsers); }); @@ -133,10 +131,10 @@ describe('UsersController Unit Tests', () => { const createdUser = await usersController.create( CREATE_USER_DTO_TEST_OBJ_2, - {} + {}, ); expect(createdUser).toEqual( - new UserDto(await usersService.findById(createdUser.id)) + new UserDto(await usersService.findById(createdUser.id)), ); }); @@ -144,36 +142,30 @@ describe('UsersController Unit Tests', () => { it('should test the create function with missing email field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD, - {} - ); - }).rejects.toThrow(ValidationError); + await expect(usersController.create( + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD, + {}, + )).rejects.toThrow(ValidationError); }); // Tests the create function with dto that is missing password it('should test the create function with missing password field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, - {} - ); - }).rejects.toThrow(BadRequestException); + await expect(usersController.create( + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + {}, + )).rejects.toThrow(BadRequestException); }); // Tests the create function with dto that is missing passwordConfirmation it('should test the create function with missing password confirmation field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD, - {} - ); - }).rejects.toThrow(ValidationError); + await expect(usersController.create( + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD, + {}, + )).rejects.toThrow(ValidationError); }); }); @@ -184,7 +176,7 @@ describe('UsersController Unit Tests', () => { configService.set('REGISTRATION_DISABLED', 'true'); await expect( - usersController.create(CREATE_USER_DTO_TEST_OBJ_2, {}) + usersController.create(CREATE_USER_DTO_TEST_OBJ_2, {}), ).rejects.toBeInstanceOf(ForbiddenError); }); }); @@ -197,9 +189,9 @@ describe('UsersController Unit Tests', () => { expect( await usersController.update( basicUser.id, - {user: basicUser}, - UPDATE_USER_DTO_TEST_OBJ - ) + { user: basicUser }, + UPDATE_USER_DTO_TEST_OBJ, + ), ).toEqual(new UserDto(await usersService.findById(basicUser.id))); }); @@ -207,26 +199,22 @@ describe('UsersController Unit Tests', () => { it('should test update function with invalid ID', async () => { expect.assertions(1); - await expect(async () => { - await usersController.update( - ID, - {user: basicUser}, - UPDATE_USER_DTO_TEST_OBJ - ); - }).rejects.toThrow(NotFoundException); + await expect(usersController.update( + ID, + { user: basicUser }, + UPDATE_USER_DTO_TEST_OBJ, + )).rejects.toThrow(NotFoundException); }); // Tests the update function with dto that is missing currentPassword it('should test the update function with a dto that is missing currentPassword field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.update( - basicUser.id, - {user: basicUser}, - UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD - ); - }).rejects.toThrow(ForbiddenException); + await expect(usersController.update( + basicUser.id, + { user: basicUser }, + UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD, + )).rejects.toThrow(ForbiddenException); }); }); @@ -238,9 +226,9 @@ describe('UsersController Unit Tests', () => { expect( await usersController.remove( basicUser.id, - {user: basicUser}, - DELETE_USER_DTO_TEST_OBJ - ) + { user: basicUser }, + DELETE_USER_DTO_TEST_OBJ, + ), ).toEqual(new UserDto(basicUser)); }); @@ -248,26 +236,22 @@ describe('UsersController Unit Tests', () => { it('should test remove function with invalid ID', async () => { expect.assertions(1); - await expect(async () => { - await usersController.remove( - ID, - {user: adminUser}, - DELETE_USER_DTO_TEST_OBJ - ); - }).rejects.toThrow(NotFoundException); + await expect(usersController.remove( + ID, + { user: adminUser }, + DELETE_USER_DTO_TEST_OBJ, + )).rejects.toThrow(NotFoundException); }); // Tests the remove function with dto that is missing password it('should test remove function with a dto that is missing password field', async () => { expect.assertions(1); - await expect(async () => { - await usersController.remove( - basicUser.id, - {user: basicUser}, - DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD - ); - }).rejects.toThrow(ForbiddenException); + await expect(usersController.remove( + basicUser.id, + { user: basicUser }, + DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, + )).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 906971830f..cb5f6f0dbe 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -1,4 +1,4 @@ -import {ForbiddenError} from '@casl/ability'; +import { ForbiddenError } from '@casl/ability'; import { Body, Controller, @@ -12,26 +12,26 @@ import { UseFilters, UseGuards, UseInterceptors, - UsePipes + UsePipes, } from '@nestjs/common'; -import {AuthzService} from '../authz/authz.service'; -import {Action} from '../casl/casl-ability.factory'; -import {ConfigService} from '../config/config.service'; -import {UniqueConstraintErrorFilter} from '../filters/unique-constraint-error.filter'; -import {ImplicitAllowJwtAuthGuard} from '../guards/implicit-allow-jwt-auth.guard'; -import {JwtAuthGuard} from '../guards/jwt-auth.guard'; -import {TestGuard} from '../guards/test.guard'; -import {LoggingInterceptor} from '../interceptors/logging.interceptor'; -import {PasswordChangePipe} from '../pipes/password-change.pipe'; -import {PasswordComplexityPipe} from '../pipes/password-complexity.pipe'; -import {PasswordsMatchPipe} from '../pipes/passwords-match.pipe'; -import {CreateUserDto} from './dto/create-user.dto'; -import {DeleteUserDto} from './dto/delete-user.dto'; -import {SlimUserDto} from './dto/slim-user.dto'; -import {UpdateUserDto} from './dto/update-user.dto'; -import {UserDto} from './dto/user.dto'; -import {User} from './user.model'; -import {UsersService} from './users.service'; +import { AuthzService } from '../authz/authz.service'; +import { Action } from '../casl/casl-ability.factory'; +import { ConfigService } from '../config/config.service'; +import { UniqueConstraintErrorFilter } from '../filters/unique-constraint-error.filter'; +import { ImplicitAllowJwtAuthGuard } from '../guards/implicit-allow-jwt-auth.guard'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { TestGuard } from '../guards/test.guard'; +import { LoggingInterceptor } from '../interceptors/logging.interceptor'; +import { PasswordChangePipe } from '../pipes/password-change.pipe'; +import { PasswordComplexityPipe } from '../pipes/password-complexity.pipe'; +import { PasswordsMatchPipe } from '../pipes/passwords-match.pipe'; +import { CreateUserDto } from './dto/create-user.dto'; +import { DeleteUserDto } from './dto/delete-user.dto'; +import { SlimUserDto } from './dto/slim-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UserDto } from './dto/user.dto'; +import { User } from './user.model'; +import { UsersService } from './users.service'; @UseInterceptors(LoggingInterceptor) @Controller('users') @@ -39,42 +39,27 @@ export class UsersController { constructor( private readonly usersService: UsersService, private readonly configService: ConfigService, - private readonly authz: AuthzService + private readonly authz: AuthzService, ) {} - @Get('/user-find-all') - @UseGuards(JwtAuthGuard) - async findAllUsers(@Request() request: {user: User}): Promise { - const abac = this.authz.abac.createForUser(request.user); - ForbiddenError.from(abac).throwUnlessCan(Action.ReadSlim, User); - const users = await this.usersService.findAllUsers(); - return users.map((user) => new SlimUserDto(user)); - } - - @UseGuards(JwtAuthGuard) - @Get(':id') - async findUserById( - @Param('id') id: string, - @Request() request: {user: User} - ): Promise { - const user = await this.usersService.findById(id); - - const abac = this.authz.abac.createForUser(request.user); - ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); - - return new UserDto(user); - } - @Get() @UseGuards(JwtAuthGuard) async adminFindAllUsers( - @Request() request: {user: User} + @Request() request: { user: User }, ): Promise { const abac = this.authz.abac.createForUser(request.user); ForbiddenError.from(abac).throwUnlessCan(Action.ReadAll, User); const users = await this.usersService.adminFindAllUsers(); - return users.map((user) => new UserDto(user)); + return users.map(user => new UserDto(user)); + } + + @UseGuards(TestGuard) + @Post('/clear') + async clear(): Promise { + // Awaited: the endpoint used to answer 200 while the truncate was still + // in flight, racing the test run that called it. + await User.truncate({ cascade: true }); } @Post() @@ -83,74 +68,91 @@ export class UsersController { @UseGuards(ImplicitAllowJwtAuthGuard) async create( @Body() createUserDto: CreateUserDto, - @Request() request: {user?: User} + @Request() request: { user?: User }, ): Promise { - const abac = request.user - ? this.authz.abac.createForUser(request.user) - : this.authz.abac.createForAnonymous(); // There should be no need to create users if user login is disabled if (!this.configService.isLocalLoginAllowed()) { throw new ForbiddenException( - 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.' + 'Local user login is disabled. Please disable LOCAL_LOGIN_DISABLED to use this feature.', ); } + const abac = request.user + ? this.authz.abac.createForUser(request.user) + : this.authz.abac.createForAnonymous(); // If registration is not allowed then validate the current user has the permission to bypass this check if (!this.configService.isRegistrationAllowed()) { ForbiddenError.from(abac) .setMessage( - 'User registration is disabled. Please ask your system administrator to create the account.' + 'User registration is disabled. Please ask your system administrator to create the account.', ) .throwUnlessCan(Action.ForceRegistration, User); } return new UserDto(await this.usersService.create(createUserDto)); } + @Get('/user-find-all') @UseGuards(JwtAuthGuard) - @Put(':id') - async update( + async findAllUsers(@Request() request: { user: User }): Promise { + const abac = this.authz.abac.createForUser(request.user); + ForbiddenError.from(abac).throwUnlessCan(Action.ReadSlim, User); + const users = await this.usersService.findAllUsers(); + return users.map(user => new SlimUserDto(user)); + } + + @UseGuards(JwtAuthGuard) + @Get(':id') + async findUserById( @Param('id') id: string, - @Request() request: {user: User}, - @Body( - new PasswordsMatchPipe(), - new PasswordChangePipe(), - new PasswordComplexityPipe() - ) - updateUserDto: UpdateUserDto + @Request() request: { user: User }, ): Promise { + const user = await this.usersService.findById(id); + const abac = this.authz.abac.createForUser(request.user); - const userToUpdate = await this.usersService.findByPkBang(id); - ForbiddenError.from(abac).throwUnlessCan(Action.Update, userToUpdate); + ForbiddenError.from(abac).throwUnlessCan(Action.Read, user); - return new UserDto( - await this.usersService.update(userToUpdate, updateUserDto, abac) - ); + return new UserDto(user); + } + + @UseGuards(JwtAuthGuard) + @Post('/logout') + async logOut(@Request() request: { user: User }): Promise { + return this.usersService.updateUserSecret(request.user); } @UseGuards(JwtAuthGuard) @Delete(':id') async remove( @Param('id') id: string, - @Request() request: {user: User}, - @Body() deleteUserDto: DeleteUserDto + @Request() request: { user: User }, + @Body() deleteUserDto: DeleteUserDto, ): Promise { const abac = this.authz.abac.createForUser(request.user); const userToDelete = await this.usersService.findByPkBang(id); ForbiddenError.from(abac).throwUnlessCan(Action.Delete, userToDelete); return new UserDto( - await this.usersService.remove(userToDelete, deleteUserDto, abac) + await this.usersService.remove(userToDelete, deleteUserDto, abac), ); } @UseGuards(JwtAuthGuard) - @Post('/logout') - async logOut(@Request() request: {user: User}): Promise { - return this.usersService.updateUserSecret(request.user); - } + @Put(':id') + async update( + @Param('id') id: string, + @Request() request: { user: User }, + @Body( + new PasswordsMatchPipe(), + new PasswordChangePipe(), + new PasswordComplexityPipe(), + ) + updateUserDto: UpdateUserDto, + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + const userToUpdate = await this.usersService.findByPkBang(id); + ForbiddenError.from(abac).throwUnlessCan(Action.Update, userToUpdate); - @UseGuards(TestGuard) - @Post('/clear') - async clear(): Promise { - User.truncate({cascade: true}); + return new UserDto( + await this.usersService.update(userToUpdate, updateUserDto, abac), + ); } } diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index fbc3fa3fdc..77048d39b1 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -1,21 +1,23 @@ -import {forwardRef, Module} from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {AuthzModule} from '../authz/authz.module'; -import {ConfigModule} from '../config/config.module'; -import {GroupsModule} from '../groups/groups.module'; -import {User} from './user.model'; -import {UsersController} from './users.controller'; -import {UsersService} from './users.service'; +import { forwardRef, Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { AuthzModule } from '../authz/authz.module'; +import { ConfigModule } from '../config/config.module'; +import { CryptoModule } from '../crypto/crypto.module'; +import { GroupsModule } from '../groups/groups.module'; +import { User } from './user.model'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; @Module({ + controllers: [UsersController], + exports: [SequelizeModule, UsersService], imports: [ SequelizeModule.forFeature([User]), AuthzModule, ConfigModule, - forwardRef(() => GroupsModule) + CryptoModule, + forwardRef(() => GroupsModule), ], providers: [UsersService], - controllers: [UsersController], - exports: [SequelizeModule, UsersService] }) export class UsersModule {} diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index bd182bedae..29725c4782 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -1,13 +1,21 @@ -import {Ability} from '@casl/ability'; +import type { Ability } from '@casl/ability'; import { BadRequestException, ForbiddenException, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {SequelizeModule} from '@nestjs/sequelize'; -import {Test} from '@nestjs/testing'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; -import {GROUPS_SERVICE_MOCK} from '../../test/constants/groups-test.constant'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { Test } from '@nestjs/testing'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { GROUPS_SERVICE_MOCK } from '../../test/constants/groups-test.constant'; import { CREATE_ADMIN_DTO, CREATE_SECOND_ADMIN_DTO, @@ -22,6 +30,8 @@ import { DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE, UPDATE_USER_DTO_TEST_OBJ, + UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL, UPDATE_USER_DTO_TEST_WITHOUT_EMAIL, UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, @@ -29,33 +39,49 @@ import { UPDATE_USER_DTO_TEST_WITHOUT_ORGANIZATION, UPDATE_USER_DTO_TEST_WITHOUT_ROLE, UPDATE_USER_DTO_TEST_WITHOUT_TITLE, - UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL, - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, - USER_ONE_DTO + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, + USER_ONE_DTO, } from '../../test/constants/users-test.constant'; -import {AuthzModule} from '../authz/authz.module'; -import {AuthzService} from '../authz/authz.service'; -import {ConfigService} from '../config/config.service'; -import {DatabaseModule} from '../database/database.module'; -import {DatabaseService} from '../database/database.service'; -import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; -import {Evaluation} from '../evaluations/evaluation.model'; -import {GroupEvaluation} from '../group-evaluations/group-evaluation.model'; -import {GroupUser} from '../group-users/group-user.model'; -import {Group} from '../groups/group.model'; -import {GroupsService} from '../groups/groups.service'; -import {SlimUserDto} from './dto/slim-user.dto'; -import {UserDto} from './dto/user.dto'; -import {User} from './user.model'; -import {UsersService} from './users.service'; +import { AuthzModule } from '../authz/authz.module'; +import { AuthzService } from '../authz/authz.service'; +import { ConfigService } from '../config/config.service'; +import { CryptoModule } from '../crypto/crypto.module'; +import type * as PasswordCrypto from '../crypto/password'; +import { hashPassword, verifyPassword } from '../crypto/password'; +import { DatabaseModule } from '../database/database.module'; +import { DatabaseService } from '../database/database.service'; +import { EvaluationTag } from '../evaluation-tags/evaluation-tag.model'; +import { Evaluation } from '../evaluations/evaluation.model'; +import { GroupEvaluation } from '../group-evaluations/group-evaluation.model'; +import { GroupUser } from '../group-users/group-user.model'; +import { Group } from '../groups/group.model'; +import { GroupsService } from '../groups/groups.service'; +import { SlimUserDto } from './dto/slim-user.dto'; +import { UserDto } from './dto/user.dto'; +import { User } from './user.model'; +import { UsersService } from './users.service'; + +// Pass-through wrap so the FIPS-refuse test can steer ONE verifyPassword +// result (real host FIPS state cannot be entered in CI — §10 it is host-level; +// verifyPassword's own FIPS behavior is proven in password.spec.ts with an +// injected getFips). Every other call goes to the real implementation. +vi.mock('../crypto/password', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, verifyPassword: vi.fn(actual.verifyPassword) }; +}); + +// ADR-006 §2: exact prefix — algorithm AND iteration count pinned, never a +// loose $pbkdf2-sha* match (the ADR anti-pattern). Module scope so the regex +// is compiled once. +const PHC_SHA512_600K_PREFIX = /^\$pbkdf2-sha512\$i=600000\$/v; describe('UsersService', () => { let authzService: AuthzService; let usersService: UsersService; let databaseService: DatabaseService; - const errorString = - 'User that was just created was not returned from the database. Create method may have failed silently.'; + const errorString + = 'User that was just created was not returned from the database. Create method may have failed silently.'; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -67,17 +93,18 @@ describe('UsersService', () => { Group, GroupEvaluation, Evaluation, - EvaluationTag + EvaluationTag, ]), - AuthzModule + AuthzModule, + CryptoModule, ], providers: [ AuthzService, ConfigService, DatabaseService, UsersService, - {provide: GroupsService, useValue: GROUPS_SERVICE_MOCK} - ] + { provide: GroupsService, useValue: GROUPS_SERVICE_MOCK }, + ], }).compile(); authzService = module.get(AuthzService); @@ -105,22 +132,62 @@ describe('UsersService', () => { expect(user.title).toEqual(USER_ONE_DTO.title); expect(user.organization).toEqual(USER_ONE_DTO.organization); expect(user.updatedAt.valueOf()).not.toBe( - USER_ONE_DTO.updatedAt.valueOf() + USER_ONE_DTO.updatedAt.valueOf(), ); expect(user.role).toEqual(USER_ONE_DTO.role); }); + it('stores encryptedPassword as a PBKDF2 PHC string that round-trips through verifyPassword (ADR-006 §4 site 1)', async () => { + expect.assertions(3); + const created = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const stored = await User.findByPk(created.id); + expect(stored?.encryptedPassword).toMatch(PHC_SHA512_600K_PREFIX); + const result = await verifyPassword({ + hash: stored?.encryptedPassword ?? '', + password: CREATE_USER_DTO_TEST_OBJ.password, + }); + expect(result.valid).toBe(true); + // A freshly written hash must already be at policy — no rehash debt. + expect(result.needsRehash).toBe(false); + }); + + it('accepts the 64-char external-auth placeholder password (ADR-006 §6 — regression pairing with e25.11)', async () => { + expect.assertions(1); + // validateOrCreateUser feeds randomBytes(32).toString('hex') — exactly + // 64 chars — through this path; it must clear the 128 cap. + const placeholder = 'ab'.repeat(32); + const created = await usersService.create({ + ...CREATE_USER_DTO_TEST_OBJ, + password: placeholder, + passwordConfirmation: placeholder, + }); + const stored = await User.findByPk(created.id); + expect(stored?.encryptedPassword).toMatch(PHC_SHA512_600K_PREFIX); + }); + + it('rejects a password over the 128-char cap with BadRequestException (§6 approved range)', async () => { + expect.assertions(1); + const overCap = 'a'.repeat(129); + await expect( + usersService.create({ + ...CREATE_USER_DTO_TEST_OBJ, + password: overCap, + passwordConfirmation: overCap, + }), + ).rejects.toThrow(BadRequestException); + }); + it('should throw an error when missing the email field', async () => { expect.assertions(1); await expect( - usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD) + usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD), ).rejects.toThrow('notNull Violation: User.email cannot be null'); }); it('should throw an error when email field is invalid', async () => { expect.assertions(1); await expect( - usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD) + usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD), ).rejects.toThrow('Validation isEmail on email failed'); }); @@ -128,15 +195,15 @@ describe('UsersService', () => { expect.assertions(1); await expect( usersService.create( - CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD - ) + CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD, + ), ).rejects.toThrow(BadRequestException); }); it('should throw an error when missing the role field', async () => { expect.assertions(1); await expect( - usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ROLE) + usersService.create(CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ROLE), ).rejects.toThrow('notNull Violation: User.role cannot be null'); }); }); @@ -146,9 +213,8 @@ describe('UsersService', () => { expect.assertions(2); const userOne = await usersService.create(CREATE_USER_DTO_TEST_OBJ); const userTwo = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); - const userDtoArray = (await usersService.adminFindAllUsers()).map( - (user) => new UserDto(user) - ); + const allUsers = await usersService.adminFindAllUsers(); + const userDtoArray = allUsers.map(user => new UserDto(user)); expect(userDtoArray).toContainEqual(new UserDto(userOne)); expect(userDtoArray).toContainEqual(new UserDto(userTwo)); }); @@ -159,9 +225,8 @@ describe('UsersService', () => { expect.assertions(2); const userOne = await usersService.create(CREATE_USER_DTO_TEST_OBJ); const userTwo = await usersService.create(CREATE_USER_DTO_TEST_OBJ_2); - const slimUserDtoArray = (await usersService.findAllUsers()).map( - (user) => new SlimUserDto(user) - ); + const allUsers = await usersService.findAllUsers(); + const slimUserDtoArray = allUsers.map(user => new SlimUserDto(user)); expect(slimUserDtoArray).toContainEqual(new SlimUserDto(userOne)); expect(slimUserDtoArray).toContainEqual(new SlimUserDto(userTwo)); }); @@ -185,7 +250,7 @@ describe('UsersService', () => { it('should throw an error if user does not exist', async () => { expect.assertions(1); await expect(usersService.findById('-1')).rejects.toThrow( - NotFoundException + NotFoundException, ); }); }); @@ -200,7 +265,7 @@ describe('UsersService', () => { expect(foundUser.lastName).toEqual(CREATE_USER_DTO_TEST_OBJ.lastName); expect(foundUser.title).toEqual(CREATE_USER_DTO_TEST_OBJ.title); expect(foundUser.organization).toEqual( - CREATE_USER_DTO_TEST_OBJ.organization + CREATE_USER_DTO_TEST_OBJ.organization, ); expect(foundUser.role).toEqual(CREATE_USER_DTO_TEST_OBJ.role); }); @@ -208,7 +273,7 @@ describe('UsersService', () => { it('should throw an error if user does not exist', async () => { expect.assertions(1); await expect( - usersService.findByEmail('doesnotexist@example.com') + usersService.findByEmail('doesnotexist@example.com'), ).rejects.toThrow(NotFoundException); }); }); @@ -228,9 +293,8 @@ describe('UsersService', () => { if (findUser === null || admin === null) { throw new TypeError(errorString); - } else { - user = findUser; } + user = findUser; userCreatedAt = user.updatedAt; abacPolicy = authzService.abac.createForUser(user); @@ -243,7 +307,7 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_OBJ, - abacPolicy + abacPolicy, ); expect(updatedUser.email).toEqual(UPDATE_USER_DTO_TEST_OBJ.email); @@ -251,43 +315,87 @@ describe('UsersService', () => { expect(updatedUser.lastName).toEqual(UPDATE_USER_DTO_TEST_OBJ.lastName); expect(updatedUser.title).toEqual(UPDATE_USER_DTO_TEST_OBJ.title); expect(updatedUser.organization).toEqual( - UPDATE_USER_DTO_TEST_OBJ.organization + UPDATE_USER_DTO_TEST_OBJ.organization, ); expect(updatedUser.role).toEqual(UPDATE_USER_DTO_TEST_OBJ.role); expect(updatedUser.email).not.toEqual(CREATE_USER_DTO_TEST_OBJ.email); expect(updatedUser.firstName).not.toEqual( - CREATE_USER_DTO_TEST_OBJ.firstName + CREATE_USER_DTO_TEST_OBJ.firstName, ); expect(updatedUser.lastName).not.toEqual( - CREATE_USER_DTO_TEST_OBJ.lastName + CREATE_USER_DTO_TEST_OBJ.lastName, ); expect(updatedUser.title).not.toEqual(CREATE_USER_DTO_TEST_OBJ.title); expect(updatedUser.organization).not.toEqual( - CREATE_USER_DTO_TEST_OBJ.organization + CREATE_USER_DTO_TEST_OBJ.organization, ); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); // This will not change currently because there is only a 'user' role that can be updated via API. expect(updatedUser.role).toEqual(user.role); expect(user.forcePasswordChange).toEqual( - UPDATE_USER_DTO_TEST_OBJ.forcePasswordChange + UPDATE_USER_DTO_TEST_OBJ.forcePasswordChange, ); }); + it('stores a changed password as PBKDF2 PHC and preserves the lifecycle semantics (ADR-006 §4 site 2)', async () => { + expect.assertions(5); + // Seed force-change ON so the clear inside update()'s password branch + // is observable — with the fixture's false baseline the assertion below + // would pass even if the clear were deleted (AC-review round-1 finding). + await user.update({ forcePasswordChange: true }, { silent: true }); + const preUpdate = await User.findByPk(user.id); + const pre = preUpdate?.passwordChangedAt; + await usersService.update( + user, + UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + abacPolicy, + ); + const stored = await User.findByPk(user.id); + expect(stored?.encryptedPassword).toMatch(PHC_SHA512_600K_PREFIX); + const result = await verifyPassword({ + hash: stored?.encryptedPassword ?? '', + password: UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD.password ?? '', + }); + expect(result.valid).toBe(true); + expect(result.needsRehash).toBe(false); + // Genuine password change (users.service.ts:84-104 unchanged): the + // lifecycle fields still move — passwordChangedAt is stamped, and + // forcePasswordChange clears when the DTO does not re-raise it. + expect(String(stored?.passwordChangedAt)).not.toBe(String(pre)); + expect(stored?.forcePasswordChange).toBe(false); + }); + + it('rejects a changed password over the 128-char cap with BadRequestException (§6 approved range)', async () => { + expect.assertions(1); + const overCap = 'a'.repeat(129); + await expect( + usersService.update( + user, + { + ...UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD, + password: overCap, + passwordConfirmation: overCap, + }, + abacPolicy, + ), + ).rejects.toThrow(BadRequestException); + }); + // Users should be able to update their account without updating their email it('should update a user without updating email', async () => { expect.assertions(2); const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_EMAIL, - abacPolicy + abacPolicy, ); expect(updatedUser.email).toEqual(CREATE_USER_DTO_TEST_OBJ.email); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -297,12 +405,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME, - abacPolicy + abacPolicy, ); expect(updatedUser.firstName).toEqual(user.firstName); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -312,12 +420,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_LAST_NAME, - abacPolicy + abacPolicy, ); expect(updatedUser.lastName).toEqual(user.lastName); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -327,12 +435,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_ORGANIZATION, - abacPolicy + abacPolicy, ); expect(updatedUser.organization).toEqual(user.organization); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -342,12 +450,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_TITLE, - abacPolicy + abacPolicy, ); expect(updatedUser.title).toEqual(user.title); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -357,12 +465,12 @@ describe('UsersService', () => { const updatedUser = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_ROLE, - abacPolicy + abacPolicy, ); expect(updatedUser.role).toEqual(user.role); expect(updatedUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -372,35 +480,35 @@ describe('UsersService', () => { const updateUserDto = await usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, - abacPolicy + abacPolicy, ); const updateUser = await usersService.findByPkBang(updateUserDto.id); expect(updateUserDto.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); expect(updateUser.forcePasswordChange).toEqual(user.forcePasswordChange); }); it('should update a user without updating password', async () => { expect.assertions(8); - const {encryptedPassword} = user; + const { encryptedPassword } = user; await usersService.update( user, UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS, - abacPolicy + abacPolicy, ); expect(user.email).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.email); expect(user.firstName).toEqual( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.firstName + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.firstName, ); expect(user.lastName).toEqual( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.lastName + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.lastName, ); expect(user.organization).toEqual( - UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.organization + UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.organization, ); expect(user.title).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.title); expect(user.role).toEqual(UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS.role); @@ -410,18 +518,18 @@ describe('UsersService', () => { it('should update a user without matching password when admin', async () => { expect.assertions(2); - const {encryptedPassword} = user; + const { encryptedPassword } = user; const updateUser = await usersService.update( user, UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, - adminAbacPolicy + adminAbacPolicy, ); expect(user.encryptedPassword).not.toEqual(encryptedPassword); expect(updateUser.updatedAt.valueOf()).not.toEqual( - userCreatedAt.valueOf() + userCreatedAt.valueOf(), ); }); @@ -431,8 +539,8 @@ describe('UsersService', () => { usersService.update( user, UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow(ForbiddenException); }); @@ -442,8 +550,8 @@ describe('UsersService', () => { usersService.update( user, UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow('Validation error: Validation isEmail on email failed'); }); @@ -452,21 +560,21 @@ describe('UsersService', () => { await usersService.update( user, UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE, - abacPolicy + abacPolicy, ); await expect( usersService.update( user, UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow(BadRequestException); }); describe('UpdateLoginMetadata', () => { it('should update user lastLogin and loginCount', async () => { expect.assertions(2); - const {lastLogin} = user; + const { lastLogin } = user; await usersService.updateLoginMetadata(user); @@ -490,10 +598,9 @@ describe('UsersService', () => { if (userResponse === null || adminResponse === null) { throw new TypeError(errorString); - } else { - user = userResponse; - adminUser = adminResponse; } + user = userResponse; + adminUser = adminResponse; abacPolicy = authzService.abac.createForUser(user); adminAbacPolicy = authzService.abac.createForUser(adminResponse); @@ -502,7 +609,7 @@ describe('UsersService', () => { it('should throw an error when password fields do not match', async () => { expect.assertions(1); await expect( - usersService.remove(user, DELETE_FAILURE_USER_DTO_TEST_OBJ, abacPolicy) + usersService.remove(user, DELETE_FAILURE_USER_DTO_TEST_OBJ, abacPolicy), ).rejects.toThrow(ForbiddenException); }); @@ -513,16 +620,55 @@ describe('UsersService', () => { usersService.remove( user, DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD, - abacPolicy - ) + abacPolicy, + ), ).rejects.toThrow(ForbiddenException); }); + it('succeeds when the supplied password matches a PBKDF2-stored hash (site 3)', async () => { + // Overwrite the bcrypt hash create() wrote (site 1 — e25.12's card) with + // a PBKDF2 hash of the same password. remove() must verify it via the + // pure verifyPassword; bcryptjs.compare returns false on a PHC string. + // DeleteUserDto.password is optional; '' makes hashPassword throw, so a + // fixture that ever loses its password fails this test loudly. + await user.update({ + encryptedPassword: await hashPassword( + DELETE_USER_DTO_TEST_OBJ.password ?? '', + ), + }); + const removedUser = await usersService.remove( + user, + DELETE_USER_DTO_TEST_OBJ, + abacPolicy, + ); + expect(removedUser.email).toEqual(user.email); + await expect(usersService.findByEmail(user.email)).rejects.toThrow( + NotFoundException, + ); + }); + + it('refuses deletion when verifyPassword returns the FIPS-refuse result (site 3 consumes .valid only)', async () => { + // Steer one result to the §3 refuse shape. remove() must read .valid + // alone — a refused bcrypt credential blocks deletion exactly like a + // wrong password. Clear first so the invocation assertion below cannot + // be satisfied by a prior test's call on the shared module mock. + vi.mocked(verifyPassword).mockClear(); + vi.mocked(verifyPassword).mockResolvedValueOnce({ + needsRehash: false, + requiresReset: true, + valid: false, + }); + await expect( + usersService.remove(user, DELETE_USER_DTO_TEST_OBJ, abacPolicy), + ).rejects.toThrow(ForbiddenException); + expect(verifyPassword).toHaveBeenCalled(); + }); + it('should remove created user', async () => { const removedUser = await usersService.remove( user, DELETE_USER_DTO_TEST_OBJ, - abacPolicy + abacPolicy, ); expect.assertions(7); expect(removedUser.email).toEqual(user.email); @@ -532,7 +678,7 @@ describe('UsersService', () => { expect(removedUser.title).toEqual(user.title); expect(removedUser.role).toEqual(user.role); await expect(usersService.findByEmail(user.email)).rejects.toThrow( - NotFoundException + NotFoundException, ); }); @@ -540,7 +686,7 @@ describe('UsersService', () => { const removedUser = await usersService.remove( user, DELETE_USER_DTO_TEST_OBJ, - adminAbacPolicy + adminAbacPolicy, ); expect.assertions(7); expect(removedUser.email).toEqual(user.email); @@ -550,7 +696,7 @@ describe('UsersService', () => { expect(removedUser.title).toEqual(user.title); expect(removedUser.role).toEqual(user.role); await expect(usersService.findByEmail(user.email)).rejects.toThrow( - NotFoundException + NotFoundException, ); }); @@ -563,32 +709,93 @@ describe('UsersService', () => { await usersService.remove( adminUser, DELETE_USER_DTO_TEST_OBJ, - adminAbacPolicy + adminAbacPolicy, ); // Make sure the existing admin has been deleted - await expect(async () => { - await usersService.findById(adminUser.id); - }).rejects.toThrow(NotFoundException); + await expect(usersService.findById(adminUser.id)).rejects.toThrow(NotFoundException); }); // Admins should not be able to remove their account if they are the only administrator it('should test remove function with admin user that is the only admin', async () => { expect.assertions(1); - await expect(async () => { - await usersService.remove( - adminUser, - DELETE_USER_DTO_TEST_OBJ, - adminAbacPolicy - ); - }).rejects.toThrow(ForbiddenException); + await expect(usersService.remove( + adminUser, + DELETE_USER_DTO_TEST_OBJ, + adminAbacPolicy, + )).rejects.toThrow(ForbiddenException); }); // Admins should be able to remove other users without their password it('should test remove function with admin user and a dto that has no password', async () => { expect( - new UserDto(await usersService.remove(user, {}, adminAbacPolicy)) + new UserDto(await usersService.remove(user, {}, adminAbacPolicy)), ).toEqual(new UserDto(user)); }); }); + + // ADR-006 §7: narrow compare-and-swap writer for lazy rehash. Touches + // encryptedPassword ONLY, gated on the stored hash still matching, silent so + // updatedAt is not bumped. Takes a userId (not a User instance) so it cannot + // leak the new hash into the un-awaited updateLoginMetadata save (AC6 by + // construction). + describe('updateEncryptedPassword (§7 compare-and-swap)', () => { + let user: User; + const ORIGINAL = '$pbkdf2-sha512$i=600000$origOrigOrigOrigOrig$origKeyOrig'; + const NEW = '$pbkdf2-sha512$i=600000$newnewnewnewnewnew$newKeyNewKey'; + + beforeEach(async () => { + const dto = await usersService.create(CREATE_USER_DTO_TEST_OBJ); + const created = await User.findByPk(dto.id); + if (created === null) { + throw new TypeError(errorString); + } + user = created; + // Seed a known stored hash directly (bypassing hashing — this card is + // persistence only). silent so the baseline updatedAt is stable. + await user.update({ encryptedPassword: ORIGINAL }, { silent: true }); + }); + + it('returns 0 and writes nothing when the stored hash no longer matches originalHash', async () => { + // The CAS-loses-the-race case (§7's damage scenario) — comes first. + const affected = await usersService.updateEncryptedPassword( + user.id, + 'a-stale-hash-that-does-not-match', + NEW, + ); + expect(affected).toBe(0); + const reloaded = await User.findByPk(user.id); + expect(reloaded?.encryptedPassword).toBe(ORIGINAL); + }); + + it('returns 1 and swaps encryptedPassword when originalHash matches', async () => { + const affected = await usersService.updateEncryptedPassword( + user.id, + ORIGINAL, + NEW, + ); + expect(affected).toBe(1); + const reloaded = await User.findByPk(user.id); + expect(reloaded?.encryptedPassword).toBe(NEW); + }); + + it('does NOT bump updatedAt on a winning write (silent: true)', async () => { + const before = await User.findByPk(user.id); + const beforeUpdatedAt = before?.updatedAt?.getTime(); + await usersService.updateEncryptedPassword(user.id, ORIGINAL, NEW); + const after = await User.findByPk(user.id); + expect(after?.updatedAt?.getTime()).toBe(beforeUpdatedAt); + }); + + it('does NOT touch passwordChangedAt or forcePasswordChange', async () => { + const before = await User.findByPk(user.id); + // Type-agnostic capture (§7 wrinkle: column may be STRING or DATE). + const beforePwChanged = String(before?.passwordChangedAt); + const beforeForce = before?.forcePasswordChange; + await usersService.updateEncryptedPassword(user.id, ORIGINAL, NEW); + const after = await User.findByPk(user.id); + expect(String(after?.passwordChangedAt)).toBe(beforePwChanged); + expect(after?.forcePasswordChange).toBe(beforeForce); + }); + }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 9a87304103..b1eb97f1f0 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,22 +1,23 @@ -import {Ability} from '@casl/ability'; +import { Ability } from '@casl/ability'; import { BadRequestException, ForbiddenException, Injectable, - NotFoundException + NotFoundException, } from '@nestjs/common'; -import {InjectModel} from '@nestjs/sequelize'; -import {compare, hash} from 'bcryptjs'; -import {FindOptions} from 'sequelize'; -import {v4} from 'uuid'; -import {AuthnService} from '../authn/authn.service'; -import {Action} from '../casl/casl-ability.factory'; -import {ConfigService} from '../config/config.service'; -import {GroupsService} from '../groups/groups.service'; -import {CreateUserDto} from './dto/create-user.dto'; -import {DeleteUserDto} from './dto/delete-user.dto'; -import {UpdateUserDto} from './dto/update-user.dto'; -import {User} from './user.model'; +import { InjectModel } from '@nestjs/sequelize'; +import { FindOptions } from 'sequelize'; +import { v4 } from 'uuid'; +import { AuthnService } from '../authn/authn.service'; +import { Action } from '../casl/casl-ability.factory'; +import { ConfigService } from '../config/config.service'; +import { verifyPassword } from '../crypto/password'; +import { PasswordService } from '../crypto/password.service'; +import { GroupsService } from '../groups/groups.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { DeleteUserDto } from './dto/delete-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { User } from './user.model'; @Injectable() export class UsersService { @@ -24,35 +25,18 @@ export class UsersService { @InjectModel(User) private readonly userModel: typeof User, private readonly configService: ConfigService, - private readonly groupsService: GroupsService + private readonly groupsService: GroupsService, + private readonly passwordService: PasswordService, ) {} async adminFindAllUsers(): Promise { return this.userModel.findAll(); } - async findAllUsers(): Promise { - return this.userModel.findAll({ - attributes: ['id', 'email', 'title', 'firstName', 'lastName'] - }); - } - async count(): Promise { return this.userModel.count(); } - async findById(id: string): Promise { - return this.findByPkBang(id); - } - - async findByEmail(email: string): Promise { - return this.findOneBang({ - where: { - email - } - }); - } - async create(createUserDto: CreateUserDto): Promise { const user = new User(); user.email = createUserDto.email; @@ -63,30 +47,115 @@ export class UsersService { user.role = createUserDto.role; user.creationMethod = createUserDto.creationMethod; try { - user.encryptedPassword = await hash(createUserDto.password, 14); + // ADR-006 §4 site 1: PBKDF2 via the validated module, PHC output (§2). + // PasswordHashError (missing password, over-cap length) maps to 400. + user.encryptedPassword = await this.passwordService.hash( + createUserDto.password, + ); } catch { throw new BadRequestException(); } return user.save(); } + async findAllUsers(): Promise { + return this.userModel.findAll({ attributes: ['id', 'email', 'title', 'firstName', 'lastName'] }); + } + + async findByEmail(email: string): Promise { + return this.findOneBang({ where: { email } }); + } + + async findById(id: string): Promise { + return this.findByPkBang(id); + } + + async findByPkBang( + identifier: Buffer | number | string | undefined, + ): Promise { + const user = await this.userModel.findByPk(identifier); + if (user === null) { + throw new NotFoundException('User with given id not found'); + } + return user; + } + + async findOneBang(options: FindOptions | undefined): Promise { + const user = await this.userModel.findOne(options); + if (user === null) { + throw new NotFoundException('User with given id not found'); + } + return user; + } + + async remove( + userToDelete: User, + deleteUserDto: DeleteUserDto, + abac: Ability, + ): Promise { + if (abac.cannot(Action.DeleteNoPassword, userToDelete)) { + // Site 3 (ADR-006 §4): verify-only — consumes .valid alone, never + // rehashes. Handles PBKDF2 and legacy bcrypt; refuses bcrypt under + // FIPS like any failed verification. + const { valid } = await verifyPassword({ + hash: userToDelete.encryptedPassword, + password: deleteUserDto.password || '', + }); + if (!valid) { + throw new ForbiddenException( + 'Password was incorrect, could not delete account', + ); + } + } + + const adminCount = await this.userModel.count({ where: { role: 'admin' } }); + // Do not allow the administrator to destroy the only + // administrator account + if (userToDelete.role === 'admin' && adminCount < 2) { + throw new ForbiddenException( + 'Cannot destroy only administrator account, please promote another user to administrator first', + ); + } + // Clean up groups owned by user + const allGroups = await this.groupsService.findAll(); + await Promise.all( + allGroups.map(async (group) => { + if (group.users.some(user => user.id === userToDelete.id)) { + await this.groupsService.ensureGroupHasOwner(group, userToDelete); + } + }), + ); + await userToDelete.destroy(); + return userToDelete; + } + async update( userToUpdate: User, updateUserDto: UpdateUserDto, - abac: Ability + abac: Ability, ): Promise { if (!abac.can('update-no-password', userToUpdate)) { await AuthnService.prototype.testPassword(updateUserDto, userToUpdate); } if ( - (updateUserDto.password === undefined || - updateUserDto.password === null) && - userToUpdate.forcePasswordChange && - !abac.can('skip-force-password-change', userToUpdate) + (updateUserDto.password === undefined + || updateUserDto.password === null) + && userToUpdate.forcePasswordChange + && !abac.can('skip-force-password-change', userToUpdate) ) { throw new BadRequestException('You must change your password'); - } else if (updateUserDto.password) { - userToUpdate.encryptedPassword = await hash(updateUserDto.password, 14); + } + if (updateUserDto.password) { + try { + // ADR-006 §4 site 2: PBKDF2 via the validated module, PHC output + // (§2). Over-cap length (§6 approved range) maps to 400, matching + // create(); bcryptjs silently truncated at 72 bytes instead. + userToUpdate.encryptedPassword = await this.passwordService.hash( + updateUserDto.password, + ); + } catch { + throw new BadRequestException(); + } userToUpdate.passwordChangedAt = new Date(); userToUpdate.forcePasswordChange = false; } @@ -94,17 +163,44 @@ export class UsersService { userToUpdate.firstName = updateUserDto.firstName || userToUpdate.firstName; userToUpdate.lastName = updateUserDto.lastName || userToUpdate.lastName; userToUpdate.title = updateUserDto.title || userToUpdate.title; - userToUpdate.organization = - updateUserDto.organization || userToUpdate.organization; + userToUpdate.organization + = updateUserDto.organization || userToUpdate.organization; if (abac.can('update-role', userToUpdate)) { // Only admins can update roles userToUpdate.role = updateUserDto.role || userToUpdate.role; } - userToUpdate.forcePasswordChange = - updateUserDto.forcePasswordChange || userToUpdate.forcePasswordChange; + userToUpdate.forcePasswordChange + = updateUserDto.forcePasswordChange || userToUpdate.forcePasswordChange; return userToUpdate.save(); } + /** + * ADR-006 §7: narrow compare-and-swap writer for lazy password rehash. + * Rewrites encryptedPassword ONLY, and only while the stored value still + * equals `originalHash` — so an in-flight password change (which the + * un-awaited updateLoginMetadata save at authn.service.ts races) is never + * silently reverted. `fields` restricts the write to the one column; + * `silent` suppresses the updatedAt bump so a mass rehash does not make + * every account look recently modified. Takes a userId (not a User + * instance) so the new hash cannot leak into that racing save. Returns the + * affected row count — 0 means another writer won; the caller does nothing. + */ + async updateEncryptedPassword( + userId: string, + originalHash: string, + newHash: string, + ): Promise { + const [affected] = await this.userModel.update( + { encryptedPassword: newHash }, + { + fields: ['encryptedPassword'], + silent: true, + where: { encryptedPassword: originalHash, id: userId }, + }, + ); + return affected; + } + async updateLoginMetadata(user: User): Promise { user.lastLogin = new Date(); user.loginCount++; @@ -115,61 +211,4 @@ export class UsersService { user.jwtSecret = v4(); await user.save(); } - - async remove( - userToDelete: User, - deleteUserDto: DeleteUserDto, - abac: Ability - ): Promise { - if ( - abac.cannot(Action.DeleteNoPassword, userToDelete) && - !(await compare( - deleteUserDto.password || '', - userToDelete.encryptedPassword - )) - ) { - throw new ForbiddenException( - 'Password was incorrect, could not delete account' - ); - } - - const adminCount = await this.userModel.count({where: {role: 'admin'}}); - // Do not allow the administrator to destroy the only - // administrator account - if (userToDelete.role === 'admin' && adminCount < 2) { - throw new ForbiddenException( - 'Cannot destroy only administrator account, please promote another user to administrator first' - ); - } - // Clean up groups owned by user - await Promise.all( - (await this.groupsService.findAll()).map(async (group) => { - if (group.users.some((user) => user.id === userToDelete.id)) { - await this.groupsService.ensureGroupHasOwner(group, userToDelete); - } - }) - ); - await userToDelete.destroy(); - return userToDelete; - } - - async findByPkBang( - identifier: string | number | Buffer | undefined - ): Promise { - const user = await this.userModel.findByPk(identifier); - if (user === null) { - throw new NotFoundException('User with given id not found'); - } else { - return user; - } - } - - async findOneBang(options: FindOptions | undefined): Promise { - const user = await this.userModel.findOne(options); - if (user === null) { - throw new NotFoundException('User with given id not found'); - } else { - return user; - } - } } diff --git a/apps/backend/test/constants/env-test.constant.ts b/apps/backend/test/constants/env-test.constant.ts deleted file mode 100644 index 0563fa1320..0000000000 --- a/apps/backend/test/constants/env-test.constant.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const ENV_MOCK_FILE = - 'PORT=8000\n' + - 'DATABASE_HOST=localhost\n' + - 'DATABASE_PORT=5432\n' + - 'DATABASE_USERNAME=postgres\n' + - 'DATABASE_PASSWORD=postgres\n' + - 'DATABASE_NAME=heimdallts_vitest_testing_service_db\n' + - 'JWT_SECRET=abc123\n' + - 'NODE_ENV=test\n'; - -export const SIMPLE_ENV_MOCK_FILE = 'PORT=8001\n'; - -export const DATABASE_URL_MOCK_ENV = - 'DATABASE_URL=postgres://abcdefghijk123456:000011112222333344455556666777778889999aaaabbbbccccddddeeeffff@ec2-00-000-11-123.compute-1.amazonaws.com:5432/database01'; diff --git a/apps/backend/test/constants/environment-test.constant.ts b/apps/backend/test/constants/environment-test.constant.ts new file mode 100644 index 0000000000..d1ed84145a --- /dev/null +++ b/apps/backend/test/constants/environment-test.constant.ts @@ -0,0 +1,35 @@ +export const ENV_MOCK_FILE + = 'PORT=8000\n' + + 'DATABASE_HOST=localhost\n' + + 'DATABASE_PORT=5432\n' + + 'DATABASE_USERNAME=postgres\n' + + 'DATABASE_PASSWORD=postgres\n' + + 'DATABASE_NAME=heimdallts_vitest_testing_service_db\n' + + 'JWT_SECRET=abc123\n' + + 'NODE_ENV=test\n'; + +export const SIMPLE_ENV_MOCK_FILE = 'PORT=8001\n'; + +export const DATABASE_URL_MOCK_ENV + = 'DATABASE_URL=postgres://abcdefghijk123456:000011112222333344455556666777778889999aaaabbbbccccddddeeeffff@ec2-00-000-11-123.compute-1.amazonaws.com:5432/database01'; + +// Connection URLs commonly carry query parameters (sslmode, application_name). +// They belong to no component and must never leak into the database name. +export const DATABASE_URL_WITH_QUERY_MOCK_ENV + = 'DATABASE_URL=postgres://queryuser:querypass@db.internal.example:6432/database02?sslmode=require&application_name=heimdall'; + +// GitLab's client secret has two accepted spellings. GITLAB_CLIENTSECRET is +// canonical — it matches GITHUB_CLIENTSECRET / GOOGLE_CLIENTSECRET / +// OKTA_CLIENTSECRET and is what .env-example and the RPM man page have always +// documented. GITLAB_SECRET is the legacy name the strategy actually read, so +// deployments configured against the code rather than the docs keep working. +export const GITLAB_CANONICAL_SECRET_ENV + = 'GITLAB_CLIENTSECRET=canonical-secret\n'; + +export const GITLAB_LEGACY_SECRET_ENV = 'GITLAB_SECRET=legacy-secret\n'; + +export const GITLAB_BOTH_SECRETS_ENV + = 'GITLAB_CLIENTSECRET=canonical-secret\nGITLAB_SECRET=legacy-secret\n'; + +export const GITLAB_EMPTY_CANONICAL_SECRET_ENV + = 'GITLAB_CLIENTSECRET=\nGITLAB_SECRET=legacy-secret\n'; diff --git a/apps/backend/test/constants/evaluation-tags-test.constant.ts b/apps/backend/test/constants/evaluation-tags-test.constant.ts index 9f4d6161fe..8f7008d5ed 100644 --- a/apps/backend/test/constants/evaluation-tags-test.constant.ts +++ b/apps/backend/test/constants/evaluation-tags-test.constant.ts @@ -1,34 +1,30 @@ -import {CreateEvaluationTagDto} from '../../src/evaluation-tags/dto/create-evaluation-tag.dto'; -import {EvaluationTagDto} from '../../src/evaluation-tags/dto/evaluation-tag.dto'; -import {EvaluationTag} from '../../src/evaluation-tags/evaluation-tag.model'; +import type { CreateEvaluationTagDto } from '../../src/evaluation-tags/dto/create-evaluation-tag.dto'; +import type { EvaluationTagDto } from '../../src/evaluation-tags/dto/evaluation-tag.dto'; +import type { EvaluationTag } from '../../src/evaluation-tags/evaluation-tag.model'; /* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-ignore export const EVALUATION_TAG_1: EvaluationTag = { + evaluationId: '1', value: 'value string', - evaluationId: '1' }; export const EVALUATION_TAG_DTO: EvaluationTagDto = { + createdAt: new Date(), + evaluationId: '1', id: '10001', + updatedAt: new Date(), value: 'value string', - evaluationId: '1', - createdAt: new Date(), - updatedAt: new Date() }; -export const CREATE_EVALUATION_TAG_DTO: CreateEvaluationTagDto = { - value: 'value string' -}; +export const CREATE_EVALUATION_TAG_DTO: CreateEvaluationTagDto = { value: 'value string' }; // @ts-ignore -export const CREATE_EVALUATION_TAG_DTO_MISSING_KEY: CreateEvaluationTagDto = { - value: 'value string' -}; +export const CREATE_EVALUATION_TAG_DTO_MISSING_KEY: CreateEvaluationTagDto = { value: 'value string' }; // @ts-ignore -export const CREATE_EVALUATION_TAG_DTO_MISSING_VALUE: CreateEvaluationTagDto = - {}; +export const CREATE_EVALUATION_TAG_DTO_MISSING_VALUE: CreateEvaluationTagDto + = {}; /* eslint-enable @typescript-eslint/ban-ts-comment */ diff --git a/apps/backend/test/constants/evaluations-test.constant.ts b/apps/backend/test/constants/evaluations-test.constant.ts index 3327ccdfca..aa45c23d03 100644 --- a/apps/backend/test/constants/evaluations-test.constant.ts +++ b/apps/backend/test/constants/evaluations-test.constant.ts @@ -1,75 +1,63 @@ -import {CreateEvaluationDto} from '../../src/evaluations/dto/create-evaluation.dto'; -import {EvaluationDto} from '../../src/evaluations/dto/evaluation.dto'; -import {UpdateEvaluationDto} from '../../src/evaluations/dto/update-evaluation.dto'; -import {Evaluation} from '../../src/evaluations/evaluation.model'; -import {CREATE_EVALUATION_TAG_DTO} from './evaluation-tags-test.constant'; +import type { CreateEvaluationDto } from '../../src/evaluations/dto/create-evaluation.dto'; +import type { EvaluationDto } from '../../src/evaluations/dto/evaluation.dto'; +import type { UpdateEvaluationDto } from '../../src/evaluations/dto/update-evaluation.dto'; +import type { Evaluation } from '../../src/evaluations/evaluation.model'; +import { CREATE_EVALUATION_TAG_DTO } from './evaluation-tags-test.constant'; /* eslint-disable @typescript-eslint/ban-ts-comment */ const DEFAULT_FILE_NAME = 'example-result.json'; // @ts-ignore export const EVALUATION_1: CreateEvaluationDto = { + evaluationTags: [], filename: DEFAULT_FILE_NAME, - evaluationTags: [] }; // @ts-ignore export const EVALUATION_WITH_TAGS_1: CreateEvaluationDto = { + evaluationTags: [CREATE_EVALUATION_TAG_DTO], filename: DEFAULT_FILE_NAME, - evaluationTags: [CREATE_EVALUATION_TAG_DTO] }; // @ts-ignore -export const CREATE_EVALUATION_DTO_WITHOUT_TAGS: CreateEvaluationDto = { - filename: DEFAULT_FILE_NAME -}; +export const CREATE_EVALUATION_DTO_WITHOUT_TAGS: CreateEvaluationDto = { filename: DEFAULT_FILE_NAME }; // @ts-ignore -export const CREATE_EVALUATION_DTO_WITHOUT_FILENAME: CreateEvaluationDto = { - evaluationTags: [CREATE_EVALUATION_TAG_DTO] -}; +export const CREATE_EVALUATION_DTO_WITHOUT_FILENAME: CreateEvaluationDto = { evaluationTags: [CREATE_EVALUATION_TAG_DTO] }; // @ts-ignore export const CREATE_EVALUATION_DTO_WITHOUT_DATA: CreateEvaluationDto = { + evaluationTags: [CREATE_EVALUATION_TAG_DTO], filename: DEFAULT_FILE_NAME, - evaluationTags: [CREATE_EVALUATION_TAG_DTO] }; // @ts-ignore export const UPDATE_EVALUATION: UpdateEvaluationDto = { - data: { - filename: DEFAULT_FILE_NAME - }, - filename: 'example-result-new.json' + data: { filename: DEFAULT_FILE_NAME }, + filename: 'example-result-new.json', }; // @ts-ignore -export const UPDATE_EVALUATION_FILENAME_ONLY: UpdateEvaluationDto = { - filename: 'example-result-new.json' -}; +export const UPDATE_EVALUATION_FILENAME_ONLY: UpdateEvaluationDto = { filename: 'example-result-new.json' }; // @ts-ignore -export const UPDATE_EVALUATION_DATA_ONLY: UpdateEvaluationDto = { - data: { - filename: DEFAULT_FILE_NAME - } -}; +export const UPDATE_EVALUATION_DATA_ONLY: UpdateEvaluationDto = { data: { filename: DEFAULT_FILE_NAME } }; // @ts-ignore export const EVALUATION_DTO: EvaluationDto = { - id: '9999', - filename: DEFAULT_FILE_NAME, - evaluationTags: [], createdAt: new Date(), - updatedAt: new Date() + evaluationTags: [], + filename: DEFAULT_FILE_NAME, + id: '9999', + updatedAt: new Date(), }; // @ts-ignore export const EVALUATION: Evaluation = { - id: '9999', - filename: DEFAULT_FILE_NAME, - evaluationTags: [], createdAt: new Date(), - updatedAt: new Date() + evaluationTags: [], + filename: DEFAULT_FILE_NAME, + id: '9999', + updatedAt: new Date(), }; /* eslint-enable @typescript-eslint/ban-ts-comment */ diff --git a/apps/backend/test/constants/groups-test.constant.ts b/apps/backend/test/constants/groups-test.constant.ts index 9b7afec57f..bf2a30b3d2 100644 --- a/apps/backend/test/constants/groups-test.constant.ts +++ b/apps/backend/test/constants/groups-test.constant.ts @@ -1,82 +1,82 @@ -import {Evaluation} from '../../src/evaluations/evaluation.model'; -import {GroupUser} from '../../src/group-users/group-user.model'; -import {CreateGroupDto} from '../../src/groups/dto/create-group.dto'; -import {UpdateGroupUserRoleDto} from '../../src/groups/dto/update-group-user.dto'; -import {Group} from '../../src/groups/group.model'; -import {User} from '../../src/users/user.model'; +import type { Evaluation } from '../../src/evaluations/evaluation.model'; +import type { GroupUser } from '../../src/group-users/group-user.model'; +import type { CreateGroupDto } from '../../src/groups/dto/create-group.dto'; +import type { UpdateGroupUserRoleDto } from '../../src/groups/dto/update-group-user.dto'; +import { Group } from '../../src/groups/group.model'; +import type { User } from '../../src/users/user.model'; export const GROUP_1 = { + desc: '', name: 'Heimdall Group', public: true, - desc: '' }; export const PRIVATE_GROUP = { + desc: 'Test description', name: 'Private Heimdall Group', public: false, - desc: 'Test description' }; export const UPDATE_GROUP: CreateGroupDto = { + desc: 'Updated test description', name: 'Updated Group', public: true, - desc: 'Updated test description' }; export const GROUPS_SERVICE_MOCK = { - async findAll(): Promise { - return []; - }, - async count(): Promise { - return 1; - }, - async findByPkBang(_id: string): Promise { - return new Group(); - }, - async findByIds(_id: string[]): Promise { - return []; + addEvaluationToGroup( + _group: Group, + _evaluation: Evaluation, + ): Promise { + return Promise.resolve(); }, - async addUserToGroup( + addUserToGroup( _group: Group, _user: User, - _role: string + _role: string, ): Promise { - return; + return Promise.resolve(); }, - async updateGroupUserRole( - _group: Group, - _updateGroupUser: UpdateGroupUserRoleDto - ): Promise { - return undefined; + count(): Promise { + return Promise.resolve(1); }, - async removeUserFromGroup(group: Group, user: User): Promise { - return group.$remove('user', user); + create(_createGroupDto: CreateGroupDto): Promise { + return Promise.resolve(new Group()); }, - async ensureGroupHasOwner(): Promise { - return; + ensureGroupHasOwner(): Promise { + return Promise.resolve(); }, - async addEvaluationToGroup( - _group: Group, - _evaluation: Evaluation - ): Promise { - return; + findAll(): Promise { + return Promise.resolve([]); + }, + findByIds(_id: string[]): Promise { + return Promise.resolve([]); + }, + findByPkBang(_id: string): Promise { + return Promise.resolve(new Group()); + }, + remove(_groupToDelete: Group): Promise { + return Promise.resolve(new Group()); }, - async removeEvaluationFromGroup( + removeEvaluationFromGroup( _group: Group, - _evaluation: Evaluation + _evaluation: Evaluation, ): Promise { - return new Group(); + return Promise.resolve(new Group()); }, - async create(_createGroupDto: CreateGroupDto): Promise { - return new Group(); + removeUserFromGroup(group: Group, user: User): Promise { + return group.$remove('user', user); }, - async update( + update( _groupToUpdate: Group, - _groupDto: CreateGroupDto + _groupDto: CreateGroupDto, ): Promise { - return new Group(); + return Promise.resolve(new Group()); + }, + updateGroupUserRole( + _group: Group, + _updateGroupUser: UpdateGroupUserRoleDto, + ): Promise { + return Promise.resolve(undefined); }, - async remove(_groupToDelete: Group): Promise { - return new Group(); - } }; diff --git a/apps/backend/test/constants/users-test.constant.ts b/apps/backend/test/constants/users-test.constant.ts index 35999256c9..33e8052e73 100644 --- a/apps/backend/test/constants/users-test.constant.ts +++ b/apps/backend/test/constants/users-test.constant.ts @@ -1,210 +1,210 @@ -import {MongoAbility} from '@casl/ability'; -import {FindOptions} from 'sequelize'; -import {CreateUserDto} from '../../src/users/dto/create-user.dto'; -import {DeleteUserDto} from '../../src/users/dto/delete-user.dto'; -import {UpdateUserDto} from '../../src/users/dto/update-user.dto'; -import {UserDto} from '../../src/users/dto/user.dto'; -import {User} from '../../src/users/user.model'; +import type { MongoAbility } from '@casl/ability'; +import type { FindOptions } from 'sequelize'; +import type { CreateUserDto } from '../../src/users/dto/create-user.dto'; +import type { DeleteUserDto } from '../../src/users/dto/delete-user.dto'; +import type { UpdateUserDto } from '../../src/users/dto/update-user.dto'; +import { UserDto } from '../../src/users/dto/user.dto'; +import { User } from '../../src/users/user.model'; /* eslint-disable @typescript-eslint/ban-ts-comment */ export const ID = '7'; -export const MINUTE_IN_MILLISECONDS = 60000; +export const MINUTE_IN_MILLISECONDS = 60_000; export const LOGIN_AUTHENTICATION = { email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP' + password: 'LETmeiN123$$$tP', }; export const LDAP_AUTHENTICATION = { + password: 'fry', username: 'fry', - password: 'fry' }; export const ADMIN_LOGIN_AUTHENTICATION = { email: 'admin@yahoo.com', - password: 'LETmeiN123$$$tP' + password: 'LETmeiN123$$$tP', }; export const BAD_LOGIN_AUTHENTICATION = { email: 'abc@yahoo.com', - password: 'Invalid_password' + password: 'Invalid_password', }; export const BAD_LDAP_AUTHENTICATION = { + password: 'zoiderg', username: 'fry', - password: 'zoiderg' }; export const SPLUNK_AUTHENTICATION = { - username: 'admin', + hostname: 'https://localhost:8089', password: 'Valid_password!', - hostname: 'https://localhost:8089' + username: 'admin', }; export const BAD_SPLUNK_AUTHENTICATION = { - username: 'admin', + hostname: 'https://localhost:8089', password: 'Invalid_password!', - hostname: 'https://localhost:8089' + username: 'admin', }; // @ts-ignore export const TEST_USER: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITH_ID: User = { ...TEST_USER, - id: '1' + id: '1', }; // @ts-ignore export const ADMIN: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'admin', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'admin', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const ADMIN_WITH_ID: User = { ...ADMIN, - id: '2' + id: '2', }; // @ts-ignore export const UPDATED_TEST_USER: User = { + createdAt: new Date(), email: 'updatedemail@yahoo.com', - firstName: 'Updated', - lastName: 'Name', - title: 'updated title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Updated Org', - loginCount: 0, + firstName: 'Updated', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Name', + loginCount: 0, + organization: 'Updated Org', + title: 'updated title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_EMAIL: User = { - firstName: 'Test', - lastName: 'Dummy', - role: 'user', - title: 'fake title', + createdAt: new Date(), // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_FIRST_NAME: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - lastName: 'Dummy', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_LAST_NAME: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + loginCount: 0, + organization: 'Fake Org', + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_ORGANIZATION: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'user', - title: 'fake title', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + role: 'user', + title: 'fake title', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITHOUT_TITLE: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'user', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'user', + updatedAt: new Date(), }; // @ts-ignore export const TEST_USER_WITH_INVALID_ROLE: User = { + createdAt: new Date(), email: 'abc@yahoo.com', - firstName: 'Test', - lastName: 'Dummy', - role: 'unknown', // Encrypted password should match password, 'LETmeiN123$$$tP' encryptedPassword: '$2b$14$35oeK.h84XPIohhjTpwuV.NuFr/5oEzbg4mxLNppvfrA42ztXr2.O', - organization: 'Fake Org', - loginCount: 0, + firstName: 'Test', lastLogin: new Date(), - createdAt: new Date(), - updatedAt: new Date() + lastName: 'Dummy', + loginCount: 0, + organization: 'Fake Org', + role: 'unknown', + updatedAt: new Date(), }; // @ts-ignore @@ -214,310 +214,310 @@ export const USER_ARRAY: User[] = [ // @ts-ignore TEST_USER_WITHOUT_FIRST_NAME, // @ts-ignore - UPDATED_TEST_USER + UPDATED_TEST_USER, ]; export const CREATE_USER_DTO_TEST_OBJ: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; export const CREATE_ADMIN_DTO: CreateUserDto = { + creationMethod: 'local', email: 'admin@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'Admin', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'admin', - creationMethod: 'local' + title: 'Admin', }; export const CREATE_SECOND_ADMIN_DTO: CreateUserDto = { ...CREATE_ADMIN_DTO, - email: 'admin2@yahoo.com' + email: 'admin2@yahoo.com', }; export const CREATE_USER_DTO_TEST_OBJ_2: CreateUserDto = { + creationMethod: 'local', email: 'def@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; -export const CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_UNMATCHING_PASSWORDS: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123%%%tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123%%%tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_FIRST_NAME: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_LAST_NAME: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ORGANIZATION: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ORGANIZATION: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_TITLE: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD: CreateUserDto = - { - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_EMAIL_FIELD: CreateUserDto + = { + creationMethod: 'local', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_EMAIL_FIELD: CreateUserDto + = { + creationMethod: 'local', email: 'NotAValidEmail', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_FIELD: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + passwordConfirmation: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore -export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD: CreateUserDto = - { +export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD_CONFIRMATION_FIELD: CreateUserDto + = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'LETmeiN123$$$tP', role: 'user', - creationMethod: 'local' + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_MISSING_ROLE: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'LETmeiN123$$$tP', - passwordConfirmation: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', - creationMethod: 'local' + password: 'LETmeiN123$$$tP', + passwordConfirmation: 'LETmeiN123$$$tP', + title: 'fake title', }; // @ts-ignore export const CREATE_USER_DTO_TEST_OBJ_WITH_INVALID_PASSWORD: CreateUserDto = { + creationMethod: 'local', email: 'abc@yahoo.com', - password: 'InvalidPass1', - passwordConfirmation: 'InvalidPass1', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', organization: 'Fake Org', + password: 'InvalidPass1', + passwordConfirmation: 'InvalidPass1', role: 'user', - creationMethod: 'local' + title: 'fake title', }; export const UPDATE_USER_DTO_TEST_OBJ: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'updatedemail@yahoo.com', firstName: 'Updated', + forcePasswordChange: true, lastName: 'Name', organization: 'Updated Org', - title: 'updated title', - role: 'user', password: 'LETmeiN123$$$tP', passwordConfirmation: 'LETmeiN123$$$tP', - currentPassword: 'LETmeiN123$$$tP', - forcePasswordChange: true + role: 'user', + title: 'updated title', }; export const UPDATE_USER_DTO_TEST_OBJ_WITH_UPDATED_PASSWORD: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Updated', + forcePasswordChange: false, lastName: 'Name', organization: 'Updated Org', - title: 'updated title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP', - forcePasswordChange: false + role: 'user', + title: 'updated title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_EMAIL: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITH_INVALID_EMAIL: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'NotAValidEmail', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_FIRST_NAME: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_LAST_NAME: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_ORGANIZATION: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_TITLE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_WITHOUT_PASSWORD_FIELDS: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'updated@example.com', firstName: 'Updated', lastName: 'Updated', organization: 'Updated', - title: 'Updated', role: 'user', - currentPassword: 'LETmeiN123$$$tP' + title: 'Updated', }; // @ts-ignore @@ -526,108 +526,104 @@ export const UPDATE_USER_DTO_WITH_NO_CURRENT_PASSWORD: UpdateUserDto = { firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', - passwordConfirmation: 'ABCdefG456!@#pT' + passwordConfirmation: 'ABCdefG456!@#pT', + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_WITH_INVALID_CURRENT_PASSWORD: UpdateUserDto = { ...UPDATE_USER_DTO_WITH_NO_CURRENT_PASSWORD, - currentPassword: 'invalid_password' + currentPassword: 'invalid_password', }; // @ts-ignore export const UPDATE_USER_DTO_WITH_ADMIN_ROLE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', role: 'admin', - currentPassword: 'LETmeiN123$$$tP' }; // @ts-ignore -export const UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD_CONFIRMATION: UpdateUserDto = - { +export const UPDATE_USER_DTO_TEST_WITHOUT_PASSWORD_CONFIRMATION: UpdateUserDto + = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + role: 'user', + title: 'fake title', }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITHOUT_ROLE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', password: 'ABCdefG456!@#pT', passwordConfirmation: 'ABCdefG456!@#pT', - currentPassword: 'LETmeiN123$$$tP' + title: 'fake title', }; // @ts-ignore -export const UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE: UpdateUserDto = - { +export const UPDATE_USER_DTO_TEST_WITHOUT_FORCE_PASSWORD_CHANGE: UpdateUserDto + = { + currentPassword: 'LETmeiN123$$$tP', email: 'changed@yahoo.com', - currentPassword: 'LETmeiN123$$$tP' }; // @ts-ignore export const UPDATE_USER_DTO_SETUP_FORCE_PASSWORD_CHANGE: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', forcePasswordChange: true, - currentPassword: 'LETmeiN123$$$tP' }; // @ts-ignore export const UPDATE_USER_DTO_TEST_WITH_NOT_COMPLEX_PASSWORD: UpdateUserDto = { + currentPassword: 'LETmeiN123$$$tP', email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', password: 'Invalidpass1', passwordConfirmation: 'Invalidpass1', - currentPassword: 'LETmeiN123$$$tP' + title: 'fake title', }; -export const UPDATE_USER_DTO_TEST_OBJ_WITH_MISSMATCHING_PASSWORDS: UpdateUserDto = - { +export const UPDATE_USER_DTO_TEST_OBJ_WITH_MISSMATCHING_PASSWORDS: UpdateUserDto + = { + currentPassword: 'LETmeiN123$$$tP', email: 'updatedemail@yahoo.com', firstName: 'Updated', + forcePasswordChange: false, lastName: 'Name', organization: 'Updated Org', - title: 'updated title', - role: 'user', password: 'ABCdefG456!@#pT', passwordConfirmation: 'defABCg789*(%Pt', - currentPassword: 'LETmeiN123$$$tP', - forcePasswordChange: false + role: 'user', + title: 'updated title', }; // @ts-ignore -export const UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD: UpdateUserDto = - { +export const UPDATE_USER_DTO_WITH_MISSING_CURRENT_PASSWORD_FIELD: UpdateUserDto + = { email: 'abc@yahoo.com', firstName: 'Test', lastName: 'Dummy', organization: 'Fake Org', - title: 'fake title', - role: 'user', password: 'ABCdefG456!@#pT', - passwordConfirmation: 'ABCdefG456!@#pT' + passwordConfirmation: 'ABCdefG456!@#pT', + role: 'user', + title: 'fake title', }; -export const DELETE_USER_DTO_TEST_OBJ: DeleteUserDto = { - password: 'LETmeiN123$$$tP' -}; +export const DELETE_USER_DTO_TEST_OBJ: DeleteUserDto = { password: 'LETmeiN123$$$tP' }; -export const DELETE_FAILURE_USER_DTO_TEST_OBJ: DeleteUserDto = { - password: 'Invalid_password' -}; +export const DELETE_FAILURE_USER_DTO_TEST_OBJ: DeleteUserDto = { password: 'Invalid_password' }; // @ts-ignore export const DELETE_USER_DTO_TEST_OBJ_WITH_MISSING_PASSWORD: DeleteUserDto = {}; @@ -645,15 +641,15 @@ export const UPDATED_USER_DTO = new UserDto(USER_ARRAY[2]); export const USER_DTO_WITHOUT_EMAIL = new UserDto(TEST_USER_WITHOUT_EMAIL); export const USER_DTO_WITHOUT_FIRST_NAME = new UserDto( - TEST_USER_WITHOUT_FIRST_NAME + TEST_USER_WITHOUT_FIRST_NAME, ); export const USER_DTO_WITHOUT_LAST_NAME = new UserDto( - TEST_USER_WITHOUT_LAST_NAME + TEST_USER_WITHOUT_LAST_NAME, ); export const USER_DTO_WITHOUT_ORGANIZATION = new UserDto( - TEST_USER_WITHOUT_ORGANIZATION + TEST_USER_WITHOUT_ORGANIZATION, ); export const USER_DTO_WITHOUT_TITLE = new UserDto(TEST_USER_WITHOUT_TITLE); @@ -661,52 +657,52 @@ export const USER_DTO_WITHOUT_TITLE = new UserDto(TEST_USER_WITHOUT_TITLE); export const USER_DTO_ARRAY: UserDto[] = [USER_ONE_DTO, USER_TWO_DTO]; export const USERS_SERVICE_MOCK = { - async adminFindAllUsers(): Promise { - return []; + adminFindAllUsers(): Promise { + return Promise.resolve([]); }, - async findAllUsers(): Promise { - return []; + count(): Promise { + return Promise.resolve(1); }, - async count(): Promise { - return 1; + create(_createUserDto: CreateUserDto): Promise { + return Promise.resolve(new User()); }, - async findById(_id: string): Promise { - return new User(); + findAllUsers(): Promise { + return Promise.resolve([]); }, - async findByEmail(_email: string): Promise { - return new User(); + findByEmail(_email: string): Promise { + return Promise.resolve(new User()); }, - async create(_createUserDto: CreateUserDto): Promise { - return new User(); + findById(_id: string): Promise { + return Promise.resolve(new User()); }, - async update( - _userToUpdate: User, - _updateUserDto: UpdateUserDto, - _abac: MongoAbility + findByPkBang( + _identifier: Buffer | number | string | undefined, ): Promise { - return new User(); - }, - async updateLoginMetadata(_user: User): Promise { - return; + return Promise.resolve(new User()); }, - async updateUserSecret(_user: User): Promise { - return; + findOneBang(_options: FindOptions | undefined): Promise { + return Promise.resolve(new User()); }, - async remove( + remove( _userToDelete: User, _deleteUserDto: DeleteUserDto, - _abac: MongoAbility + _abac: MongoAbility, ): Promise { - return new User(); + return Promise.resolve(new User()); }, - async findByPkBang( - _identifier: string | number | Buffer | undefined + update( + _userToUpdate: User, + _updateUserDto: UpdateUserDto, + _abac: MongoAbility, ): Promise { - return new User(); + return Promise.resolve(new User()); + }, + updateLoginMetadata(_user: User): Promise { + return Promise.resolve(); + }, + updateUserSecret(_user: User): Promise { + return Promise.resolve(); }, - async findOneBang(_options: FindOptions | undefined): Promise { - return new User(); - } }; /* eslint-enable @typescript-eslint/ban-ts-comment */ diff --git a/apps/backend/test/demo-group-seeder.spec.ts b/apps/backend/test/demo-group-seeder.spec.ts new file mode 100644 index 0000000000..f6f6ce21e8 --- /dev/null +++ b/apps/backend/test/demo-group-seeder.spec.ts @@ -0,0 +1,231 @@ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +// CommonJS seeder outside the TS project, loaded through a runtime dynamic +// import held on an object — the pattern seeders.spec.ts established and that +// eslint-plugin-n can resolve, because the specifier is a variable. +const SEEDER_PATH = '../seeders/20260815000100-create-demo-group.js'; + +type Row = Record; + +type FakeQueryInterface = { + bulkDelete: ReturnType; + bulkInsert: ReturnType; + sequelize: { + QueryTypes: { SELECT: string }; + query: ReturnType; + }; +}; + +type Seeder = { + DEMO_GROUP: { desc: string; name: string; public: boolean }; + DEMO_MEMBERSHIPS: { email: string; role: string }[]; + down: (queryInterface: FakeQueryInterface) => Promise; + up: (queryInterface: FakeQueryInterface) => Promise; +}; + +const loaded: { module?: Seeder } = {}; + +beforeAll(async () => { + loaded.module = (await import(SEEDER_PATH)) as Seeder; +}); + +function seeder(): Seeder { + const module_ = loaded.module; + if (!module_) { + throw new Error('seeder module was not loaded'); + } + return module_; +} + +/** + * Routes each SELECT the seeder makes. Defaults describe a database where the + * demo users exist but the group does not — the normal first-run shape. + */ +function fakeQueryInterface( + state: { + groupRows?: Row[]; + membershipRows?: Row[]; + userRows?: Row[]; + } = {}, +): FakeQueryInterface { + const userRows = state.userRows ?? [ + { email: 'admin@example.com', id: '1' }, + { email: 'user@example.com', id: '2' }, + ]; + const groupRows = state.groupRows ?? []; + const membershipRows = state.membershipRows ?? []; + + const bulkInsert = vi.fn().mockResolvedValue(undefined); + const query = vi.fn().mockImplementation((sql: string) => { + if (sql.includes('"Users"')) { + return Promise.resolve(userRows); + } + if (sql.includes('"GroupUsers"')) { + return Promise.resolve(membershipRows); + } + if (sql.includes('"Groups"')) { + // After the seeder inserts the group, its re-read must find it — the id + // is autoincrement, so the seeder cannot know it without asking. + return Promise.resolve( + groupRows.length > 0 || bulkInsert.mock.calls.some((c) => c[0] === 'Groups') + ? [{ id: '10' }] + : [], + ); + } + throw new Error(`unstubbed query: ${sql}`); + }); + + return { + bulkDelete: vi.fn().mockResolvedValue(undefined), + bulkInsert, + sequelize: { QueryTypes: { SELECT: 'SELECT' }, query }, + }; +} + +/** Rows passed to bulkInsert for one table, flattened across calls. */ +function inserted(queryInterface: FakeQueryInterface, table: string): Row[] { + return queryInterface.bulkInsert.mock.calls.flatMap((call) => + call[0] === table ? (call[1] as Row[]) : [], + ); +} + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('SEED_DEMO_DATA', ''); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('demo group seeder — production guard', () => { + it('no-ops when NODE_ENV is production and SEED_DEMO_DATA is unset', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // Asserted independently rather than assumed to be inherited from the user + // seeder: a guard that exists only in a sibling file protects nothing here, + // and cmd.sh runs db:seed:all on every container start. + expect(queryInterface.bulkInsert).not.toHaveBeenCalled(); + }); + + it('seeds in production when SEED_DEMO_DATA opts in explicitly', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('SEED_DEMO_DATA', 'true'); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + expect(queryInterface.bulkInsert).toHaveBeenCalled(); + }); +}); + +describe('demo group seeder — group and membership', () => { + it('creates exactly one group, with the NOT NULL columns supplied', async () => { + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + const groups = inserted(queryInterface, 'Groups'); + expect(groups).toHaveLength(1); + // Groups.name is NOT NULL and UNIQUE; desc is NOT NULL default ''; public + // is NOT NULL. Supplying them explicitly is what keeps the insert legal. + expect(groups[0].name).toBe(seeder().DEMO_GROUP.name); + expect(groups[0].desc).toBe(seeder().DEMO_GROUP.desc); + expect(groups[0].public).toBe(seeder().DEMO_GROUP.public); + }); + + it('makes admin@example.com an OWNER and user@example.com a MEMBER', async () => { + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + const memberships = inserted(queryInterface, 'GroupUsers'); + const roleByUserId = new Map( + memberships.map((row) => [row.userId, row.role]), + ); + // GroupUsers.role is the GROUP-SCOPED concept (owner|member) — a different + // column from Users.role (admin|user), which sked.1 owns. Writing the wrong + // one produces a seed that looks right and tests nothing. + expect(roleByUserId.get('1')).toBe('owner'); + expect(roleByUserId.get('2')).toBe('member'); + expect(memberships).toHaveLength(2); + for (const row of memberships) { + expect(row.groupId).toBe('10'); + } + }); + + it('REUSES the seeded users instead of creating group-specific accounts', async () => { + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // Vulcan's 05_memberships.rb assigns memberships to the existing demo + // users; it does not mint group-owner@/group-member@ accounts. This card + // follows that deliberately. + expect(inserted(queryInterface, 'Users')).toHaveLength(0); + }); +}); + +describe('demo group seeder — idempotency', () => { + it('inserts nothing when the group and both memberships already exist', async () => { + const queryInterface = fakeQueryInterface({ + groupRows: [{ id: '10' }], + membershipRows: [{ userId: '1' }, { userId: '2' }], + }); + + await seeder().up(queryInterface); + + expect(queryInterface.bulkInsert).not.toHaveBeenCalled(); + }); + + it('adds only the missing membership when the group already exists', async () => { + const queryInterface = fakeQueryInterface({ + groupRows: [{ id: '10' }], + membershipRows: [{ userId: '1' }], + }); + + await seeder().up(queryInterface); + + expect(inserted(queryInterface, 'Groups')).toHaveLength(0); + const memberships = inserted(queryInterface, 'GroupUsers'); + expect(memberships).toHaveLength(1); + expect(memberships[0].userId).toBe('2'); + expect(memberships[0].role).toBe('member'); + }); +}); + +describe('demo group seeder — missing prerequisite', () => { + it('skips cleanly when the demo users are absent, without throwing', async () => { + const queryInterface = fakeQueryInterface({ userRows: [] }); + + // cmd.sh runs db:seed:all under `set -e`, so throwing here would abort the + // whole seed run and, in a container, the boot. + await expect(seeder().up(queryInterface)).resolves.not.toThrow(); + expect(queryInterface.bulkInsert).not.toHaveBeenCalled(); + }); +}); + +describe('demo group seeder — down', () => { + it('removes the memberships and the group, leaving the users intact', async () => { + const queryInterface = fakeQueryInterface({ groupRows: [{ id: '10' }] }); + + await seeder().down(queryInterface); + + const tables = queryInterface.bulkDelete.mock.calls.map((call) => call[0]); + expect(tables).toEqual(['GroupUsers', 'Groups']); + // Users are owned by sked.1's seeder; removing them here would break its + // down() contract and delete accounts this card never created. + expect(tables).not.toContain('Users'); + }); +}); diff --git a/apps/backend/test/demo-users-seeder.spec.ts b/apps/backend/test/demo-users-seeder.spec.ts new file mode 100644 index 0000000000..0628727260 --- /dev/null +++ b/apps/backend/test/demo-users-seeder.spec.ts @@ -0,0 +1,291 @@ +import { validatePasswordBoolean } from '@heimdall/password-complexity'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +// The seeder is CommonJS (sequelize-cli owns it), lives OUTSIDE the TS project, +// and requires '../dist/src/crypto/password' — so `yarn backend build` must have +// run first (the Verification command does exactly that before test:ci). Loaded +// via a runtime dynamic import held on an object, so the assignment is a +// property write rather than a top-level rebind — the same pattern +// seeders.spec.ts established for the administrator seeder. +const SEEDER_PATH = '../seeders/20260815000000-create-demo-users.js'; +// Held in a variable for the same reason as SEEDER_PATH: a literal specifier +// into build output cannot be statically resolved by eslint-plugin-n. +const CRYPTO_PATH = '../dist/src/crypto/password.js'; + +/** The PHC prefix a PBKDF2-SHA512 hash must carry (crypto/password.ts §1-2). */ +const PBKDF2_SHA512 = /^\$pbkdf2-sha512\$i=\d+\$/; + +const byText = (a: string, b: string): number => a.localeCompare(b); + +/** + * SEED_PASSWORD override fixture. Another shift/unshift column walk, so it + * satisfies the app's own policy — the previous fixture did not, which a + * reviewer measured. A card about seeding policy-valid credentials should not + * use a credential the product would refuse; the assertion below keeps it + * from drifting back. + */ +const OVERRIDE_PASSWORD = '2wsx3edc@WSX#EDC'; + +type FakeQueryInterface = { + bulkDelete: ReturnType; + bulkInsert: ReturnType; + sequelize: { + QueryTypes: { SELECT: string }; + query: ReturnType; + }; +}; + +type SeededUser = { + createdAt: Date; + creationMethod: string; + email: string; + encryptedPassword: string; + firstName: string; + forcePasswordChange: boolean; + role: string; +}; + +type Seeder = { + DEMO_EMAILS: string[]; + DEMO_PASSWORD: string; + down: (queryInterface: FakeQueryInterface) => Promise; + up: (queryInterface: FakeQueryInterface) => Promise; +}; + +const loaded: { module?: Seeder } = {}; + +beforeAll(async () => { + loaded.module = (await import(SEEDER_PATH)) as Seeder; +}); + +function seeder(): Seeder { + const module_ = loaded.module; + if (!module_) { + throw new Error('seeder module was not loaded'); + } + return module_; +} + +/** + * A queryInterface whose existing-user probe returns `existingEmails`, so + * idempotency can be driven without a database. + */ +function fakeQueryInterface(existingEmails: string[] = []): FakeQueryInterface { + return { + bulkDelete: vi.fn().mockResolvedValue(undefined), + bulkInsert: vi.fn().mockResolvedValue(undefined), + sequelize: { + QueryTypes: { SELECT: 'SELECT' }, + query: vi + .fn() + .mockResolvedValue(existingEmails.map((email) => ({ email }))), + }, + }; +} + +/** Rows handed to bulkInsert, flattened across calls. */ +function insertedUsers(queryInterface: FakeQueryInterface): SeededUser[] { + return queryInterface.bulkInsert.mock.calls.flatMap( + (call) => call[1] as SeededUser[], + ); +} + +beforeEach(() => { + // The KDF enforces a floor of 100000 iterations, so this is the cheapest + // LEGAL setting — not a weakened parameter, just the bottom of the allowed + // range, and it keeps a spec that hashes on most tests from paying the + // 600000-iteration production default each time. + vi.stubEnv('PASSWORD_HASH_ITERATIONS', '100000'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('demo user seeder — production guard', () => { + it('no-ops when NODE_ENV is production and SEED_DEMO_DATA is unset', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('SEED_DEMO_DATA', ''); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // packaging/rpm/cmd.sh runs `db:seed:all` on EVERY container start, so this + // is the card's entire safety property. Asserting that the guard code + // exists would prove nothing — assert that no insert happens. + expect(queryInterface.bulkInsert).not.toHaveBeenCalled(); + }); + + it('seeds in development with no environment variable set', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('SEED_DEMO_DATA', ''); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + expect(queryInterface.bulkInsert).toHaveBeenCalled(); + }); + + it('seeds in test with no environment variable set', async () => { + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SEED_DEMO_DATA', ''); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + expect(queryInterface.bulkInsert).toHaveBeenCalled(); + }); + + it('seeds in production when SEED_DEMO_DATA opts in explicitly', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('SEED_DEMO_DATA', 'true'); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // Vulcan's two-concern pattern: an operator can still demo deliberately. + expect(queryInterface.bulkInsert).toHaveBeenCalled(); + }); +}); + +describe('demo user seeder — roster', () => { + it('seeds exactly the four documented accounts with their roles', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + const byEmail = new Map( + insertedUsers(queryInterface).map((user) => [user.email, user]), + ); + expect([...byEmail.keys()].toSorted(byText)).toEqual([ + 'admin@example.com', + 'api-admin@example.com', + 'api-user@example.com', + 'user@example.com', + ]); + // Users.role is the APP-WIDE concept (admin|user). GroupUsers.role + // (owner|member) is a different column entirely and belongs to sked.2. + expect(byEmail.get('admin@example.com')?.role).toBe('admin'); + expect(byEmail.get('user@example.com')?.role).toBe('user'); + expect(byEmail.get('api-admin@example.com')?.role).toBe('admin'); + expect(byEmail.get('api-user@example.com')?.role).toBe('user'); + }); + + it('hashes through the FIPS-approved PBKDF2 path, not bcrypt', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // Assert the POSITIVE format. `not.toMatch(/bcrypt/)` would pass against + // scrypt, pbkdf2 and plaintext alike, which is how a vacuous hash + // assertion shipped once already. + for (const user of insertedUsers(queryInterface)) { + expect(user.encryptedPassword).toMatch(PBKDF2_SHA512); + } + }); + + it('never forces a password change — the login loop must not be interrupted', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // These accounts exist for a fast, repeatable login loop. The + // administrator seeder sets forcePasswordChange true on purpose; doing the + // same here would make every demo login land on a change-password screen. + // Added because a mutation flipping this flag was detected by nothing. + for (const user of insertedUsers(queryInterface)) { + expect(user.forcePasswordChange).toBe(false); + } + }); + + it('uses a default password this app would actually accept', () => { + // A seeded password the app rejects is a broken seed. Checked against the + // real policy module rather than eyeballed against PASSWORD_MIN_LENGTH. + expect(validatePasswordBoolean(seeder().DEMO_PASSWORD)).toBe(true); + }); + + it('honours SEED_PASSWORD when set', async () => { + // The override fixture must itself be a credential this app would accept, + // or the test quietly models something the product forbids. + expect(validatePasswordBoolean(OVERRIDE_PASSWORD)).toBe(true); + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('SEED_PASSWORD', OVERRIDE_PASSWORD); + const queryInterface = fakeQueryInterface(); + + await seeder().up(queryInterface); + + // Distinct hashes per run make a direct comparison impossible, so assert + // the override reached the hasher by verifying against it. + const { verifyPassword } = (await import(CRYPTO_PATH)) as { + verifyPassword: (arguments_: { + hash: string; + password: string; + }) => Promise<{ needsRehash: boolean; valid: boolean }>; + }; + const seeded = insertedUsers(queryInterface)[0]; + // verifyPassword resolves {needsRehash, valid} — not a bare boolean. + await expect( + verifyPassword({ + hash: seeded.encryptedPassword, + password: OVERRIDE_PASSWORD, + }), + ).resolves.toMatchObject({ valid: true }); + }); +}); + +describe('demo user seeder — idempotency', () => { + it('inserts nothing when every account already exists', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const queryInterface = fakeQueryInterface([ + 'admin@example.com', + 'api-admin@example.com', + 'api-user@example.com', + 'user@example.com', + ]); + + await seeder().up(queryInterface); + + expect(queryInterface.bulkInsert).not.toHaveBeenCalled(); + }); + + it('inserts only the accounts that are missing', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const queryInterface = fakeQueryInterface([ + 'admin@example.com', + 'user@example.com', + ]); + + await seeder().up(queryInterface); + + expect( + insertedUsers(queryInterface) + .map((user) => user.email) + .toSorted(byText), + ).toEqual(['api-admin@example.com', 'api-user@example.com']); + }); +}); + +describe('demo user seeder — down', () => { + it('removes exactly the seeded accounts', async () => { + const queryInterface = fakeQueryInterface(); + + await seeder().down(queryInterface); + + expect(queryInterface.bulkDelete).toHaveBeenCalledTimes(1); + const [table, where] = queryInterface.bulkDelete.mock.calls[0]; + expect(table).toBe('Users'); + // Scoped to the roster — a bare bulkDelete('Users') would wipe real users. + expect(where).toEqual({ email: seeder().DEMO_EMAILS }); + }); +}); diff --git a/apps/backend/test/seeders-directory-guard.spec.ts b/apps/backend/test/seeders-directory-guard.spec.ts new file mode 100644 index 0000000000..1718768089 --- /dev/null +++ b/apps/backend/test/seeders-directory-guard.spec.ts @@ -0,0 +1,54 @@ +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * sequelize-cli loads EVERY file matching its pattern from the seeders + * directory and calls `up` on it. A support module parked there — a shared + * helper, a constants file — is therefore not inert: umzug throws + * "Could not find migration method: up", sequelize-cli's seedAll catch calls + * process.exit(1), and `cmd.sh` runs `db:seed:all` under `set -e` on line 8 + * BEFORE `yarn backend start` on line 9. The container never boots. + * + * That shipped once (demo-seed-helpers.js, caught by AC review on + * heimdall2-sked.2, confirmed by running db:seed:all and reading exit 1), so it + * is guarded here rather than left to reviewer vigilance. This is an + * executable guard over the real directory, not a grep: it scans what + * sequelize-cli will actually load. + * + * Matches sequelize-cli's own pattern, node_modules/sequelize-cli/lib/core/ + * migrator.js:52 — every .js/.cjs/.ts/.cts except .d.ts. + */ +// fileURLToPath rather than import.meta.dirname: the latter is only backported +// to ^22.16.0 and this repo's engines floor is >=22.18.0, a range that also +// admits Node 23, where it does not exist. +const SEEDERS_DIRECTORY = fileURLToPath(new URL('../seeders', import.meta.url)); +const SEQUELIZE_CLI_PATTERN = /^(?!.*\.d\.ts$).*\.(?:cjs|cts|js|ts)$/; + +function filesSequelizeWillLoad(): string[] { + return readdirSync(SEEDERS_DIRECTORY) + .filter((name) => SEQUELIZE_CLI_PATTERN.test(name)) + .toSorted((a, b) => a.localeCompare(b)); +} + +describe('seeders directory', () => { + it('is not empty — a guard over an empty list would pass vacuously', () => { + expect(filesSequelizeWillLoad().length).toBeGreaterThan(0); + }); + + it.each(filesSequelizeWillLoad())( + '%s exports both up and down, so db:seed:all can run it', + async (name) => { + const loaded = (await import(path.join(SEEDERS_DIRECTORY, name))) as { + down?: unknown; + up?: unknown; + }; + + // `up` is what umzug calls and what its absence kills the run over. + // `down` is required for the seeder to be reversible. + expect(typeof loaded.up).toBe('function'); + expect(typeof loaded.down).toBe('function'); + }, + ); +}); diff --git a/apps/backend/test/seeders.spec.ts b/apps/backend/test/seeders.spec.ts new file mode 100644 index 0000000000..590a46d83b --- /dev/null +++ b/apps/backend/test/seeders.spec.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +type Seeder = { up: (queryInterface: FakeQueryInterface) => Promise }; + +// The seeder is CommonJS (sequelize-cli owns it), lives OUTSIDE the TS project, +// and requires '../dist/src/crypto/password' — so `yarn backend build` must +// have run (the Verification command does exactly that before test:ci). It is +// loaded via a runtime dynamic import (not a static ESM import of an +// out-of-project .js) and held on an object so the assignment is a property +// write, not a top-level rebind. `seeder()` reads it back. +const SEEDER_PATH = '../seeders/20200514154327-create-administrator.js'; +const loaded: { module?: Seeder } = {}; + +beforeAll(async () => { + loaded.module = (await import(SEEDER_PATH)) as Seeder; +}); + +type FakeQueryInterface = { + bulkInsert: ReturnType; + sequelize: { + query: ReturnType; + QueryTypes: { SELECT: string }; + }; +}; + +type InsertedAdmin = { + creationMethod: string; + email: string; + encryptedPassword: string; + forcePasswordChange: boolean; + role: string; +}; + +// A queryInterface whose admin-count query returns `adminCount`, capturing any +// bulkInsert so the seeded row can be inspected. `counts` feeds the §12 +// write-gate derivation queries the seeder runs (site 8): total Users and +// HashMigrationMarkers rows — both default to '0', the fresh-install shape. +function fakeQueryInterface( + adminCount: string, + counts: { markers?: string; users?: string } = {}, +): FakeQueryInterface { + const bulkInsert = vi.fn().mockResolvedValue(undefined); + const query = vi.fn().mockImplementation((sql: string) => { + if (sql.includes('HashMigrationMarkers')) { + return Promise.resolve([{ count: counts.markers ?? '0' }]); + } + if (sql.includes('COUNT') && sql.includes("role = 'admin'")) { + return Promise.resolve([{ count: adminCount }]); + } + if (sql.includes('COUNT')) { + return Promise.resolve([{ count: counts.users ?? '0' }]); + } + return Promise.resolve([{ result: 2 }]); + }); + return { bulkInsert, sequelize: { query, QueryTypes: { SELECT: 'SELECT' } } }; +} + +function insertedAdmin(qi: FakeQueryInterface): InsertedAdmin { + return qi.bulkInsert.mock.calls[0][1][0] as InsertedAdmin; +} + +function seeder(): Seeder { + if (loaded.module === undefined) { + throw new Error('seeder module not loaded'); + } + return loaded.module; +} + +// §9 validation message, shared by the gate service and the seeder's +// compiled decision function. +const ENV_VALIDATION_MESSAGE = /PASSWORD_HASH_WRITE_ENABLED must be 'true' or 'false'/v; + +function markerInserts(qi: FakeQueryInterface): number { + return qi.bulkInsert.mock.calls.filter( + (call: unknown[]) => call[0] === 'HashMigrationMarkers', + ).length; +} + +describe('administrator bootstrap seeder (site 8)', () => { + // vi.stubEnv layers over process.env without manual bracket mutation; each + // test that needs a value stubs it, and unstub restores everything. The + // seeder merges process.env last, so a stub takes effect. (.env-ci sets no + // ADMIN_* keys, so the unset-default tests are clean without pre-clearing.) + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('stores an encryptedPassword with the $pbkdf2-sha512$ prefix on a clean DB', async () => { + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + expect(insertedAdmin(qi).encryptedPassword.startsWith('$pbkdf2-sha512$')).toBe( + true, + ); + }); + + it('awaits the hash — the stored value is a resolved string, not a Promise', async () => { + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + const stored = insertedAdmin(qi).encryptedPassword; + expect(typeof stored).toBe('string'); + expect(stored).not.toContain('[object Promise]'); + }); + + it('is idempotent — inserts nothing when an administrator already exists', async () => { + const qi = fakeQueryInterface('1'); + await seeder().up(qi); + expect(qi.bulkInsert).not.toHaveBeenCalled(); + }); + + it('defaults to local creationMethod when ADMIN_USES_EXTERNAL_AUTH is unset', async () => { + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + expect(insertedAdmin(qi).creationMethod).toBe('local'); + }); + + it('honors ADMIN_USES_EXTERNAL_AUTH=true — creationMethod ldap, still a real hash', async () => { + vi.stubEnv('ADMIN_USES_EXTERNAL_AUTH', 'true'); + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + const admin = insertedAdmin(qi); + expect(admin.creationMethod).toBe('ldap'); + // The placeholder password is still PBKDF2-hashed (never bcrypt). + expect(admin.encryptedPassword.startsWith('$pbkdf2-sha512$')).toBe(true); + }); + + it('uses ADMIN_EMAIL when provided, and forces a password change', async () => { + vi.stubEnv('ADMIN_EMAIL', 'boss@example.mil'); + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + const admin = insertedAdmin(qi); + expect(admin.email).toBe('boss@example.mil'); + expect(admin.role).toBe('admin'); + expect(admin.forcePasswordChange).toBe(true); + }); + + // ADR-006 §12: site 8 is in the write gate's scope. The DECISION is the + // same compiled pure function the Nest gate uses (hash-write-decision.js); + // the seeder supplies the DB probes and — because cmd.sh runs it BEFORE the + // app's first boot — plants the durable marker when its own write is the + // first PBKDF2 write, so the app's later derivation stays enabled (sticky). + describe('§12 write gate (site 8)', () => { + it('upgrade shape — existing users, no marker, env unset → bcrypt fallback readable by pre-N pods, and NO marker planted', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', ''); + const qi = fakeQueryInterface('0', { markers: '0', users: '5' }); + await seeder().up(qi); + expect(insertedAdmin(qi).encryptedPassword.startsWith('$2b$14$')).toBe( + true, + ); + expect(markerInserts(qi)).toBe(0); + }); + + it('marker present — PBKDF2 writes already began, so the admin hashes PBKDF2 even with existing users (no duplicate marker)', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', ''); + const qi = fakeQueryInterface('0', { markers: '1', users: '5' }); + await seeder().up(qi); + expect( + insertedAdmin(qi).encryptedPassword.startsWith('$pbkdf2-sha512$'), + ).toBe(true); + expect(markerInserts(qi)).toBe(0); + }); + + it('fresh install — the seeder performs the first PBKDF2 write and PLANTS the §12 marker', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', ''); + const qi = fakeQueryInterface('0', { markers: '0', users: '0' }); + await seeder().up(qi); + expect( + insertedAdmin(qi).encryptedPassword.startsWith('$pbkdf2-sha512$'), + ).toBe(true); + expect(markerInserts(qi)).toBe(1); + const markerCall = qi.bulkInsert.mock.calls.find( + (call: unknown[]) => call[0] === 'HashMigrationMarkers', + ) as [string, { markerVersion: number }[]]; + expect(markerCall[1][0].markerVersion).toBe(1); + }); + + it('explicit PASSWORD_HASH_WRITE_ENABLED=false wins — bcrypt even on an otherwise fresh DB', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', 'false'); + const qi = fakeQueryInterface('0'); + await seeder().up(qi); + expect(insertedAdmin(qi).encryptedPassword.startsWith('$2b$14$')).toBe( + true, + ); + expect(markerInserts(qi)).toBe(0); + }); + + it('an invalid PASSWORD_HASH_WRITE_ENABLED throws (§9: never clamp silently) — same rule as the gate service', async () => { + vi.stubEnv('PASSWORD_HASH_WRITE_ENABLED', 'yes'); + const qi = fakeQueryInterface('0'); + await expect(seeder().up(qi)).rejects.toThrow(ENV_VALIDATION_MESSAGE); + }); + }); +}); diff --git a/apps/backend/test/tenable/README.md b/apps/backend/test/tenable/README.md index 29a67884b3..eb8de3badf 100644 --- a/apps/backend/test/tenable/README.md +++ b/apps/backend/test/tenable/README.md @@ -20,8 +20,11 @@ This project simulates a subset of the Tenable.sc REST API using [Prism](https:/ ```bash > npm install -g @stoplight/prism-cli ``` + ### 2. Run the Mock Server + Navigate to the folder containing tenable-sc-mock.yaml and run this command: + ```bash > prism mock tenable-sc-mock.yaml ``` @@ -29,19 +32,26 @@ Navigate to the folder containing tenable-sc-mock.yaml and run this command: Server starts on: `http://localhost:4010` ### 3. Example Requests (using curl) + ✅ Get Current User + ```bash > curl -X GET http://localhost:4010/rest/currentUser -H "x-apikey: accesskey=abc123; secretkey=def456" Note: The `accesskey` and `secretkey` in the curl command can be any string. ``` + ✅ Get Scan Results + ```bash > curl -G http://localhost:4010/rest/scanResult --data-urlencode "fields=name,description" --data-urlencode "startTime=2024-01-01" --data-urlencode "endTime=2024-02-01" -H "x-apikey: accesskey=abc123; secretkey=def456" ``` + ✅ Download Scan Result (binary response) + ```bash > curl -X POST "http://localhost:4010/rest/scanResult/1234/download?downloadType=v2" \ -H "x-apikey: accesskey=abc123; secretkey=def456" \ --output result.zip ``` + Note: This will return mocked binary content (e.g. a placeholder). \ No newline at end of file diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 250a0a9fd1..994e30c159 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -1,15 +1,19 @@ import swc from 'unplugin-swc'; -import {defineConfig} from 'vitest/config'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - hookTimeout: 20000, - testTimeout: 20000, - fileParallelism: false - }, plugins: [ - swc.vite({ - module: {type: 'es6'}, - }), + swc.vite({ module: { type: 'es6' } }), ], + test: { + // ADR-006 §12: the write gate's no-env derivation probes live DB state + // (marker row, Users count), which would make every suite's hashing + // behavior depend on truncation order. Tests therefore run with writes + // explicitly enabled; hash-write-gate.service.spec.ts manipulates + // process.env per case to exercise the derivation itself. + env: { PASSWORD_HASH_WRITE_ENABLED: 'true' }, + fileParallelism: false, + hookTimeout: 20_000, + testTimeout: 20_000, + }, }); diff --git a/apps/frontend/.env.development b/apps/frontend/.env.development new file mode 100644 index 0000000000..81681b57a2 --- /dev/null +++ b/apps/frontend/.env.development @@ -0,0 +1,14 @@ +# Frontend dev-server configuration (vue-cli env file, loaded for `start:dev`). +# This file is CHECKED IN — put personal overrides in .env.development.local +# (gitignored) instead of editing this file. +# +# API_PROXY_TARGET points the webpack dev server's proxy at the backend so the +# browser talks to ONE origin (this dev server) and API calls are forwarded. +# Leave it empty in .env.development.local to develop heimdall-lite standalone +# with no backend (GET /server then fails and the app runs in lite mode — +# src/store/server.ts handles that path). +# +# The frontend deliberately reads NOTHING from apps/backend/.env: sharing the +# backend's PORT here (as both this server's bind port and the proxy target) +# is what broke dev on 2026-08-10. +API_PROXY_TARGET=http://127.0.0.1:3000 diff --git a/apps/frontend/LICENSE.md b/apps/frontend/LICENSE.md new file mode 100644 index 0000000000..6a712fb952 --- /dev/null +++ b/apps/frontend/LICENSE.md @@ -0,0 +1,33 @@ +© 2026 The MITRE Corporation. + +Approved for Public Release; Distribution Unlimited. Case Number 18-3678. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright/ digital rights legend, this list of conditions and the following Notice. + +- Redistributions in binary form must reproduce the above copyright copyright/ digital rights legend, this list of conditions and the following Notice in the documentation and/or other materials provided with the distribution. + +- Neither the name of The MITRE Corporation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +NOTICE + +MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE file included with this project. + +This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1b8c41d4b0..42794124c5 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,11 +3,29 @@ "version": "2.13.1", "license": "Apache-2.0", "description": "Heimdall is a JavaScript based security results viewer and review tool supporting multiple security results formats, such as: InSpec, SonarQube, OWASP-Zap, and Fortify which you can load locally or from S3 and other data sources.", + "keywords": [ + "security", + "compliance", + "inspec", + "hdf", + "ohdf", + "stig", + "sonarqube", + "fortify", + "owasp-zap", + "mitre-saf" + ], + "homepage": "https://github.com/mitre/heimdall2#readme", + "bugs": "https://github.com/mitre/heimdall2/issues", + "author": "MITRE Corporation", "repository": { "type": "git", - "url": "https://github.com/mitre/heimdall2", + "url": "git+https://github.com/mitre/heimdall2.git", "directory": "apps/frontend" }, + "publishConfig": { + "access": "public" + }, "files": [ "dist", "src/server.js" @@ -34,6 +52,7 @@ "@aws-sdk/client-sts": "^3.427.0", "@e965/xlsx": "^0.20.0", "@heimdall/common": "^2.13.0", + "@heimdall/password-complexity": "^2.13.0", "@mdi/font": "^7.0.96", "@types/chroma-js": "^3.1.2", "@types/d3-hierarchy": "^3.1.7", @@ -97,7 +116,8 @@ "esbuild": "^0.28.0", "jsdom": "^29.0.0", "vite-svg-loader": "^5.1.0", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "webpack": "5.106.2" }, "engines": { "node": ">=22" diff --git a/apps/frontend/src/components/cards/ComplianceChart.vue b/apps/frontend/src/components/cards/ComplianceChart.vue index b82d675e73..6ce070daf2 100644 --- a/apps/frontend/src/components/cards/ComplianceChart.vue +++ b/apps/frontend/src/components/cards/ComplianceChart.vue @@ -58,9 +58,9 @@ export default class ComplianceChart extends Vue { get series(): number[] { try { const val = calculateCompliance(this.filter); - if (isNaN(val) || typeof val !== 'number') return []; + if (Number.isNaN(val) || typeof val !== 'number') return []; return [val]; - } catch (e) { + } catch { return []; } } diff --git a/apps/frontend/src/components/cards/EvaluationInfo.vue b/apps/frontend/src/components/cards/EvaluationInfo.vue index 24cbb34a01..66048a6f4d 100644 --- a/apps/frontend/src/components/cards/EvaluationInfo.vue +++ b/apps/frontend/src/components/cards/EvaluationInfo.vue @@ -15,7 +15,7 @@
Groups: @@ -50,7 +50,7 @@ diff --git a/apps/frontend/src/components/generic/LogoutButton.vue b/apps/frontend/src/components/generic/LogoutButton.vue index 55fdb0fe06..79ee58f706 100644 --- a/apps/frontend/src/components/generic/LogoutButton.vue +++ b/apps/frontend/src/components/generic/LogoutButton.vue @@ -23,7 +23,8 @@ import Component, {mixins} from 'vue-class-component'; }) export default class LogoutButton extends mixins(ServerMixin) { logOut() { - ServerModule.Logout(); + // Fire-and-forget: Logout clears state and navigates on its own schedule. + void ServerModule.Logout(); } } diff --git a/apps/frontend/src/components/global/ExportASFFModal.vue b/apps/frontend/src/components/global/ExportAsffModal.vue similarity index 90% rename from apps/frontend/src/components/global/ExportASFFModal.vue rename to apps/frontend/src/components/global/ExportAsffModal.vue index 1a9555ec5e..2ab1df6df4 100644 --- a/apps/frontend/src/components/global/ExportASFFModal.vue +++ b/apps/frontend/src/components/global/ExportAsffModal.vue @@ -106,11 +106,7 @@ export default class ExportASFFModal extends Vue { } get exportDisabled() { - if (this.awsAccountId && this.target && this.region) { - return false; - } else { - return true; - } + return !(this.awsAccountId && this.target && this.region); } openRegionDocumentation() { @@ -131,28 +127,27 @@ export default class ExportASFFModal extends Vue { return res; } - exportASFF() { + async exportASFF(): Promise { const ids = FilteredDataModule.selected_file_ids; const fileData: FileData[] = []; - FilteredDataModule.evaluations(ids).forEach(async (evaluation) => { + FilteredDataModule.evaluations(ids).forEach((evaluation) => { const findings = new FromHdfToAsffMapper(evaluation.data, { input: evaluation.from_file.filename, awsAccountId: this.awsAccountId, target: this.target, region: this.region }).toAsff() as unknown as Record[]; - this.sliceIntoChunks(findings, 100).forEach(async (chunk, index) => { + this.sliceIntoChunks(findings, 100).forEach((chunk, index) => { fileData.push({ filename: `${evaluation.from_file.filename}.p${index}.json`, data: JSON.stringify(chunk) }); }); }); - saveSingleOrMultipleFiles(fileData, 'ASFF').then(() => { - // Preserve AWS Account ID and Region across exports - this.target = ''; - this.closeModal(); - }); + await saveSingleOrMultipleFiles(fileData, 'ASFF'); + // Preserve AWS Account ID and Region across exports + this.target = ''; + this.closeModal(); } } diff --git a/apps/frontend/src/components/global/ExportCaat.vue b/apps/frontend/src/components/global/ExportCaat.vue index 151a8d3bf8..5d8ed96308 100644 --- a/apps/frontend/src/components/global/ExportCaat.vue +++ b/apps/frontend/src/components/global/ExportCaat.vue @@ -23,7 +23,6 @@ import Component from 'vue-class-component'; import {Prop} from 'vue-property-decorator'; import {Filter, FilteredDataModule} from '../../store/data_filters'; import {InspecDataModule} from '../../store/data_store'; -import {EvaluationFile} from '../../store/report_intake'; @Component({ components: { @@ -36,14 +35,16 @@ export default class ExportCaat extends Vue { exportCaat() { const inputData = this.filter.fromFile.map((fileId: string) => { const file = ( - InspecDataModule.allEvaluationFiles as EvaluationFile[] + InspecDataModule.allEvaluationFiles ).find((f) => f.uniqueId === fileId); const data = file?.evaluation ?? ''; const filename = file?.filename || fileId; - const controls = FilteredDataModule.controls({ - ...this.filter, - fromFile: [fileId] - }).slice(); + const controls = [ + ...FilteredDataModule.controls({ + ...this.filter, + fromFile: [fileId] + }) + ]; return {data, filename, controls}; }); const caat = new FromHDFToCAATMapper(inputData).toCAAT(false); diff --git a/apps/frontend/src/components/global/ExportCKLModal.vue b/apps/frontend/src/components/global/ExportCklModal.vue similarity index 90% rename from apps/frontend/src/components/global/ExportCKLModal.vue rename to apps/frontend/src/components/global/ExportCklModal.vue index 5069383059..6c3a34b985 100644 --- a/apps/frontend/src/components/global/ExportCKLModal.vue +++ b/apps/frontend/src/components/global/ExportCklModal.vue @@ -270,7 +270,7 @@ Export @@ -320,12 +320,15 @@ type ExtendedEvaluationFile = (EvaluationFile | ProfileFile) & gidexample: string; }; -type FileData = { +interface FileData { filename: string; data: string; -}; +} const isNotSelected: CustomRule = (_, file) => !file.selected; + +const RELEASE_INFO_PATTERN = + /Release: (?\d+)\D+(?:\d.*?)?Date: (?\d{1,2} \w{3} \d{4})/v; function validateField(prop: string): CustomRule { return (_, file: ExtendedEvaluationFile) => { let results = validateChecklistMetadata(file); @@ -362,10 +365,11 @@ export default class ExportCKLModal extends Vue { types = Object.values(Assettype); techareas = Object.values(Techarea); files: ExtendedEvaluationFile[] = this.evaluations(this.filter.fromFile); + selected: ExtendedEvaluationFile[] = []; @Watch('showingModal') onModalChange(newState: boolean) { - if (newState === false) { + if (!newState) { this.closeModal(); } } @@ -375,8 +379,6 @@ export default class ExportCKLModal extends Vue { this.files = this.evaluations(newFilter.fromFile); } - selected: ExtendedEvaluationFile[] = []; - /** * Invoked when file(s) are loaded. */ @@ -396,14 +398,29 @@ export default class ExportCKLModal extends Vue { this.selected = []; } + // Fetch-and-guard for the repeated files[i].profiles[j] path: indices come + // from the template's own v-for, so the guard states an invariant — the old + // blind chain crashed on undefined in the same impossible case. + profileAt(fileIndex: number, profileIndex: number) { + const profile = this.files.at(fileIndex)?.profiles.at(profileIndex); + if (profile === undefined) { + throw new TypeError( + `No profile at file ${fileIndex}, profile ${profileIndex}` + ); + } + return profile; + } + setDateSelection(fileIndex: number, profileIndex: number, date: string) { - this.files[fileIndex].profiles[profileIndex].releasedate = date; - this.files[fileIndex].profiles[profileIndex].showCalendar = false; + const profile = this.profileAt(fileIndex, profileIndex); + profile.releasedate = date; + profile.showCalendar = false; } clearDateSelection(fileIndex: number, profileIndex: number) { - this.files[fileIndex].profiles[profileIndex].releasedate = ''; - this.files[fileIndex].profiles[profileIndex].showCalendar = false; + const profile = this.profileAt(fileIndex, profileIndex); + profile.releasedate = ''; + profile.showCalendar = false; } // Get our evaluation info for our export table @@ -485,15 +502,19 @@ export default class ExportCKLModal extends Vue { Techarea.Empty ) ), - webordatabase: _.get( - file, - 'evaluation.data.passthrough.checklist.asset.webordatabase', + // Checklists carry this flag as a boolean, so it is stringified + // rather than assumed to be text; the default matches that shape. + webordatabase: String( _.get( file, - 'evaluation.data.passthrough.metadata.webordatabase', - 'false' - ) - ).toString(), + 'evaluation.data.passthrough.checklist.asset.webordatabase' + ) ?? + _.get( + file, + 'evaluation.data.passthrough.metadata.webordatabase', + false + ) + ), webdbsite: _.get( file, 'evaluation.data.passthrough.checklist.asset.webdbsite', @@ -522,10 +543,8 @@ export default class ExportCKLModal extends Vue { splitReleaseInfo(info: string): string[] { const defaultReturn = ['', '']; - const pattern = - /Release: (?\d+)\D+(?:\d.*?)?Date: (?\d{1,2} \w{3} \d{4})/v; - const matches = RegExp(pattern).exec(info); - if (matches && matches.groups) { + const matches = RELEASE_INFO_PATTERN.exec(info); + if (matches?.groups) { return [matches.groups.release, matches.groups.date]; } return defaultReturn; @@ -544,7 +563,7 @@ export default class ExportCKLModal extends Vue { ); for (const profileStig of profileOrStigs) { const depends = _.get(profileStig, 'depends') as unknown as Dependency[]; - if (Array.isArray(depends) && depends.length !== 0) { + if (Array.isArray(depends) && depends.length > 0) { continue; } const [releasenumber, releasedate] = this.splitReleaseInfo( @@ -553,27 +572,21 @@ export default class ExportCKLModal extends Vue { const version = coerce( _.get(profileStig, 'header.version', _.get(profileStig, 'version', '')) ); + const fallbackName = _.get(profileStig, 'name', ''); + const stigTitle = _.get( + profileStig, + 'header.title', + _.get(profileStig, 'title', fallbackName) + ); results.push({ - name: _.get( - profileStig, - 'header.title', - _.get(profileStig, 'name', '') - ), - title: _.get( - profileStig, - 'header.title', - _.get(profileStig, 'title', _.get(profileStig, 'name', '')) - ), - titleplaceholder: _.get( - profileStig, - 'header.title', - _.get(profileStig, 'title', _.get(profileStig, 'name', '')) - ), + name: _.get(profileStig, 'header.title', fallbackName), + title: stigTitle, + titleplaceholder: stigTitle, version: version?.major ?? 0, versionplaceholder: (version?.major ?? 0).toString(), - releasenumber: parseInt(releasenumber, 10) || version?.minor || 0, + releasenumber: Number(releasenumber) || version?.minor || 0, releasenumberplaceholder: ( - parseInt(releasenumber, 10) || + Number(releasenumber) || version?.minor || 0 ).toString(), @@ -606,10 +619,11 @@ export default class ExportCKLModal extends Vue { // Only format for UCs where the name ends with values contained in the baselineArray const baselineArray = ['stig-baseline', 'cis-baseline', 'srg-baseline']; for (const baseline of baselineArray) { - if (name.indexOf(baseline) > 0) { - index = name.indexOf(baseline); - break; + if (name.indexOf(baseline) <= 0) { + continue; } + index = name.indexOf(baseline); + break; } // We need to format the name @@ -620,16 +634,16 @@ export default class ExportCKLModal extends Vue { this.originalProfileTitle.set(originalTitleIndex, name); } // Get the name value up to the index, replace dashes with spaces - newName = name.substring(0, index).split('-').join(' '); + newName = name.slice(0, index).replaceAll('-', ' '); // Convert the first letter of each word into uppercase newName = newName.replaceAll(/^\w|[A-Z]|\b\w/gv, function (word) { return word.toUpperCase(); }); - newName = newName + 'Security Technical Implementation Guide'; + newName += 'Security Technical Implementation Guide'; } // Update the file title for the profile being processed - this.files[fileIndex].profiles[profileIndex].title = newName; + this.profileAt(fileIndex, profileIndex).title = newName; return newName; } @@ -639,7 +653,7 @@ export default class ExportCKLModal extends Vue { const index = fileIndex + profileIndex; if (this.originalProfileTitle.has(index)) { newName = this.originalProfileTitle.get(index)!; - this.files[fileIndex].profiles[profileIndex].title = newName; + this.profileAt(fileIndex, profileIndex).title = newName; } return newName; } @@ -713,7 +727,7 @@ export default class ExportCKLModal extends Vue { ); } - exportCKL(): void { + async exportCKL(): Promise { if (this.selected.length === 0) { return SnackbarModule.failure('No files have been loaded.'); } @@ -738,7 +752,7 @@ export default class ExportCKLModal extends Vue { }); } } - saveSingleOrMultipleFiles(fileData, 'ckl'); + await saveSingleOrMultipleFiles(fileData, 'ckl'); this.closeModal(); } diff --git a/apps/frontend/src/components/global/ExportCSVModal.vue b/apps/frontend/src/components/global/ExportCsvModal.vue similarity index 91% rename from apps/frontend/src/components/global/ExportCSVModal.vue rename to apps/frontend/src/components/global/ExportCsvModal.vue index 72fe06c187..a431d28842 100644 --- a/apps/frontend/src/components/global/ExportCSVModal.vue +++ b/apps/frontend/src/components/global/ExportCsvModal.vue @@ -88,14 +88,12 @@ const fieldNames = [ 'Waiver Data' ]; -type ControlSetRow = { - [key: string]: unknown; -}; +type ControlSetRow = Record; -type File = { +interface File { filename: string; data: string; -}; +} type ControlSetRows = ControlSetRow[]; @@ -110,6 +108,8 @@ export default class ExportCSVModal extends Vue { showingModal = false; fields = _.clone(fieldNames); fieldsToAdd: string[] = _.clone(fieldNames); + files: File[] = []; + rows: ControlSetRows = []; closeModal() { this.showingModal = false; @@ -126,13 +126,10 @@ export default class ExportCSVModal extends Vue { }); } - files: File[] = []; - rows: ControlSetRows = []; - descriptionsToString( descriptions?: | ExecJSON.ControlDescription[] - | {[key: string]: string} + | Record | null ): string { let result = ''; @@ -142,7 +139,7 @@ export default class ExportCSVModal extends Vue { const caveats = descriptions.filter( (description) => description.label === 'caveat' ); - if (caveats.length) { + if (caveats.length > 0) { descriptions = descriptions.filter( (description) => description.label !== 'caveat' ); @@ -225,7 +222,7 @@ export default class ExportCSVModal extends Vue { case fieldNames[3]: result[fieldNames[3]] = control.data.title; break; - //Description + // Description case fieldNames[4]: result[fieldNames[4]] = control.data.desc; break; @@ -293,10 +290,9 @@ export default class ExportCSVModal extends Vue { const root = ctrl.root; if (hitIds.has(root.hdf.wraps.id)) { continue; - } else { - hitIds.add(root.hdf.wraps.id); - rows.push(this.convertRow(file, root)); } + hitIds.add(root.hdf.wraps.id); + rows.push(this.convertRow(file, root)); } return rows; } @@ -317,10 +313,9 @@ export default class ExportCSVModal extends Vue { return _.truncate(string, {length: 100}); } - async convertData(file: EvaluationFile | ProfileFile) { + convertData(file: EvaluationFile | ProfileFile): void { // Convert all controls from a file to ControlSetRows - let rows: ControlSetRows = []; - rows = this.convertRows(file); + const rows: ControlSetRows = this.convertRows(file); // Convert rows to CSV const csvBody = stringify(rows); // Generate headers for CSV @@ -334,24 +329,25 @@ export default class ExportCSVModal extends Vue { }); } - exportCSV() { + async exportCSV(): Promise { this.files = []; - const fileConvertPromises = this.filter.fromFile.map((fileId) => { - const file = InspecDataModule.allFiles.find((f) => f.uniqueId === fileId); - if (file) { - return this.convertData(file); + try { + for (const fileId of this.filter.fromFile) { + const file = InspecDataModule.allFiles.find( + (f) => f.uniqueId === fileId + ); + if (file) { + this.convertData(file); + } } - return null; - }); - Promise.all(fileConvertPromises) - .then(() => saveSingleOrMultipleFiles(this.files, 'csv')) - .finally(() => { - this.closeModal(); - }); + await saveSingleOrMultipleFiles(this.files, 'csv'); + } finally { + this.closeModal(); + } } cleanUpFilename(filename: string): string { - return filename.replace(/\s+/gv, '_'); + return filename.replaceAll(/\s+/gv, '_'); } } diff --git a/apps/frontend/src/components/global/ExportHTMLModal.vue b/apps/frontend/src/components/global/ExportHtmlModal.vue similarity index 99% rename from apps/frontend/src/components/global/ExportHTMLModal.vue rename to apps/frontend/src/components/global/ExportHtmlModal.vue index 4e731384cd..9db4d291ed 100644 --- a/apps/frontend/src/components/global/ExportHTMLModal.vue +++ b/apps/frontend/src/components/global/ExportHtmlModal.vue @@ -159,7 +159,7 @@ export default class ExportHTMLModal extends Vue { saveAs( new Blob([body], {type: 'text/html;charset=utf-8'}), - `${this.exportType}_Report_${new Date().toString()}.html`.replace( + `${this.exportType}_Report_${new Date().toString()}.html`.replaceAll( /[ :]/gv, '_' ) diff --git a/apps/frontend/src/components/global/ExportJson.vue b/apps/frontend/src/components/global/ExportJson.vue index 83b7f78138..15554e92af 100644 --- a/apps/frontend/src/components/global/ExportJson.vue +++ b/apps/frontend/src/components/global/ExportJson.vue @@ -20,10 +20,10 @@ import {saveSingleOrMultipleFiles} from '@/utilities/export_util'; import Vue from 'vue'; import Component from 'vue-class-component'; -export type FileData = { +export interface FileData { filename: string; data: string; -}; +} @Component({ components: { @@ -33,13 +33,12 @@ export type FileData = { export default class ExportJSON extends Vue { populate_files(): FileData[] { const ids = FilteredDataModule.selected_file_ids; - const fileData: FileData[] = []; - for (const evaluation of FilteredDataModule.evaluations(ids)) { - fileData.push({ + const fileData: FileData[] = FilteredDataModule.evaluations(ids).map( + (evaluation) => ({ filename: this.cleanup_filename(evaluation.from_file.filename), data: JSON.stringify(evaluation.data) - }); - } + }) + ); for (const prof of FilteredDataModule.profiles(ids)) { fileData.push({ filename: prof.from_file.filename, @@ -49,16 +48,19 @@ export default class ExportJSON extends Vue { return fileData; } - //exports .zip of jsons if multiple are selected, if one is selected it will export that .json file - export_json() { + // exports .zip of jsons if multiple are selected, if one is selected it will export that .json file + async export_json(): Promise { const files = this.populate_files(); - saveSingleOrMultipleFiles(files, 'json'); + await saveSingleOrMultipleFiles(files, 'json'); } cleanup_filename(filename: string): string { - filename = filename.replace(/\s+/gv, '_'); - if (filename.substring(filename.length - 6) !== '.json') { - filename = filename + '.json'; + filename = filename.replaceAll(/\s+/gv, '_'); + // endsWith, not a substring compare: the old check took the last SIX + // characters and compared them to the five-character '.json', so it + // could never match and every export gained a second extension. + if (!filename.endsWith('.json')) { + filename += '.json'; } return filename; } diff --git a/apps/frontend/src/components/global/ExportNist.vue b/apps/frontend/src/components/global/ExportNist.vue index 480877f896..7adbf465d2 100644 --- a/apps/frontend/src/components/global/ExportNist.vue +++ b/apps/frontend/src/components/global/ExportNist.vue @@ -47,7 +47,7 @@ export default class ExportNIST extends Vue { format_tag(control: NistControl): string | null { // For now just do raw text. Once Mo's nist work is done we can make sure these are well formed if (control.rawText) { - return control.rawText.replace(/\s/gv, ''); + return control.rawText.replaceAll(/\s/gv, ''); } else if (control.subSpecifiers.length < 2) { // Too short: abort return null; @@ -55,7 +55,8 @@ export default class ExportNIST extends Vue { // Just construct as best we can let base = `${control.subSpecifiers[0]}-${control.subSpecifiers[1]}`; for (let i = 2; i < control.subSpecifiers.length; i++) { - base += control.subSpecifiers[i]; + // Non-null proven by the loop bound. + base += control.subSpecifiers.at(i)!; } return base; } @@ -89,8 +90,8 @@ export default class ExportNIST extends Vue { const tags = c.root.hdf.parsedNistTags; tags.forEach((t) => { if ( - !nistControls.some( - (otherTag) => this.format_tag(otherTag) === this.format_tag(t) + nistControls.every( + (otherTag) => this.format_tag(otherTag) !== this.format_tag(t) ) ) { nistControls.push(t); @@ -99,7 +100,7 @@ export default class ExportNIST extends Vue { }); // Sort them - nistControls = nistControls.sort((a, b) => a.localCompare(b)); + nistControls = nistControls.toSorted((a, b) => a.localCompare(b)); // Turn to strings const asStringsMostly = nistControls.map((c) => this.format_tag(c)); @@ -120,7 +121,7 @@ export default class ExportNIST extends Vue { export_nist() { // Get files we plan on exporting - const files: Array = [ + const files: (FileID | undefined)[] = [ undefined, ...FilteredDataModule.selected_file_ids ]; @@ -149,10 +150,10 @@ export default class ExportNIST extends Vue { newName += appendage; i++; } - wb.SheetNames.push(newName); - const ws = XLSX.utils.aoa_to_sheet(sheet.data); - wb.Sheets[newName] = ws; + // The library's own API registers name and sheet atomically, replacing + // the manual SheetNames.push plus a computed-key write into Sheets. + XLSX.utils.book_append_sheet(wb, ws, newName); }); const wbout = XLSX.write(wb, {bookType: 'xlsx', type: 'binary'}); @@ -167,7 +168,7 @@ export default class ExportNIST extends Vue { /** Outputs the given number as a 2-digit string. Brittle **/ pad_two_digits(s: number): string { - return s < 10 ? `0${s}` : `${s}`; + return s < 10 ? `0${s}` : String(s); } convertDate(d: Date, delimiter: string): string { diff --git a/apps/frontend/src/components/global/ExportSplunkModal.vue b/apps/frontend/src/components/global/ExportSplunkModal.vue index b76ae5235b..60c7a75ae9 100644 --- a/apps/frontend/src/components/global/ExportSplunkModal.vue +++ b/apps/frontend/src/components/global/ExportSplunkModal.vue @@ -96,7 +96,7 @@ import Vue from 'vue'; import Component from 'vue-class-component'; import {Logger} from 'winston'; import {SnackbarModule} from '../../store/snackbar'; -import AuthStep from '../global/upload_tabs/splunk/AuthStep.vue'; +import AuthStep from '../global/upload-tabs/splunk/AuthStep.vue'; @Component({ components: { @@ -112,10 +112,10 @@ export default class ExportSplunkModal extends Vue { splunkConfig: SplunkConfig | null = null; logger: unknown = { - info: this.addLogMessage, - debug: this.addLogMessage, - verbose: this.addLogMessage, - error: this.addLogMessage + info: (message: string) => this.addLogMessage(message), + debug: (message: string) => this.addLogMessage(message), + verbose: (message: string) => this.addLogMessage(message), + error: (message: string) => this.addLogMessage(message) }; addLogMessage(message: string) { @@ -136,7 +136,8 @@ export default class ExportSplunkModal extends Vue { onAuthenticationComplete(splunkConfig: SplunkConfig) { this.splunkConfig = splunkConfig; this.step = 2; - this.convertAndUpload(); + // Fire-and-forget: progress and failures surface in the modal status log. + void this.convertAndUpload(); } got_files(files: FileID[]) { @@ -148,25 +149,29 @@ export default class ExportSplunkModal extends Vue { this.splunkConfig = null; } - async convertAndUpload() { + async convertAndUpload(): Promise { const ids = FilteredDataModule.selected_file_ids; - FilteredDataModule.evaluations(ids).forEach(async (evaluation) => { - this.statusLog += `Starting Upload of File: ${evaluation.from_file.filename}\n`; - if (this.splunkConfig) { - new FromHDFToSplunkMapper(evaluation, this.logger as Logger) - .toSplunk(this.splunkConfig, evaluation.from_file.filename) - .then(() => { + // Uploads still run concurrently; each logs its own outcome. + await Promise.all( + FilteredDataModule.evaluations(ids).map(async (evaluation) => { + this.statusLog += `Starting Upload of File: ${evaluation.from_file.filename}\n`; + if (this.splunkConfig) { + try { + await new FromHDFToSplunkMapper( + evaluation, + this.logger as Logger + ).toSplunk(this.splunkConfig, evaluation.from_file.filename); this.statusLog += `Sucessfully uploaded file ${evaluation.from_file.filename}\n`; - }) - .catch((error) => { - this.statusLog += `Failed to upload file ${evaluation.from_file.filename}:\n\t${error}\n`; - }); - } else { - SnackbarModule.failure( - 'Failed to upload to Splunk: Invalid Configuration (undefined)' - ); - } - }); + } catch (error) { + this.statusLog += `Failed to upload file ${evaluation.from_file.filename}:\n\t${String(error)}\n`; + } + } else { + SnackbarModule.failure( + 'Failed to upload to Splunk: Invalid Configuration (undefined)' + ); + } + }) + ); } } diff --git a/apps/frontend/src/components/global/ExportXCCDFResults.vue b/apps/frontend/src/components/global/ExportXCCDFResults.vue deleted file mode 100644 index 67367e2e05..0000000000 --- a/apps/frontend/src/components/global/ExportXCCDFResults.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - diff --git a/apps/frontend/src/components/global/ExportXccdfResults.vue b/apps/frontend/src/components/global/ExportXccdfResults.vue new file mode 100644 index 0000000000..04dc7e3f84 --- /dev/null +++ b/apps/frontend/src/components/global/ExportXccdfResults.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/frontend/src/components/global/RegistrationModal.vue b/apps/frontend/src/components/global/RegistrationModal.vue index 30e7453743..8367c3e583 100644 --- a/apps/frontend/src/components/global/RegistrationModal.vue +++ b/apps/frontend/src/components/global/RegistrationModal.vue @@ -193,13 +193,17 @@ export default class RegistrationModal extends Vue { @Prop({default: false}) readonly visible!: boolean; login() { - this.$router.push('/login'); + // vue-router 3 rejects benign duplicate navigation; nothing depends on it. + void this.$router.push('/login'); } async register(): Promise { - this.buttonLoading = true; // checking if the input is valid - if ((this.$refs.form as HTMLFormElement).validate()) { + if (!(this.$refs.form as HTMLFormElement).validate()) { + return; + } + this.buttonLoading = true; + try { const creds: SignupHash = { firstName: this.firstName, lastName: this.lastName, @@ -218,11 +222,14 @@ export default class RegistrationModal extends Vue { this.$emit('close-modal'); this.$emit('update-user-table'); } else { - this.$router.push('/login'); + // vue-router 3 rejects benign duplicate navigation; nothing depends + // on it. + void this.$router.push('/login'); SnackbarModule.notify( 'You have successfully registered, please sign in' ); } + } finally { this.buttonLoading = false; } } diff --git a/apps/frontend/src/components/global/SearchBar.vue b/apps/frontend/src/components/global/SearchBar.vue index 85da6eb10b..6655032f43 100644 --- a/apps/frontend/src/components/global/SearchBar.vue +++ b/apps/frontend/src/components/global/SearchBar.vue @@ -41,6 +41,12 @@ import Vue from 'vue'; import Component from 'vue-class-component'; import {Watch} from 'vue-property-decorator'; +// Placeholder callback so typingTimer always holds a timer handle; the first +// keystroke clears it and installs the real one. +const noOp = () => { + return; +}; + @Component({ components: { SearchHelpModal @@ -51,6 +57,17 @@ export default class SearchBar extends Vue { search: HTMLInputElement; }; + /** If we are currently showing the search help modal */ + showSearchHelp = false; + + /** Determines if we should make the search bar collapse-able */ + showSearchMobile = false; + + /** + * If the user is currently typing in the search bar + */ + typingTimer = setTimeout(noOp, 0); + /** * The current search terms, as modeled by the search bar */ @@ -62,19 +79,6 @@ export default class SearchBar extends Vue { SearchModule.updateSearch(term); } - /** If we are currently showing the search help modal */ - showSearchHelp = false; - - /** Determines if we should make the search bar collapse-able */ - showSearchMobile = false; - - /** - * If the user is currently typing in the search bar - */ - typingTimer = setTimeout(() => { - return; - }, 0); - /** * Handles focusing on the search bar */ @@ -95,7 +99,7 @@ export default class SearchBar extends Vue { if (this.typingTimer) { clearTimeout(this.typingTimer); } - this.typingTimer = setTimeout(this.onDoneTyping, 100); + this.typingTimer = setTimeout(() => this.onDoneTyping(), 100); } } diff --git a/apps/frontend/src/components/global/Sidebar.vue b/apps/frontend/src/components/global/Sidebar.vue index 54b39bc10b..a304a16978 100644 --- a/apps/frontend/src/components/global/Sidebar.vue +++ b/apps/frontend/src/components/global/Sidebar.vue @@ -85,14 +85,15 @@ export default class Sidebar extends mixins(RouteMixin) { // get all visible (uploaded) evaluation files get visible_evaluation_files(): EvaluationFile[] { + // toSorted: sorting in place here silently reordered the store's array const files = InspecDataModule.allEvaluationFiles; - return files.sort((a, b) => a.filename.localeCompare(b.filename)); + return files.toSorted((a, b) => a.filename.localeCompare(b.filename)); } // get all visible (uploaded) profile files get visible_profile_files(): ProfileFile[] { const files = InspecDataModule.allProfileFiles; - return files.sort((a, b) => a.filename.localeCompare(b.filename)); + return files.toSorted((a, b) => a.filename.localeCompare(b.filename)); } get all_evaluations_selected(): Trinary { @@ -133,34 +134,37 @@ export default class Sidebar extends mixins(RouteMixin) { compareView(): void { if (this.current_route === 'results') { this.navigateWithNoErrors('/compare'); - } - if (this.current_route === 'compare') { + } else if (this.current_route === 'compare') { this.navigateWithNoErrors('/results'); } } - removeSelectedEvaluations(): void { + async removeSelectedEvaluations(): Promise { const selectedFiles = FilteredDataModule.selected_evaluation_ids; - selectedFiles.forEach((fileId) => { - EvaluationModule.removeEvaluation(fileId); + for (const fileId of selectedFiles) { + // The evaluation lookup reads the file entry, so it must finish + // before removeFile deletes that entry. + await EvaluationModule.removeEvaluation(fileId); InspecDataModule.removeFile(fileId); // Remove any database files that may have been in the URL // by calling the router and causing it to write the appropriate // route to the URL bar this.navigateWithNoErrors(`/${this.current_route}`); - }); + } } - removeSelectedProfiles(): void { + async removeSelectedProfiles(): Promise { const selectedFiles = FilteredDataModule.selected_profile_ids; - selectedFiles.forEach((fileId) => { - EvaluationModule.removeEvaluation(fileId); + for (const fileId of selectedFiles) { + // The evaluation lookup reads the file entry, so it must finish + // before removeFile deletes that entry. + await EvaluationModule.removeEvaluation(fileId); InspecDataModule.removeFile(fileId); // Remove any database files that may have been in the URL // by calling the router and causing it to write the appropriate // route to the URL bar this.navigateWithNoErrors(`/${this.current_route}`); - }); + } } } diff --git a/apps/frontend/src/components/global/Topbar.vue b/apps/frontend/src/components/global/Topbar.vue index 4fc29f6135..38bd65717f 100644 --- a/apps/frontend/src/components/global/Topbar.vue +++ b/apps/frontend/src/components/global/Topbar.vue @@ -72,7 +72,7 @@ export default class Topbar extends mixins(ServerMixin) { get elipsisTitle() { return this.title.length > 50 - ? `${this.title.substring(0, 50)}...` + ? `${this.title.slice(0, 50)}...` : this.title; } } diff --git a/apps/frontend/src/components/global/TopbarDropdown.vue b/apps/frontend/src/components/global/TopbarDropdown.vue index 99ed58b911..4a65b4a707 100644 --- a/apps/frontend/src/components/global/TopbarDropdown.vue +++ b/apps/frontend/src/components/global/TopbarDropdown.vue @@ -122,9 +122,9 @@ export default class TopbarDropdown extends mixins(ServerMixin) { this.userInfo.firstName.charAt(0) + this.userInfo.lastName.charAt(0) ); } else if (this.userInfo.firstName) { - return this.userInfo.firstName.substring(0, 2); + return this.userInfo.firstName.slice(0, 2); } else { - return this.userInfo.email.substring(0, 2); + return this.userInfo.email.slice(0, 2); } } diff --git a/apps/frontend/src/components/global/UploadNexus.vue b/apps/frontend/src/components/global/UploadNexus.vue index 36f8b83c1d..abf5117b9d 100644 --- a/apps/frontend/src/components/global/UploadNexus.vue +++ b/apps/frontend/src/components/global/UploadNexus.vue @@ -71,13 +71,13 @@ diff --git a/apps/frontend/src/components/global/admin/UserManagement.vue b/apps/frontend/src/components/global/admin/UserManagement.vue index 82abb6d3e5..68340ddccd 100644 --- a/apps/frontend/src/components/global/admin/UserManagement.vue +++ b/apps/frontend/src/components/global/admin/UserManagement.vue @@ -94,7 +94,7 @@ export default class UserManagement extends Vue { createUserDialog = false; search = ''; users: IUser[] = []; - headers: Object[] = [ + headers: object[] = [ { text: 'Email', align: 'start', @@ -109,7 +109,8 @@ export default class UserManagement extends Vue { ]; mounted() { - this.getUsers(); + // Fire-and-forget: HTTP failures surface via the interceptor snackbar. + void this.getUsers(); } deleteUserDialog(user: IUser): void { @@ -117,19 +118,21 @@ export default class UserManagement extends Vue { this.dialogDelete = true; } - deleteUserConfirm(): void { + async deleteUserConfirm(): Promise { if (this.editedUser) { - axios - .delete(`/users/${this.editedUser.id}`) - .then((response) => { - SnackbarModule.notify( - `Successfully deleted user ${response.data.email}` - ); - }) - .finally(() => { - this.getUsers(); - this.closeActionDialog(); - }); + try { + const response = await axios.delete( + `/users/${this.editedUser.id}` + ); + SnackbarModule.notify( + `Successfully deleted user ${response.data.email}` + ); + } finally { + // Refresh even when the delete failed (the interceptor snackbar + // reports it) so the table shows the actual server state. + await this.getUsers(); + this.closeActionDialog(); + } } } @@ -145,15 +148,13 @@ export default class UserManagement extends Vue { } } - getUsers(): void { - axios - .get('/users') - .then((response) => { - this.users = response.data; - }) - .finally(() => { - this.loading = false; - }); + async getUsers(): Promise { + try { + const response = await axios.get('/users'); + this.users = response.data; + } finally { + this.loading = false; + } } } diff --git a/apps/frontend/src/components/global/groups/GroupAPIKeysModal.vue b/apps/frontend/src/components/global/groups/GroupApiKeysModal.vue similarity index 69% rename from apps/frontend/src/components/global/groups/GroupAPIKeysModal.vue rename to apps/frontend/src/components/global/groups/GroupApiKeysModal.vue index 06ada01c8b..58aa1c20c4 100644 --- a/apps/frontend/src/components/global/groups/GroupAPIKeysModal.vue +++ b/apps/frontend/src/components/global/groups/GroupApiKeysModal.vue @@ -161,119 +161,87 @@ export default class GroupAPIKeysModal extends Vue { ]; mounted() { - this.updateAPIKeys(); + // Fire-and-forget: HTTP failures surface via the interceptor snackbar. + void this.updateAPIKeys(); } - updateAPIKeys() { + async updateAPIKeys(): Promise { this.loading = true; - axios - .get(`/apikeys`, { + try { + const {data} = await axios.get(`/apikeys`, { params: { groupId: this.group.id } - }) - .then(({data}) => { - this.apiKeys = data; - this.loading = false; - }) - .catch((error) => { - this.loading = false; - // Default error handling works fine - throw error; }); + this.apiKeys = data; + } finally { + this.loading = false; + } } - refreshAPIKey(item: IApiKey) { + async refreshAPIKey(item: IApiKey): Promise { this.loading = true; - axios - .delete(`/apikeys/${item.id}`, { + try { + await axios.delete(`/apikeys/${item.id}`, { data: { currentPassword: this.password } - }) - .then(() => { - this.apiKeys = this.apiKeys.filter((key) => key.id !== item.id); + }); + this.apiKeys = this.apiKeys.filter((key) => key.id !== item.id); - // Re-create the key - axios - .post('/apikeys', { - groupId: this.group.id, - name: item.name, - currentPassword: this.password - }) - .then(({data}) => { - this.apiKeys.push(data); - SnackbarModule.notify('API Key recreated successfully'); - this.loading = false; - }) - .catch((error) => { - this.loading = false; - // Default error handling works fine - throw error; - }); - }) - .catch((error) => { - this.loading = false; - // Default error handling works fine - throw error; + // Re-create the key + const {data} = await axios.post('/apikeys', { + groupId: this.group.id, + name: item.name, + currentPassword: this.password }); + this.apiKeys.push(data); + SnackbarModule.notify('API Key recreated successfully'); + } finally { + this.loading = false; + } } - addAPIKey() { + async addAPIKey(): Promise { this.loading = true; - axios - .post('/apikeys', { + try { + const {data} = await axios.post('/apikeys', { groupId: this.group.id, currentPassword: this.password - }) - .then(({data}) => { - this.apiKeys.push(data); - SnackbarModule.notify('API Key added successfully'); - this.loading = false; - }) - .catch((error) => { - this.loading = false; - // Default error handling works fine - throw error; }); + this.apiKeys.push(data); + SnackbarModule.notify('API Key added successfully'); + } finally { + this.loading = false; + } } - setKeyName(item: IApiKey) { + async setKeyName(item: IApiKey): Promise { this.loading = true; - axios - .put(`/apikeys/${item.id}`, { + try { + await axios.put(`/apikeys/${item.id}`, { name: item.name, currentPassword: this.password - }) - .then(() => { - SnackbarModule.notify('API Key name updated successfully'); - this.loading = false; - }) - .catch((error) => { - this.loading = false; - // Default error handling works fine - throw error; }); + SnackbarModule.notify('API Key name updated successfully'); + } finally { + this.loading = false; + } } - deleteAPIKey(item: IApiKey) { + async deleteAPIKey(item: IApiKey): Promise { this.loading = true; - axios - .delete(`/apikeys/${item.id}`, { + try { + const {data} = await axios.delete(`/apikeys/${item.id}`, { data: { currentPassword: this.password } - }) - .then(({data}) => { - this.apiKeys = this.apiKeys.filter((key) => key.id !== data.id); - SnackbarModule.notify('API Key deleted successfully'); - this.loading = false; - }) - .catch((error) => { - this.loading = false; - // Default error handling works fine - throw error; }); + this.apiKeys = this.apiKeys.filter((key) => key.id !== data.id); + SnackbarModule.notify('API Key deleted successfully'); + } finally { + this.loading = false; + } } } diff --git a/apps/frontend/src/components/global/groups/GroupManagement.vue b/apps/frontend/src/components/global/groups/GroupManagement.vue index da87b1361a..a692348476 100644 --- a/apps/frontend/src/components/global/groups/GroupManagement.vue +++ b/apps/frontend/src/components/global/groups/GroupManagement.vue @@ -210,15 +210,14 @@ export default class GroupManagement extends Vue { this.dialogDelete = true; } - deleteGroupConfirm(): void { + async deleteGroupConfirm(): Promise { if (this.editedGroup) { - GroupsModule.DeleteGroup(this.editedGroup) - .then((data) => { - SnackbarModule.notify(`Successfully deleted group ${data.name}`); - }) - .finally(() => { - this.closeActionDialog(); - }); + try { + const data = await GroupsModule.DeleteGroup(this.editedGroup); + SnackbarModule.notify(`Successfully deleted group ${data.name}`); + } finally { + this.closeActionDialog(); + } } } @@ -247,14 +246,15 @@ export default class GroupManagement extends Vue { get groupData(): (IGroup & {members: ISlimUser[]; owners: ISlimUser[]})[] { let groups: IGroup[]; if (this.adminPanel) { - groups = GroupsModule.myGroups.concat( - GroupsModule.allGroups.filter( + groups = [ + ...GroupsModule.myGroups, + ...GroupsModule.allGroups.filter( (group) => !GroupsModule.myGroups .map((myGroup) => myGroup.id) .includes(group.id) ) - ); + ]; } else { groups = GroupsModule.myGroups; } diff --git a/apps/frontend/src/components/global/groups/GroupModal.vue b/apps/frontend/src/components/global/groups/GroupModal.vue index 9170c5872b..83900464c0 100644 --- a/apps/frontend/src/components/global/groups/GroupModal.vue +++ b/apps/frontend/src/components/global/groups/GroupModal.vue @@ -102,7 +102,7 @@ diff --git a/apps/frontend/src/components/global/groups/Users.vue b/apps/frontend/src/components/global/groups/Users.vue index 5a6c7d2b93..b56f11f8be 100644 --- a/apps/frontend/src/components/global/groups/Users.vue +++ b/apps/frontend/src/components/global/groups/Users.vue @@ -93,7 +93,7 @@ export default class Users extends Vue { @Prop({type: Boolean, required: false, default: false}) readonly admin!: boolean; - editedUserID: string = '0'; // Default to '0', as the id indices start at '1' + editedUserID = '0'; // Default to '0', as the id indices start at '1' usersToAdd: string[] = []; dialogDelete = false; @@ -116,14 +116,20 @@ export default class Users extends Vue { } ]; - get displayedHeaders() { - // If the user is editing the group, then display the actions column. + get displayedHeaders(): DataTableHeader[] { + // A computed must not mutate its own dependency. Pushing into this.headers + // marked the computed dirty, so every re-evaluation — each toggle of + // `editable` — appended another Actions column (5, 6, 7, ...). Return a new + // array instead and leave this.headers as the fixed base set. if (this.editable) { - this.headers.push({ - text: 'Actions', - value: 'actions', - sortable: false - }); + return [ + ...this.headers, + { + text: 'Actions', + value: 'actions', + sortable: false + } + ]; } return this.headers; } @@ -150,7 +156,12 @@ export default class Users extends Vue { ...editedUser, groupRole: newRole }; - this.currentUsers[userToUpdate] = updatedGroupUser; + // splice, not index assignment: Vue 2 cannot observe arr[i] = x at all + // (this write was silently non-reactive), and indexOf's -1 used to create + // a stray '-1' own property instead of updating anyone. + if (userToUpdate !== -1) { + this.currentUsers.splice(userToUpdate, 1, updatedGroupUser); + } if (this.numberOfOwners() < 1) { saveable = false; @@ -177,10 +188,11 @@ export default class Users extends Vue { deleteUserConfirm(): boolean { let saveable = true; const userToDelete = this.currentUsers.indexOf(this.getEditedUser()); - if ( - this.currentUsers[userToDelete].groupRole === 'owner' && - this.numberOfOwners() < 2 - ) { + // Guard BEFORE .at(): indexOf's -1 would read the LAST user via .at(-1) + // where the old blind index crashed on undefined. + const userBeingDeleted = + userToDelete === -1 ? undefined : this.currentUsers.at(userToDelete); + if (userBeingDeleted?.groupRole === 'owner' && this.numberOfOwners() < 2) { saveable = false; } if (this.editedUserID !== '0') { @@ -202,11 +214,11 @@ export default class Users extends Vue { // Filter out users that are already in the group from the user search get availableUsers(): IVuetifyItems[] { - const currentUserIds: string[] = this.currentUsers.map((user) => user.id); + const currentUserIds = new Set(this.currentUsers.map((user) => user.id)); const users: IVuetifyItems[] = []; for (const user of ServerModule.allUsers) { if ( - !currentUserIds.includes(user.id) && + !currentUserIds.has(user.id) && (user.id !== ServerModule.userInfo.id || this.admin || !this.create) ) { users.push({ diff --git a/apps/frontend/src/components/global/login/LDAPLogin.vue b/apps/frontend/src/components/global/login/LDAPLogin.vue index 58722c6e01..f5b1ac9eb6 100644 --- a/apps/frontend/src/components/global/login/LDAPLogin.vue +++ b/apps/frontend/src/components/global/login/LDAPLogin.vue @@ -69,15 +69,15 @@ export default class LDAPLogin extends Vue { username = ''; password = ''; - ldapLogin() { + async ldapLogin(): Promise { const creds: LDAPLoginHash = { username: this.username, password: this.password }; - ServerModule.LoginLDAP(creds).then(() => { - this.$router.push('/'); - SnackbarModule.notify('You have successfully signed in.'); - }); + await ServerModule.LoginLDAP(creds); + // vue-router 3 rejects benign duplicate navigation; nothing depends on it. + void this.$router.push('/'); + SnackbarModule.notify('You have successfully signed in.'); } } diff --git a/apps/frontend/src/components/global/login/LocalLogin.vue b/apps/frontend/src/components/global/login/LocalLogin.vue index c9e14fc22b..73eed111fd 100644 --- a/apps/frontend/src/components/global/login/LocalLogin.vue +++ b/apps/frontend/src/components/global/login/LocalLogin.vue @@ -184,24 +184,25 @@ export default class LocalLogin extends Vue { buttonLoading = false; showPassword = false; - login() { + async login(): Promise { this.buttonLoading = true; const creds: LoginHash = { email: this.email, password: this.password }; - ServerModule.Login(creds) - .then(() => { - this.$router.push('/'); - SnackbarModule.notify('You have successfully signed in.'); - }) - .finally(() => { - this.buttonLoading = false; - }); + try { + await ServerModule.Login(creds); + // vue-router 3 rejects benign duplicate navigation; nothing depends + // on it. + void this.$router.push('/'); + SnackbarModule.notify('You have successfully signed in.'); + } finally { + this.buttonLoading = false; + } } get showAlternateAuth() { - return ServerModule.enabledOAuth.length !== 0; + return ServerModule.enabledOAuth.length > 0; } get localLoginEnabled() { @@ -219,7 +220,7 @@ export default class LocalLogin extends Vue { } oauthLogin(site: string) { - window.location.href = `/authn/${site}`; + location.assign(`/authn/${site}`); } get oidcName() { diff --git a/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue b/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue index f5308bbec9..06a2ef3275 100644 --- a/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue +++ b/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue @@ -53,29 +53,31 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { saving = false; select_file() { - if (this.file.hasOwnProperty('evaluation')) { + if (Object.hasOwn(this.file, 'evaluation')) { FilteredDataModule.toggle_evaluation(this.file.uniqueId); - } else if (this.file.hasOwnProperty('profile')) { + } else if (Object.hasOwn(this.file, 'profile')) { FilteredDataModule.toggle_profile(this.file.uniqueId); } } select_file_exclusive() { - if (this.file.hasOwnProperty('evaluation')) { + if (Object.hasOwn(this.file, 'evaluation')) { FilteredDataModule.select_exclusive_evaluation(this.file.uniqueId); - } else if (this.file.hasOwnProperty('profile')) { + } else if (Object.hasOwn(this.file, 'profile')) { FilteredDataModule.select_exclusive_profile(this.file.uniqueId); } } - //checks if file is selected + // checks if file is selected get selected(): boolean { return FilteredDataModule.selected_file_ids.includes(this.file.uniqueId); } - //removes uploaded file from the currently observed files - remove_file() { - EvaluationModule.removeEvaluation(this.file.uniqueId); + // removes uploaded file from the currently observed files + async remove_file(): Promise { + // The evaluation lookup reads the file entry, so it must finish + // before removeFile deletes that entry. + await EvaluationModule.removeEvaluation(this.file.uniqueId); InspecDataModule.removeFile(this.file.uniqueId); // Remove any database files that may have been in the URL // by calling the router and causing it to write the appropriate @@ -83,21 +85,22 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { this.navigateWithNoErrors(`/${this.current_route}`); } - //saves file to database + // saves file to database save_file() { if (this.file?.database_id) { SnackbarModule.failure('This file is already in the database.'); } else if (this.file) { - this.save_to_database(this.file); + // Fire-and-forget: save_to_database reports its own outcome. + void this.save_to_database(this.file); } } - //determines if the use can save the file + // determines if the use can save the file get disable_saving() { - return typeof this.file?.database_id !== 'undefined' || this.saving; + return this.file?.database_id !== undefined || this.saving; } - save_to_database(file: EvaluationFile | ProfileFile) { + async save_to_database(file: EvaluationFile | ProfileFile): Promise { this.saving = true; const createEvaluationDto: ICreateEvaluation = { @@ -111,48 +114,42 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) { const formData = new FormData(); // Add the DTO objects to form data for (const [key, value] of Object.entries(createEvaluationDto)) { - if (typeof value !== 'undefined') { + if (value !== undefined) { formData.append(key, value); } } // Add evaluation data to the form - if (file.hasOwnProperty('evaluation')) { - formData.append( - 'data', - new Blob([JSON.stringify(_.get(file, 'evaluation.data'))], { - type: 'text/plain' - }) - ); - } else { - formData.append( - 'data', - new Blob([JSON.stringify(_.get(file, 'profile.data'))], { - type: 'text/plain' - }) + const dataPath = Object.hasOwn(file, 'evaluation') + ? 'evaluation.data' + : 'profile.data'; + const serializedData = JSON.stringify(_.get(file, dataPath)); + formData.append( + 'data', + new Blob([serializedData], {type: 'text/plain'}) + ); + try { + const response = await axios.post('/evaluations', formData); + SnackbarModule.notify('File saved successfully'); + file.database_id = parseInt(response.data.id); + await EvaluationModule.loadEvaluation(response.data.id); + const loadedDatabaseIds = InspecDataModule.loadedDatabaseIds.join(','); + this.navigateWithNoErrors(`/${this.current_route}/${loadedDatabaseIds}`); + } catch (error) { + // A network failure has no response body; the old chain crashed + // reading it and reported nothing. + SnackbarModule.failure( + axios.isAxiosError<{message?: string}>(error) + ? (error.response?.data?.message ?? error.message) + : String(error) ); + } finally { + this.saving = false; } - axios - .post('/evaluations', formData) - .then((response) => { - SnackbarModule.notify('File saved successfully'); - file.database_id = parseInt(response.data.id); - EvaluationModule.loadEvaluation(response.data.id); - const loadedDatabaseIds = InspecDataModule.loadedDatabaseIds.join(','); - this.navigateWithNoErrors( - `/${this.current_route}/${loadedDatabaseIds}` - ); - }) - .catch((error) => { - SnackbarModule.failure(error.response.data.message); - }) - .finally(() => { - this.saving = false; - }); } - //gives different icons for a file if it is just a profile + // gives different icons for a file if it is just a profile get icon(): string { - if (this.file.hasOwnProperty('profile')) { + if (Object.hasOwn(this.file, 'profile')) { return 'mdi-note'; } else { return 'mdi-google-analytics'; diff --git a/apps/frontend/src/components/global/tags/TagRow.vue b/apps/frontend/src/components/global/tags/TagRow.vue index 0cdad2737c..71c83288fa 100644 --- a/apps/frontend/src/components/global/tags/TagRow.vue +++ b/apps/frontend/src/components/global/tags/TagRow.vue @@ -105,7 +105,7 @@ export default class TagRow extends Vue { this.search = ''; } - save() { + async save() { const original = this.evaluationTagsToStrings(); const toAdd: string[] = this.tags.filter((tag) => !original.includes(tag)); const toRemove: IEvaluationTag[] = this.evaluation.evaluationTags.filter( @@ -119,20 +119,23 @@ export default class TagRow extends Vue { EvaluationModule.deleteTag(tag) ); - Promise.all(addedTagPromises.concat(removedTagPromises)) - .then(() => SnackbarModule.notify('Successfully updated tags.')) - .finally(() => { - if (this.onLoadingPanel) { - EvaluationModule.getAllEvaluations(this.params); - if ( - EvaluationModule.evaluationLoaded(this.evaluation.id) !== undefined - ) { - EvaluationModule.loadEvaluation(this.evaluation.id); - } - } else { - EvaluationModule.loadEvaluation(this.evaluation.id); + try { + await Promise.all([...addedTagPromises, ...removedTagPromises]); + SnackbarModule.notify('Successfully updated tags.'); + } finally { + // Refresh even when a tag call failed (the axios interceptor snackbar + // reports the failure) so the list shows the actual server state. + if (this.onLoadingPanel) { + await EvaluationModule.getAllEvaluations(this.params); + if ( + EvaluationModule.evaluationLoaded(this.evaluation.id) !== undefined + ) { + await EvaluationModule.loadEvaluation(this.evaluation.id); } - }); + } else { + await EvaluationModule.loadEvaluation(this.evaluation.id); + } + } } // Used to update the Tags in the v-combobox @@ -144,28 +147,28 @@ export default class TagRow extends Vue { return this.evaluation.evaluationTags.map((tag) => tag.value) || []; } - async deleteTag(tag: IEvaluationTag) { + deleteTag(tag: IEvaluationTag) { this.activeTag = tag; this.deleteTagDialog = true; } - deleteTagConfirm() { - EvaluationModule.deleteTag(this.activeTag).then(() => { - SnackbarModule.notify('Deleted tag successfully.'); - if (this.onLoadingPanel) { - EvaluationModule.getAllEvaluations(this.params); - if ( - EvaluationModule.evaluationLoaded(this.evaluation.id) !== undefined - ) { - EvaluationModule.loadEvaluation(this.evaluation.id); - } - } else { - EvaluationModule.loadEvaluation(this.evaluation.id).then(() => { - this.syncEvaluationTags(); - }); - } - }); + async deleteTagConfirm() { + // Close the dialog immediately; the delete proceeds in the background + // and failures surface via the axios interceptor snackbar. this.deleteTagDialog = false; + await EvaluationModule.deleteTag(this.activeTag); + SnackbarModule.notify('Deleted tag successfully.'); + if (this.onLoadingPanel) { + await EvaluationModule.getAllEvaluations(this.params); + if ( + EvaluationModule.evaluationLoaded(this.evaluation.id) !== undefined + ) { + await EvaluationModule.loadEvaluation(this.evaluation.id); + } + } else { + await EvaluationModule.loadEvaluation(this.evaluation.id); + this.syncEvaluationTags(); + } } get allEvaluationTags(): string[] { diff --git a/apps/frontend/src/components/global/upload_tabs/DatabaseReader.vue b/apps/frontend/src/components/global/upload-tabs/DatabaseReader.vue similarity index 88% rename from apps/frontend/src/components/global/upload_tabs/DatabaseReader.vue rename to apps/frontend/src/components/global/upload-tabs/DatabaseReader.vue index f1d111c930..b0acf8a526 100644 --- a/apps/frontend/src/components/global/upload_tabs/DatabaseReader.vue +++ b/apps/frontend/src/components/global/upload-tabs/DatabaseReader.vue @@ -84,7 +84,7 @@ diff --git a/apps/frontend/src/components/global/upload_tabs/FileReader.vue b/apps/frontend/src/components/global/upload-tabs/FileReader.vue similarity index 84% rename from apps/frontend/src/components/global/upload_tabs/FileReader.vue rename to apps/frontend/src/components/global/upload-tabs/FileReader.vue index b9a0df8e1a..6e674b9bbc 100644 --- a/apps/frontend/src/components/global/upload_tabs/FileReader.vue +++ b/apps/frontend/src/components/global/upload-tabs/FileReader.vue @@ -147,51 +147,52 @@ interface VueFileAgentRecord { */ @Component export default class FileReader extends mixins(ServerMixin) { - fileRecords: Array = []; + fileRecords: VueFileAgentRecord[] = []; loading = false; percent = 0; isActiveDialog = false; filesSelected() { this.loading = true; - this.commit_files(this.fileRecords.map((record) => record.file)); + // Fire-and-forget: per-file failures surface via the snackbar inside + // commit_files. + void this.commit_files(this.fileRecords.map((record) => record.file)); this.fileRecords = []; } /** Callback for our file reader */ - commit_files(files: File[]) { + async commit_files(files: File[]): Promise { const totalFiles = files.length; let index = 1; document.body.style.cursor = 'wait'; - Promise.all( - files.map(async (file) => { - try { - const fileId = await InspecIntakeModule.loadFile({file}); - this.percent = Math.floor((index++ / totalFiles) * 100); - return fileId; - } catch (err) { - SnackbarModule.failure(String(err)); - document.body.style.cursor = 'default'; - } - }) - ) - // Since some HDF converters can return multiple results sets, we can sometimes have multiple file IDs returned - .then((fileIds: (FileID | FileID[] | void)[]) => { - const allIds: FileID[] = []; - fileIds.forEach((fileId) => { - if (Array.isArray(fileId)) { - allIds.push(...fileId.filter(Boolean)); - } else if (fileId) { - allIds.push(fileId); + try { + const fileIds: (FileID | FileID[] | void)[] = await Promise.all( + files.map(async (file) => { + try { + const fileId = await InspecIntakeModule.loadFile({file}); + this.percent = Math.floor((index++ / totalFiles) * 100); + return fileId; + } catch (error) { + SnackbarModule.failure(String(error)); + document.body.style.cursor = 'default'; } - }); - this.$emit('got-files', allIds); - }) - .finally(() => { - this.loading = false; - this.percent = 0; - document.body.style.cursor = 'default'; + }) + ); + // Since some HDF converters can return multiple results sets, we can sometimes have multiple file IDs returned + const allIds: FileID[] = []; + fileIds.forEach((fileId) => { + if (Array.isArray(fileId)) { + allIds.push(...fileId.filter(Boolean)); + } else if (fileId) { + allIds.push(fileId); + } }); + this.$emit('got-files', allIds); + } finally { + this.loading = false; + this.percent = 0; + document.body.style.cursor = 'default'; + } } get title_class(): string[] { diff --git a/apps/frontend/src/components/global/upload_tabs/HelpFooter.vue b/apps/frontend/src/components/global/upload-tabs/HelpFooter.vue similarity index 100% rename from apps/frontend/src/components/global/upload_tabs/HelpFooter.vue rename to apps/frontend/src/components/global/upload-tabs/HelpFooter.vue diff --git a/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue b/apps/frontend/src/components/global/upload-tabs/LoadFileList.vue similarity index 87% rename from apps/frontend/src/components/global/upload_tabs/LoadFileList.vue rename to apps/frontend/src/components/global/upload-tabs/LoadFileList.vue index cedc607260..0ecb7d2e17 100644 --- a/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue +++ b/apps/frontend/src/components/global/upload-tabs/LoadFileList.vue @@ -265,7 +265,7 @@ import ActionDialog from '@/components/generic/ActionDialog.vue'; import CopyButton from '@/components/generic/CopyButton.vue'; import GroupRow from '@/components/global/groups/GroupRow.vue'; import TagRow from '@/components/global/tags/TagRow.vue'; -import EditEvaluationModal from '@/components/global/upload_tabs/EditEvaluationModal.vue'; +import EditEvaluationModal from '@/components/global/upload-tabs/EditEvaluationModal.vue'; import {EvaluationModule} from '@/store/evaluations'; import {SnackbarModule} from '@/store/snackbar'; import {InspecDataModule} from '@/store/data_store'; @@ -290,7 +290,7 @@ import RouteMixin from '@/mixins/RouteMixin'; } }) export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { - @Prop({required: true}) readonly headers!: Object[]; + @Prop({required: true}) readonly headers!: object[]; @Prop({type: Boolean, default: false}) loading!: boolean; @Prop({type: String, default: 'id'}) readonly fileKey!: string; @Prop({required: true}) evaluationsLoaded!: IEvaluation[]; @@ -320,10 +320,10 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { pagination = { page: this.page, itemsPerPage: this.totalItemsPerPage, - sortBy: ([] = ['createdAt']), - sortDesc: ([] = [true]), - groupBy: ([] = []), - groupDesc: ([] = []), + sortBy: ['createdAt'], + sortDesc: [true], + groupBy: [], + groupDesc: [], mustSort: false, multiSort: false }; @@ -340,7 +340,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { async getEvaluations(params: IEvalPaginationParams): Promise { document.body.style.cursor = 'wait'; - EvaluationModule.getAllEvaluations(params); + await EvaluationModule.getAllEvaluations(params); } clearSearchItemsClicked() { @@ -380,7 +380,9 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { endSearchLoadPage() { this.searching = false; if (this.page == 1) { - this.updateDisplayPage(); + // Fire-and-forget refresh: HTTP failures surface via the axios + // interceptor snackbar, and nothing here depends on completion. + void this.updateDisplayPage(); } else { this.page = 1; // Reload the page } @@ -408,18 +410,18 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { return {offset, limit}; } - //-------------------------------------------------------------------- + // -------------------------------------------------------------------- // Called when the Search button is invoked (@click="executeSearch()") async executeSearch() { // Clearing the fields using the clearable icon sets the model to null - this.searchItems = this.searchItems == null ? '' : this.searchItems; - this.searchGroups = this.searchGroups == null ? '' : this.searchGroups; - this.searchTags = this.searchTags == null ? '' : this.searchTags; + this.searchItems ??= ''; + this.searchGroups ??= ''; + this.searchTags ??= ''; if ( - this.searchItems.trim().length == 0 && - this.searchGroups.trim().length == 0 && - this.searchTags.trim().length == 0 + this.searchItems.trim().length === 0 && + this.searchGroups.trim().length === 0 && + this.searchTags.trim().length === 0 ) { SnackbarModule.notify( 'No search criteria provided (provide a file, group, or tag name)!' @@ -432,7 +434,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { } this.searching = true; - this.getSearchEvaluation(); + await this.getSearchEvaluation(); } } @@ -440,7 +442,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { const delimiterChr = searchValue.indexOf(',') > 0 ? ',' : ' '; if (delimiterChr == ',') { // Remove any blank spaces - searchValue = searchValue.replace(/\s/gv, ''); + searchValue = searchValue.replaceAll(/\s/gv, ''); } const searchParam = searchValue.split(delimiterChr).join('|'); return `(${searchParam})`; @@ -474,7 +476,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { this.evaluationsCount = EvaluationModule.evaluationsCount; } - //------------------------------------------------------------------- + // ------------------------------------------------------------------- // Called when any of the sorted fields are invoked (@update:sort-by) async updateSortBy(sortField: string) { /* Hack: Implementing custom headers slots, the v-data-table sorting is @@ -488,12 +490,10 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { The else block of the this.pagination.sortBy[0] == sortField is never executed. Leaving it here incase we rectify the implementation. */ - if (sortField.length == 0) { + if (sortField.length === 0) { this.pagination.sortDesc[0] = - this.sortOrder[this.sortOrder.length - 1] == 'DESC' ? false : true; + this.sortOrder.at(-1) == 'DESC' ? false : true; this.pagination.sortBy[0] = this.sortByField; - const sortOrder = this.pagination.sortDesc[0] ? 'DESC' : 'ASC'; - this.sortOrder = this.getSortClause(this.sortByField, sortOrder); } else { if (this.pagination.sortBy[0] == sortField) { this.pagination.sortDesc[0] = !this.pagination.sortDesc[0]; @@ -502,9 +502,9 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { this.pagination.sortDesc[0] = false; } this.sortByField = sortField; - const sortOrder = this.pagination.sortDesc[0] ? 'DESC' : 'ASC'; - this.sortOrder = this.getSortClause(this.sortByField, sortOrder); } + const sortOrder = this.pagination.sortDesc[0] ? 'DESC' : 'ASC'; + this.sortOrder = this.getSortClause(this.sortByField, sortOrder); // Call the Database - update display const params = this.getQueryParams(); @@ -516,17 +516,17 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { getSortClause(field: string, order: string): string[] { // Map sorted fields to database names. if (field == 'filename' || field == 'createdAt') { - return new Array(`${field}`, `${order}`); + return [field, order]; } else if (field == 'groups') { - return new Array('groups', 'name', order); + return ['groups', 'name', order]; } else if (field == 'evaluationTags') { - return new Array('evaluationTags', 'value', order); + return ['evaluationTags', 'value', order]; } else { - return new Array(field, order); + return [field, order]; } } - //------------------------------------------------------------------------ + // ------------------------------------------------------------------------ // Called when page navigation arrows are invoked (@update:items-per-page) // or when the Rows per page is invoked (@update:page) and not in Page 1 // or when the page variable is programmatically set. @@ -539,7 +539,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { this.updatingPage = true; if (this.searching) { - this.getSearchEvaluation(); + await this.getSearchEvaluation(); } else { this.itemsPerPageShowing = this.pagination.itemsPerPage; @@ -552,13 +552,13 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { this.updatingPage = false; } - //---------------------------------------------------- + // ---------------------------------------------------- // Called when Rows per page is invoked (@update:page) // Note: If not on Page 1 the @update:items-per-page // is invoked first, hence the need for the flag async updateItemsPerPage(itemsCount: number) { // Updating the page reset to Page 1 - //this.page = 1; + // this.page = 1; if (this.updatingPage) { return; } else { @@ -572,7 +572,7 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { const action = this.getAction(); if (action == 'query') { if (this.searching) { - this.getSearchEvaluation(); + await this.getSearchEvaluation(); } else { this.itemsPerPageShowing = this.pagination.itemsPerPage; @@ -616,10 +616,11 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { let action = 'none'; if (this.pagination.itemsPerPage < this.itemsPerPageShowing) { action = 'slice'; - } else if (this.pagination.itemsPerPage > this.itemsPerPageShowing) { - if (this.itemsPerPageShowing <= this.evaluationsCount) { - action = 'query'; - } + } else if ( + this.pagination.itemsPerPage > this.itemsPerPageShowing && + this.itemsPerPageShowing <= this.evaluationsCount + ) { + action = 'query'; } return action; @@ -632,9 +633,8 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { async updateEvaluations() { const params = this.getQueryParams(); - this.getEvaluations(params).then(() => { - this.evaluationsLoaded = EvaluationModule.pagedEvaluations; - }); + await this.getEvaluations(params); + this.evaluationsLoaded = EvaluationModule.pagedEvaluations; } editItem(item: IEvaluation) { @@ -653,28 +653,29 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) { } async deleteItemConfirm(): Promise { - EvaluationModule.deleteEvaluation(this.activeItem).then(async () => { - SnackbarModule.notify('Deleted evaluation successfully.'); - this.updateEvaluations(); - // Remove the file from the visualization panel if it is loaded. - const fileId = await InspecDataModule.loadedFileIsForDatabaseIds( - Number(this.activeItem.id) - ); - if (FilteredDataModule.selected_file_ids.includes(fileId)) { - //removes uploaded file from the currently observed files - EvaluationModule.removeEvaluation(fileId); - InspecDataModule.removeFile(fileId); - // Remove any database files that may have been in the URL - // by calling the router and causing it to write the appropriate - // route to the URL bar - this.navigateWithNoErrors(`/${this.current_route}`); - } - }); + // Close the dialog immediately; the delete proceeds in the background + // and failures surface via the axios interceptor snackbar. this.deleteItemDialog = false; + await EvaluationModule.deleteEvaluation(this.activeItem); + SnackbarModule.notify('Deleted evaluation successfully.'); + await this.updateEvaluations(); + // Remove the file from the visualization panel if it is loaded. + const fileId = InspecDataModule.loadedFileIsForDatabaseIds( + Number(this.activeItem.id) + ); + if (FilteredDataModule.selected_file_ids.includes(fileId)) { + // removes uploaded file from the currently observed files + await EvaluationModule.removeEvaluation(fileId); + InspecDataModule.removeFile(fileId); + // Remove any database files that may have been in the URL + // by calling the router and causing it to write the appropriate + // route to the URL bar + this.navigateWithNoErrors(`/${this.current_route}`); + } } createShareLink(item: IEvaluation) { - return `${window.location.origin}/results/${item.id}`; + return `${location.origin}/results/${item.id}`; } } diff --git a/apps/frontend/src/components/global/upload_tabs/SampleList.vue b/apps/frontend/src/components/global/upload-tabs/SampleList.vue similarity index 89% rename from apps/frontend/src/components/global/upload_tabs/SampleList.vue rename to apps/frontend/src/components/global/upload-tabs/SampleList.vue index 9928ec7c6f..fe198c4256 100644 --- a/apps/frontend/src/components/global/upload_tabs/SampleList.vue +++ b/apps/frontend/src/components/global/upload-tabs/SampleList.vue @@ -140,7 +140,7 @@ export default class SampleList extends Vue { selectedFiles: Sample[] = []; isActiveDialog = false; - headers: Object[] = [ + headers: object[] = [ { text: 'Filename', align: 'left', @@ -164,15 +164,15 @@ export default class SampleList extends Vue { }; // Fires when user selects entries and loads them into the visualization panel - load_samples(selectedSamples: Sample[]) { - if (selectedSamples.length != 0) { - const promises: Promise[] = []; + async load_samples(selectedSamples: Sample[]): Promise { + if (selectedSamples.length > 0) { this.loading = true; SpinnerModule.reset(); SpinnerModule.visibility(true); let index = 1; - for (const sample of selectedSamples) { - const requestFile = fetchSample(sample).then(async (data: File) => { + const promises: Promise[] = selectedSamples.map( + async (sample) => { + const data = await fetchSample(sample); const done = await InspecIntakeModule.loadFile({ file: data, filename: sample.filename @@ -181,22 +181,22 @@ export default class SampleList extends Vue { const value = Math.floor((index++ / selectedSamples.length) * 100); SpinnerModule.setValue(value); return done; - }); - promises.push(requestFile); - } + } + ); - Promise.all(promises) - .then((fileIds: (FileID | FileID[])[]) => { - this.$emit('got-files', fileIds.flat(2)); - }) - .catch((error) => { - SnackbarModule.failure(String(error)); - }) - .finally(() => { - this.loading = false; - SpinnerModule.visibility(false); - this.selectedFiles = []; - }); + try { + const fileIds = await Promise.all(promises); + // Each sample resolves to an array of ids per contained profile, so the + // settled results nest two levels deep. + const SAMPLE_ID_NESTING = 2; + this.$emit('got-files', fileIds.flat(SAMPLE_ID_NESTING)); + } catch (error) { + SnackbarModule.failure(String(error)); + } finally { + this.loading = false; + SpinnerModule.visibility(false); + this.selectedFiles = []; + } } else { SnackbarModule.notify( 'Please select a sample for viewing in the visualization panel' diff --git a/apps/frontend/src/components/global/upload_tabs/aws/AuthStepBasic.vue b/apps/frontend/src/components/global/upload-tabs/aws/AuthStepBasic.vue similarity index 97% rename from apps/frontend/src/components/global/upload_tabs/aws/AuthStepBasic.vue rename to apps/frontend/src/components/global/upload-tabs/aws/AuthStepBasic.vue index dfe4451cfe..35b592c665 100644 --- a/apps/frontend/src/components/global/upload_tabs/aws/AuthStepBasic.vue +++ b/apps/frontend/src/components/global/upload-tabs/aws/AuthStepBasic.vue @@ -52,7 +52,7 @@ import Vue from 'vue'; import Component from 'vue-class-component'; import {Prop} from 'vue-property-decorator'; -import FileList from '@/components/global/upload_tabs/aws/FileList.vue'; +import FileList from '@/components/global/upload-tabs/aws/FileList.vue'; import {LocalStorageVal} from '@/utilities/helper_util'; import {requireFieldRule} from '@/utilities/upload_util'; diff --git a/apps/frontend/src/components/global/upload_tabs/aws/AuthStepMFA.vue b/apps/frontend/src/components/global/upload-tabs/aws/AuthStepMfa.vue similarity index 97% rename from apps/frontend/src/components/global/upload_tabs/aws/AuthStepMFA.vue rename to apps/frontend/src/components/global/upload-tabs/aws/AuthStepMfa.vue index ca96c43290..f126091c8a 100644 --- a/apps/frontend/src/components/global/upload_tabs/aws/AuthStepMFA.vue +++ b/apps/frontend/src/components/global/upload-tabs/aws/AuthStepMfa.vue @@ -37,6 +37,7 @@ import {requireFieldRule} from '@/utilities/upload_util'; /** Localstorage keys */ const localMFASerial = new LocalStorageVal('aws_s3_mfa_serial'); +const MFA_CODE = /^\d{6}$/; /** * File reader component for taking in inspec JSON data. @@ -56,7 +57,7 @@ export default class S3Reader extends Vue { reqRule = requireFieldRule; mfaRule = (v: string | null | undefined) => - (v || '').trim().match('^\\d{6}$') !== null || + MFA_CODE.test((v || '').trim()) || 'Field must be the 6 number code from a valid authenticator device'; /** On mount, try to look up stored auth info */ diff --git a/apps/frontend/src/components/global/upload_tabs/aws/FileList.vue b/apps/frontend/src/components/global/upload-tabs/aws/FileList.vue similarity index 78% rename from apps/frontend/src/components/global/upload_tabs/aws/FileList.vue rename to apps/frontend/src/components/global/upload-tabs/aws/FileList.vue index 717f2d7476..0c7e4cc1dd 100644 --- a/apps/frontend/src/components/global/upload_tabs/aws/FileList.vue +++ b/apps/frontend/src/components/global/upload-tabs/aws/FileList.vue @@ -9,7 +9,7 @@ /> @@ -73,26 +73,28 @@ export default class FileList extends Vue { * Loads it into our system. */ async loadFile(index: number): Promise { - // Get it out of the list - const file = this.files[index]; + // Get it out of the list — index comes from the template's own v-for; + // the old blind index crashed later on undefined in the same case. + const file = this.files.at(index); + if (file === undefined) { + throw new TypeError(`No S3 file at index ${index}`); + } // Fetch it from s3, and promise to submit it to be loaded afterwards - await fetchS3File(this.auth, file.Key!, this.formBucketName).then( - (content) => { - try { - JSON.parse(content); - } catch (parseError) { - SnackbarModule.failure( - `Selected file: ${file.Key} is not a valid formatted json file.` - ); - return; - } - InspecIntakeModule.loadText({ - text: content, - filename: file.Key! - }).then((uniqueId) => this.$emit('got-files', [uniqueId])); - } - ); + const content = await fetchS3File(this.auth, file.Key!, this.formBucketName); + try { + JSON.parse(content); + } catch { + SnackbarModule.failure( + `Selected file: ${file.Key} is not a valid formatted json file.` + ); + return; + } + const uniqueId = await InspecIntakeModule.loadText({ + text: content, + filename: file.Key! + }); + this.$emit('got-files', [uniqueId]); } /** Recalls the last entered bucket name. */ diff --git a/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue b/apps/frontend/src/components/global/upload-tabs/aws/S3Reader.vue similarity index 77% rename from apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue rename to apps/frontend/src/components/global/upload-tabs/aws/S3Reader.vue index e92f4f4a94..d50badbdf7 100644 --- a/apps/frontend/src/components/global/upload_tabs/aws/S3Reader.vue +++ b/apps/frontend/src/components/global/upload-tabs/aws/S3Reader.vue @@ -60,9 +60,9 @@ import {_Object, ListObjectsV2Command, S3Client} from '@aws-sdk/client-s3'; import Vue from 'vue'; import Component from 'vue-class-component'; -import AuthStepBasic from '@/components/global/upload_tabs/aws/AuthStepBasic.vue'; -import AuthStepMFA from '@/components/global/upload_tabs/aws/AuthStepMFA.vue'; -import FileList from '@/components/global/upload_tabs/aws/FileList.vue'; +import AuthStepBasic from '@/components/global/upload-tabs/aws/AuthStepBasic.vue'; +import AuthStepMFA from '@/components/global/upload-tabs/aws/AuthStepMfa.vue'; +import FileList from '@/components/global/upload-tabs/aws/FileList.vue'; import {FileID} from '@/store/report_intake'; import {SnackbarModule} from '@/store/snackbar'; import { @@ -117,47 +117,44 @@ export default class S3Reader extends Vue { * Handle a basic login. * Gets a session token */ - handleBasic() { + async handleBasic(): Promise { // Attempt to assume role based on if we've determined 2fa necessary - getSessionToken( - this.accessToken, - this.secretToken, - this.region || 'us-east-1', - AUTH_DURATION - ).then( - // Success of get session token - now need to determine if MFA necessary - (success) => { - this.assumedRole = success; - this.step = 3; - }, - + let success; + try { + success = await getSessionToken( + this.accessToken, + this.secretToken, + this.region || 'us-east-1', + AUTH_DURATION + ); + } catch (error) { // Failure of initial get session token: want to set error normally - (failure) => { - this.handleError(failure); - } - ); + this.handleError(error); + return; + } + // Success of get session token - now need to determine if MFA necessary + this.assumedRole = success; + this.step = 3; } /** If the user tries to login by going to MFA, first check that the account is valid */ - handleGotoMfa() { + async handleGotoMfa(): Promise { // Attempt to assume role based on if we've determined 2fa necessary // Don't need the duration to be very long - getSessionToken( - this.accessToken, - this.secretToken, - this.region || 'us-east-1', - 10 - ).then( - // Success of get session token - now need to determine if MFA necessary - () => { - this.step = 2; - }, - + try { + await getSessionToken( + this.accessToken, + this.secretToken, + this.region || 'us-east-1', + 10 + ); + } catch (error) { // Failure of initial get session token: want to set error normally - (failure) => { - this.handleError(failure); - } - ); + this.handleError(error); + return; + } + // Success of get session token - now need to determine if MFA necessary + this.step = 2; } handleCancelMfa() { @@ -176,7 +173,7 @@ export default class S3Reader extends Vue { /** Handle an MFA login. * Determine whether further action is necessary */ - handleProceedMFA() { + async handleProceedMFA(): Promise { // Build our mfa params const mfa: MFAInfo = { SerialNumber: this.mfaSerial || null, @@ -184,22 +181,22 @@ export default class S3Reader extends Vue { }; // Attempt to assume role based on if we've determined 2fa necessary - getSessionToken( - this.accessToken, - this.secretToken, - this.region || 'us-east-1', - AUTH_DURATION, - mfa - ).then( - (success) => { - // Keep them - this.assumedRole = success; - this.step = 3; - }, - (failure) => { - this.handleError(failure); - } - ); + let success; + try { + success = await getSessionToken( + this.accessToken, + this.secretToken, + this.region || 'us-east-1', + AUTH_DURATION, + mfa + ); + } catch (error) { + this.handleError(error); + return; + } + // Keep them + this.assumedRole = success; + this.step = 3; } /** On mount, try to look up stored auth info */ @@ -227,8 +224,8 @@ export default class S3Reader extends Vue { }) ); this.files = response.Contents || []; - } catch (err) { - this.handleError(err); + } catch (error) { + this.handleError(error); } } @@ -241,14 +238,14 @@ export default class S3Reader extends Vue { /** Callback to handle an AWS error. * Sets shown error. */ - handleError(error: {name: string; message: string} | unknown): void { + handleError(error: unknown): void { const formattedError = transcribeError(error); // Toast whatever error we got SnackbarModule.failure(formattedError); } /** Callback on got files */ - gotFiles(files: Array) { + gotFiles(files: FileID[]) { this.$emit('got-files', files); } } diff --git a/apps/frontend/src/components/global/upload_tabs/splunk/AuthStep.vue b/apps/frontend/src/components/global/upload-tabs/splunk/AuthStep.vue similarity index 95% rename from apps/frontend/src/components/global/upload_tabs/splunk/AuthStep.vue rename to apps/frontend/src/components/global/upload-tabs/splunk/AuthStep.vue index 9dee12327b..7de121c452 100644 --- a/apps/frontend/src/components/global/upload_tabs/splunk/AuthStep.vue +++ b/apps/frontend/src/components/global/upload-tabs/splunk/AuthStep.vue @@ -59,7 +59,7 @@ diff --git a/apps/frontend/src/views/Compare.vue b/apps/frontend/src/views/Compare.vue index be86448cbf..11877b230d 100644 --- a/apps/frontend/src/views/Compare.vue +++ b/apps/frontend/src/views/Compare.vue @@ -316,7 +316,7 @@ export default class Compare extends Vue { return new ComparisonContext(selectedData); } - /** Yields the control pairings that have changed*/ + /** Yields the control pairings that have changed */ get delta_sets(): [string, ControlSeries][] { return this.searched_sets.filter(([_id, series]) => { const controls = Object.values(series).map( @@ -361,34 +361,37 @@ export default class Compare extends Vue { } get show_sets(): [string, ControlSeries][] { - const sets: [string, ControlSeries][] = Array.from( - this.changedOnly ? this.delta_sets : this.searched_sets - ); + // toSorted copies, so the old protective spread of the source is gone + const sets: [string, ControlSeries][] = this.changedOnly + ? this.delta_sets + : this.searched_sets; let searchModifier = -1; if (this.ascending) { searchModifier = 1; } - return sets.sort( + return sets.toSorted( ([a, _seriesA], [b, _seriesB]) => a.localeCompare(b) * searchModifier ); } getPassthroughFields() { for (const file of this.files) { - if ('passthrough' in file.evaluation.data) { - const passthroughData = _.get(file.evaluation.data, 'passthrough'); - if (_.isObject(passthroughData)) { - this.compareItems = this.compareItems.concat( - Object.keys(passthroughData) - .filter( - (key) => - !this.compareItems.includes(`Passthrough Field: ${key}`) - ) - .map((key) => `Passthrough Field: ${key}`) - ); - } + if (!('passthrough' in file.evaluation.data)) { + continue; } + const passthroughData = _.get(file.evaluation.data, 'passthrough'); + if (!_.isObject(passthroughData)) { + continue; + } + this.compareItems = [ + ...this.compareItems, + ...Object.keys(passthroughData) + .filter( + (key) => !this.compareItems.includes(`Passthrough Field: ${key}`) + ) + .map((key) => `Passthrough Field: ${key}`) + ]; } } @@ -404,7 +407,7 @@ export default class Compare extends Vue { a: SourcedContextualizedEvaluation, b: SourcedContextualizedEvaluation ) { - const field = this.sortControlSetsBy.split('Passthrough Field: ')[1]; + const field = this.sortControlSetsBy.split('Passthrough Field: ', 2)[1]; const aPassthroughField = _.get(a.data, `passthrough.${field}`); const bPassthroughField = _.get(b.data, `passthrough.${field}`); if ( @@ -416,7 +419,7 @@ export default class Compare extends Vue { ) { if (typeof aPassthroughField === 'string') { return (aPassthroughField as string).localeCompare( - bPassthroughField as string + bPassthroughField ); } else if (typeof aPassthroughField === 'number') { return aPassthroughField - Number(bPassthroughField); @@ -435,9 +438,9 @@ export default class Compare extends Vue { } get files(): EvaluationFile[] { - const fileList = Array.from( - FilteredDataModule.evaluations(FilteredDataModule.selected_file_ids) - ); + const fileList = [ + ...FilteredDataModule.evaluations(FilteredDataModule.selected_file_ids) + ]; switch (this.sortControlSetsBy) { case '': @@ -455,7 +458,7 @@ export default class Compare extends Vue { break; default: if (this.sortControlSetsBy.startsWith('Passthrough Field')) { - fileList.sort(this.comparePassthrough); + fileList.sort((a, b) => this.comparePassthrough(a, b)); } break; } @@ -539,7 +542,7 @@ export default class Compare extends Vue { } get total_failed(): number { - if (this.files.length < 1) { + if (this.files.length === 0) { return 0; } let highestFailed = 0; diff --git a/apps/frontend/src/views/Login.vue b/apps/frontend/src/views/Login.vue index fffaa7e8f7..42f91639c6 100644 --- a/apps/frontend/src/views/Login.vue +++ b/apps/frontend/src/views/Login.vue @@ -82,28 +82,32 @@ export default class Login extends Vue { checkLoggedIn() { if (ServerModule.token) { - this.$router.push('/'); + // vue-router 3 rejects benign duplicate navigation; nothing depends + // on it. + void this.$router.push('/'); } } checkForAuthenticationError() { - if (this.$cookies.get('authenticationError')) { - SnackbarModule.failure( - `Sorry, a problem occurred while signing you in. The reason given was: ${this.$cookies.get( - 'authenticationError' - )}` - ); - this.$cookies.remove('authenticationError'); + if (!this.$cookies.get('authenticationError')) { + return; } + SnackbarModule.failure( + `Sorry, a problem occurred while signing you in. The reason given was: ${this.$cookies.get( + 'authenticationError' + )}` + ); + this.$cookies.remove('authenticationError'); } signup() { - this.$router.push('/signup'); + // vue-router 3 rejects benign duplicate navigation; nothing depends on it. + void this.$router.push('/signup'); } get anyAuthProvidersAvailable() { return ( - ServerModule.localLoginEnabled || ServerModule.enabledOAuth.length !== 0 + ServerModule.localLoginEnabled || ServerModule.enabledOAuth.length > 0 ); } @@ -116,7 +120,7 @@ export default class Login extends Vue { } get logoffFailure() { - const queryString = window.location.search; + const queryString = location.search; const urlParams = new URLSearchParams(queryString); return ( urlParams.get('logoff')?.toLowerCase() === 'true' && @@ -125,7 +129,7 @@ export default class Login extends Vue { } get logoffSnackbar() { - const queryString = window.location.search; + const queryString = location.search; const urlParams = new URLSearchParams(queryString); if ( !this.logoffFailure && diff --git a/apps/frontend/src/views/Results.vue b/apps/frontend/src/views/Results.vue index 639ad5d2d6..f1614632d6 100644 --- a/apps/frontend/src/views/Results.vue +++ b/apps/frontend/src/views/Results.vue @@ -240,7 +240,7 @@ color="warning" top > - + All results are filtered out. Use the mdi-filter-remove button in the top right to clear filters and show all. @@ -271,16 +271,16 @@ import InfoCardRow from '@/components/cards/InfoCardRow.vue'; import StatusChart from '@/components/cards/StatusChart.vue'; import Treemap from '@/components/cards/treemap/Treemap.vue'; import UploadButton from '@/components/generic/UploadButton.vue'; -import ExportASFFModal from '@/components/global/ExportASFFModal.vue'; +import ExportASFFModal from '@/components/global/ExportAsffModal.vue'; import ExportCaat from '@/components/global/ExportCaat.vue'; -import ExportCKLModal from '@/components/global/ExportCKLModal.vue'; -import ExportCSVModal from '@/components/global/ExportCSVModal.vue'; -import ExportHTMLModal from '@/components/global/ExportHTMLModal.vue'; +import ExportCKLModal from '@/components/global/ExportCklModal.vue'; +import ExportCSVModal from '@/components/global/ExportCsvModal.vue'; +import ExportHTMLModal from '@/components/global/ExportHtmlModal.vue'; import ExportJson from '@/components/global/ExportJson.vue'; import ExportNist from '@/components/global/ExportNist.vue'; import PrintButton from '@/components/global/PrintButton.vue'; import ExportSplunkModal from '@/components/global/ExportSplunkModal.vue'; -import ExportXCCDFResults from '@/components/global/ExportXCCDFResults.vue'; +import ExportXCCDFResults from '@/components/global/ExportXccdfResults.vue'; import RouteMixin from '@/mixins/RouteMixin'; import { ExtendedControlStatus, @@ -341,8 +341,8 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { treeFilters: TreeMapState = []; controlSelection: string | null = null; - gotStatus: boolean = false; - gotSeverity: boolean = false; + gotStatus = false; + gotSeverity = false; /** Model for if all-filtered snackbar should be showing */ filterSnackbar = false; @@ -394,7 +394,7 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { } get evaluationFiles(): SourcedContextualizedEvaluation[] { - return Array.from(FilteredDataModule.evaluations(this.file_filter)).sort( + return FilteredDataModule.evaluations(this.file_filter).toSorted( compare_times ); } @@ -484,13 +484,13 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { // Return if any params not null/empty let result: boolean; if ( - SearchModule.severityFilter.length !== 0 || - SearchModule.statusFilter.length !== 0 || - SearchModule.controlIdSearchTerms.length !== 0 || - SearchModule.codeSearchTerms.length !== 0 || - SearchModule.tagFilter.length !== 0 || + SearchModule.severityFilter.length > 0 || + SearchModule.statusFilter.length > 0 || + SearchModule.controlIdSearchTerms.length > 0 || + SearchModule.codeSearchTerms.length > 0 || + SearchModule.tagFilter.length > 0 || this.searchTerm || - this.treeFilters.length + this.treeFilters.length > 0 ) { result = true; } else { @@ -533,7 +533,7 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { return this.$router.currentRoute.path.replaceAll(/[^a-z]/giv, ''); } - //changes width of eval info if it is in server mode and needs more room for tags + // changes width of eval info if it is in server mode and needs more room for tags get info_width(): number { if (ServerModule.serverMode) { return 500; @@ -541,7 +541,7 @@ export default class Results extends mixins(RouteMixin, ServerMixin) { return 300; } - //basically a v-model for the eval info cards when there is no slide group + // basically a v-model for the eval info cards when there is no slide group toggle_profile( file: SourcedContextualizedEvaluation | SourcedContextualizedProfile ) { diff --git a/apps/frontend/tests/setup.ts b/apps/frontend/tests/setup.ts index 886c8b5bd4..38d002e563 100644 --- a/apps/frontend/tests/setup.ts +++ b/apps/frontend/tests/setup.ts @@ -1,5 +1,6 @@ import {vi} from 'vitest'; -import Vue, {CreateElement} from 'vue'; +import type {CreateElement} from 'vue'; +import Vue from 'vue'; import Vuetify from 'vuetify'; Vue.use(Vuetify); diff --git a/apps/frontend/tests/unit/Compare.spec.ts b/apps/frontend/tests/unit/Compare.spec.ts index ed4ec75b90..e687bd1777 100644 --- a/apps/frontend/tests/unit/Compare.spec.ts +++ b/apps/frontend/tests/unit/Compare.spec.ts @@ -1,18 +1,20 @@ import {FilteredDataModule} from '@/store/data_filters'; import {SearchModule} from '@/store/search'; import {calculateCompliance, StatusCountModule} from '@/store/status_counts'; -import {ComparisonContext, ControlSeries} from '@/utilities/delta_util'; +import type { ControlSeries} from '@/utilities/delta_util'; +import {ComparisonContext} from '@/utilities/delta_util'; import Compare from '@/views/Compare.vue'; -import {shallowMount, Wrapper} from '@vue/test-utils'; +import type { Wrapper} from '@vue/test-utils'; +import {shallowMount} from '@vue/test-utils'; import {beforeEach, describe, expect, it} from 'vitest'; -import Vue from 'vue'; +import type Vue from 'vue'; import Vuetify from 'vuetify'; -import {loadSample, removeAllFiles} from '../util/testingUtils'; +import {loadSample, removeAllFiles} from '../util/testing-utils'; const vuetify = new Vuetify(); const wrapper: Wrapper = shallowMount(Compare, {vuetify, propsData: {}}); -export interface SeriesItem { +interface SeriesItem { name: string; data: number[]; } @@ -21,8 +23,8 @@ const redHatControlCount = 247; const nginxControlCount = 41; const nginxDelta = 3; -describe.sequential('Compare', async () => { - describe('Compare table data', async () => { +describe.sequential('Compare', () => { + describe('Compare table data', () => { beforeEach(async () => { removeAllFiles(); @@ -141,23 +143,37 @@ describe.sequential('Compare', async () => { FilteredDataModule.selected_file_ids ); const currDelta = new ComparisonContext(selectedData); - for (const pairing of Object.values(currDelta.pairings)) { - for (const ctrl of Object.values(pairing)) { - if (ctrl === null) { - continue; - } else if (ctrl.root.hdf.status === 'Passed') { + const pairedControls = Object.values(currDelta.pairings).flatMap( + (pairing) => Object.values(pairing) + ); + pairedControls.forEach((ctrl) => { + if (ctrl === null) { + return; + } + switch (ctrl.root.hdf.status) { + case 'Passed': { passed++; - } else if (ctrl.root.hdf.status === 'Failed') { + break; + } + case 'Failed': { failed++; - } else if (ctrl.root.hdf.status === 'Not Applicable') { + break; + } + case 'Not Applicable': { na++; - } else if (ctrl.root.hdf.status === 'Not Reviewed') { + break; + } + case 'Not Reviewed': { nr++; - } else if (ctrl.root.hdf.status === 'Profile Error') { + break; + } + case 'Profile Error': { pe++; + break; } + // Any other status is not counted here. } - } + }); const expected = { Failed: StatusCountModule.hash({ omit_overlayed_controls: true, @@ -201,7 +217,7 @@ describe.sequential('Compare', async () => { it('sev chart gets correct data with 2 files', async () => { await loadSample('NGINX With Failing Tests'); await loadSample('NGINX Clean Sample'); - //the values in expected are the correct data + // the values in expected are the correct data expect((wrapper.vm as Vue & {sev_series: number[][]}).sev_series).toEqual( [ [0, 0], @@ -215,7 +231,7 @@ describe.sequential('Compare', async () => { it('sev chart gets correct data with 2 files with differing profiles', async () => { await loadSample('NGINX With Failing Tests'); await loadSample('Red Hat With Failing Tests'); - //the values in expected are the correct data + // the values in expected are the correct data expect((wrapper.vm as Vue & {sev_series: number[][]}).sev_series).toEqual( [ [0, 6], @@ -229,7 +245,7 @@ describe.sequential('Compare', async () => { it('sev chart gets correct data with 2 files with overlayed profiles', async () => { await loadSample('Three Layer RHEL7 Overlay Example'); await loadSample('Acme Overlay Example'); - //the values in expected are the correct data + // the values in expected are the correct data expect((wrapper.vm as Vue & {sev_series: number[][]}).sev_series).toEqual( [ [0, 8], diff --git a/apps/frontend/tests/unit/ControlRowCol.spec.ts b/apps/frontend/tests/unit/ControlRowCol.spec.ts index bb52e65d64..f1ec9a2235 100644 --- a/apps/frontend/tests/unit/ControlRowCol.spec.ts +++ b/apps/frontend/tests/unit/ControlRowCol.spec.ts @@ -1,9 +1,10 @@ import ControlRowCol from '@/components/cards/controltable/ControlRowCol.vue'; -import {mount, Wrapper} from '@vue/test-utils'; +import type { Wrapper} from '@vue/test-utils'; +import {mount} from '@vue/test-utils'; import {beforeEach, describe, expect, it} from 'vitest'; -import Vue from 'vue'; +import type Vue from 'vue'; import Vuetify from 'vuetify'; -import {addElemWithDataAppToBody} from '../util/testingUtils'; +import {addElemWithDataAppToBody} from '../util/testing-utils'; addElemWithDataAppToBody(); @@ -26,11 +27,11 @@ describe('The Topbar', () => { }); }); - it('displays the result message', async () => { + it('displays the result message', () => { expect(wrapper.text()).toContain('This is the message'); }); - it('displays the proper status', async () => { + it('displays the proper status', () => { expect(wrapper.get('button.statuspassed').text()).toEqual('PASSED'); }); }); diff --git a/apps/frontend/tests/unit/ExportJson.spec.ts b/apps/frontend/tests/unit/ExportJson.spec.ts new file mode 100644 index 0000000000..394f95750e --- /dev/null +++ b/apps/frontend/tests/unit/ExportJson.spec.ts @@ -0,0 +1,33 @@ +import ExportJson from '@/components/global/ExportJson.vue'; +import type {Wrapper} from '@vue/test-utils'; +import {shallowMount} from '@vue/test-utils'; +import {describe, expect, it} from 'vitest'; +import type Vue from 'vue'; +import Vuetify from 'vuetify'; +import {addElemWithDataAppToBody} from '../util/testing-utils'; + +addElemWithDataAppToBody(); + +describe('ExportJson filenames', () => { + const vuetify = new Vuetify(); + const wrapper: Wrapper = shallowMount(ExportJson, {vuetify}); + const cleanup = (filename: string): string => + ( + wrapper.vm as Vue & {cleanup_filename(filename: string): string} + ).cleanup_filename(filename); + + // The old check compared the last SIX characters against the five-character + // '.json', so it could never match and every export gained a second + // extension. + it('leaves a name that already ends in .json alone', () => { + expect(cleanup('results.json')).toBe('results.json'); + }); + + it('adds the extension to a name that lacks it', () => { + expect(cleanup('results')).toBe('results.json'); + }); + + it('replaces whitespace runs with underscores', () => { + expect(cleanup('my results file')).toBe('my_results_file.json'); + }); +}); diff --git a/apps/frontend/tests/unit/GroupUsers.spec.ts b/apps/frontend/tests/unit/GroupUsers.spec.ts new file mode 100644 index 0000000000..134be9421e --- /dev/null +++ b/apps/frontend/tests/unit/GroupUsers.spec.ts @@ -0,0 +1,164 @@ +import Users from '@/components/global/groups/Users.vue'; +import type {ISlimUser} from '@heimdall/common/interfaces'; +import {mount} from '@vue/test-utils'; +import {describe, expect, it} from 'vitest'; +import Vuetify from 'vuetify'; +import {addElemWithDataAppToBody} from '../util/testing-utils'; + +addElemWithDataAppToBody(); + +interface UsersVm { + currentUsers: ISlimUser[]; + editedUserID: string; + displayedHeaders: {text: string; value: string}[]; + onUpdateGroupUserRole(newRole: string): boolean; + deleteUserConfirm(): boolean; +} + +function twoOwnersAndAMember(): ISlimUser[] { + return [ + { + id: 'u1', + email: 'one@example.com', + firstName: 'One', + lastName: 'Owner', + groupRole: 'owner' + }, + { + id: 'u2', + email: 'two@example.com', + firstName: 'Two', + lastName: 'Owner', + groupRole: 'owner' + }, + { + id: 'u3', + email: 'three@example.com', + firstName: 'Three', + lastName: 'Member', + groupRole: 'member' + } + ]; +} + +// `editable: false` renders each role as a plain rather than a Vuetify +// v-select, so a role is assertable as row text instead of through the select's +// internals. The methods under test are called directly either way. +function mountUsers(currentUsers: ISlimUser[]) { + const vuetify = new Vuetify(); + return mount(Users, { + vuetify, + propsData: {value: currentUsers, editable: false} + }); +} + +function roleCellsOf(wrapper: ReturnType): string[] { + return wrapper.findAll('tbody tr').wrappers.map((row) => row.text()); +} + +describe('Users (group membership table)', () => { + // Pins the reactivity fix. The array is mutated either way — by the old + // `currentUsers[i] = x` as much as by `splice` — so asserting on + // vm.currentUsers would pass against the bug. Vue 2 cannot observe an index + // write, so only the RENDERED row distinguishes them. + it('renders a promoted role, not just records it in the array', async () => { + const wrapper = mountUsers(twoOwnersAndAMember()); + const vm = wrapper.vm as unknown as UsersVm; + + expect(roleCellsOf(wrapper)[2]).toContain('member'); + + vm.editedUserID = 'u3'; + vm.onUpdateGroupUserRole('owner'); + await wrapper.vm.$nextTick(); + + expect(roleCellsOf(wrapper)[2]).toContain('owner'); + expect(roleCellsOf(wrapper)[2]).not.toContain('member'); + }); + + // Pins the `userToUpdate !== -1` guard. getEditedUser() falls back to a fresh + // {id:'0', email:''} when editedUserID is its default '0', and indexOf on that + // fresh object returns -1 — so the old `currentUsers[-1] = x` wrote a stray + // '-1' own property onto the array instead of updating anybody. + it('does not write a stray "-1" property when no user is being edited', () => { + const users = twoOwnersAndAMember(); + const wrapper = mountUsers(users); + const vm = wrapper.vm as unknown as UsersVm; + + expect(vm.editedUserID).toBe('0'); + vm.onUpdateGroupUserRole('owner'); + + expect(Object.hasOwn(vm.currentUsers, '-1')).toBe(false); + expect(vm.currentUsers).toHaveLength(3); + expect(vm.currentUsers.map((user) => user.groupRole)).toStrictEqual([ + 'owner', + 'owner', + 'member' + ]); + }); + + // Pins the guard in deleteUserConfirm. With editedUserID at its default the + // indexOf is -1, and the old `this.currentUsers[-1].groupRole` read + // undefined.groupRole — a TypeError, not a wrong answer. + it('does not throw when confirming a delete with no user selected', () => { + const wrapper = mountUsers(twoOwnersAndAMember()); + const vm = wrapper.vm as unknown as UsersVm; + + expect(vm.editedUserID).toBe('0'); + expect(() => vm.deleteUserConfirm()).not.toThrow(); + expect(vm.currentUsers).toHaveLength(3); + }); + + // displayedHeaders is a computed that PUSHES into this.headers. Because + // this.headers is reactive, that push invalidates the computed's own cache, so + // every re-evaluation appends another "Actions" column. Four headers plus + // Actions is five, no matter how many times it is read or re-rendered. + it('does not accumulate the Actions column when edit mode is toggled', async () => { + const wrapper = mount(Users, { + vuetify: new Vuetify(), + propsData: {value: twoOwnersAndAMember(), editable: true} + }); + const vm = wrapper.vm as unknown as UsersVm; + + expect(vm.displayedHeaders).toHaveLength(5); + + // Toggling `editable` is the real trigger: it is a dependency of the + // computed, so each flip back to true re-evaluates the getter. Reading the + // getter repeatedly is NOT enough — the cache holds — which is why this + // exercises the prop rather than the data. + for (let pass = 0; pass < 3; pass++) { + await wrapper.setProps({editable: false}); + await wrapper.setProps({editable: true}); + expect(vm.displayedHeaders).toHaveLength(5); + } + + expect( + vm.displayedHeaders.filter((header) => header.value === 'actions') + ).toHaveLength(1); + }); + + // The sole owner must still be protected — the guard must not have turned the + // owner check into a no-op that always reports saveable. + it('reports not-saveable when deleting the last remaining owner', () => { + const users: ISlimUser[] = [ + { + id: 'u1', + email: 'one@example.com', + firstName: 'One', + lastName: 'Owner', + groupRole: 'owner' + }, + { + id: 'u2', + email: 'two@example.com', + firstName: 'Two', + lastName: 'Member', + groupRole: 'member' + } + ]; + const wrapper = mountUsers(users); + const vm = wrapper.vm as unknown as UsersVm; + + vm.editedUserID = 'u1'; + expect(vm.deleteUserConfirm()).toBe(false); + }); +}); diff --git a/apps/frontend/tests/unit/LoginDataLoading.spec.ts b/apps/frontend/tests/unit/LoginDataLoading.spec.ts new file mode 100644 index 0000000000..621ea3aef7 --- /dev/null +++ b/apps/frontend/tests/unit/LoginDataLoading.spec.ts @@ -0,0 +1,293 @@ +/** + * ADR-008 Phase 1 — login must not block on application data. + * + * Regression under test: `14c13a0e9` turned three fire-and-forget calls into an + * awaited chain inside `GetUserInfo`, so a rejecting `GET /groups/my` propagates + * out of every login entry path and the caller's `router.push` never runs + * (`LocalLogin.vue:194-197`). Local, LDAP, all five OAuth providers and page + * reload all funnel through `GetUserInfo`, which is why these specs drive the + * store rather than a component. + * + * These are `describe.sequential`: `vitest.config.mts` sets `sequence.concurrent` + * and every spec in this suite shares ONE Vuex store, so a concurrent sibling + * would observe this file's mutations mid-assertion. + */ +import LocalLogin from '@/components/global/login/LocalLogin.vue'; +import {GroupsModule} from '@/store/groups'; +import {ServerModule} from '@/store/server'; +import type {IGroup, IStartupSettings, IUser} from '@heimdall/common/interfaces'; +import {mount} from '@vue/test-utils'; +import Vuetify from 'vuetify'; +import {addElemWithDataAppToBody} from '../util/testing-utils'; +import axios from 'axios'; +import Vue from 'vue'; +import vueCookiesPlugin from 'vue-cookies'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +// main.ts registers this and the unit suite never loads main.ts. CheckForServer +// calls Vue.$cookies.remove() UNCONDITIONALLY (server.ts:176-177), so without +// the plugin it throws a TypeError into its own catch — which is written for +// "the server said no" — and silently returns before reaching GetUserInfo. +Vue.use(vueCookiesPlugin); +addElemWithDataAppToBody(); + +const USER: IUser = { + id: '1', + email: 'admin@example.com', + firstName: 'Ada', + lastName: 'Admin', + title: 'Admin', + role: 'admin', + organization: 'MITRE', + loginCount: 1, + lastLogin: undefined, + creationMethod: 'local', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01') +}; + +const GROUP: IGroup = { + id: '7', + name: 'All Groups Entry', + public: true, + users: [], + desc: 'returned by GET /groups', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01') +}; + +const STARTUP: IStartupSettings = { + apiKeysEnabled: false, + banner: '', + classificationBannerColor: '', + classificationBannerText: '', + classificationBannerTextColor: '', + enabledOAuth: [], + externalUrl: '', + oidcName: '', + ldap: true, + registrationEnabled: true, + localLoginEnabled: true, + tenableHostUrl: '', + forceTenableFrontend: false, + splunkHostUrl: '' +}; + +/** The failure the regression turns into a lockout. */ +const MY_GROUPS_FAILURE = new Error('Request failed with status code 500'); + +/** The user-directory prefetch — the other secondary call GetUserInfo makes. */ +const ALL_USERS_FAILURE = new Error('Request failed with status code 503'); + +interface GetCall { + url: string; +} + +/** + * Which secondary fetch fails. Both must be covered independently: making + * FetchGroupData settled fixes `/groups/my` on its own, so a suite that only + * fails that endpoint cannot tell whether GetUserInfo still awaits the + * secondary calls — a mutation reverting the un-awaiting survived until + * `all-users` existed. + */ +type FailingEndpoint = 'my-groups' | 'all-users'; + +/** + * Route every GET the login chain makes. `/groups/my` is the default failure — + * the same endpoint `3bdd1f146` broke in production, which is how this class of + * defect was found. + */ +function stubApi(fail: FailingEndpoint = 'my-groups'): {calls: GetCall[]} { + const calls: GetCall[] = []; + + vi.spyOn(axios, 'get').mockImplementation((path) => { + calls.push({url: path}); + + if (path === '/server') { + return Promise.resolve({status: 200, data: STARTUP}); + } + if (path === '/users/user-find-all') { + return fail === 'all-users' + ? Promise.reject(ALL_USERS_FAILURE) + : Promise.resolve({data: []}); + } + if (path.startsWith('/users/')) { + // A COPY, never the fixture itself. SET_USER_INFO stores the reference it + // is given, so handing over the fixture lets a later SET_USERID('') mutate + // it — which silently emptied USER.id and made every later spec pass by + // early-returning out of GetUserInfo instead of exercising it. + return Promise.resolve({data: {...USER}}); + } + if (path === '/groups') { + return Promise.resolve({data: [{...GROUP}]}); + } + if (path === '/groups/my') { + return fail === 'my-groups' + ? Promise.reject(MY_GROUPS_FAILURE) + : Promise.resolve({data: []}); + } + return Promise.reject(new Error(`unstubbed GET ${path}`)); + }); + + vi.spyOn(axios, 'post').mockImplementation((path) => { + if (path === '/authn/login' || path === '/authn/login/ldap') { + return Promise.resolve({data: {userID: USER.id, accessToken: 'jwt.abc'}}); + } + return Promise.reject(new Error(`unstubbed POST ${path}`)); + }); + + return {calls}; +} + +/** Put both stores back to their construction-time state. */ +function resetStores(): void { + ServerModule.SET_TOKEN(''); + // SET_USER_INFO first: SET_USERID writes through to userInfo.id, so clearing + // the object afterwards would discard it. + ServerModule.SET_USER_INFO({...USER, id: '', role: ''}); + ServerModule.SET_USERID(''); + ServerModule.SET_LOADING(true); + GroupsModule.SET_ALL_GROUPS([]); + GroupsModule.SET_MY_GROUPS([]); + GroupsModule.SET_LOADING(true); +} + +describe.sequential('login must not block on application data (ADR-008)', () => { + beforeEach(() => { + resetStores(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('Login() resolves when GET /groups/my rejects — the local path', async () => { + const {calls} = stubApi(); + + // LocalLogin.vue:194 awaits this; :197 pushes the route only if it resolves. + await expect( + ServerModule.Login({email: USER.email, password: 'password'}) + ).resolves.toBeUndefined(); + + // Without this the test is vacuous: a stub-routing slip or an early return + // would resolve the promise without ever reaching the failing endpoint. + expect(calls.map((c) => c.url)).toContain('/groups/my'); + }); + + it('LoginLDAP() resolves when GET /groups/my rejects — the LDAP path', async () => { + const {calls} = stubApi(); + + // LDAPLogin.vue:77 has no try at all, so a rejection here is unhandled. + await expect( + ServerModule.LoginLDAP({username: 'ada', password: 'password'}) + ).resolves.toBeUndefined(); + + expect(calls.map((c) => c.url)).toContain('/groups/my'); + }); + + it('Login() resolves when GET /users/user-find-all rejects', async () => { + const {calls} = stubApi('all-users'); + + // The OTHER secondary fetch. Settled group semantics cannot rescue this + // one, so it is what proves GetUserInfo stopped AWAITING the secondary + // calls rather than merely stopping one of them from rejecting. + await expect( + ServerModule.Login({email: USER.email, password: 'password'}) + ).resolves.toBeUndefined(); + + expect(calls.map((c) => c.url)).toContain('/users/user-find-all'); + }); + + it('CheckForServer() resolves and commits server mode when GET /groups/my rejects', async () => { + const {calls} = stubApi(); + // CheckForServer reads the token and userID back out of localStorage + // (server.ts:173-174). Both must be truthy or it falls through to + // Vue.$cookies, which this suite never registers — that throw lands in the + // swallowing catch and GetUserInfo is never reached, which is the OAuth and + // page-reload path this spec exists to cover. + ServerModule.SET_TOKEN('jwt.abc'); + ServerModule.SET_USERID(USER.id); + + // router.ts:86 awaits this inside the guard for OAuth arrival and reload. + await expect(ServerModule.CheckForServer()).resolves.not.toThrow(); + expect(ServerModule.serverMode).toBe(true); + expect(calls.map((c) => c.url)).toContain('/groups/my'); + }); + + it('commits the profile even though the group fetch fails', async () => { + stubApi(); + + await ServerModule.Login({email: USER.email, password: 'password'}); + + // router.ts:99 reads this value for the requiresAdmin guard, so an admin + // deep-linking to /admin depends on it surviving a failing group fetch. + expect(ServerModule.userInfo.role).toBe('admin'); + expect(ServerModule.userInfo.email).toBe(USER.email); + }); + + it('commits the groups that DID load when one list rejects', async () => { + stubApi(); + + // Driven directly and awaited, NOT through Login: Login deliberately no + // longer awaits this, so asserting after it would be a race on microtask + // ordering rather than a statement about settled semantics. + await GroupsModule.FetchGroupData(); + + // Promise.all was fail-fast, discarding the successful /groups response + // because its sibling rejected. + expect(GroupsModule.allGroups).toHaveLength(1); + expect(GroupsModule.allGroups[0].name).toBe(GROUP.name); + expect(GroupsModule.myGroups).toHaveLength(0); + expect(GroupsModule.loading).toBe(false); + }); + + it('re-enters the loading state on every FetchGroupData, not just the first', async () => { + stubApi(); + GroupsModule.SET_LOADING(false); + + const inFlight = GroupsModule.FetchGroupData(); + // groups.ts:26 initializes true and :122 sets it false once, never back — + // so a refetch renders "loaded, empty" while it is still in flight. + expect(GroupsModule.loading).toBe(true); + + await inFlight; + }); + + it('LocalLogin navigates into the app when GET /groups/my rejects', async () => { + stubApi(); + const push = vi.fn(); + const wrapper = mount(LocalLogin, { + vuetify: new Vuetify(), + mocks: {$router: {push}}, + stubs: {'router-link': true} + }); + + const vm = wrapper.vm as unknown as { + email: string; + password: string; + login(): Promise; + }; + vm.email = USER.email; + vm.password = 'password'; + await vm.login(); + + // The user-visible outcome. LocalLogin.vue:194-197 only pushes if the awaited + // Login resolves, so promise settlement alone does not prove anyone gets in. + expect(push).toHaveBeenCalledWith('/'); + wrapper.destroy(); + }); + + it('runs the CheckForServer body once per page load, not once per navigation', async () => { + const {calls} = stubApi(); + ServerModule.SET_TOKEN('jwt.abc'); + ServerModule.SET_USERID(USER.id); + + await ServerModule.CheckForServer(); + await ServerModule.CheckForServer(); + await ServerModule.CheckForServer(); + + // The !this.loading early return at server.ts:163 already provides this; + // ADR-008 Decision §6 pins it rather than adding memoization. + expect(calls.filter((c) => c.url === '/server')).toHaveLength(1); + }); +}); diff --git a/apps/frontend/tests/unit/RegistrationModal.spec.ts b/apps/frontend/tests/unit/RegistrationModal.spec.ts new file mode 100644 index 0000000000..49172b43e7 --- /dev/null +++ b/apps/frontend/tests/unit/RegistrationModal.spec.ts @@ -0,0 +1,56 @@ +import RegistrationModal from '@/components/global/RegistrationModal.vue'; +import {ServerModule} from '@/store/server'; +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import Vue from 'vue'; +import Vuelidate from 'vuelidate'; +import Vuetify from 'vuetify'; +import {addElemWithDataAppToBody} from '../util/testing-utils'; + +Vue.use(Vuelidate); +addElemWithDataAppToBody(); + +interface RegistrationForm { + register(): Promise; + buttonLoading: boolean; + $refs: {form: {validate(): boolean}}; +} + +describe('The registration modal', () => { + const vuetify = new Vuetify(); + + function mountForm(): RegistrationForm { + const wrapper = mount(RegistrationModal, { + vuetify, + propsData: {visible: true} + }); + return wrapper.vm as unknown as RegistrationForm; + } + + it('leaves the button spinner off when validation fails', async () => { + const vm = mountForm(); + vm.$refs.form.validate = () => false; + await vm.register(); + expect(vm.buttonLoading).toBe(false); + }); + + it('resets the button spinner when registration fails', async () => { + const register = vi + .spyOn(ServerModule, 'Register') + .mockRejectedValue(new Error('registration exploded')); + try { + const vm = mountForm(); + vm.$refs.form.validate = () => true; + try { + await vm.register(); + } catch { + // register rejects when registration fails; the spinner reset + // below is what this test pins. + } + expect(register).toHaveBeenCalledOnce(); + expect(vm.buttonLoading).toBe(false); + } finally { + register.mockRestore(); + } + }); +}); diff --git a/apps/frontend/tests/unit/Results.spec.ts b/apps/frontend/tests/unit/Results.spec.ts index dd38ae1c27..9f9b1d54f0 100644 --- a/apps/frontend/tests/unit/Results.spec.ts +++ b/apps/frontend/tests/unit/Results.spec.ts @@ -1,10 +1,12 @@ import ControlTable from '@/components/cards/controltable/ControlTable.vue'; -import {Filter, FilteredDataModule} from '@/store/data_filters'; +import type {Filter} from '@/store/data_filters'; +import { FilteredDataModule} from '@/store/data_filters'; import Results from '@/views/Results.vue'; -import {shallowMount, Wrapper} from '@vue/test-utils'; -import {ContextualizedControl} from 'inspecjs'; +import type { Wrapper} from '@vue/test-utils'; +import {shallowMount} from '@vue/test-utils'; +import type {ContextualizedControl} from 'inspecjs'; import {beforeEach, describe, expect, it} from 'vitest'; -import Vue from 'vue'; +import type Vue from 'vue'; import Vuetify from 'vuetify'; import { expectedCount, @@ -12,7 +14,7 @@ import { loadSample, removeAllFiles, DataLoadApproach -} from '../util/testingUtils'; +} from '../util/testing-utils'; interface ListElt { // A unique id to be used as a key. @@ -31,7 +33,6 @@ const $router = { } }; const vuetify = new Vuetify(); -let controlTableWrapper: Wrapper; const wrapper: Wrapper = shallowMount(Results, { vuetify, @@ -41,14 +42,17 @@ const wrapper: Wrapper = shallowMount(Results, { propsData: {} }); -describe('Datatable', () => { +// Sequential, like Compare: these tests share one store, and vitest is +// configured to run tests concurrently, so any await in a test body would +// otherwise let a sibling's loaded files appear in this one's assertions. +describe.sequential('Datatable', () => { beforeEach(() => { removeAllFiles(); }); - it('displays correct number of controls with many files', () => { - loadAll(); - controlTableWrapper = shallowMount(ControlTable, { + it('displays correct number of controls with many files', async () => { + await loadAll(); + const controlTableWrapper = shallowMount(ControlTable, { vuetify, mocks: { $router @@ -66,16 +70,21 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: Array; + + items: any[]; } ).items.length ).toBe(expected); }); it('displays correct number of controls with many files generated from a single sample file while using the loadFile method', () => { - loadSample('Conveyor Sample', DataLoadApproach.File); - controlTableWrapper = shallowMount(ControlTable, { + // Deliberately not awaited, unlike its siblings. Awaiting it makes + // expectedCount ask for per-file counts fixtures that do not exist for the + // files this sample splits into, which exposes that the assertion below + // currently compares zero against zero. Tracked as heimdall2-0tp, which + // has to decide what this test should assert before it can be awaited. + void loadSample('Conveyor Sample', DataLoadApproach.File); + const controlTableWrapper = shallowMount(ControlTable, { vuetify, mocks: { $router @@ -93,16 +102,16 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: Array; + + items: any[]; } ).items.length ).toBe(expected); }); - it('control row and table data is correct', () => { - loadAll(); - controlTableWrapper = shallowMount(ControlTable, { + it('control row and table data is correct', async () => { + await loadAll(); + const controlTableWrapper = shallowMount(ControlTable, { vuetify, mocks: { $router @@ -114,25 +123,25 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: Array; + + items: any[]; } ).items .map((item: ListElt) => item.control.data.id) - .sort() + .toSorted((a, b) => a.localeCompare(b)) ).toEqual( FilteredDataModule.controls({ fromFile: FilteredDataModule.selected_file_ids, omit_overlayed_controls: true }) .map((c) => c.data.id) - .sort() + .toSorted((a, b) => a.localeCompare(b)) ); }); - it('it can properly filter overridden results', () => { - loadSample('Small Profile With Severity Overrides'); - controlTableWrapper = shallowMount(ControlTable, { + it('can properly filter overridden results', async () => { + await loadSample('Small Profile With Severity Overrides'); + const controlTableWrapper = shallowMount(ControlTable, { vuetify, mocks: { $router @@ -148,8 +157,8 @@ describe('Datatable', () => { expect( ( controlTableWrapper.vm as Vue & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: Array; + + items: any[]; } ).items.length ).toBe(3); // the file loaded includes 3 controls with severity override tags diff --git a/apps/frontend/tests/unit/Sidebar.spec.ts b/apps/frontend/tests/unit/Sidebar.spec.ts index 50461adabc..c77b89b93a 100644 --- a/apps/frontend/tests/unit/Sidebar.spec.ts +++ b/apps/frontend/tests/unit/Sidebar.spec.ts @@ -1,13 +1,14 @@ import Sidebar from '@/components/global/Sidebar.vue'; import {FilteredDataModule} from '@/store/data_filters'; import {InspecDataModule} from '@/store/data_store'; -import {createLocalVue, shallowMount, Wrapper} from '@vue/test-utils'; +import type { Wrapper} from '@vue/test-utils'; +import {createLocalVue, shallowMount} from '@vue/test-utils'; import {beforeAll, describe, expect, it} from 'vitest'; -import Vue from 'vue'; +import type Vue from 'vue'; import VueRouter from 'vue-router'; import Vuetify from 'vuetify'; -import {EvaluationFile, ProfileFile} from '../../src/store/report_intake'; -import {loadAll} from '../util/testingUtils'; +import type {EvaluationFile, ProfileFile} from '../../src/store/report_intake'; +import {loadAll} from '../util/testing-utils'; const vuetify = new Vuetify(); const localVue = createLocalVue(); @@ -21,9 +22,11 @@ const wrapper: Wrapper = shallowMount(Sidebar, { propsData: {} }); -describe('Sidebar tests', () => { - beforeAll(() => { - loadAll(); +// Sequential for the same reason as Datatable and Compare: one shared store +// under vitest's concurrent default. +describe.sequential('Sidebar tests', () => { + beforeAll(async () => { + await loadAll(); }); it('has the correct number of sidebar links', () => { diff --git a/apps/frontend/tests/unit/UploadButton.spec.ts b/apps/frontend/tests/unit/UploadButton.spec.ts index c4fd997c2f..e8e184281e 100644 --- a/apps/frontend/tests/unit/UploadButton.spec.ts +++ b/apps/frontend/tests/unit/UploadButton.spec.ts @@ -1,11 +1,12 @@ import UploadButton from '@/components/generic/UploadButton.vue'; import Modal from '@/components/global/Modal.vue'; import UploadNexus from '@/components/global/UploadNexus.vue'; -import {mount, Wrapper} from '@vue/test-utils'; +import type { Wrapper} from '@vue/test-utils'; +import {mount} from '@vue/test-utils'; import {beforeEach, describe, expect, it} from 'vitest'; -import Vue from 'vue'; +import type Vue from 'vue'; import Vuetify from 'vuetify'; -import {addElemWithDataAppToBody} from '../util/testingUtils'; +import {addElemWithDataAppToBody} from '../util/testing-utils'; addElemWithDataAppToBody(); diff --git a/apps/frontend/tests/unit/async_util.spec.ts b/apps/frontend/tests/unit/async_util.spec.ts new file mode 100644 index 0000000000..011eef02c0 --- /dev/null +++ b/apps/frontend/tests/unit/async_util.spec.ts @@ -0,0 +1,18 @@ +import {readFileAsync} from '@/utilities/async_util'; +import {describe, expect, it} from 'vitest'; + +describe('readFileAsync', () => { + // The read used to be a FileReader wrapped in a hand-rolled promise, whose + // result was typed loosely enough to stringify to '[object ArrayBuffer]'. + // Blob#text can only ever produce the file's text. + it('resolves with the text of the file', async () => { + const file = new File(['{"hello":"world"}'], 'result.json', { + type: 'application/json' + }); + await expect(readFileAsync(file)).resolves.toBe('{"hello":"world"}'); + }); + + it('resolves with an empty string for an empty file', async () => { + await expect(readFileAsync(new File([], 'empty.json'))).resolves.toBe(''); + }); +}); diff --git a/apps/frontend/tests/unit/parsing_and_counting.spec.ts b/apps/frontend/tests/unit/parsing_and_counting.spec.ts index 486d544aeb..4917a5c28d 100644 --- a/apps/frontend/tests/unit/parsing_and_counting.spec.ts +++ b/apps/frontend/tests/unit/parsing_and_counting.spec.ts @@ -3,10 +3,11 @@ import * as _ from 'lodash'; import {describe, expect, it} from 'vitest'; import {InspecDataModule} from '@/store/data_store'; import {InspecIntakeModule} from '@/store/report_intake'; -import {ControlStatusHash, StatusCountModule} from '@/store/status_counts'; +import type {ControlStatusHash} from '@/store/status_counts'; +import { StatusCountModule} from '@/store/status_counts'; import {AllRaw} from '../util/fs'; -describe('Parsing', async () => { +describe('Parsing', () => { it('Report intake can read every raw file in hdf_data', async () => { const raw = AllRaw(); @@ -34,8 +35,8 @@ describe('Parsing', async () => { execFiles.forEach((file) => { // Get the corresponding count file const countFilename = `tests/hdf_data/counts/${file.filename}.info.counts`; - const countFileContent = readFileSync(countFilename, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const countFileContent = readFileSync(countFilename, 'utf8'); + const counts: Record = JSON.parse(countFileContent); // Get the expected counts diff --git a/apps/frontend/tests/unit/search_store.spec.ts b/apps/frontend/tests/unit/search_store.spec.ts new file mode 100644 index 0000000000..abce8df04c --- /dev/null +++ b/apps/frontend/tests/unit/search_store.spec.ts @@ -0,0 +1,13 @@ +import {valueToSeverity} from '@/store/search'; +import {describe, expect, it} from 'vitest'; + +describe('valueToSeverity', () => { + it('accepts a known severity whatever its case', () => { + expect(valueToSeverity('high')).toBe('high'); + expect(valueToSeverity('HIGH')).toBe('high'); + }); + + it('falls back to none for a value that is not a severity', () => { + expect(valueToSeverity('not-a-severity')).toBe('none'); + }); +}); diff --git a/apps/frontend/tests/unit/store_async_constructs.spec.ts b/apps/frontend/tests/unit/store_async_constructs.spec.ts new file mode 100644 index 0000000000..9e7bea6266 --- /dev/null +++ b/apps/frontend/tests/unit/store_async_constructs.spec.ts @@ -0,0 +1,64 @@ +import {AppInfoModule} from '@/store/app_info'; +import {InspecDataModule} from '@/store/data_store'; +import {InspecIntakeModule, isHDF} from '@/store/report_intake'; +import type {AxiosInstance} from 'axios'; +import axios from 'axios'; +import {describe, expect, it, vi} from 'vitest'; +import {AllRaw} from '../util/fs'; + +describe('isHDF', () => { + it('recognizes execution JSON passed as a string', () => { + expect(isHDF('{"profiles": []}')).toBe(true); + }); + + it('recognizes profile JSON passed as an object', () => { + expect(isHDF({controls: [], sha256: 'abc123'})).toBe(true); + }); + + it('rejects a string that is not JSON', () => { + expect(isHDF('definitely not json')).toBe(false); + }); + + it('rejects JSON that is neither an execution nor a profile', () => { + expect(isHDF('{"some": "other format"}')).toBe(false); + }); + + it('rejects missing data', () => { + expect(isHDF(undefined)).toBe(false); + }); +}); + +describe('The data store database-id lookups', () => { + it('map between file id and database id synchronously once a file is loaded', async () => { + const fixture = AllRaw()['bad_nginx.json']; + const fileId = await InspecIntakeModule.loadText({ + filename: 'bad_nginx.json', + text: fixture.content, + database_id: '42' + }); + + expect(InspecDataModule.loadedDatabaseIdsForFileId(fileId)).toBe('42'); + expect(InspecDataModule.loadedFileIsForDatabaseIds(42)).toBe(fileId); + }); +}); + +describe('CheckForUpdates', () => { + it('completes the version fetch before its promise resolves', async () => { + const get = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(resolve, 10, {data: [{name: 'v99.0.0'}]}); + }) + ); + const create = vi + .spyOn(axios, 'create') + .mockReturnValue({get} as unknown as AxiosInstance); + try { + await AppInfoModule.CheckForUpdates(); + expect(get).toHaveBeenCalledOnce(); + expect(AppInfoModule.latestVersion).toBe('99.0.0'); + } finally { + create.mockRestore(); + } + }); +}); diff --git a/apps/frontend/tests/unit/tenable_util.spec.ts b/apps/frontend/tests/unit/tenable_util.spec.ts new file mode 100644 index 0000000000..b9a5cd11c8 --- /dev/null +++ b/apps/frontend/tests/unit/tenable_util.spec.ts @@ -0,0 +1,118 @@ +import type {AuthInfo} from '@/utilities/tenable_util'; +import { + INCORRECT_CREDENTIALS_MSG, + LOGIN_TIMEOUT_MSG, + TenableUtil +} from '@/utilities/tenable_util'; +import JSZip from 'jszip'; +import {describe, expect, it, vi} from 'vitest'; + +// The wording the 400 branch used to substitute for the backend's own message. +// Hoisted to module scope so it is compiled once (e18e/prefer-static-regex). +const CSP_WORDING = /Content Security Policy/; + +const config: AuthInfo = { + accesskey: 'test-access-key', + secretkey: 'test-secret-key', + host_url: 'https://tenable.example.org:443' +}; + +describe('TenableUtil', () => { + it('configures its axios instance with the login timeout', () => { + const util = new TenableUtil(config); + expect(util.axios_instance.defaults.timeout).toBe(60_000); + }); + + it('resolves true when lite-mode login succeeds', async () => { + const util = new TenableUtil(config); + util.axios_instance.get = vi + .fn() + .mockResolvedValue({status: 200, data: {}}); + await expect(util.loginToTenable()).resolves.toBe(true); + }); + + it('rejects with an Error carrying the credential message on a 403', async () => { + const util = new TenableUtil(config); + util.axios_instance.get = vi.fn().mockRejectedValue({ + response: {data: {error_code: 74}}, + status: 403 + }); + await expect(util.loginToTenable()).rejects.toBeInstanceOf(Error); + await expect(util.loginToTenable()).rejects.toThrowError( + INCORRECT_CREDENTIALS_MSG + ); + }); + + it('maps a timed-out request to the login-timeout message', async () => { + const util = new TenableUtil(config); + util.axios_instance.get = vi.fn().mockRejectedValue({ + code: 'ECONNABORTED', + message: 'timeout of 60000ms exceeded', + request: {} + }); + await expect(util.loginToTenable()).rejects.toBeInstanceOf(Error); + await expect(util.loginToTenable()).rejects.toThrowError( + LOGIN_TIMEOUT_MSG + ); + }); + + it('rejects with the server-provided message when server-mode login is unsuccessful', async () => { + const util = new TenableUtil(config); + util.isServer = true; + util.axios_instance.post = vi + .fn() + .mockResolvedValue({data: {success: false, message: 'backend says no'}}); + await expect(util.loginToTenable()).rejects.toBeInstanceOf(Error); + await expect(util.loginToTenable()).rejects.toThrowError( + 'backend says no' + ); + }); + + // The backend refuses an off-allowlist host with a coded 400 + // (HOST_NOT_ALLOWED, heimdall2-86f6.6). Before this test the 400 branch + // recognised only INVALID_HOST_URL, so every other coded rejection fell + // through to the Content Security Policy explanation — telling the operator + // their browser policy blocked a request the SERVER had refused. Asserting + // the absence of the CSP wording is what makes this test discriminating: the + // exact-message assertion alone would not say which explanation was wrong. + it('surfaces a server-side host rejection instead of blaming the browser CSP', async () => { + const util = new TenableUtil(config); + util.isServer = true; + util.axios_instance.post = vi.fn().mockRejectedValue({ + code: 'ERR_BAD_REQUEST', + response: { + data: { + code: 'HOST_NOT_ALLOWED', + message: 'The requested Tenable host is not permitted by this server', + status: 400 + } + }, + status: 400 + }); + await expect(util.loginToTenable()).rejects.toThrowError( + 'The requested Tenable host is not permitted by this server' + ); + await expect(util.loginToTenable()).rejects.not.toThrowError(CSP_WORDING); + }); + + it('unzips the first file of a downloaded scan result', async () => { + const util = new TenableUtil(config); + const zip = new JSZip(); + zip.file('9213.nessus', ''); + const buffer = await zip.generateAsync({type: 'arraybuffer'}); + util.axios_instance.post = vi.fn().mockResolvedValue({data: buffer}); + await expect(util.getVulnerabilities('9213')).resolves.toBe( + '' + ); + }); + + it('rejects when the downloaded zip is empty', async () => { + const util = new TenableUtil(config); + const zip = new JSZip(); + const buffer = await zip.generateAsync({type: 'arraybuffer'}); + util.axios_instance.post = vi.fn().mockResolvedValue({data: buffer}); + await expect(util.getVulnerabilities('9213')).rejects.toThrowError( + 'ZIP file is empty.' + ); + }); +}); diff --git a/apps/frontend/tests/util/fs.ts b/apps/frontend/tests/util/fs.ts index bd5d91942e..aaa5fa8fb0 100644 --- a/apps/frontend/tests/util/fs.ts +++ b/apps/frontend/tests/util/fs.ts @@ -1,12 +1,23 @@ import * as fs from 'fs'; +/** Orders by UTF-16 code unit, which is what a bare sort() does. */ +function byCodeUnit(a: string, b: string): number { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} + /** Returns sorted list of files in a directory */ export function list_files(dirPath: string) { // Init result array const result = fs.readdirSync(dirPath); // Sort by filename - return result.sort(); + return result.toSorted(byCodeUnit); } export interface FileResult { @@ -23,7 +34,7 @@ export function read_files(dirName: string): FileResult[] { // Read them all return files.map((filename) => { - const content = fs.readFileSync(dirName + filename, 'utf-8'); + const content = fs.readFileSync(dirName + filename, 'utf8'); return { name: filename, content @@ -31,7 +42,7 @@ export function read_files(dirName: string): FileResult[] { }); } -export type FileHash = {[key: string]: FileResult}; +export type FileHash = Record; export function populate_hash(results: FileResult[]) { const hash: FileHash = {}; results.forEach((f) => { diff --git a/apps/frontend/tests/util/testingUtils.ts b/apps/frontend/tests/util/testing-utils.ts similarity index 63% rename from apps/frontend/tests/util/testingUtils.ts rename to apps/frontend/tests/util/testing-utils.ts index f7988de8cd..95a3fb6a7d 100644 --- a/apps/frontend/tests/util/testingUtils.ts +++ b/apps/frontend/tests/util/testing-utils.ts @@ -1,6 +1,7 @@ import {InspecDataModule} from '@/store/data_store'; import {InspecIntakeModule} from '@/store/report_intake'; -import {Sample, samples} from '@/utilities/sample_util'; +import type {Sample} from '@/utilities/sample_util'; +import { samples} from '@/utilities/sample_util'; import {readFileSync} from 'fs'; import {AllRaw} from './fs'; @@ -19,7 +20,10 @@ export function loadSample( if (sample === undefined) { return null; } - const data: string = require(`../../public${sample.path}`); + // Parse like require() did, so the stringified payload below is unchanged. + const data: unknown = JSON.parse( + readFileSync(`public${sample.path}`, 'utf8') + ); return dataLoadApproach === DataLoadApproach.Text ? InspecIntakeModule.loadText({ filename: sampleName, @@ -31,15 +35,18 @@ export function loadSample( }); } -export function loadAll(): void { +export async function loadAll(): Promise { const data = AllRaw(); - Object.values(data).forEach((fileResult) => { - // Do intake - InspecIntakeModule.loadText({ - filename: fileResult.name, - text: fileResult.content - }); - }); + // Awaited, not fire-and-forget: an intake that settles after the next + // test's removeAllFiles() puts this test's files into that one's state. + await Promise.all( + Object.values(data).map((fileResult) => + InspecIntakeModule.loadText({ + filename: fileResult.name, + text: fileResult.content + }) + ) + ); } export function removeAllFiles(): void { @@ -53,7 +60,7 @@ export function removeAllFiles(): void { // warning on the console when running tests. export function addElemWithDataAppToBody() { const app = document.createElement('div'); - app.setAttribute('data-app', 'true'); + app.dataset.app = 'true'; document.body.append(app); } @@ -72,15 +79,15 @@ export function expectedCount( InspecDataModule.executionFiles.forEach((file) => { // Get the corresponding count file const countFilename = `tests/hdf_data/counts/${file.filename}.info.counts`; - const countFileContent = readFileSync(countFilename, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const countFileContent = readFileSync(countFilename, 'utf8'); + const counts: Record = JSON.parse(countFileContent); - statuses['failed'] += counts.failed.total; - statuses['passed'] += counts.passed.total; - statuses['notReviewed'] += counts.skipped.total; - statuses['notApplicable'] += counts.no_impact.total; - statuses['profileError'] += counts.error.total; + statuses.failed += counts.failed.total; + statuses.passed += counts.passed.total; + statuses.notReviewed += counts.skipped.total; + statuses.notApplicable += counts.no_impact.total; + statuses.profileError += counts.error.total; }); return statuses[status]; diff --git a/apps/frontend/vue.config.js b/apps/frontend/vue.config.js index 5e19f47225..81b1d09f09 100644 --- a/apps/frontend/vue.config.js +++ b/apps/frontend/vue.config.js @@ -3,8 +3,7 @@ const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); // lookup constants const fs = require('fs'); -const packageJson = fs.readFileSync('./package.json'); -const parsed = JSON.parse(packageJson); +const parsed = JSON.parse(fs.readFileSync('./package.json', 'utf8')); const version = parsed.version || 0; const description = parsed.description || ''; const repository = parsed.repository.url || ''; @@ -12,6 +11,7 @@ const license = parsed.license || ''; const changelog = parsed.changelog || ''; const branch = parsed.branch || ''; const issues = parsed.issues || ''; +const NODE_PROTOCOL_PREFIX = /^node:/v; // tsconfig specification const path = require('path'); @@ -26,14 +26,14 @@ module.exports = { lintOnSave: 'warning', publicPath: '/', devServer: { - // JWT_SECRET is a required secret for the backend. If it is sourced - // then it is safe to assume the app is in server mode in development. - // - // PORT is not required so use the default backend port value - // is used here if JWT_SECRET is applied but PORT is undefined - proxy: process.env.JWT_SECRET - ? `http://127.0.0.1:${process.env.PORT || 3000}` - : '' + // API_PROXY_TARGET (apps/frontend/.env.development) points this dev + // server's proxy at the backend — server-mode development. Unset/empty + // (e.g. via .env.development.local) means no proxy: GET /server fails and + // the app runs as heimdall-lite standalone (src/store/server.ts catches + // that path). The frontend owns this setting; it deliberately reads + // NOTHING from apps/backend/.env — reusing the backend's PORT here (as + // both the bind port and the proxy target) broke dev on 2026-08-10. + proxy: process.env.API_PROXY_TARGET || '' }, outputDir: '../../dist/frontend', configureWebpack: { @@ -59,8 +59,8 @@ module.exports = { }, devtool: 'source-map', plugins: [ - new webpack.NormalModuleReplacementPlugin(/^node:/v, (resource) => { - resource.request = resource.request.replace(/^node:/v, ''); + new webpack.NormalModuleReplacementPlugin(NODE_PROTOCOL_PREFIX, (resource) => { + resource.request = resource.request.replace(NODE_PROTOCOL_PREFIX, ''); }), new webpack.DefinePlugin({ 'process.env.PACKAGE_VERSION': `"${version}"`, diff --git a/cmd.sh b/cmd.sh index 8622ac2d0d..c5ab37b94f 100644 --- a/cmd.sh +++ b/cmd.sh @@ -1,5 +1,9 @@ #!/bin/sh set -e +# ADR-006 §11: libuv reads UV_THREADPOOL_SIZE at first threadpool use, before +# app config loads — set it in the process environment (defense in depth with +# the Dockerfile ENV; also covers non-container invocations of this script). +export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-8}" yarn backend sequelize db:migrate yarn backend sequelize db:seed:all yarn backend start diff --git a/cypress.config.ts b/cypress.config.ts index c0f7c1c812..88c4736bdb 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ video: true, chromeWebSecurity: false, e2e: { - setupNodeEvents(on, config) { + setupNodeEvents(on, _config) { installLogsPrinter(on) }, baseUrl: 'http://127.0.0.1:3000', @@ -16,8 +16,8 @@ export default defineConfig({ specPattern: 'test/integration/**/*.cy.{js,jsx,ts,tsx}' }, // Extends timeout counter to 60s - defaultCommandTimeout: 60000, - requestTimeout: 30000, + defaultCommandTimeout: 60_000, + requestTimeout: 30_000, // Forces failed tests to retry up to 3 times retries: {runMode: 3} }); diff --git a/docker-compose.yml b/docker-compose.yml index c1accd727b..42a124cb15 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,34 @@ services: depends_on: database: condition: service_healthy + # ADR-006 §17: probe the readiness endpoint only. /admin/migration-status + # is NEVER probed — its counts are full table scans. + # + # RELEASE COUPLING (deliberate, not hidden): this file pulls + # `release-latest`, and /health/ready ships with the next release. Both + # land in the same repo state, so release-latest consumers get them + # together; a git-pull user running a stale cached image will see + # "unhealthy" until `docker compose pull`. That status stays purely + # informational — see the nginx depends_on note below. + # + # The response body is matched, not just the HTTP status, because the app + # serves the SPA as a catch-all for unmatched routes: an image WITHOUT + # /health/ready answers 200 with index.html, which `curl -f` alone would + # accept as healthy. Matching the readiness envelope is what makes a stale + # image report unhealthy instead of silently lying. + # + # start_period exceeds the database's 80s because the server's first boot + # additionally runs migrations and seeding before it can serve traffic. + healthcheck: + test: + [ + "CMD-SHELL", + "curl -fsS http://localhost:3000/health/ready | grep -q '\"status\":\"ok\"'" + ] + interval: 30s + timeout: 60s + retries: 5 + start_period: 120s nginx: image: nginx:alpine @@ -60,6 +88,10 @@ services: ports: - "80:80" - "443:443" + # Deliberately NOT upgraded to `condition: service_healthy` (blast-radius + # control): until a release ships with /health/ready, a stale cached image + # would never report healthy and nginx would wait forever. Revisit only + # after the endpoints are in a published release. depends_on: - "server" diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs new file mode 100644 index 0000000000..2341049053 --- /dev/null +++ b/docs/.vitepress/config.mjs @@ -0,0 +1,162 @@ +import {defineConfig} from 'vitepress'; +import {target} from './target.mjs'; + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: 'Heimdall', + description: 'Visualize and analyze your security results', + + // Every per-target difference is declared in target.mjs, selected by + // HEIMDALL_DOCS_TARGET at build time. Nothing else in this file re-derives it. + base: target.base, + + // Publishing is STRUCTURAL: only content under site/ builds, so a tree + // outside it cannot be published and there is no exclude list to forget. + // This repo already keeps working documents beside the site — docs/research/ + // and the ADRs — and the flat layout in ADR-005 §2.3.1 would have tried to + // build them into the public site. (vulcan learned this the same way; + // ADR-005 predates their fix, so §2.3.1 is superseded — the §2.3 SECTION MAP + // below still governs.) + srcDir: 'site', + + // Clean URLs without .html extension + cleanUrls: true, + + // Last updated time (reads git timestamps — CI needs fetch-depth: 0) + lastUpdated: true, + + // Dead links FAIL the build (VitePress default; ADR-005 §5.1 makes it a + // standing rule). Deliberately no ignoreDeadLinks entry — if one becomes + // necessary it must arrive with its reason. + + head: [['meta', {name: 'theme-color', content: '#005288'}]], + + themeConfig: { + // Sections come from ADR-005 §2.3. Phase 1 ships the skeleton; Phase 3 + // migrates the 24 wiki pages into it. Section landing pages exist so the + // structure is navigable — and so the dead-link check has something real + // to check — while their content is explicitly Phase 3's. + // + // site/ carries EXTERNAL USER-FACING documentation only (Aaron, + // 2026-08-11). Internal project records — ADRs, plans, research — live + // beside the site under docs/ and are structurally unpublishable because + // srcDir points at site/. There is deliberately no "decisions" section. + nav: [ + {text: 'Guide', link: '/getting-started/'}, + {text: 'Deploy', link: '/deployment/'}, + {text: 'Converters', link: '/converters/'}, + {text: 'Developers', link: '/developers/'}, + {text: 'API', link: '/api/'} + ], + + sidebar: { + '/getting-started/': [ + { + text: 'Getting Started', + items: [ + {text: 'Overview', link: '/getting-started/'}, + {text: 'Quick Start', link: '/getting-started/quick-start'}, + {text: 'Installation', link: '/getting-started/installation'}, + {text: 'Configuration', link: '/getting-started/configuration'}, + { + text: 'Environment Variables', + link: '/getting-started/environment-variables' + }, + { + text: 'Troubleshooting', + link: '/getting-started/troubleshooting' + } + ] + }, + { + text: 'User Guide', + items: [{text: 'Overview', link: '/user-guide/'}] + } + ], + '/user-guide/': [ + { + text: 'Getting Started', + items: [ + {text: 'Overview', link: '/getting-started/'}, + {text: 'Quick Start', link: '/getting-started/quick-start'}, + {text: 'Installation', link: '/getting-started/installation'}, + {text: 'Configuration', link: '/getting-started/configuration'}, + { + text: 'Environment Variables', + link: '/getting-started/environment-variables' + }, + { + text: 'Troubleshooting', + link: '/getting-started/troubleshooting' + } + ] + }, + { + text: 'User Guide', + items: [{text: 'Overview', link: '/user-guide/'}] + } + ], + '/deployment/': [ + { + text: 'Deployment', + items: [{text: 'Overview', link: '/deployment/'}] + } + ], + '/converters/': [ + { + text: 'Converters', + items: [{text: 'Overview', link: '/converters/'}] + } + ], + '/developers/': [ + { + text: 'Developers', + items: [{text: 'Overview', link: '/developers/'}] + } + ], + '/api/': [ + {text: 'API', items: [{text: 'Overview', link: '/api/'}]} + ], + '/security/': [ + {text: 'Security', items: [{text: 'Overview', link: '/security/'}]} + ], + '/release-notes/': [ + { + text: 'Release Notes', + items: [{text: 'Overview', link: '/release-notes/'}] + } + ], + '/about/': [ + {text: 'About', items: [{text: 'Overview', link: '/about/'}]} + ] + }, + + // Built-in local search: zero dependencies, fully offline — which is the + // requirement for an airgapped install, and the wiki's biggest missing + // feature (ADR-005 §2.3.3). + search: {provider: 'local'}, + + // Outbound chrome, gated by target — dead links in an airgapped deployment. + ...(target.outboundChrome + ? { + socialLinks: [ + {icon: 'github', link: 'https://github.com/mitre/heimdall2'} + ], + editLink: { + pattern: + 'https://github.com/mitre/heimdall2/edit/master/docs/site/:path', + text: 'Edit this page on GitHub' + } + } + : {}), + + footer: { + message: 'Part of the MITRE Security Automation Framework (SAF)', + copyright: 'Copyright © 2026 MITRE Corporation' + }, + + docFooter: {prev: 'Previous', next: 'Next'}, + + outline: {level: [2, 3], label: 'On this page'} + } +}); diff --git a/docs/.vitepress/target.mjs b/docs/.vitepress/target.mjs new file mode 100644 index 0000000000..70cfab0245 --- /dev/null +++ b/docs/.vitepress/target.mjs @@ -0,0 +1,71 @@ +// What differs between builds of this documentation, in one place. +// +// Adapted from vulcan's docs/.vitepress/target.mjs (the ADR-005 §5.1 reference +// implementation). Shaped after VitePress's own `locales` table: shared +// configuration stays in config.mjs and each entry here declares ONLY what that +// target overrides. If a value is the same everywhere it does not belong here — +// the moment an entry looks like a whole configuration it has become a second +// source of truth. +// +// Targets are selected at BUILD time and are mutually exclusive, because `base` +// is baked into the generated asset URLs: a site built for GitHub Pages cannot +// be served from the application's /docs/ path, and vice versa. Offline/in-app +// docs therefore require their own build, not a copy of the published one. + +const TARGETS = { + // Published to GitHub Pages alongside Heimdall Lite: one deploy-pages artifact + // carries the Lite SPA at the site root and this site under /docs/ + // (ADR-005 §2.2.1). Same base as the in-app target — the documentation lives + // at /docs/ whether it is served by Pages or by the application itself. + pages: { + base: '/docs/', + inApp: false, + // Outbound chrome — the GitHub edit link and the social icons — is + // meaningful only where the internet is. Served in-app (and the driving + // case is a disconnected lab running the RPM install) every one of those + // links is dead, so the in-app target turns them off. + outboundChrome: true + }, + + // Local developer preview, served from the site root. + local: { + base: '/', + inApp: false, + outboundChrome: true + }, + + // Served by the Heimdall application itself, for offline/airgapped installs. + // The mount path is the application's fact — it is what defines the route — + // so it is passed in rather than restated here. + // + // HOW the application serves this build is deliberately NOT decided here: + // heimdall2 is NestJS + a Vue SPA whose ServeStaticModule answers 200 for any + // unmatched route, so the mount has to be researched before it is wired + // (Aaron, 2026-08-11). This target exists so the build is ready when that + // decision lands; nothing outside docs/ depends on it yet. + app: { + base: process.env.HEIMDALL_DOCS_BASE || '/docs/', + inApp: true, + outboundChrome: false + } +}; + +export const TARGET_NAMES = Object.keys(TARGETS); + +export function resolveTarget(name = process.env.HEIMDALL_DOCS_TARGET) { + const key = name || 'local'; + + // Object.hasOwn, not a truthiness check on TARGETS[key]: a plain object + // inherits from Object.prototype, so `constructor`, `toString` and friends + // resolve to inherited functions and would slip past the guard, yielding a + // target with base === undefined and a build with broken asset URLs. + if (!Object.hasOwn(TARGETS, key)) { + throw new Error( + `Unknown documentation target ${JSON.stringify(name)}. Expected one of: ${TARGET_NAMES.join(', ')}` + ); + } + + return {name: key, ...TARGETS[key]}; +} + +export const target = resolveTarget(); diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 0000000000..c5c07baa60 --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,11 @@ +// Minimal theme (ADR-005 §2.3.1: "minimal — SAF logo, theme color only"). +// +// Phase 1 deliberately ships a pass-through: the theme color travels as a head +// meta tag in config.mjs, and the SAF logo is an image asset that arrives with +// the rest of docs/site/public/ in the Phase 3 content migration. Declaring the +// extension point now — rather than adding it later — is what lets the in-app +// target style itself without touching the published build (vulcan applies its +// in-app stylesheet through exactly this seam). +import DefaultTheme from 'vitepress/theme'; + +export default DefaultTheme; diff --git a/docs/adrs/adr-005-vitepress-documentation-site.md b/docs/adrs/adr-005-vitepress-documentation-site.md new file mode 100644 index 0000000000..bc3b9c30f6 --- /dev/null +++ b/docs/adrs/adr-005-vitepress-documentation-site.md @@ -0,0 +1,432 @@ +# ADR-005: VitePress Documentation Site + +**Status:** Proposed +**Date:** 2026-07-10 +**Author:** Aaron Lippold +**Related:** ADR-004 (its Phase 9 documentation channel is the motivating problem), `mitre/vulcan` docs site (the proven reference implementation) + +--- + +## 1. Context + +### 1.1 The Problem + +Heimdall's user and operator documentation lives in the GitHub wiki (`mitre/heimdall2.wiki.git`) — a **separate git repository with no pull-request support**: no reviews, no branch protection, no CI, no forking through the UI. Anyone with write access pushes directly. Documentation changes therefore cannot ship in the same reviewed change set as the code they describe. + +ADR-004 made this concrete: its breaking change to `REGISTRATION_DISABLED` names the wiki as a **required** communication channel (ADR-004 §6.2), yet the wiki rewrite cannot ride PR #8383 — it is a separate, unreviewed push someone must remember to do at release time. Verified 2026-07-09 against a clone of the wiki repo: exactly one page documents `REGISTRATION_DISABLED` (as the pre-ADR-004 boolean), no page documents JIT provisioning at all, and the login page's help icon (`LocalLogin.vue`) deep-links users into the wiki. + +Additional forces: + +- The wiki is 26 pages (24 content pages + `_Sidebar`/`_Footer`) of plain Markdown — already portable. +- Repo-level user-facing docs (`README.md`, `apps/backend/README.md`, `libs/*/README.md`, `CODE_OF_CONDUCT.md`) have no published home. **Corrected 2026-08-11 (Aaron):** this bullet originally added ADRs to that list and argued they were "invisible to deployers" — the premise that produced the `decisions/` site section. ADRs are internal project records, not deployer documentation; they are deliberately never published and live in `docs/adrs/` beside the site (§2.3). +- `mitre/vulcan` solved this exact problem with VitePress; its setup was read directly this session (`docs/.vitepress/config.mjs`, `.github/workflows/docs.yml`) and serves as the reference implementation. +- **Hard constraint:** Heimdall is a Yarn-workspaces monorepo (`workspaces: ["apps/*", "libs/*", "test"]`) with a Vue 2 frontend. A docs toolchain on Vue 3 must be invisible to the app build. Modifying the root `package.json` workspaces configuration is prohibited (a past `nohoist` change broke the entire frontend build). + +### 1.2 Requirements + +1. Documentation changes are PR-reviewable and can ship in the same PR as code changes. +2. The Vue 3 docs toolchain and the Vue 2 app are mutually invisible — no shared dependency resolution, no root `package.json` changes. +3. External user-facing content is written to current practice rather than moved verbatim. **Corrected 2026-08-11 (Aaron):** the original requirement read "existing content (wiki pages, repo Markdown, ADRs) migrates rather than being rewritten"; a full 56-document inventory found the wiki largely stale, so it is rewritten section by section (§2.3), and ADRs never enter the site at all. +4. Publishing is automated with no manual copy step (triggered by `release: published` + `workflow_dispatch` per §2.2.1 — corrected 2026-08-11 from "automatic on merge"). + +--- + +## 2. Decision + +Adopt **VitePress**, following the `mitre/vulcan` pattern: a self-contained `docs/` directory inside `mitre/heimdall2` with its own `package.json` and `docs/yarn.lock`, built and deployed to GitHub Pages by a dedicated workflow, publishing **external user-facing documentation only** — the rewritten wiki content and the user-facing repo Markdown. Internal project records (ADRs, plans, research) stay in `docs/` beside the site and are never published (§2.3). The docs site supersedes the wiki as the canonical documentation channel; the wiki is reduced to pointer stubs. + +### 2.1 Isolation Design (the load-bearing detail) + +Verified against this repo's actual configuration: + +- Root workspaces are `["apps/*", "libs/*", "test"]` (mirrored in `lerna.json`). A top-level `docs/` **matches none of these globs**, so root `yarn install` never sees it: no hoisting, no shared resolution, no lockfile interaction. +- `docs/` gets its own `package.json` + `docs/yarn.lock`, installed only by `yarn install` inside `docs/`. VitePress 2 and Vue 3 exist solely in `docs/node_modules`. +- Node module resolution walks **up** from a file, never sideways into `docs/node_modules` — the app's Vue 2 and the docs' Vue 3 cannot meet. +- The root `package.json`, `lerna.json`, and all workspace configuration are **not modified**. This is an invariant, not an implementation detail: the acceptance proof is that root `yarn install` and the full app build behave identically before and after the scaffold. +- Docker: the Dockerfile copies specific paths (no blanket `COPY .`); `docs/` is additionally added to `.dockerignore` to make the exclusion explicit. +- Docs pages read files (or symlink root Markdown, as Vulcan does); they never `import` app code. + +### 2.2 Deployment + +> **Amended 2026-08-11 (Aaron).** The original text — "triggered on pushes to master +> touching `docs/**` … Base path `/heimdall2/` (project pages)" — was copied from +> Vulcan's `docs.yml` without reconciling it against two facts about *this* +> repository. Both are corrected below. The trigger and the base path in the +> original text are superseded; the rest of the recipe stands. + +The documentation has **two deployment targets**, selected at build time by +`docs/.vitepress/target.mjs` (§2.3.1). They are separate builds because `base` is +baked into asset URLs. + +**A. In-app (the driving requirement).** The built site ships **with the +application** so a disconnected or airgapped installation — the RPM case — has +its documentation offline. Because it travels inside the release artifact, the +in-app documentation is **release-pinned by construction**: the docs on disk +always describe exactly the version installed. How the NestJS application serves +this build (static mount, CSP, packaging paths) is Phase 7. + +**B. Published site.** A `.github/workflows/docs.yml` with `fetch-depth: 0` +(VitePress `lastUpdated` uses git timestamps), Node from `.nvmrc` (currently 22), +yarn cache keyed to `docs/yarn.lock`, and SHA-pinned actions. + +- **Trigger: `release: published`, plus `workflow_dispatch`** for out-of-band + documentation fixes — **not** pushes to master. Three reasons, in order of + weight: (1) publishing from master would contradict target A — the public site + would describe unreleased features while the docs shipped inside the user's + install describe the release, and the two must not disagree; (2) this + repository's existing Pages deployment (`.github/workflows/gh-pages.yml`) + already uses `release: published`, so release-triggered is the established + convention here; (3) mirroring the product's release process is the documented + practice for *product* documentation, as distinct from a tool's own + development-tip docs, which is the model Vulcan follows. `workflow_dispatch` + covers the real cost of this choice — a typo fix that would otherwise wait for + a release. +- **Base path: `/docs/` (see §2.2.1), and it is NOT `/heimdall2/`.** There is no + `mitre.github.io/heimdall2/` site. This repository's Pages is bound to a custom + domain and is **already occupied**: `gh api repos/mitre/heimdall2/pages` reports + `status: built`, `cname: heimdall-lite.mitre.org`, `source: {branch: gh-pages, + path: /}`, `build_type: legacy` — it serves **Heimdall Lite**, deployed on every + published release, with an approved TLS certificate. Any documentation + deployment must therefore choose a hosting shape (subdirectory of the existing + site, a dedicated docs domain, a separate repository, or migrating this + repository's Pages to an `actions/deploy-pages` workflow publishing one + artifact) **and must not clobber Heimdall Lite** — `peaceiris/actions-gh-pages` + publishes to the branch root and removes existing files by default. The chosen + shape determines `base`; it is one value in the target table. **That hosting + decision is open** — see §2.2.1. + +**The wiki stays live until the published site exists.** Heimdall's documentation +is public today; an in-app-only site would remove access for anyone without a +running instance. §5.3's dependency chain already enforces this — Phase 6 +(decommission) depends on Phase 5, which depends on Phase 2. + +#### 2.2.1 Hosting shape — DECIDED 2026-08-11 (Aaron) + +**One Pages site, published by `actions/deploy-pages` from a single artifact: +Heimdall Lite at `/`, the documentation at `/docs/`.** Four shapes were weighed — +a subdirectory added to the existing branch deploy, a dedicated docs domain, a +separate documentation repository, and this one. + +Why this one: + +- It is GitHub's current mechanism. The repository is on `build_type: legacy` + (GitHub serves whatever sits on the `gh-pages` branch, force-pushed there by + `peaceiris/actions-gh-pages`). Migrating to `actions/upload-pages-artifact` + + `actions/deploy-pages` removes the orphan branch entirely and deploys with OIDC + into a `github-pages` environment. +- **Atomic.** One artifact carries both sites, so there is no `destination_dir` / + `keep_files` arrangement to get wrong — and the default behaviour of the branch + deploy is to REMOVE existing files, which is exactly how Heimdall Lite would be + destroyed by a careless documentation deploy. +- **The cadences already match.** Both Heimdall Lite and the documentation deploy + on `release: published` (§2.2 B), so a single deployment is coherent rather than + a compromise. Publishing from master would have forced them apart. +- Deployment history and rollback become visible in the Actions UI, and + environment protection rules apply. + +Costs and risks, stated plainly: + +- It changes how a **live public site** deploys. `heimdall-lite.mitre.org` serves + real users under an approved certificate; a botched migration takes it down. + The migration must be verified end to end before a release relies on it. +- It requires a repository **settings change** (Pages source → GitHub Actions), + which is an administrator action, not a code change. Aaron holds admin on the + repository and performs this step when the workflow is ready — so it is a + sequencing item, not a blocker. +- The custom domain moves from a `CNAME` file written into the published + directory to the Pages configuration itself; the existing "Write + Heimdall-Lite CNAME file" step in `gh-pages.yml` is removed with it. + +Two consequences worth carrying forward: + +- **The published base becomes `/docs/`** — the same value the in-app target uses. + Whether the two targets can then share a single build, or still warrant separate + builds to strip outbound chrome for the airgapped case, is an implementation + question for Phase 2/Phase 7 rather than an architectural one. +- **This does not settle the domain NAME.** The site remains + `heimdall-lite.mitre.org` — a hostname named for the browser-only viewer, while + the documentation mostly describes Heimdall Server. Rebinding the Pages custom + domain is a separate, later decision and is not required by this one. + +### 2.3 Proposed Structure and Content Migration + +> **Amended 2026-08-11 (Aaron), governing rule for this whole section:** +> **`site/` carries EXTERNAL USER-FACING documentation only.** Internal project +> records — ADRs, plans, research notes — are never published: they live beside +> the site under `docs/`, where `srcDir: 'site'` makes them structurally +> unbuildable into it. The original `decisions/` row (published ADRs) is removed +> accordingly, and Phase 4 carries only user-facing repo files (README, +> CODE_OF_CONDUCT, LICENSE, attributions), not ADRs. +> +> The internal trees are `docs/adrs/`, `docs/plans/` and `docs/research/` — +> Vulcan's `docs/{decisions,plans,research,site}` layout, with `adrs/` in place +> of its `decisions/` per the owner. Nothing outside `docs/site/` is published. + +The 24 wiki content pages map into the site sections below; existing repo Markdown is symlinked or included, never duplicated. **Amended 2026-08-11 (Aaron): content is REWRITTEN, not moved** — the inventory found the wiki largely stale, so each section is authored fresh against the current product by its own card (§5.3), using the wiki as source material rather than as text to relocate. Two rules survive that change unaltered: license/notice/attribution files move **verbatim**, and the `REGISTRATION_DISABLED` content is owned by ADR-004 Phase 9. + +| Site section | Content | Source | +|---|---|---| +| `getting-started/` | Installation, configuration, environment variables, troubleshooting | Wiki: Environment-Variables-Configuration, Troubleshooting, Docker-Bake; repo: `.env-example` narrative | +| `user-guide/` | Using Heimdall, groups/users, attestations, auth methods | Wiki: Group-and-User-Management, Manual-Attestations, Heimdall-Authentication-Methods | +| `deployment/` | Production installs, platform configs, releases | Wiki: Oracle-Linux-Production-Install, MITRE-Heimdall-Lite-and-Demo-Deployment-Configurations, Heimdall-Heroku-Documentation, How-to-create-a-Heimdall2-release | +| `developers/` | Architecture, code style, components, processes, tips | Wiki: Heimdall-Architecture-Information, Developers-Code-Style, Heimdall-Frontend-Components, Heimdall-Class-Diagrams, Heimdall-Processes-Documentation, Heimdall-Development-Tips-&-Tricks, Heimdall-Interface-Connections; repo: `apps/backend/README.md`, `libs/*/README.md` | +| `converters/` | HDF converter docs | Wiki: HDF-Converter-Mappings, HDF-Converters-How-Tos, CCI-Converter | +| `api/` | API documentation | Wiki: Heimdall-API-Documentation (vitepress-openapi rendering of a machine-readable spec is an investigation item, not a commitment) | +| `security/` | Security control responses | Wiki: Heimdall-Server-Security-Control-Responses | +| `about/` | Attributions, code of conduct, license | Wiki: Technology-Attributions (verbatim); repo: `CODE_OF_CONDUCT.md`, `LICENSE.md`, `README.md` (symlinked) | +| Landing (`index.md`) | Home + navigation | Wiki: Home, _Sidebar (becomes the sidebar config) | + +#### 2.3.1 Concrete file tree (reference layout for Phases 1, 3, 4) + +> **Amended 2026-08-11 (Phase 1 implementation, authorized by Aaron).** Two changes, +> both adopted from Vulcan *after* this ADR was written (Vulcan landed them on +> 2026-08-10; this ADR is dated 2026-07-10): +> +> 1. **Content lives under `docs/site/`, selected by `srcDir: 'site'`** — not flat +> under `docs/`. Publishing becomes structural: a tree outside `site/` cannot be +> published, so there is no exclude list to forget. This repository already keeps +> working documents beside the site (`docs/research/`, the ADRs themselves), which +> the flat layout would have tried to build into the public site. +> 2. **A build-target seam, `docs/.vitepress/target.mjs`**, carries `base`, `inApp` +> and `outboundChrome`. Rationale: the documentation must ship **with the +> application** so a disconnected/airgapped lab running the RPM install has it +> offline. `base` is baked into asset URLs at build time, so the published site +> and the in-app site are necessarily separate builds (both now at base +> `/docs/` per §2.2.1, so they differ by outbound chrome rather than path), and +> outbound chrome (GitHub edit link, social icons) is turned off for the in-app +> target because those links are dead without a network. +> +> The §2.3 section map below is unchanged and still governs nav/sidebar. How the +> NestJS application serves the in-app build is deliberately not settled here. + +Pages marked **NEW** are thin additive pages created during migration (an index, a checklist skeleton); they are not content rewrites and do not violate §4.3. + +``` +docs/ # the docs PROJECT (own package.json + yarn.lock) +├── .vitepress/ +│ ├── config.mjs # nav/sidebar, srcDir: site, local search, dead-link check on +│ ├── target.mjs # per-target base/inApp/outboundChrome (pages | local | app) +│ └── theme/ # minimal — SAF logo, theme color only +├── adrs/ # INTERNAL — architecture decision records; NEVER published +├── plans/ # INTERNAL — implementation plans; NEVER published +├── research/ # INTERNAL — research notes; NEVER published +└── site/ # the PUBLISHED tree — only this builds + ├── public/ # migrated images, saf-logo.svg + ├── index.md # landing page (spec below) + ├── getting-started/ +│ ├── quick-start.md ← Home.md (docker-compose path, split out) +│ ├── installation.md ← Home.md + Docker-Bake.md +│ ├── configuration.md ← Environment-Variables-Configuration.md (overview half) +│ ├── environment-variables.md← Environment-Variables-Configuration.md — THE canonical env +│ │ reference; everything else links here, never duplicates +│ │ (ADR-004 Phase 9 target) +│ └── troubleshooting.md ← Troubleshooting.md +├── user-guide/ +│ ├── overview.md ← Home.md (usage half) +│ ├── groups-and-users.md ← Group-and-User-Management.md +│ ├── attestations.md ← Manual-Attestations.md +│ └── authentication.md ← Heimdall-Authentication-Methods.md — owns the ADR-004 +│ account_not_provisioned explanation; LocalLogin.vue's help +│ icon points here +├── deployment/ +│ ├── production-checklist.md # NEW — TLS-mandatory (Helmet HSTS), REGISTRATION_DISABLED +│ │ posture (ADR-004 §8), LOCAL_LOGIN_DISABLED ordering caveat, +│ │ JWT/API-key secrets +│ ├── oracle-linux.md ← Oracle-Linux-Production-Install.md +│ ├── lite-and-demo.md ← MITRE-Heimdall-Lite-and-Demo-Deployment-Configurations.md +│ ├── heroku.md ← Heimdall-Heroku-Documentation.md (migrate with a +│ │ possibly-outdated banner; dropping content is the owner's +│ │ per-page call, not the migrator's) +│ └── releases.md ← How-to-create-a-Heimdall2-release.md +├── converters/ +│ ├── mappings.md ← HDF-Converter-Mappings.md +│ ├── how-tos.md ← HDF-Converters-How-Tos.md +│ └── cci-converter.md ← Control-Correlation-Identifier-(CCI)-Converter.md +├── developers/ +│ ├── architecture.md ← Heimdall-Architecture-Information.md +│ ├── frontend-components.md ← Heimdall-Frontend-Components.md +│ ├── class-diagrams.md ← Heimdall-Class-Diagrams.md +│ ├── processes.md ← Heimdall-Processes-Documentation.md +│ ├── interface-connections.md← Heimdall-Interface-Connections.md +│ ├── code-style.md ← Developers-Code-Style.md +│ ├── tips-and-tricks.md ← Heimdall-Development-Tips-&-Tricks.md +│ ├── backend.md ← apps/backend/README.md (included, not duplicated) +│ └── libraries.md ← libs/inspecjs + libs/hdf-converters READMEs +├── api/ +│ └── index.md ← Heimdall-API-Documentation.md (vitepress-openapi later, +│ only if a maintained machine-readable spec exists — §4.3) +├── security/ +│ └── control-responses.md ← Heimdall-Server-Security-Control-Responses.md +├── release-notes/ # NEW section — versioned upgrade/migration notes; the +│ └── index.md ADR-004 breaking-change note is its first durable entry +│ (GitLab upgrade-notes pattern; wiki has no equivalent) +└── about/ + ├── attributions.md ← Technology-Attributions.md (verbatim) + ├── code-of-conduct.md → symlink ../CODE_OF_CONDUCT.md + └── license.md → symlink ../LICENSE.md (verbatim) +``` + +#### 2.3.2 Landing page (`index.md`) + +VitePress `layout: home` hero + features: + +- **Hero:** name "Heimdall", text "Visualize and analyze your security results", tagline covering InSpec + the 30+ formats via hdf-converters, SAF logo. Actions: Quick Start → `/getting-started/quick-start`, Live Demo → the demo URL currently in `README.md` (taken from there, not invented), Environment Variables → the canonical reference. +- **Features (4):** View & Analyze (upload HDF, filter, drill into controls) · 30+ Converters · Deploy Anywhere (Docker, RPM, cloud, enterprise SSO/LDAP) · Compliance-Ready (NIST 800-53 views, attestations, exports). +- **Top nav:** Guide · Deploy · Converters · Developers · API, plus GitHub link. (Corrected 2026-08-11: the `Decisions` entry was removed with the `decisions/` section — §2.3.) + +#### 2.3.3 Site capabilities + +- **Local search** via VitePress's built-in provider (`themeConfig.search: {provider: 'local'}`) — zero dependencies, and the wiki's biggest missing feature. +- **`getting-started/environment-variables.md` is the single source of truth for configuration** — other pages link to it; duplicating variable descriptions elsewhere is a review-blocking error. +- **Known gap, deliberately not filled here:** the repo has no `CONTRIBUTING.md`. Docs sites conventionally link one from the footer; whether to create one — and its content — is a separate owner decision, out of this ADR's scope. + +### 2.4 Wiki Decommission + +Wikis cannot redirect, so each migrated wiki page is edited down to a one-line pointer to its new URL, and wiki editing is restricted to collaborators. Hardcoded wiki deep links in the product move to the docs site — verified inventory: `LocalLogin.vue` (external-authentication help icon), `apps/backend/.env-example` (header link), `README.md` (wiki references). + +--- + +## 3. Alternatives Considered + +### Option A: Keep the wiki (do nothing) + +**Pros:** zero work; contributors know where it is. +**Cons:** the motivating problem — docs can never be PR-reviewed or ship with code changes; ADR-004's required channel stays a manual out-of-band push. **Rejected.** + +### Option B: Docs-in-repo, plain Markdown only (no site generator) + +Move wiki pages into `docs/` and rely on GitHub's Markdown rendering. +**Pros:** PR-reviewable, zero toolchain, zero isolation concerns. +**Cons:** no navigation/search/landing page for deployers; 24+ user-facing pages become a flat file listing; no versioned public URL to point the login page's help link at. **Rejected** — solves review but not publication. + +### Option C: Keep the wiki, sync from repo via GitHub Action + +Author docs in-repo, push to the wiki repo on merge. +**Pros:** PR review; wiki URLs keep working. +**Cons:** two sources of truth with drift risk; wiki remains the renderer (no nav/search/theme); sync action is bespoke infrastructure; direct wiki edits silently diverge. **Rejected** — more moving parts than publishing directly. + +### Option D: MkDocs (Material) + +**Pros:** mature, excellent search, used widely by MITRE SAF projects. +**Cons:** Python toolchain in a Node monorepo (new ecosystem for contributors and CI); no organizational reference implementation as close as Vulcan's. **Rejected** — viable, but VitePress keeps the toolchain Node-native and copies a working in-house pattern. + +### Option E: Docusaurus + +**Pros:** mature, React-based, versioned docs built in. +**Cons:** React toolchain in a Vue shop; heavier than needed; same isolation question with a larger surface. **Rejected.** + +**Why VitePress:** Node/Vue-native (matches the team), the isolation problem is already solved and proven in-house (`mitre/vulcan` — same Vue 2 app + Vue 3 docs split, config and deploy workflow read directly and reusable nearly verbatim), and its `srcDir` makes the published tree structural, so internal records cannot leak into the site (§2.3). (Corrected 2026-08-11: this reason originally read "it publishes ADRs as first-class pages (Vulcan's `decisions/` section)" — the superseded premise.) + +--- + +## 4. Consequences + +### 4.1 Positive + +- Documentation changes ship in the same reviewed PR as code (ADR-004 Phase 9's wiki row is superseded the moment this lands — the `REGISTRATION_DISABLED` page becomes an in-PR `getting-started/environment-variables.md` edit). +- Internal records (ADRs, plans, research) sit beside the site in the same reviewed repo, so a decision and the documentation it changes ship in one PR — without exposing project internals to end users. **Corrected 2026-08-11 (Aaron):** this line originally claimed ADRs gain "a published, linkable home (`decisions/`)"; they are not published (§2.3). +- The login page's help link points at a reviewed, versioned page instead of a wiki page anyone with write access can alter. +- Publishing is automatic; there is no manual copy step to forget at release time. + +### 4.2 Negative / Risks + +- One more toolchain to keep current (VitePress/Vue 3 in `docs/`), though Dependabot picks up `docs/package.json` automatically. +- Wiki URLs in the wild break unless the stub-pointer pass is done thoroughly. +- The isolation invariant depends on nobody "helpfully" adding `docs` to the workspaces globs or importing app code into docs — stated as a hard rule here and enforced by the scaffold card's acceptance criteria. +- VitePress 2 is in alpha (Vulcan runs `2.0.0-alpha.11` in production docs); pin the version, upgrade deliberately. + +### 4.3 Out of Scope + +- Custom domain (GitHub Pages project URL is sufficient to start) +- ~~Rewriting/modernizing page content during migration (move-and-organize only)~~ — **reversed 2026-08-11 (Aaron):** rewriting is now the work itself. Each site section is authored fresh against the current product by its own card (§5.3), with the wiki as source material. +- Publishing ADRs, plans or research (§2.3 — internal records never enter `site/`) +- Versioned docs (per-release snapshots) +- vitepress-openapi API rendering (investigation item — depends on a maintained machine-readable API spec) + +--- + +## 5. Implementation Plan + +### 5.1 Quality Standards (inherited by every card) + +- **Isolation invariant:** root `package.json`, `lerna.json`, and workspace config are never modified. Every card's verification includes: root `yarn install` and app builds behave identically before/after. +- **Existing pattern:** Vulcan's `docs/.vitepress/config.mjs` and `docs.yml` are the reference — deviate only with a stated reason. +- **Verbatim rule for legal/attribution content:** `LICENSE.md`, `Technology-Attributions`, `CODE_OF_CONDUCT.md` move without any wording changes. +- **Dead links fail the build:** VitePress builds with dead-link checking on; every migration card's verification is `yarn build` inside `docs/`. +- **SHA-pinned actions** in the workflow, matching Vulcan. +- **No app imports in docs pages** — file reads and symlinks only. + +### 5.2 Shared Abstractions + +| Shared need | Used by | Built in | +|---|---|---| +| `docs/` scaffold (package.json, config.mjs, theme, index) | every content card | Phase 1 | +| Sidebar/nav structure (from §2.3 table) | every content card | Phase 1 | +| Deploy workflow + Pages setup | publication | Phase 2 | + +### 5.3 Phases + +Tracked as epic **`heimdall2-yvx`** on the heimdall2 beads board; every row below is a child card (`heimdall2-yvx.`), and the Depends on column mirrors the board's own dependencies. ADR-004's Phase 9 card (`heimdall2-4qg.9`) soft-references this epic: once the docs site is live, its wiki deliverables become docs-site page edits. + +**Board access:** the board is a shared Dolt database published at `refs/dolt/data` in this repository. Install `bd` from [gastownhall/beads](https://github.com/gastownhall/beads), then run `bd dolt pull` from a heimdall2 checkout with an existing beads clone, or `bd bootstrap` on a fresh machine. **Upgrade note (2026-07-10):** the board schema was migrated v49 → v54 — if you have a pre-existing beads clone, run `bd dolt pull` on your *current* bd binary **before** upgrading bd; if you upgraded first and bd refuses to start, `bd bootstrap` re-clones (push any local issues first). The team agent skills used to work these cards (card template, TDD gates, AC verification) live in [mitre/mitre-saf-skills](https://github.com/mitre/mitre-saf-skills). + +> **Phase list REPLACED 2026-08-11 (Aaron).** The original six phases assumed a +> move-and-organize migration, so all 24 wiki pages sat on one card. After the +> 56-document inventory and the rewrite-not-move ruling (§2.3), the epic was +> re-planned from 6 cards to 17: one card per site section, plus the `docs/` +> reorganization that establishes the internal-vs-published contract, the +> canonical environment-variables reference every other page links to, the +> Kubernetes/Helm documentation that exists nowhere today, and in-app serving +> for airgapped installs. Card numbers are identifiers, not an order — read the +> Depends on column. What did NOT change: the wiki stays alive until a public +> site replaces it, so `yvx.6` runs last, behind `yvx.5` and `yvx.2`. + +Foundation: + +| Card | Scope | Depends on | Size | +|---|---|---|---| +| `yvx.1` ✅ | Scaffold: `docs/` with own package.json/yarn.lock, VitePress config (srcDir `site`, target seam, cleanUrls, lastUpdated, dead-link check), minimal theme, landing page, section skeleton, `.gitignore`/`.dockerignore`/eslint-ignore entries. AC: root install/build byte-identical | — | sp:3 | +| `yvx.7` ✅ | Reorganize `docs/` into internal (`adrs/`, `plans/`, `research/`) and published (`site/`) trees; amend this ADR to state that contract | — | sp:2 | +| `yvx.8` ✅ | The canonical `environment-variables.md` — heimdall2 has no environment-variables document at all today, and two competing partial references (a wiki page and the 489-line RPM man page) that will drift | 7 | sp:5 | + +Content — one card per site section, written fresh against the current product: + +| Card | Scope | Depends on | Size | +|---|---|---|---| +| `yvx.9` ✅ | `getting-started/` — quick start, install index, configuration, troubleshooting | 8 | sp:5 | +| `yvx.10` | `user-guide/` — how to actually use the Heimdall UI (compare, treemap, filters, exports, tags). The largest gap: documented nowhere today | 7 | sp:5 | +| `yvx.11` | `deployment/` — one page per install method, each linking to the method's own runbook rather than duplicating it, plus hardening, backup and upgrade | 8 | sp:5 | +| `yvx.12` | `deployment/kubernetes.md` — the `mitre/heimdall-helm` chart, undocumented everywhere today. Includes the probe caveat: the SPA catch-all returns 200 for any unmatched route, so a status-only `httpGet` probe reports false-healthy | 11 | sp:5 | +| `yvx.13` | `converters/` — supported formats, how-tos, CCI converter | 7 | sp:3 | +| `yvx.14` | `developers/` — architecture, setup, code style, release process | 7 | sp:3 | +| `yvx.15` | `api/`, `security/`, `about/` — the remaining sections | 8 | sp:3 | + +Delivery and decommission: + +| Card | Scope | Depends on | Size | +|---|---|---|---| +| `yvx.17` | In-app documentation for offline/airgapped installs: build the `app` target, serve it from NestJS at `/docs/` (static mount ordered ahead of the SPA catch-all), resolve the CSP conflict with VitePress's inline theme scripts, ship the output in the RPM and container images | — | sp:5 | +| `yvx.2` | Publication: one `actions/deploy-pages` artifact per §2.2.1 — Heimdall Lite at `/`, docs at `/docs/`, on `release: published` + `workflow_dispatch`, SHA-pinned, without clobbering Lite | 1 | sp:3 | +| `yvx.4` | User-facing repo Markdown symlinked into the site (README, CODE_OF_CONDUCT, LICENSE verbatim, attributions); the ADR half of this card was removed when §2.3 made internal records unpublishable | 3 | sp:2 | +| `yvx.5` | Product link updates: `LocalLogin.vue` help URL, `.env-example` header, `README.md` wiki references → docs-site URLs | 2, 3 | sp:1 | +| `yvx.6` | Wiki decommission: every page reduced to a pointer stub, editing restricted, final parity check against the wiki clone | 3, 4, 5 | sp:2 | +| `yvx.16` | Correct the FIPS posture statement — gated on the FIPS release actually shipping, because the current statement is TRUE for the released product | 11 | sp:1 | + +`yvx.3` (migrate all 24 wiki pages as one card) is **closed as superseded** by the rewrite ruling and the per-section cards above. + +**Cross-epic dependency added 2026-08-14.** Every content card above, plus `yvx.4` +and `yvx.17`, additionally depends on **`heimdall2-fhtn`** — the repo-wide Prettier +reformat (~576 files). Prettier formats Markdown, so pages written before that +commit would simply be rewritten by it, costing a second review of the same lines. +The Depends-on column above lists intra-epic order only; the board carries the +`fhtn` edges. + +--- + +## 6. References + +- `mitre/vulcan` `docs/.vitepress/config.mjs` and `.github/workflows/docs.yml` — reference implementation (read directly 2026-07-09) +- `mitre/heimdall2.wiki.git` — migration source, cloned and audited 2026-07-09 (26 files, 24 content pages) +- ADR-004 §3.4 / §6.2 / Phase 9 — the documentation channel this ADR upgrades +- [VitePress documentation](https://vitepress.dev/) +- Root `package.json` workspaces / `lerna.json` — the isolation constraint (verified this session) diff --git a/docs/adrs/adr-006-fips-validated-password-hashing.md b/docs/adrs/adr-006-fips-validated-password-hashing.md new file mode 100644 index 0000000000..97175db03a --- /dev/null +++ b/docs/adrs/adr-006-fips-validated-password-hashing.md @@ -0,0 +1,1273 @@ +# ADR-006: PBKDF2 Password Hashing via a FIPS 140-3 Validated Module + +**Status:** Proposed +**Date:** 2026-07-29 +**Author:** Aaron Lippold +**Branch:** `feature/fips-compliant-password-hashing` +**Base:** `master` @ `2e1649c9e` +**Epic:** `heimdall2-e25` + +> **On the title.** This document avoids the phrase "FIPS compliant." FedRAMP +> *Policy for Cryptographic Module Selection and Use* v1.1 (approved +> 2025-01-16), rule **FRR8**: representations "must use terminology approved by +> NIST" and CSPs "must not use ambiguous or CSP-defined terms such as 'FIPS +> compliant.'" The accurate claim is that hashing is performed **by a FIPS +> 140-3 validated module**. + +## Evidence standard + +Every normative claim is marked **[V]** (verified against a primary source — +NIST/CMVP PDF, DISA STIG API, vendor source, or a direct read of this +repository) or **[U]** (unverified; not load-bearing; must not appear in an +SSP or POA&M without confirmation). + +This exists because an earlier review of this ADR produced confident, +well-formatted citations — STIG check text, CMVP guidance, FedRAMP rule IDs — +that had **never been read**. The reviewer retracted them, and on retraction +discovered its own central argument ran backwards. Independent verification +later confirmed most of the substance and **refuted one key citation**, which +is corrected in §3. + +Two rules follow for anyone extending this: + +- **Do not promote a [U] to [V] without reading the source.** +- **Assessor-facing artifacts cite only [V] items.** SP 800-53A Rev 5's SC-13 + assessment objects explicitly include "cryptographic module validation + certificates; list of FIPS-validated cryptographic modules" **[V]** — exactly + the class of claim that was fabricated the first time. + +## Context + +Heimdall hashes passwords with bcrypt via `bcryptjs` (pure JavaScript, cost 14) +and stores API keys as bcrypt hashes of JWT signatures. + +**The problem is boundary, not strength.** bcrypt at cost 14 is strong. But +`bcryptjs` computes its **hash in pure JavaScript** — the Blowfish key schedule +and the digest never enter `node:crypto` or OpenSSL, so on a FIPS-enabled host +hash generation runs *undetected and unblocked*, entirely outside the validated +module **[V]**. + +Be precise about this: bcryptjs v3 *does* import `node:crypto`, using +`crypto.randomBytes()` for salt generation. The claim is narrower than "it never +touches crypto" — it is that the **hashing** is not performed by a validated +module, which is exactly what V-222571's check text turns on. The conclusion is +unchanged; the earlier phrasing overstated it. **[V]** + +**What this actually costs, stated precisely** — an earlier draft overstated it: + +- **V-222542** ("must only store cryptographic representations of passwords", + **CAT I**, CCI-004062) requires "strong cryptographic hash + functions" with a random salt and prohibits MD5. **The phrase "FIPS-validated" + appears nowhere in the rule.** bcrypt already satisfies it. **[V]** +- **V-222571** (**CAT II**, CCI-002450) is the rule we fail, and its finding + condition is **invocation-scoped**. **[V]** +- **V-222572** (**CAT II**, CCI-002450) — "must utilize FIPS-validated + cryptographic modules when protecting unclassified information." Omitted from + the prior draft. **[V]** + +So: **two CAT II findings, not a CAT I failure.** Worth fixing; not worth +overstating to an assessor. + +**Supporting control.** SP 800-53A Rev 5 **IA-5(1)(d)**: "for password-based +authentication, passwords are stored using an **approved salted key derivation +function**, preferably using a keyed hash." **[V]** PBKDF2-HMAC fits this text +more directly than bcrypt. This is the strongest affirmative control and the +prior draft never cited it. + +### Comparable projects, scoped to FIPS mode + +The prior draft surveyed seven projects, concluded "five of seven use lazy +rehash," and justified an unconditional bcrypt fallback with it. **That survey +measured non-FIPS behavior.** Corrected: + +| Project | In FIPS mode | Verified | +|---|---|---| +| **Keycloak** | **Refuses.** The provider never registers, so verification is never reached; affected users "will not be able to login after switch to the FIPS environment" — remedy is "ask users to reset the password." Note the quotes describe **argon2** (Keycloak 25+ default), not bcrypt. The refuse-and-reset *pattern* is what transfers. | **[V]** source + docs | +| **GitLab** | **Gates on FIPS mode.** "Bcrypt: Used by default. **PBKDF2+SHA512: Used when FIPS mode is enabled.**" Concedes bcrypt hashes "cannot be re-encrypted without user help." Issue **#360659** — "Force password resets for users with bcrypt login passwords" (closed 2022-07-27). | **[V]** | +| **Mattermost** | **Supporting precedent.** As of v11 it defaults to PBKDF2-HMAC-SHA256 @ 600,000 behind a `requirefips` build tag — the same shape as this design. | **[V]** | + +All three now point the same way: **gate on FIPS mode, and define a terminal +state.** Keycloak refuses outright, GitLab gates and drives toward forced resets, +Mattermost ships a FIPS build that uses PBKDF2 by default. An ungated, +unterminated bcrypt fallback — which is what an earlier draft of §3 specified — +matches none of them. + +### The Grafana lesson + +Grafana has used PBKDF2 since inception and remains at **10,000 iterations** +with no upgrade path, because parameters were never encoded in the stored hash +**[V]**. Encoding parameters is structural, not cosmetic. + +## Decision + +### 1. PBKDF2 via `node:crypto` + +**Parameters:** PBKDF2-HMAC-SHA-512 (default; `sha256`/`sha384` selectable), +600,000 iterations, 32-byte salt from `crypto.randomBytes()`, derived key +matching digest width. + +**Why this is an approved operation.** The prior draft argued "PBKDF2 is the +only NIST-approved password KDF, therefore compliant." That **overstates**: + +- SP 800-132 §4: the derived Master Key is for generating Data Protection Keys; + "**The MK shall not be used for other purposes.**" **[V]** +- IG **§D.N**: password-derived keys "**may only be used in storage + applications.**" Every RHEL security policy examined repeats this. **[V]** + +Hash-and-compare verification is not "a storage application" in that sense. + +**But the module remains approved, and CMVP says so.** IG **2.4.C** anticipates +exactly this **[V]**: + +> "If the module operator (e.g., calling application) can do things outside of +> the module's control/visibility that can take an otherwise approved algorithm +> and use it in a non-approved way (e.g., use PBKDF **and/or AES XTS** outside +> of storage applications), the corresponding module service **may still be +> considered approved** ... and the Security Policy shall clarify how to use the +> service in an approved manner." + +So this is a **documentation obligation, not a design defect**. Two consequences: + +1. **What actually satisfies V-222571** is that the **HMAC-SHA-512 primitive + executes inside the validated module**; secure hashing is an approved + security function. PBKDF2's iteration structure is a construction *over* an + approved primitive, not an appeal to SP 800-132's key-derivation scope. +2. **The SSP must state this reasoning** and disclose the storage-application + scope limit. + +**Our parameters clear every bound the module enforces [V]** — policy +`140sp4857.pdf`: **a portion of** the salt ≥128 bits from the SP 800-90Ar1 DRBG, iterations ≥1000, +derived key ≥112 bits. The same policy lists "PBKDF2 (short password; short +salt; insufficient iterations; <112-bit keys)" as a **non-approved service** — +the failure mode is under-parameterisation, which we are well clear of. + +**On password strength:** IG §D.N states "**SP 800-132 does not impose any +strictly defined requirements on the strength of a password**" **[V]**. An +earlier draft claimed a 112-bit floor implied a 14-character minimum. Wrong — +the "14" is a BouncyCastle byte-length check that Keycloak works around by +*padding*. Heimdall's 15-character minimum is good practice, not a FIPS +obligation, and must not be presented as one. + +### 2. PHC string format + +``` +$pbkdf2-sha512$i=600000$$ +``` + +Per the [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md), +matching npm `phc-pbkdf2`. Standard base64, padding stripped. PHC is a strict +*subset* of Modular Crypt Format; bcrypt's `$2b$14$…` is valid MCF, invalid PHC. +Both coexist in one column. + +The leading `$` is load-bearing: `$` appears in no base64 alphabet nor in +bcrypt's radix-64 **[V]**, so dispatch is an unambiguous lookup on `parts[1]`. +It also admits `$argon2id$v=19$m=…` unchanged if NIST approves Argon2. + +**Storage width [V]:** 154 characters. Both `Users.encryptedPassword` and +`ApiKeys.apiKey` are `VARCHAR(255)` in model *and* migration — 101 characters +of headroom, no `VARCHAR(60)` anywhere. Postgres *errors* on overflow rather +than truncating, but it would fire **inside the login path**, so an AC asserts +output ≤255 for all three digests. + +### 3. Migration: FIPS-gated fallback with a real terminal state + +`verifyPassword` dispatches on stored format **and FIPS state**: + +| Stored prefix | FIPS off | FIPS on (`getFips() === 1`) | +|---|---|---| +| `$pbkdf2-sha{256,384,512}$` | verify, `needsRehash: false` | verify, `needsRehash: false` | +| `$2a$`/`$2b$`/`$2y$` | `bcryptjs.compare()`; `needsRehash: valid` | **refuse — do not invoke bcryptjs**; `{valid: false, needsRehash: false, requiresReset: true}` | +| anything else | reject without throwing | reject without throwing | + +#### Why the gate is required — the STIG, and only the STIG + +An earlier version rested on CMVP **IG 2.4.A**. **That argument is withdrawn — +it ran backwards.** IG 2.4.A scopes to functions "**within the scope of the +module**"; `bcryptjs` is not within OpenSSL's boundary, so 2.4.A never reaches +it. IG 2.4.A *example 1* in fact lists "store authentication data using MD5" +among non-approved algorithms **permitted** in approved mode where no security +is claimed of the module — subject to that example's own conditions (no +security claimed; the result is "considered unprotected plaintext"). **[V]** + +**FIPS 140-3 does not itself prohibit calling bcryptjs.** CMVP validates +modules, not applications; nothing the caller does voids OpenSSL's certificate. + +**The prohibition is application-scoped and comes from the STIG.** V-222571's +check text, in full **[V]**: + +> "If FIPS-validated cryptographic modules are **not used when generating +> hashes** or if the application is configured to use the MD5 or SHA1 hashing +> algorithm, this is a finding. +> +> **If hashing of application components has been identified in the application +> security plan as not being required and if a documented acceptance of risk is +> provided, this is not a finding.** +> +> **If the application resides on a National Security System (NSS) and uses an +> algorithm weaker than SHA-384, this is a finding.**" + +The prior draft quoted only the first sentence and labelled it "verbatim." Two +consequences of the full text: + +- A documented **risk acceptance** is an available path. We are not taking it, + but an assessor knows it exists. +- **On NSS, `PASSWORD_HASH_ALGORITHM=sha256` is itself a finding.** §9 must + carry that warning. + +`bcryptjs.compare()` *generates* a bcrypt hash in pure JS inside no validated +module — the finding condition, literally. So the accurate claim is narrow: +**an ungated bcrypt call in a deployment asserting FIPS is a CAT II STIG +finding, not a FIPS 140-3 violation.** That is why Keycloak refuses and GitLab +gates. + +**SP 800-131A does not apply** — its legacy-use doctrine covers algorithms that +were *once* approved. Bcrypt never was. **[V]** (verified for Rev 2; Rev 3 ipd +adds PBKDF language, so pin the revision when citing). + +#### Three phases, with an end + +1. **Non-FIPS operation** — lazy rehash on login. Transparent, no disruption. +2. **Cutover** — a migration **invalidates** every remaining `$2%` credential: + overwrite `encryptedPassword` with an unusable sentinel *and* set + `forcePasswordChange`. Setting the flag alone does **not** convert a hash — + the user would still have to log in (refused under FIPS) and submit their + old password (verified via bcrypt). The prior draft's phase 2 did not produce + the terminal state it claimed. +3. **FIPS enablement** — no bcrypt hashes remain, so the gate never fires in + normal operation. + +The sentinel is any value `hashPassword` cannot produce; `verifyPassword` +rejects it on the unknown-format path with no new branch. + +**Fresh installs have no transition** — provided the seeder is fixed (§4 site 8). + +#### Recovery is a prerequisite, and it already exists + +Heimdall has **no self-service password reset** — no forgot-password flow, no +reset token, anywhere in backend or frontend **[V]**. And admins **cannot edit +their own account without supplying their password** — `casl-ability.factory.ts:65`: +```ts +// Force admins to supply their password when editing their own user. +cannot(Action.Manage, User, {id: user.id}); +``` +**[V]** So a single-admin deployment that enables FIPS before cutover would be +locked out with no in-application remedy. + +**The remedy is `heimdall-cli reset-password`**, which exists today at +[github.com/mitre/heimdall-cli](https://github.com/mitre/heimdall-cli). It +writes directly to the database via `psql` with parameterized binding, and sets +`passwordChangedAt`/`forcePasswordChange` — correct for a genuine reset. **[V]** + +**But it hashes with bcrypt (cost 14)**, so after this change it would write a +credential the FIPS-gated server refuses — turning the break-glass tool into a +break-glass *trap*. Fixing it is a **blocking cross-repo dependency**, not a +follow-up. See §14. + +### 4. Eight call sites + +The prior draft enumerated seven and omitted the seeder — the one that runs on +every deployment. Line numbers verified at `2e1649c9e` **[V]**. + +| # | File | Line | Function | Change | +|---|---|---|---|---| +| 1 | `users.service.ts` | 66 | `create()` | → service hash | +| 2 | `users.service.ts` | 89 | `update()` | → service hash | +| 3 | `users.service.ts` | 126 | `remove()` | → **pure** `verifyPassword`, `.valid` only | +| 4 | `authn.service.ts` | 53 | `validateUser()` | verify **+ CAS rehash** — primary migration path | +| 5 | `authn.service.ts` | 75 | `validateApiKey()` | verify + CAS rehash | +| 6 | `authn.service.ts` | 208 | `testPassword()` | → **pure** `verifyPassword` (see below) | +| 7 | `apikey.service.ts` | 43 | `create()` | → service hash | +| **8** | **`seeders/20200514154327-create-administrator.js`** | **56** | admin bootstrap | **`bcrypt.hashSync` → compiled pure function, awaited** | + +**Site 8 matters most.** `cmd.sh:4` runs `db:seed:all` on **every container +start**, and the RPM path runs the same seeder. Unchanged, **every fresh install +provisions its administrator — the highest-privilege account — with a bcrypt +hash on day one**, in a change whose purpose is to eliminate them. With +`ADMIN_USES_EXTERNAL_AUTH=true` it may never migrate. It is also CommonJS, +synchronous, and runs outside both Nest DI and the TypeScript build. + +**Exact require path [V]:** rootDir is inferred across `src/`, `db/`, `config/`, +so `src/crypto/password.ts` compiles to **`dist/src/crypto/password.js`** — note +the `src/` segment. From `seeders/*.js` that is `require('../dist/src/crypto/password')`. +`.sequelizerc` already depends on build output, and `cmd.sh` runs under `set -e`, +so a bad require is a **boot crash loop, not a degraded seed**. An AC must +assert the seeder resolves in the built image, and `password.ts` must stay +dependency-free so the inferred layout cannot shift. + +**AC:** a fresh install, zero logins → `bcrypt_remaining = 0`. + +#### Two structural constraints + +**`testPassword` is called unbound [V].** `users.service.ts:79` does +`await AuthnService.prototype.testPassword(...)`. This works *only because* +`testPassword` uses the module-scope `compare` and never touches `this`. Making +it `this.passwordService.verify(...)` throws `TypeError`, and `UsersService` +cannot inject `AuthnService` (circular). **Site 6 must use the pure function.** + +**No persistence method can honor the lifecycle constraint [V].** +`usersService.update()` unconditionally sets `passwordChangedAt` and +`forcePasswordChange`. Two narrow writers are required — +`UsersService.updateEncryptedPassword()` and an `ApiKeyService` equivalent — +following the existing `updateLoginMetadata`/`updateUserSecret` pattern. + +### 5. Module structure + +Only `hashPassword` needs configuration; `verifyPassword` reads parameters from +the hash. Hence: + +- `apps/backend/src/crypto/password.ts` — **pure functions**. Usable from the + seeder and scripts with no DI container (§4 sites 6 and 8 both require this). +- `apps/backend/src/crypto/password.service.ts` — Nest injectable reading + `ConfigService`. +- `apps/backend/src/crypto/crypto.module.ts` — **required**: `ConfigModule` is + *not* `@Global()` **[V]**. + +**As built (2026-08-14).** §10 and §12 added their own files to the same module; +this is the current layout, and `crypto.module.ts` is self-contained (it provides +the models and the gate service) so DI changes do not ripple through spec modules: + +- `fips.ts` — §10's `assertFipsMode`, with the same injectable-`getFips` seam. +- `hash-write-decision.ts` — §12's derived write gate and + `SUPPORTED_HASH_MARKER_VERSION`, the write **epoch** integer. It is deliberately + not a package version: those are unreliable here (root is `0.0.0`, backend and + frontend skew) and semver strings compare wrongly as text. +- `hash-write-gate.service.ts` — plants the marker via `findOrCreate` on the first + PBKDF2 write (§12 mechanism 2) and implements mechanism 3's refusal to start when + the database records an epoch newer than this build understands. +- `hash-migration-marker.model.ts` — the marker row itself; the migration is + `apps/backend/migrations/20260810133411-create-hash-migration-marker.js`. + +```ts +// Declared with `type`, not `interface` — the lint config enforces +// @typescript-eslint/consistent-type-definitions: 'type'. +export type PasswordHashAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export type PasswordHashOptions = { + algorithm?: PasswordHashAlgorithm; // default 'sha512' + iterations?: number; // default 600000 +}; + +export type PasswordVerifyResult = { + valid: boolean; + needsRehash: boolean; // required, always present + requiresReset?: boolean; // bcrypt encountered while FIPS on +}; + +export function hashPassword( + password: string, + options?: PasswordHashOptions +): Promise; + +export function verifyPassword(args: { + hash: string; + password: string; + getFips?: () => number; // default crypto.getFips — INJECTABLE +}): Promise; + +// Also exported as built (2026-08-14): +// hashPasswordWithSalt(...) — deterministic-salt form, for the §6 validation +// vectors and libs/password-hash-vectors +// configureKdfLimiter(...) — bounds concurrent PBKDF2 work (§7); at 600k +// iterations an unbounded queue is a DoS surface +// kdfLimiterState() — {active, queued}, read by §17's health detail +``` + +**`getFips` must be injectable.** §10's `assertFipsMode` already is; without the +same seam here the document's central new behavior is untestable in non-FIPS CI. +`vi.mock('crypto')` is unusable (the module also needs real `pbkdf2`, +`randomBytes`, `timingSafeEqual`), and `vi.spyOn` works only under a namespace +import — a destructured `import {getFips}` compiles to a non-writable binding +under swc. **AC: namespace import, never destructured.** + +**The FIPS-refusal test must assert non-invocation**, not just the return value. +V-222571 fires on *generating* a hash, so an implementation that calls +`bcryptjs.compare()` and discards the result would pass a return-value +assertion while committing the exact finding. + +### 6. Input validation — exact sequence + +Each item is a verified trap **[V]** (confirmed by execution on Node 24): + +1. Reject non-string/empty. **`''.split('$')` is `['']`**, so a `parts[0] === ''` + check *passes* for the empty string — the field-count check catches it. These + are `AND`, not alternatives. +2. `split('$')` must yield **exactly 5** parts, and `parts[0] === ''`. +3. **Strict allowlist over the full identifier** — + `Set(['pbkdf2-sha256','pbkdf2-sha384','pbkdf2-sha512'])`. Never prefix-match: + `crypto.pbkdf2` accepts `md5` and `sha1`, so `$pbkdf2-md5$…` would verify. + Allowlisting only the digest still admits `$pbkdf2-sha512-md5$` via naive + splitting. The prior draft specified `$pbkdf2-sha*$` — an algorithm-confusion + downgrade in a document banning MD5. +4. **Iterations by regex only** — `/^i=([1-9][0-9]{0,8})$/`. **`parseInt('6e5')` + is `6`** (a forged hash verifies at six iterations — a 100,000× downgrade + that looks well-formed); `parseInt('600000abc')` is `600000`; + `Number('0x10000')` is `65536`. +5. **Upper bound mandatory** — reject above 10,000,000. Node permits 2³¹−1, + roughly 8.6 minutes of one libuv thread per verification; four such rows + exhaust the default 4-thread pool and take authentication down. + **No lower bound on the verify path** — see §9. +6. Decode salt and key, **re-encode and compare** (padding stripped both sides). + `Buffer.from(str,'base64')` is lenient: `'AA@@AA'` and `'A A A A'` decode to + the same three bytes as `'AAAA'`. +7. **Assert key length equals the digest's width, and salt ≥16 bytes, *before* + calling `pbkdf2`.** `keylen=0` throws an **untyped** error (`e.code` is + `undefined`) *before* any downstream guard — so the ADR's own required test + ("malformed hash rejected without throwing") cannot pass without this. A hash + claiming `sha512` with a 32-byte key would otherwise verify: silent + acceptance of a downgraded artifact. +8. Guard length before `timingSafeEqual`, which **throws** + `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH` on mismatch. Return `false`. + +**Maximum password length: 128 characters**, enforced **on hashing only** (see +§9). Two reasons: **(a) DoS** — this is Django **CVE-2013-1443** exactly ("A +password one megabyte in size... roughly one minute of computation") **[V]**; +Heimdall sets `json({limit:'50mb'})` and rate-limits only `/authn/login` at +20/min/IP **[V]**. **(b) Approved range** — policy `140sp4857.pdf` states +PBKDF2 "8-128 characters" **[V]**. + +**Critical: `authn.service.ts:109` generates a 256-character password [V].** +```ts +const randomPass = crypto.randomBytes(128).toString('hex'); +``` +It is the placeholder for **every** externally-authenticated user (OIDC, LDAP, +GitHub, GitLab, Google, Okta) and goes straight to `usersService.create()`. +A cap enforced inside the hash path would make **every external-auth user +creation throw**. **Shorten it to `randomBytes(32)`** (64 hex characters, 256 +bits — ample for a credential never used to log in) so the cap stays uniform on +every path. An exemption would be a bypass waiting to be misused. + +Removing bcrypt also removes its 72-byte truncation, so a >72-character password +is validated in full after rehash. Behaviorally correct; worth a test. + +### 7. Concurrency: compare-and-swap + +**`authn.service.ts:54` already calls `updateLoginMetadata(user)` without +`await` [V]** — a floating promise ending in `user.save()`. Adding a rehash +`save()` on the same instance gives two concurrent unawaited writes to one row. + +**The damaging interleaving:** a user changes their password (writes H2) while +an in-flight login rehashes the **old** password and writes H1′. Last-write-wins +silently reverts the change. **If that change was a response to compromise, the +compliance fix reinstates the compromise.** + +**Use `Model.update`, not raw SQL. [V]** The prior draft prescribed raw SQL plus +`silent: true` — but `sequelize.query()` **has no `silent` option** (zero hits in +Sequelize v6 source); it exists on `Model.update`. One call gives CAS, field +restriction, suppressed `updatedAt`, and the affected count: + +```ts +const [affected] = await this.userModel.update( + {encryptedPassword: newHash}, + {where: {id: user.id, encryptedPassword: originalHash}, + fields: ['encryptedPassword'], silent: true} +); +``` + +Zero rows means another writer won — do nothing. `silent: true` suppresses the +`updatedAt` bump so a mass migration does not make every account look recently +modified. Wrap in try/catch: **a failed rehash must never fail an otherwise +successful login.** + +**The rehash must not mutate the Sequelize instance.** Assigning +`user.encryptedPassword = newHash` to keep the object coherent puts that field +into the in-flight `updateLoginMetadata` `save()` — **outside** the CAS +predicate, recreating the exact revert this prevents. + +The same un-awaited pattern exists at `apikey.service.ts:44` **[V]**. + +#### Lifecycle fields + +`user.model.ts` declares `forcePasswordChange` (55) and `passwordChangedAt` (68) +**[V]**. A rehash changes only the stored representation. Writing +`passwordChangedAt` would silently reset the password-expiry clock for every +migrating user — a security regression introduced by a compliance fix. + +**Test scope, corrected.** The prior draft required asserting `lastLogin`, +`loginCount`, and `updatedAt` unchanged. **That AC is unsatisfiable on the path +it protects** — `updateLoginMetadata` changes all three on every successful +login by design. Assert **`passwordChangedAt` and `forcePasswordChange` +unchanged**, `encryptedPassword` changed and now `$pbkdf2-`-prefixed, verified +after `await user.reload()` — not against the in-memory instance. + +**Known wrinkle [V]:** migration `20200417145649` creates `passwordChangedAt` as +`Sequelize.STRING` while the model declares `DataType.DATE`, and +`synchronize: true` outside test means a synchronize-built DB gets `DATE` and a +migration-built one `VARCHAR(255)`. The test must compare type-agnostically. +Pre-existing; documented, not fixed here. + +### 8. Known limitation: iteration upgrades do not propagate + +A PBKDF2 hash always returns `needsRehash: false`, even when its stored `i=` is +below policy. **This is the Grafana failure mode this ADR criticizes**, and it +is a deliberate trade — reading parameters from the hash is what makes iteration +changes non-breaking. Recorded so a maintainer does not "fix" it accidentally. + +### 9. Configuration + +| Variable | Type | Default | Notes | +|---|---|---|---| +| `PASSWORD_HASH_ALGORITHM` | `sha256\|sha384\|sha512` | `sha512` | **NSS deployments must not use `sha256`** — V-222571 makes anything weaker than SHA-384 a finding | +| `PASSWORD_HASH_ITERATIONS` | int ≥100000 | `600000` | | +| `PASSWORD_MIN_LENGTH` | int | `15` | already read by heimdall-cli | +| `PASSWORD_MAX_LENGTH` | int ≤128 | `128` | §6 cap | +| `PASSWORD_REQUIRE_CLASSES` | int | `4` | already read by heimdall-cli | +| `PASSWORD_MAX_CONSECUTIVE` | int | `3` | already read by heimdall-cli | +| `PASSWORD_KDF_CONCURRENCY` | int ≥1 | `2` | §11 limiter — max concurrent KDF ops; bounded queue rejects overflow to the generic auth failure | +| `FIPS_MODE` | boolean | unset | assertion + fallback gate | +| `PASSWORD_HASH_WRITE_ENABLED` | boolean | see §12 | rollout gate | + +Out-of-range values **throw at startup**; they do not clamp silently. + +**Floors and caps apply to hashing only, never verification.** The prior draft +contradicted itself — §6 required stored iterations within `[100_000, +10_000_000]` during the *verify* parse while §9 stated the floor "applies to +hashing only... or users are locked out." A user hashed under an earlier +`PASSWORD_HASH_ITERATIONS=50000` was simultaneously rejected and required to +succeed. **Verification enforces only the upper bound** (the DoS guard) plus a +sanity floor of 1000, the module's own documented minimum **[V]**. The same +applies to `PASSWORD_MAX_LENGTH`: capping on verify would lock out any user +whose password exceeds it. If an oversized password reaches the rehash path, +**skip the rehash and log it** — never fail the login (§7). + +**Configurable complexity is now in scope, and neither side supports it yet.** +`libs/password-complexity` hardcodes its rules, and so does the Go +`heimdall-cli` — **[V]**. Only the *retired Python* CLI read +`PASSWORD_MIN_LENGTH` / `PASSWORD_REQUIRE_CLASSES` / `PASSWORD_MAX_CONSECUTIVE` +from `backend.env`; that capability was lost when the CLI was rewritten in Go, +and an earlier draft of this ADR wrongly attributed it to the current binary. + +So the two implementations disagree about what a valid password is **by +duplication**, not by configuration drift: each hardcodes its own copy of the +rules, with nothing keeping them aligned. That is the same class of bug as the +hash format, and it needs fixing on both sides — the variable names above are the +contract, and **teaching the Go CLI to read them belongs on §14's blocking +cross-repo list**, not to a later phase. + +### 10. FIPS mode on RHEL — the prior draft had this backwards + +**Red Hat's Node actively discourages `--force-fips`. [V]** Verified by running +the UBI9 image: + +``` +$ node --force-fips -p 'require("crypto").getFips()' +ERROR: Using options related to FIPS is not recommended, + configure FIPS in openssl instead. +``` + +The RHEL model is **host FIPS mode → OpenSSL enables → Node inherits**, not an +application flag. The prior draft built a three-layer design around +`--force-fips` — a `start:fips` script, a launcher preflight, and systemd +`StartLimitBurst` hardening to survive the resulting crash loop. **On RHEL none +of that is correct**, and the 2023 `fips_compliance` branch's `start:fips` was +wrong for the same reason. + +**This simplifies the epic.** No preflight, no `start:fips`, and the crash-loop +blocker disappears because the flag is never passed. `--force-fips` remains +documented **only** for non-RHEL deployments running stock Node with a manually +configured provider. + +**The startup assertion becomes more important, not less** — with no flag +forcing the issue, it is the only thing between us and silent non-FIPS +operation. This is GitLab's Workhorse failure: it shipped without the `fips` +build tag and `fips.Enabled()` returned **false with no error**. + +`assertFipsMode({fipsMode, getFips})` must be **exported and injectable** — +`bootstrap()` in `main.ts` is not exported and cannot be unit-tested **[V]**. +When `FIPS_MODE` is unset, **log loudly at boot that no assertion was +performed**; silence is how the Workhorse class of failure survives. + +**Never call `crypto.setFips()`** — under `--force-fips` it triggers a native +`CHECK()` that **aborts the process**; it does not throw. **[V]** + +Two error families must not be conflated: `ERR_OSSL_EVP_UNSUPPORTED` is an +OpenSSL 3 legacy-provider problem, *not* FIPS; `EVP_DigestInit_ex:disabled for +FIPS` is a real denial. + +### 11. Performance — measured, and it inverts the prior rating + +The prior draft asserted "≈ bcrypt cost 14 (~200-400 ms)" and rated the change a +Low/Low performance *regression*. **Both wrong.** Measured on Node 24 — **on a +dev laptop; superseded for capacity planning by the on-target block below +(2026-08-08), kept for the bcrypt-vs-PBKDF2 ratio it demonstrates:** + +| Operation | Latency | Throughput | Event-loop lag | +|---|---|---|---| +| `bcryptjs` compare cost 14 (**current production**) | **1120 ms** | 0.9/sec | 788 ms | +| PBKDF2-SHA512 @600k (**this ADR**) | **145 ms** | 20/sec | 1.4 ms | +| PBKDF2-SHA512 @220k (OWASP floor) | ~55 ms | ~50/sec | — | + +**A 7.7× latency and 22× throughput improvement.** The prior draft buried its +own strongest justification. + +**The cost it failed to document:** `crypto.pbkdf2` dispatches to the **libuv +threadpool (default 4)**. Throughput pins at ~20 auth/sec *regardless of +concurrency*, and the pool is shared with `fs`, `dns.lookup`, and `zlib` — +`fs.readFile` went **1.16 ms → 337 ms** with 8 PBKDF2 operations queued. +"Async, does not block the event loop" is true but misleading. `UV_THREADPOOL_SIZE` +must be set — and it is **not an application env var**; libuv reads it at first +threadpool use, so it belongs in the Dockerfile, `cmd.sh`, or the systemd unit. + +**600k is well supported.** OWASP's floor for PBKDF2-SHA512 is 220,000, and its +guidance now explicitly recommends "600,000 or more" in FIPS-140 contexts — so +the chosen value sits on the recommendation, not above it. It remains defensible +at 220k if latency matters more, but 600k is only safe if `UV_THREADPOOL_SIZE` is +raised and a global KDF concurrency limit lands.** Keeping 600k while addressing +neither is the one indefensible combination. Benchmark on the target RHEL +container before finalizing. *(Done — the block below is that benchmark.)* + +**On-target measurement (2026-08-08, spike `docs/research/fips-host-spike.md` +F4) — supersedes the laptop numbers above for capacity planning. [V]** +On a FIPS-mode t3.medium (1 physical core / 2 HT): + +- Single op: **594.3 ms p50 / 600.7 ms p95** (40-sample sequential) at 600k (the + laptop's 145 ms was real but ~4× optimistic for commodity cloud hardware). +- **Throughput ≈ 1.7 ops/sec × physical core** — flat across concurrency 1→32 + and threadpool 4 vs 8; two isolated containers split the same aggregate. + The bound is silicon. **Size by physical cores, never vCPUs** (burstable + instances' "2 vCPU" = 2 HT siblings of one core). +- **`fs.readFile` under SUSTAINED 8-deep KDF load: p50 12.1 s (threadpool 4) + → 4.1 s (threadpool 8)** — the starvation warning above holds on target + hardware far beyond the laptop's 337 ms, and both required mitigations now + carry measured justification (the limiter bounds how many pool slots KDFs + occupy; the larger pool cuts read-wait 3×). + +**Iteration default decided (Aaron, 2026-08-08): 600,000 stays.** The measured +p95 exceeded the spike card's 500 ms review threshold and the decision was +taken deliberately: the production deployment is SSO-dominant (Okta/Keycloak — +external-auth users never invoke PBKDF2), leaving few, privileged local +accounts as the only payers; deployments tune via `PASSWORD_HASH_ITERATIONS`; +and parameters ride in each PHC hash, so a future change needs no migration. +The API-key path — the one KDF consumer with real request volume — is being +removed from iterated hashing entirely under its own ADR (ADR-007, carded). + +**Settled design (2026-08-05).** The limiter is a hand-rolled counting semaphore +(~15 lines, zero dependencies) **inside `password.ts`**, wrapping every pbkdf2 +dispatch. It cannot live in the service layer: §5's pure-path callers (sites 3 +and 6) run in the server process too, and the seeder's bare-require constraint +forbids dependencies — so a service-layer limiter would leak, and p-limit is not +an option. Default concurrency 2 (leaves ≥2 of libuv's default 4 threads for +`fs`/`dns`), overridable via `PASSWORD_KDF_CONCURRENCY` (§9). The pending queue +is bounded (default 100); overflow rejects with a typed error the auth layer +maps to the generic failure — unbounded queueing would convert the thread- +starvation DoS into memory exhaustion, and a distinct error would leak state. +`UV_THREADPOOL_SIZE=8` is set in the Dockerfile, `cmd.sh`, and the systemd unit +— libuv reads it at first threadpool use, so it is not an application env var +and cannot go through ConfigService. + +**Login is a DoS amplification vector [V]** — 20 req/min/IP on `/authn/login` is +the only protection; no global cap, no account lockout (`loginCount` increments +only on success). + +**API keys: 600k iterations is pointless there.** The hashed value is a JWT +HS256 signature — 43 base64url characters, **256 bits of machine entropy**. +Iterated KDFs raise per-guess cost against *low-entropy human* input; brute +force from a stolen hash is infeasible at any iteration count. GitHub and Stripe +store API tokens as a single SHA-256. The path is **not** reachable +unauthenticated (`jwt.verify` gates it, and is cheap) **[V]**, and per-request +cost still *drops* 1120 → 145 ms. Recorded as a known inefficiency; changing it +is out of scope (§15). + +### 12. Rollout and rollback + +**(a) Rolling deploys.** Old and new pods share one database. A user rehashed by +a new pod then hits an old pod: `bcryptjs.compare()` returns `false` on a PBKDF2 +hash (it does not throw — verified: `compare()` short-circuits on +`hashValue.length !== 60`) **[V]**, so they get "Incorrect Username or +Password" — *intermittent* auth failure that appears to self-resolve as the +deploy completes, amplified by the rate limit turning retries into 429s. + +**API keys are worse.** `validateApiKey` serves CI and `saf` CLI — no human to +retry, silent pipeline failure. And an API key **cannot be recovered**; the +server stores only a hash of a signature it never retains. + +**(b) Version skips (air-gapped RPM).** Forward skips are safe. **Reverse is +catastrophic**, and `dnf downgrade` is one command. + +**Mechanism:** + +1. **`PASSWORD_HASH_WRITE_ENABLED`**, default `false` in release N, `true` in + N+1. When false, `verifyPassword` still reports `needsRehash` but call sites + skip the write. **Scope must be stated explicitly**: the gate covers *all* + writes (sites 1, 2, 7, 8) during the rolling window, not just rehash — + otherwise a password change or a new SSO user on an N pod is unreadable by a + pre-N pod. Consequently §4's "fresh install → `bcrypt_remaining = 0`" AC + applies from **N+1**, or the gate must be derived: enabled unconditionally on + a fresh install (no pre-N peer can exist), defaulted off only on upgrade. + Settled (2026-08-05): the **derived form** — forced on for fresh installs, + default off on upgrade — so the fresh-install AC holds from release N. +2. **A durable DB marker planted in release N**, recording that PBKDF2 writes + have begun. **Planting trigger must be defined** — at install it records + something untrue; on first write it flips during the canary while most rows + are still bcrypt. Define which, and who reads it. Settled (2026-08-05): + planted on the **first PBKDF2 write** — the marker never records something + untrue — and its readers are mechanism 3's startup version check and §17's + authenticated health detail. +3. **Enforcement is in the application, not RPM `%pre`.** The prior draft + specified a `%pre` guard; **it cannot fire on the downgrades it targets** — + on downgrade the `%pre` that runs belongs to the **older** package, built + before the guard existed. It also cannot abort the transaction, and would + need Postgres access mid-transaction on exactly the air-gapped hosts it + serves. Instead: **the application refuses to start when it reads a marker + newer than its own code version.** That works on RPM and container paths + alike, needs no scriptlet DB access, and is the only mechanism that also + catches the `pg_dump`-restore hazard. +4. **Graceful-degradation AC** — an integration test running the old verify path + against a PBKDF2 hash, asserting a clean `false`, no throw, no 500. + +**Read replicas.** The lazy rehash is a write on the login path; a lagging +replica read would return the stale bcrypt hash and rehash again — an unbounded +loop. Heimdall does not use read replicas today; recorded as an assumption. + +### 13. Dependency audit + +**Own code is clean [V]** — no `md5`/`sha1`/`createHash` in +`apps/backend/src`, `apps/backend/config`, `libs/common`, or +`libs/password-complexity`. Only `crypto.randomBytes`. `uuid` v4 only. No +`@aws-sdk/*` or `hdf-converters` in the backend. + +**Express ETag — the prior draft's diagnosis was wrong. [V]** `etag/index.js:47` +uses **`createHash('sha1')`**, not MD5; the empty-body constant +`2jmj7l5rSw0yVb/vlWAYkK/YBwk` is exactly `sha1('')`. SHA-1 **is** approved in the +OpenSSL 3 FIPS provider, so this likely does **not** break under plain FIPS. +*Two reviewers asserted MD5; both were wrong — the source read is definitive.* + +**But verify under `FIPS:STIG`**, whose permitted hash list is SHA-2/SHA-3 only. +If it does break, use a **SHA-256 custom generator**, never `app.set('etag', +false)` — Heimdall serves large HDF JSON payloads, so losing 304 revalidation +costs more than rehashing. `serve-static` uses stat-based tags and no hash. + +**`pg` MD5 auth breaks under FIPS** — see §16. + +**Runtime audit required.** Static analysis cannot see transitive dependencies. +Unaudited: `passport-google-oauth` (bundles an OAuth 1.0a HMAC-SHA1 path), +`passport-ldapauth` (SASL DIGEST-MD5 if configured), `express-session`. +Carded (2026-08-05) as `heimdall2-e25.3`, executed on the FIPS host alongside +the §15 spike — same trip, separate deliverable — and it exercises §16's +pg-against-md5-auth failure case live. + +### 14. Repository boundary and the cross-repo dependency + +This is no longer hypothetical. Current topology: + +| Repo | Owns | +|---|---| +| **mitre/heimdall2** | application, `packaging/rpm/` (imported `35d47dee3`), `libs/password-hash-vectors/` (the contract) | +| **mitre/heimdall-cli** | Go admin binary, own release pipeline, consumes the vectors | +| **mitre/saf-packaging** | cross-SAF policy, airgap/repo infrastructure, other tools | + +**Why the CLI stays separate.** Of its fifteen commands, fourteen are +deployment-domain (start/stop/status/logs/backup/restore/certs/fapolicyd/…). +Exactly one, `reset-password`, touches an app contract. Its value is being a +**static binary that works when the app is broken** — no Node, no `dist/`, no +working install. Note the history: an earlier Python CLI hashed by shelling out +to the app's `bcryptjs` — true single-implementation — and was **deliberately +replaced** one day later by the Go binary for "single binary, no Python/vendor +dependencies" **[V]**. That trade was made on purpose; the contract restores +safety without giving it back. + +**The contract: `libs/password-hash-vectors/`.** heimdall2 owns the format and +publishes versioned vectors — known password→hash pairs plus the malformed-hash +corpus (which §6's tests need anyway) and a `formatVersion` stamp. Both +implementations test against it; a mismatch is a **build failure**. + +**Blocking cross-repo work in this epic:** + +1. Add `Pbkdf2Hasher` implementing the CLI's existing `PasswordHasher` + interface — the seam is already there +2. Write PHC format, not bcrypt; **remove the bcrypt write path entirely** +3. Consume the published vectors, asserting `formatVersion` +4. Update `heimdall-cli-reset-password.1`, which documents bcrypt cost 14 +5. **Read the `PASSWORD_*` environment variables** (§9). The Go CLI hardcodes + its complexity rules, as does `libs/password-complexity` — each carries its + own copy with nothing keeping them aligned. Only the retired Python CLI read + them. **[V]** +6. **Restore `cmd/gen-manpages`.** It did not survive the extraction to a + standalone repository, and the spec's `%files` claims + `%{_mandir}/man1/heimdall-cli*.1*` while the CLI's `.gitignore` excludes + `man/man1/` as generated output. So the pages are neither committed nor + generatable — the RPM cannot currently build. `packaging/rpm/Makefile`'s + `man:` target fails with an explicit message rather than a confusing + `go: cannot find main module`. **[V]** + +Until (1)–(3) land, enabling the FIPS gate turns break-glass into a trap. (6) +blocks the RPM build outright. + +**RPM packaging is now in-tree**, so §10's deployment changes and §16's Postgres +detection are ordinary cards in this repo rather than cross-repo coordination. + +#### The build pipeline carries the contract + +The import (`35d47dee3`) left four references pointing at saf-packaging's layout, +where `heimdall-server/`, `heimdall-cli/`, and `scripts/` were siblings. Repaired +in `d2fca993e` and `7fb61561c`: + +| Was | Now | +|---|---| +| `scripts/fetch-source.sh` downloaded a release tarball | `git archive v$(VERSION)` from the local repository — no network, so airgapped builds work without a mirror | +| `CLI_DIR := ../heimdall-cli` (sibling path) | clone at **`HEIMDALL_CLI_REF`** | +| `man:` used the same broken sibling path | generated from the *same* pinned checkout as the binary, so pages cannot drift from the commands they document | +| `NAME`/`VERSION` from `rpmspec` (RHEL-only) | POSIX `sed`; the repository `VERSION` file is canonical and `check-version` fails the build if the spec disagrees | + +**`HEIMDALL_CLI_REF` is where the §14 contract actually lives.** It records +exactly which CLI a given RPM shipped. Release builds must pin a tag. Combined +with `libs/password-hash-vectors/`'s `formatVersion`, a CLI that cannot produce +the current hash format fails the build rather than shipping a break-glass tool +that writes credentials the server refuses. + +Two bugs were fixed in passing: `CLI_COMMIT` was `git rev-parse HEAD` evaluated +in the *packaging* repo, so `heimdall-cli --version` reported a heimdall2 commit +as the CLI commit; and a spec/repository version mismatch had no detection at +all — which is how the `feat/rpm-build` copy sat at 2.12.6 while the shipped one +tracked to 2.13.1. + +**CI builds the RPM** (`987bfffc9`, `.github/workflows/build-rpm.yml`) for +el8/el9 × x86_64/aarch64 on native runners, smoke-tests installation in a clean +container with no build dependencies present, and attaches the artifacts to +GitHub Releases with `actions/attest-build-provenance`. heimdall2 published no +downloadable release assets before this. + +Note the RPM cannot use the usual SRPM-as-handoff idiom: `Source15` is a +pre-built architecture-specific CLI binary, so an x86_64 SRPM cannot build an +aarch64 RPM. Each architecture does a full native build, with sources identical +by construction — same tag, same `HEIMDALL_CLI_REF`. + +**Not yet build-verified.** `rpmspec` does not exist on macOS and hosted runners +are not FIPS-enabled, so the RPM has never actually been built from its new home. +That verification gates removing saf-packaging's copies (`heimdall2-30c.5`), and +belongs on the same FIPS-host trip as the §15 `[U]` questions. + +#### Distribution + +RPMs are built in two places, for two different reasons. + +**Fedora COPR is the build farm.** Project `mitresaf/saf` +([copr.fedorainfracloud.org/coprs/mitresaf/saf](https://copr.fedorainfracloud.org/coprs/mitresaf/saf/), +ID 249476) created 2026-07-30 with `epel-8` and `epel-9` chroots on x86_64 and +aarch64. **[V]** As an open-source project we get real `mock` chroots on native +multi-architecture builders at no cost — an authentic EL build environment +rather than the approximation a container-on-Ubuntu CI job provides. The +precedent is directly relevant: Caddy, which this RPM already `Recommends:`, +ships via `dnf copr enable @caddy/caddy` as its official RHEL channel. + +Three operational facts, verified: + +- **`enable_net` must be on.** It has defaulted to *false* since June 2022, and + `%build` runs `yarn install`. Every build fails without it. Confirmed set on + the project (`enable_net: True`). **[V]** +- **COPR is not durable storage.** `mitresaf/saf` uses the Pulp backend, which + retains only the **5 most recent successful builds per package**; content is + also removed 180 days after a chroot reaches EOL. **GitHub Releases is + therefore the archival home**, not an alternative to it. **[V]** +- **COPR signs with its own per-project key**, published at + `results/mitresaf/saf/pubkey.gpg` — which does not exist until the first + successful build. **[V]** + +That last point resolves a real inconsistency: `saf.repo` currently sets +`gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-SAF-MITRE` while +`heimdall-server.repo` points at COPR's `pubkey.gpg`. **These disagree, and both +reference a project namespace (`@mitre/saf`) that never existed.** Either users +verify COPR's key, or artifacts are re-signed with the MITRE key on the way to +GitHub Releases. Both files must be corrected once the first build publishes a +key. + +**Signing, when it happens, uses RSA — not ed25519.** RHEL 9 ships rpm 4.16, which predates EdDSA +verification support; EdDSA-signed RPMs sign but will not install (rpm#1877 +documents the behaviour on rpm 4.17/openSUSE). The key +should be published over HTTPS at a MITRE URL *and* shipped inside a +`heimdall-server-release` RPM to `/etc/pki/rpm-gpg/`, which is the pattern that +survives air-gap. **[U]** — the RSA/EdDSA constraint is verified; whether MITRE +wants detached GPG signatures in addition to COPR's is a security-team decision, +not an engineering default. + +**EPEL proper is not a viable target.** Not because of bundled `node_modules` — +Fedora made npm bundling the default in F34 — but mechanically: Koji builds are +network-isolated and `%build` runs `yarn install --frozen-lockfile`. Submitting +would require vendoring `node_modules` into the source tarball and generating the +`Provides: bundled(npm(...))` manifest the spec explicitly declines to produce, +then clearing first-package sponsorship. Quarters, not weeks. **[V]** + +**The air-gapped bundle matters more than the online repo.** DoD sites mirror +internally regardless, so `airgap/build-bundle.sh` (createrepo_c output plus the +key and a `file:///` `.repo`) is the artifact most deployments actually consume. +RKE2 is the closest analogue — FIPS/government focus, an online repo plus air-gap +tarballs in GitHub Releases — and it is worth following. + +**Resulting layers:** + +| Layer | Mechanism | Why | +|---|---|---| +| Build farm | COPR `mitresaf/saf` | free native multi-arch, real mock chroots | +| Durable artifacts | GitHub Releases | COPR retention makes this mandatory | +| Online convenience | COPR repo | `dnf copr enable mitresaf/saf` for current-version users | +| Air-gapped | `airgap/build-bundle.sh` | the path the target deployments use | +| CI | `.github/workflows/build-rpm.yml` | PR-time proof the spec builds and installs | + +**Sequencing note.** COPR generates the signing key and repository tree on first +successful build, so the `.repo` corrections above are blocked until one lands. +A first manual build (`copr-cli build mitresaf/saf `) doubles as the +verification that the packaging move works — in a real mock chroot on both +architectures, which is stronger evidence than the OL9 VM would provide. + +### 15. Platform + +`Dockerfile:1` sets `ARG BASE_CONTAINER=registry.access.redhat.com/ubi9/nodejs-22-minimal:1` +**[V]**. Verified by running it: + +- **`shared_openssl: true`** — Node 22.23.1 against OpenSSL 3.5.5, shared build, + so it uses system OpenSSL rather than a bundled copy **[V]** +- **`fips.so` is present** at `/usr/lib64/ossl-modules/` (1.3 MB), and the + provider identifies as **"Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider", + version `3.0.7-cda111b5812c30d4`** **[V]** + +**That version is NOT the validated one, and it never will be.** Certificate +#4857 validates version **`3.0.7-395c1a240fbfffd8`**; the string the running +container reports appears in no CMVP record. This is not a mistake to fix by +pinning — it is structural. Red Hat validated one specific openssl build and has +shipped security errata since, so **any current UBI image carries a newer, +non-validated maintenance build of the same module**, essentially always. + +Pinning the literally-validated build is the strictly worse option: it forgoes +every CVE fix issued since validation, which contradicts this ADR's own +security-over-compliance tiebreaker. The remedy is **documentation, not pinning**: + +1. Cite certificate **#4857** and its validated version `3.0.7-395c1a240fbfffd8` +2. **Disclose the deployed build** as a Red Hat maintenance build of that module +3. Self-affirm the operational environment under CMVP Management Manual **§7.9** + (Level 1 porting — see §14) +4. Cite the FedRAMP *Policy for Cryptographic Module Selection and Use* — the + same document this ADR already cites for FRR8 — which directs CSPs to + prioritize security patching over remaining on a frozen validated binary + +This posture must be verified on the FIPS-host trip alongside the `[U]` items +below. In particular, **whether the containerized provider activates at all +without its own `fipsmodule.cnf` is upstream of any version-citation question** — +if it does not activate, the version discussion is moot. +- **`fipsmodule.cnf` is absent — and unnecessary. RESOLVED [V]** (spike, + 2026-08-08, `docs/research/fips-host-spike.md` F1): on a FIPS-enabled RHEL 9.4 + host, the container reports `getFips() === 1` with no `fipsmodule.cnf` + anywhere in the image — activation is pure host inheritance via + `/proc/sys/crypto/fips_enabled`, exactly the §10 model. The provider is + active and self-identifies as `3.0.7-cda111b5812c30d4` (F2), confirming the + maintenance-build posture above. + +**Four constraints:** + +1. **It is an overridable `ARG`.** `--build-arg BASE_CONTAINER=node:22-alpine` + silently produces exactly the compliance theater this ADR warns against. + Consider failing the build if the base is not UBI. +2. **A UBI image carries no validation of its own.** Red Hat's position: + products are not FIPS validated, cryptographic components are. **A FIPS-mode + RHEL host is a hard requirement.** +3. Node **never** reads `/proc/sys/crypto/fips_enabled`; RHEL's *OpenSSL* does. + That runtime check **is** the inheritance mechanism. +4. **The FIPS provider ships as a separate RPM [V]; "since 9.2" is refuted + [V]** (spike F3): `fips.so` is owned by + `openssl-fips-provider-3.0.7-2.el9.x86_64`, whose changelog shows initial + packaging 2024-01-24 — impossible for 9.2 (GA May 2023). The positive + placement ("9.4") is an inference from the date **[U]**, per the spike's + own grading; the load-bearing facts are the separate RPM and the refutation. + +Stock nodejs.org binaries **do** support FIPS — `BUILDING.md`: "It is not +necessary to rebuild Node.js to enable support for FIPS" **[V]** — but require +`openssl fipsinstall`, `OPENSSL_CONF`, and `OPENSSL_MODULES` (documented in +`doc/api/crypto.md`, **not** BUILDING.md). + +**Operational environment.** CMVP **Management Manual §7.9**: a user "may +perform post-validation porting of a module and affirm the module's continued +validation compliance," and a Level 1 software module "will remain compliant +with the FIPS 140-3 validation on any general-purpose platform/processor that +supports the specified operating system... or another compatible operating +system." CMVP "makes no statement as to the correct operation of the module... +when ported and executed in an OE not listed on the validation certificate." +**[V]** So an untested OE **does not void validation** at Level 1 — the customer +self-affirms. + +### 16. PostgreSQL + +**Narrower than the prior draft implied [V].** `docker-compose.yml:3` pins +**`postgres:17`**; Postgres 14+ defaults to `scram-sha-256`. Exposure is RHEL 8 +AppStream (Postgres 13) and pre-existing customer databases. + +**And the prior remediation did not address the case it identified.** +`POSTGRES_HOST_AUTH_METHOD`/`POSTGRES_INITDB_ARGS` are Docker-image variables +that take effect **only during `initdb` on an empty data directory**. Even +`password_encryption = 'scram-sha-256'` affects only passwords set *after* the +change; existing roles keep their `md5…` verifier in `pg_authid`: + +```sql +ALTER SYSTEM SET password_encryption = 'scram-sha-256'; +SELECT pg_reload_conf(); +ALTER ROLE heimdall WITH PASSWORD ''; -- rewrites the verifier +-- then flip pg_hba.conf md5 → scram-sha-256 and reload +SELECT rolname, left(rolpassword, 14) FROM pg_authid WHERE rolname = 'heimdall'; +``` + +Add the `pg_authid` check to `packaging/rpm`'s setup detection so an operator is +warned *before* the app fails to connect. + +### 17. Observability + +**The prior removal criterion — "zero rows across all deployments" — is +unsatisfiable.** MITRE ships to air-gapped customers; the vendor never sees +their tables. + +- **Log every rehash** at `info` via the existing Winston logger: user id, + `from: bcrypt`, `to: pbkdf2-sha512`, iterations. Since §7 forbids touching + `passwordChangedAt`, nothing else records that a credential converted. +- **A `/health` endpoint.** None exists **[V]**. Split it: unauthenticated + liveness returning `{status, version}` only; **authenticated** admin detail + for `fips`, `passwordHashWriteEnabled`, and `bcryptRemaining`. The counts are + a full scan of `Users` — a readiness probe that scans a user table every few + seconds is a self-inflicted outage, and publishing migration state + unauthenticated is a disclosure decision. Note `app.controller.ts:11` already + exposes an unauthenticated `/server` endpoint — the right thing to compare + against. +- **Progress query covering both tables** — the prior draft's covered `Users` + only, so `bcrypt_remaining = 0` could be true while every `ApiKeys.apiKey` + row was still `$2b$`: + +```sql +SELECT count(*) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS bcrypt_remaining, + count(*) FILTER (WHERE "encryptedPassword" LIKE '$pbkdf2-%') AS pbkdf2_migrated, + max(age(now(), "lastLogin")) FILTER (WHERE "encryptedPassword" LIKE '$2%') AS oldest_unmigrated +FROM "Users"; +``` +Plus the `ApiKeys` equivalent. Ship as `heimdall-cli report`, not a wiki snippet +air-gapped operators cannot reach. +- **Admin UI** — per-user legacy-hash badge, bulk force-password-change, and + **bulk API-key invalidation** (keys are *regenerated*, not reset). + +**Admin surface, settled design (2026-08-05)** — built on plumbing that already +exists, verified by code read: + +- **`passwordHashScheme`** (`bcrypt` | `pbkdf2` | `invalidated`) — derived from + the stored prefix via the crypto module's shared constant, never persisted, + and exposed **only on the admin list response**. The self-view and every + unauthenticated surface omit it — the Risks table's enumeration rule. Rendered + as a badge column in `UserManagement.vue`'s existing v-data-table. +- **`POST /users/force-password-change`** — admin-only; `{userIds}` or + `{scheme: 'bcrypt'}`; one SQL UPDATE. `forcePasswordChange` is already plumbed + end-to-end (`update-user.dto.ts:40` accepts it, `users.service.ts:103` applies + it) **[V]** — the endpoint adds bulk, not new semantics. +- **Bulk legacy API-key invalidation** — admin-only **deletion** of + `$2%`-prefixed `ApiKeys` rows; regeneration is the only recovery, per this + section's own rule. +- **A fourth "Migration" admin tab** (`Admin.vue` already hosts Users / Groups / + Statistics tabs **[V]**) showing FIPS state, write-gate state, both tables' + counts, and the two bulk actions — fed by the authenticated migration-status + endpoint, so the tab and the operator query share one source. +- The `/health` split concretely: **`GET /health`** (unauthenticated liveness, + `{status, version}` only) and **`GET /health/details`** (JwtAuthGuard + CASL + admin, following `StatisticsController`'s existing pattern **[V]**). + +**Endpoint policy, ratified 2026-08-10 (Aaron, health-endpoint review against +Kubernetes probe guidance, Spring Boot Actuator's `show-details: +when-authorized` pattern, and the Azure Health Endpoint Monitoring pattern) — +this supersedes the prior bullet's naming:** + +| Purpose | Endpoint | Auth | Consumer | +|---|---|---|---| +| startupProbe / livenessProbe / LB target check / systemd smoke | `GET /health` | none | kubelet, load balancers, uptime monitors | +| readinessProbe / compose healthcheck | `GET /health/ready` | none | kubelet, docker-compose (Terminus DB ping — the one hard dependency, nothing else) | +| migration/ops report | `GET /admin/migration-status` | admin (JwtAuthGuard + CASL ViewStatistics) | Migration tab, operators | +| login-page bootstrap | `GET /server` | none | frontend (pre-existing contract) | + +- **Renamed:** the migration report moved from `/health/details` to + `/admin/migration-status` — it is an admin report, not a health check, and + the old name invited probing it. The old path returns 404; nothing consumed + it before the rename. +- **Liveness carries no dependency checks** (a DB outage must never restart + app pods) and **readiness checks only the hard dependency** (Postgres); + optional integrations (Splunk, Tenable) never gate either. +- **`version` stays on unauthenticated `/health`**: the frontend bundle + already ships the exact version publicly (About modal), so removing it here + alone changes nothing real; revisit only as a two-surface change if the + accreditation posture demands it. +- **The migration report is NEVER probed** — its counts are full table scans, + uncached by design. +- **Deployment rule:** `/health/*` is probed from inside the boundary and not + routed through the public ingress/load-balancer path (documented in the + deployment runbook); a separate management port is deferred until a + `/metrics` endpoint exists. +- Post-cutover recovery needs no new code: an admin sets a temporary password + through the existing `UserModal` admin path (which skips currentPassword) and + `forcePasswordChange` compels rotation at next login — documented in the + deployment runbook. + +**Restated removal criterion:** no earlier than N+3, and only after +`bcrypt_remaining = 0` across **both tables** is confirmed via the health +endpoint, or a forced-reset release has shipped. + +## STIG and control mapping + +| Rule | Severity | Requirement | Status | +|---|---|---|---| +| **V-222542** | CAT I | Salted iterated hash; MD5 prohibited. **No FIPS mention.** CCI-004062 | Already satisfied; remains so | +| **V-222571** | CAT II | FIPS-validated modules **when generating hashes** | Satisfied once §3's gate lands and legacy hashes retire | +| **V-222572** | CAT II | FIPS-validated modules for unclassified data | Same condition | +| **V-222543** | CAT I | Passwords transmitted cryptographically protected | **NOT satisfied — prior draft claimed it was.** `main.ts:39-45` *explicitly removes* `upgrade-insecure-requests`; cookie `secure` only in production **[V]**. Requires a TLS reverse proxy — deployment requirement, not an application control | +| **V-222570** | CAT II | FIPS-validated modules when **signing application components** (code signing) | **Mapping questionable** — the prior draft mapped JWT signing to a code-signing rule. Regardless, `apikey.service.ts:29` signs HS256 with an **empty-string key** when `API_KEY_SECRET` is unset, and `JWT_SECRET` is combined by **string concatenation** **[V]**. Tracked as `heimdall2-0bi`. The rule offers an AoR path we do not have | +| **V-230223** (RHEL 8) / **V-258241** (RHEL 9) | CAT I | System-wide FIPS crypto policy via `update-crypto-policies` | **Customer host responsibility.** No application change satisfies an OS rule. V-230223 is a RHEL **8** rule; our base is UBI **9** | + +**Supporting:** IA-5(1)(d) is the affirmative control **[V]**. SC-13 assessment +objects name validation certificates explicitly **[V]**. + +**Corrections to the prior draft's `[U]` list:** + +- **IA-7 — dropped. It is the wrong control.** IA-7 governs authenticating an + operator **to a cryptographic module**, not an application verifying an end + user's password. DISA's own implementation (APSC-DV-001860): "If the + application does not provide authenticated access to a cryptographic module, + the requirement is not applicable." **[V]** The correct citation is SC-13 via + CCI-002450 — implemented as **APSC-DV-002030**, which *is* V-222571. +- **V-16793 — dropped. Retired.** Zero occurrences in the current ASD STIG + (V6R4, revised 2025-09-09); superseded by the 2016 move to `APSC-DV-*` + rules. Nearest current coverage is APSC-DV-002380 (SC-4) and APSC-DV-002330 + (SC-28), both CAT II. **[V]** +- **Memory zeroization — not applicable.** FIPS 140-3 AS09.28 requires zeroising + SSPs "**within the module**"; the application is outside it. IG 9.6.A + explicitly exempts our case: "An approved hash algorithm for a CSP such as a + password that does not need to be recovered but is used to check if it matches + any other values." **[V]** No Node practice exists because the requirement was + never scoped there — the HTTP body parser produces a string before our code + sees it. Mention only as defense-in-depth, if at all. +- **SI-6** — Rev 5 title is "Security **and Privacy** Function Verification", + and it is **HIGH baseline only** (absent from LOW and MODERATE) **[V]**. + Defensible for a startup FIPS check; not mandatory at MODERATE. +- **SP 800-63B peppering** — **SHOULD, not SHALL**, in Rev 3 §5.1.1.2 and Rev 4 + §3.1.1.2 **[V]**. Cite **Rev 4**; Rev 3 was withdrawn 2025-08-01. We do not + pepper; recorded as a decision. Our 32-byte salt far exceeds the 32-*bit* + minimum. +- **NIST IR 8547** — still an **Initial Public Draft**, and "Deprecated after + 2030 / Disallowed after 2035" applies **only to the 112-bit row**; at ≥128-bit + strength there is no 2030 deprecation **[V]**. Cite with both qualifiers or + omit. + +## Certificates + +The prior draft cited **two wrong certificates** — the first thing an assessor +checks. **[V]**: + +| Cited | Actual | Verdict | +|---|---|---| +| #4985 "RHEL OpenSSL" | **OpenSSL FIPS Provider**, vendor *The OpenSSL Project* | Wrong vendor | +| #4754 "Red Hat FIPS 140-3 policy" | **RHEL 9 libgcrypt** | Wrong library, and **Historical**, superseded by #5366 | + +**Correct: #4857** — "Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider", +**Active**, validated 2024-10-29, sunset 2029-10-28. **#4746** covered RHEL 9.0 and went +**Historical on 2026-07-30** — do not cite it. + +The running module self-identifies as `3.0.7-cda111b5812c30d4`, which is a +Red Hat **maintenance build**, not the validated `3.0.7-395c1a240fbfffd8` (§15). +The SSP must name the certificate and its validated version, disclose the +deployed build, and self-affirm the operational environment. + +## Scope + +**In scope:** pure module + service + Nest module; migration at all **eight** +sites; FIPS-gated fallback; §6 validation; CAS writes; narrow persistence +methods; env vars including configurable complexity; startup assertion; +`/health`; rehash logging; cutover invalidation; progress reporting; +`libs/password-hash-vectors/`; `packaging/rpm` FIPS + Postgres detection; +**heimdall-cli PBKDF2 support (cross-repo, blocking)**. + +**NOT in scope:** + +1. **Changing what API keys hash** — bcrypt's 72-byte limit is why only the + signature is hashed; changing it invalidates every existing key. Needs its + own ADR and a rotation plan. §11's inefficiency is recorded, not acted on. +2. **Migrating to better-auth** (`izw`). Forward note: v3 returns a bare boolean + because better-auth's `verify` contract requires it; our richer return is + possible *because* heimdall2 has no such constraint. A future adapter + discards `needsRehash` on better-auth's path while an outer hook rehashes. +3. **Self-service password reset** — Heimdall has **no email infrastructure** + (zero `nodemailer`/SMTP anywhere) **[V]**, and outbound mail is often + unavailable in the target deployments. `heimdall-cli` plus admin UI covers + recovery. A forgot-password flow is a separate epic gated on SMTP. +4. **Removing `bcryptjs`** — required for legacy verification until §17's + criterion is met. +5. **Fixing V-222570** (empty-string JWT key, concatenated secret) — real, + verified, tracked as `heimdall2-0bi`. +6. **The `passwordChangedAt` column-type mismatch** — pre-existing (§7). +7. **Elastic-style `pbkdf2_stretch`** — exists to defeat *bcrypt's 72-byte + truncation*, which PBKDF2 does not have. (The prior draft justified excluding + it by the 15-character minimum, which is a non-sequitur.) + +## Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Rollback / mixed-version lockout | Medium | **High** | §12 — write gate, durable marker, app-side version check. API keys unrecoverable; regeneration is the only path | +| Break-glass tool writes an unusable credential | **High if unfixed** | **High** | §14 — heimdall-cli PBKDF2 support is blocking, not follow-up | +| Fresh install ships a bcrypt admin | **High if unfixed** | **High** | §4 site 8 | +| Rehash reverts a password change | Medium | **High** | §7 compare-and-swap | +| Silent FIPS bypass | Medium | **High** | §10 assertion; loud log when `FIPS_MODE` unset | +| Auth throughput ceiling / threadpool starvation | Medium | Medium | §11 — `UV_THREADPOOL_SIZE`, global KDF limiter, benchmark on target hardware | +| DoS via long password or forged iterations | Medium | Medium | §6 — 128-char cap on hashing, iteration bounds | +| Operator enables FIPS before cutover | Medium | **High** | §3 phased order; heimdall-cli break-glass; document the ordering as a hard rule | +| Transitive dependency uses a non-approved digest | Medium | Medium | §13 runtime audit | +| Dormant accounts never migrate | **High** | Low | §3 cutover + §17 bulk action. bcrypt remains strong — the gap is compliance, not security | +| `requiresReset` becomes an enumeration oracle | Medium | Medium | **Return the generic 401 to unauthenticated callers.** `local.strategy.ts` collapses every failure into one message today; distinguishing "needs reset" would tell an attacker which accounts exist *and* are dormant. Surface migration state only through the authenticated admin surface and logs | +| Timing side-channel | Low | Low | Under FIPS the refuse path does no KDF work at all, so separation is effectively infinite rather than the 7.7× in §11. Mitigation is a **dummy hash on the absent, unknown-format, *and refuse* paths**. Note: the prior draft cited Django's `harden_runtime()` — **incorrectly**; that equalizes *intra-PBKDF2 iteration* differences and cannot bridge a bcrypt-vs-PBKDF2 gap | + +## Alternatives considered + +1. **Argon2id** — OWASP's first recommendation, **not FIPS-approved**; no + revised SP 800-132 draft exists. Keycloak defaults to it and must override in + FIPS mode. The PHC format admits it later with no parser change. +2. **Hard cutover** (v3's approach) — forces a reset for every user. §3's phased + design reaches the same terminal state without it. +3. **Keep bcrypt, add `--force-fips`** — compliance theater, and on RHEL the + flag is discouraged outright (§10). +4. **Unconditional bcrypt fallback** (prior draft) — weaker than both Keycloak + and GitLab. +5. **Adopt an npm package** — no viable candidate. `@phc/pbkdf2` last published + **2018**, repo dead since 2021, no types, 13 stars. `pbkdf2-password` defaults + to **SHA-1**. Everything maintained uses a non-approved KDF or native/WASM + bindings that bypass OpenSSL. **[V]** +6. **Spring-style opt-in rehash service** — `UserDetailsPasswordService` + silently no-ops when unwired. Django's inline setter fails loudly. We follow + Django. +7. **Move heimdall-cli into this monorepo** — rejected (§14). Fourteen of its + fifteen commands are deployment-domain, its `go.mod` already declares a + top-level module path, and its value is being a zero-dependency static binary. + +## Guiding principle + +GitLab's stated tiebreaker, adopted: **when security and compliance cannot both +be satisfied, favor security.** Nothing here requires that trade — PBKDF2 at +600k is both — but it governs any future conflict. + +## References + +**Standards** — [SP 800-132](https://csrc.nist.gov/pubs/sp/800/132/final) · +[FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) · +[FIPS 140-3 IG](https://csrc.nist.gov/CSRC/media/Projects/cryptographic-module-validation-program/documents/fips%20140-3/FIPS%20140-3%20IG.pdf) +(2.4.A, 2.4.C, 9.6.A, D.N) · +[CMVP Management Manual](https://csrc.nist.gov/csrc/media/Projects/cryptographic-module-validation-program/documents/fips%20140-3/FIPS-140-3-CMVP%20Management%20Manual.pdf) +(§7.9) · [SP 800-53A Rev 5](https://csrc.nist.gov/pubs/sp/800/53/a/r5/final) +(SC-13, IA-5(1)(d), SI-6) · [SP 800-63B-4](https://pages.nist.gov/800-63-4/sp800-63b.html) · +[FedRAMP Cryptographic Module Policy v1.1](https://www.fedramp.gov/resources/documents/FedRAMP_Policy_for_Cryptographic_Module_Selection_v1.1.0.pdf) +(FRR8) · [PHC string format](https://github.com/C2SP/C2SP/blob/main/phc-strings.md) · +[OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) + +**Certificates** — [#4857](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4857) (Active) · +[#4746](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4746) (sunsets 2026-07-30) · +[#4985](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4985) (OpenSSL Project, *not* Red Hat) · +[#4754](https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/4754) (libgcrypt, Historical) + +**Implementations** — [GitLab FIPS](https://docs.gitlab.com/development/fips_gitlab/) · +[GitLab password storage](https://docs.gitlab.com/security/password_storage/) · +[GitLab #360659](https://gitlab.com/gitlab-org/gitlab/-/issues/360659) · +[Keycloak FIPS](https://www.keycloak.org/server/fips) · +[Django CVE-2013-1443](https://www.djangoproject.com/weblog/2013/sep/15/security/) · +[Django hashers.py](https://github.com/django/django/blob/main/django/contrib/auth/hashers.py) · +[phc-pbkdf2](https://github.com/simonepri/phc-pbkdf2) + +**Known breakage** — [jshttp/etag#17](https://github.com/jshttp/etag/issues/17) · +[node-postgres#1706](https://github.com/brianc/node-postgres/issues/1706) (a PR, not an issue) + +**In-repo / cross-repo** — Heimdall v3 `a52f6ceb` (`mitre/heimdall`) · +`fips_compliance` branch `cbfa40946`, `b384fd335`, `310c24a3c` · +[mitre/heimdall-cli](https://github.com/mitre/heimdall-cli) · +`packaging/rpm/` (imported `35d47dee3`) diff --git a/docs/adrs/adr-008-frontend-data-loading-policy.md b/docs/adrs/adr-008-frontend-data-loading-policy.md new file mode 100644 index 0000000000..da4708d634 --- /dev/null +++ b/docs/adrs/adr-008-frontend-data-loading-policy.md @@ -0,0 +1,358 @@ +# ADR-008: Login Must Not Block on Application Data + +**Status:** Accepted — path A (fix in this PR), ruled by Aaron 2026-08-15 +**Date:** 2026-08-15 +**Author:** Aaron Lippold +**Branch:** `feature/fips-compliant-password-hashing` +**PR charter:** `heimdall2-zv9y` +**Related:** `heimdall2-8han` (lint-sweep behavior-change ledger), `heimdall2-hl1.7.3` +(Vuex→Pinia), `heimdall2-3ys` (composables), `heimdall2-izw.16` (better-auth) + +> **Numbering.** ADR-007 is reserved by card `heimdall2-8dy` (API-key credential hashing). + +> **Revision note.** The first draft of this ADR (2026-08-15) framed the login coupling as a +> latent design flaw and proposed a frontend-wide data-loading policy. A six-agent review +> refuted that framing. **The coupling is branch-local and three days old**, several supporting +> claims were wrong, and one proposed change would have caused a regression. This revision +> re-grounds the document on `origin/master` vs. this branch. Every correction is attributed +> inline as `[review: ]`. Nothing from the first draft is silently retained. + +## Evidence standard + +Every claim cites a file and line at commit `40e29b572`, a git object, or an official framework +document. Claims about *this branch* are additionally checked against `origin/master`, because +the first draft's central error was never asking whether the cited code was upstream or +branch-local `[review: adversarial]`. + +## Context + +### What actually happened + +On 2026-08-14 no user could enter the Heimdall GUI. Two independent defects, both introduced by +the ESLint sweep on this branch, combined: + +1. **`3bdd1f146`** (autofix) alphabetized `GroupsController`, moving `@Get(':id')` above + `@Get('/my')`. NestJS registers routes in declaration order, so `GET /groups/my` resolved to + `findById('my')` and Postgres rejected `"my"` as a bigint. Fixed and closed as + `heimdall2-8han.3`. +2. **`14c13a0e9`** ("stop losing async flow in stores and routing") converted three + fire-and-forget calls into an awaited chain, making login **block** on that failing endpoint. + +**Both are the same root cause: an automated lint cleanup silently changing runtime behavior.** +That is the subject of epic `heimdall2-8han`, and `14c13a0e9` belongs in `8han.2`'s ledger. + +### The coupling is branch-local — this is the correction that reframes the document + +`git merge-base --is-ancestor 14c13a0e9 origin/master` → **not on master**. On master: + +``` +origin/master:apps/frontend/src/store/server.ts:200 this.GetUserInfo(); // unawaited +origin/master:apps/frontend/src/store/groups.ts this.FetchAllGroups(); // fire-and-forget + this.FetchMyGroups(); +``` + +The commit that changed it: + +``` +- this.GetUserInfo(); ++ // Awaited: Login resolved before the profile fetch finished, racing the ++ // post-login navigation that callers chain onto it. ++ await this.GetUserInfo(); +``` + +On master, `router.push('/')` ran while those fetches were still in flight. There was no "path +that forgot the `catch`" — there was nothing to catch until this branch made the promise +awaited `[review: adversarial]`. **The first draft's "asymmetry proves this is an oversight" +argument is withdrawn.** + +The awaits were not gratuitous: the commit messages record real defects (a race against +post-login navigation; a loading flag flipping false before either list resolved). The fix must +preserve those intents, not revert them. + +### Where the blocking chain lives + +| Step | Location | +|---|---| +| `await ServerModule.Login(creds)` | `LocalLogin.vue:194` | +| ↳ token + userID committed **and persisted to localStorage** | `store/server.ts:93-97`, `:100-103`, `:195-196` | +| ↳ `await handleLogin()` → `await GetUserInfo()` | `store/server.ts:199` | +| ↳ `GET /users/{id}`, profile committed | `store/server.ts:253-255` | +| ↳ `await FetchAllUsers()` → `GET /users/user-find-all` | `store/server.ts:261` | +| ↳ `await GroupsModule.FetchGroupData()` → `Promise.all([...])` **rejects** | `store/server.ts:262`, `store/groups.ts:121` | +| `void this.$router.push('/')` — never reached | `LocalLogin.vue:197` | + +`login()` is `try { … } finally { … }` with no `catch` (`LocalLogin.vue:193-201`). + +### Severity, corrected + +The first draft claimed "the blast radius is total." **It is not.** The token and userID are +persisted to `localStorage` *before* the failing call, and on reload `CheckForServer` reads them +back and swallows the failure (`store/server.ts:173-183`, `:185`). A user who presses F5 lands +inside the application with a valid session and a populated profile; only the group lists are +empty `[review: adversarial]`. The defect is real and user-blocking on the login path, but it is +recoverable by refresh. + +### Every entry path — and why fixing `LocalLogin.vue` is not enough + +| Entry path | Route into the chain | +|---|---| +| Local login | `LocalLogin.vue:194` → `Login` → `handleLogin` → `GetUserInfo` | +| LDAP | `LDAPLogin.vue:77` → `LoginLDAP` → `handleLogin` → `GetUserInfo` — **no `try` at all** | +| GitHub / GitLab / Google / OIDC / Okta | `LocalLogin.vue:223` `location.assign('/authn/')` → backend `redirect('/')` (`authn.controller.ts:179-181`) → **`CheckForServer:183`** → `GetUserInfo`. These never touch a `Login*` action. | +| Page reload | `router.ts:86` → `CheckForServer:183` → `GetUserInfo` | + +`ServerModule.LoginGithub` (`store/server.ts:214-222`) has **zero callers** — dead code +`[review: cartographer, architecture]`. + +**All four paths funnel through `GetUserInfo:261-262`.** That is the single choke point +`[review: cartographer]`. + +### What the prefetch serves + +`GET /users/user-find-all` returns every user in the deployment +(`apps/backend/src/users/users.controller.ts:93-100`) on every login. Its consumers are **six** +sites, not the five the first draft listed: `GroupManagement.vue:250-259`, `Users.vue:138,:219`, +`GroupModal.vue:188`, `EditEvaluationModal.vue:139`, `EvaluationMixin.ts:14`, +`RegistrationModal.vue:221` `[review: cartographer]`. + +**Withdrawn claim.** The first draft said "two of those sites already fetch for themselves… the +per-view pattern is already established." Both cited lines are **post-mutation store refreshes** +— `GroupModal.vue:216` inside `save()` after the write, `RegistrationModal.vue:221` after +`Register()` — not initial-load fetches. They are evidence *for* the store-as-cache design, not +precedent against it `[review: citations, adversarial]`. Four of the six consumers have no fetch +of their own, so Phase 2's blast radius is ~3× what the first draft stated `[review: citations]`. + +### What the frameworks and libraries say + +- **vue-router 3** offers only the guard hooks; there is no session-resolution hook and no + `router.isReady()` (that is vue-router 4). The established substitute is a **memoized + in-flight promise**. Relevant because `router.ts:86` currently awaits `CheckForServer()` on + **every navigation** `[review: framework]`. +- **NestJS** signs roles into the payload in its own guide, and this backend already does: + `authn.service.ts:93-98` signs `{email, forcePasswordChange, role, sub}` + `[review: architecture, framework]`. +- **Vue is `~2.7.16`** (`apps/frontend/package.json:94`), not 2.6 — the Composition API is + built in `[review: framework]`. +- **`@tanstack/vue-query` v5** declares peer `vue: ^2.6.0 || ^3.3.0` via `vue-demi`, so it runs + on 2.7.16 today and survives the Vue 3 + Pinia migrations unchanged. `swrv` (frozen + `v2-latest` 0.10.0, no releases 2024-2026) and `vue-promised` (last publish 2021) are rejected + on supply-chain grounds under `heimdall2-30c` `[review: framework]`. +- **better-auth** peers on `vue: ^3.0.0` and is unusable on 2.7. It would replace the + `GetUserInfo` session bootstrap at `izw.16` — but it says nothing about group lists, so it does + **not** obsolete per-view loading `[review: framework]`. + +### Two pre-existing defects found during review — carded separately, not fixed here + +1. **The 401 auto-logout is dead in a default deployment, and compares two different things.** + `main.ts:38-49` gates logout on `origin === ServerModule.externalUrl`. + - *Why it is dead by default:* `externalUrl` initializes `''` (`store/server.ts:68`) and is + populated from the backend's `EXTERNAL_URL` (`store/server.ts:113`), which ships commented + out (`packaging/rpm/heimdall-backend.env:13`). A real origin never equals `''` + `[review: security]`. + - *Why it is fragile even when set:* `origin` is derived from `error.config?.url` + (`main.ts:33-35`), and Heimdall's own API calls use **relative** URLs (`/users/{id}`, + `/groups/my`), which resolve against `location.origin`. So the left side is always the + page's origin while the right side is a **backend-configured absolute URL** — they match + only when an operator sets `EXTERNAL_URL` to exactly the browser's origin. Measured at + `3090a5f0f`. + + Either way every 401 — including a revoked token — renders a toast and the session continues. + Session termination is STIG-relevant. +2. **`GetUserInfo`'s fail-closed path is unreachable.** `axios.get` at `:253` sits *outside* the + `try` opening at `:254`, so the `catch → Logout()` whose comment says "clear their token" + cannot run for its stated purpose `[review: security, architecture]`. + +## Decision + +**Login completes when credentials are exchanged. Application data must never block it, and +must never deny a session.** + +1. **Only the JWT is session-critical — and `requiresAdmin` needs no change to make that true.** + The review stated that the guard "reads `role` from the JWT claim already signed at + `authn.service.ts:93-98`". **Measured at `3090a5f0f`, that is false:** `router.ts:99` reads + `ServerModule.userInfo.role`, i.e. the *fetched profile*. The claim is signed, but the guard + does not consult it `[review: architecture, framework, security — premise false]`. + + The proposed remedy (switch the guard to the claim) is **dropped from Phase 1**, because the + risk it dissolves does not exist once the choke-point fix is scoped correctly: + - Phase 1 releases only the **two secondary fetches** (`FetchAllUsers`, `FetchGroupData`). The + profile fetch and its `SET_USER_INFO` commit stay awaited inside `GetUserInfo`, so + `userInfo.role` is populated before any post-login navigation. There is no ordering race for + an admin deep-link to lose. + - Switching to the claim would be **strictly less current**: this ADR's own Consequences record + that the claim is stale until token expiry, whereas the profile is re-fetched per login and + reload. Trading a fresh value for a stale one to fix a race that Phase 1 already prevents is + a downgrade. + + The load-bearing half of the point stands unchanged and is why nothing here is a security + decision: the frontend guard is **UI routing only**. `JwtStrategy` re-loads the user from the + database per request, so neither the claim nor the profile ever authorizes anything. +2. **The fix goes at the choke point, not the call sites.** `GetUserInfo:261-262` stops awaiting + the secondary fetches; `groups.ts:121` stops being fail-fast. One change covers local, LDAP, + all five OAuth providers and page reload. Per-component `catch` blocks would fix one path of + four `[review: cartographer]`. +3. **`CheckForServer` must NEVER reject.** It is awaited inside the router guard (`router.ts:86`), + and the guard's own comment records that a rejection there means `next()` is never called and + navigation hangs silently. It classifies internally — connection failure / 401 / server error + — commits state, and always resolves. **The first draft's "make it distinguish errors" would + have re-created that hang for every OAuth login and reload** `[review: architecture]`. +4. **Fail-soft applies to transport and 5xx only.** A **401** on any request terminates the + session and returns to `/login`; it is never a toast. A **403** renders as an authorization + error `[review: security]`. +5. **Errors surface through the existing channel.** `main.ts:48` → `SnackbarModule.HTTPFailure` + already fires for every failed request. Call sites must **not** add their own toast — that + double-reports. The repo's existing idiom ("Fire-and-forget: HTTP failures surface via the + interceptor snackbar", `UserManagement.vue:111-113`) is the pattern `[review: cartographer]`. +6. **`CheckForServer()` already runs once per page load — no change needed, only a pinning + test.** The framework lens proposed memoizing it on the belief that `router.ts:86` pays for it + on every navigation. **Measured at `3090a5f0f`, that is false:** `server.ts:163` early-returns + on `!this.loading`; `loading` initializes `true` (`:66`) and is only ever committed `false` + (the `finally` at `:189`). `SET_LOADING(true)` is never called on this module, so the body + executes exactly once per page load and the early return covers every later navigation. The + behavior is correct and **unpinned** — Phase 1 adds the test, not the memoization + `[review: framework — claim withdrawn on measurement]`. +7. **The intended destination survives login** — including across the full page reload that OAuth + performs — with the `redirect` value validated (`^/(?!/)`) and navigated via `router.push`, + never `location.href`/`location.replace` `[review: security]`. + + > **PENDING SCOPE RULING — this is a NEW FEATURE, not a regression repair.** Measured at + > `3090a5f0f`, **no post-login redirect mechanism exists**: `router.ts:90-97` calls + > `next('/login')` and discards the intended destination, and the only `redirect` token in the + > frontend is `router.ts:77`, an unrelated vue-router catch-all. There is therefore **no + > open-redirect vulnerability to fix** — the validation the security lens specified guards a + > parameter nothing reads. Building it is a UX improvement worth having, but it is not part of + > repairing what `14c13a0e9` broke, which is the basis on which path A was ruled. Carded only + > if Aaron scopes it into this PR. +8. **Later, application data moves per-view** — using `@tanstack/vue-query`, not five hand-rolled + loading/error pairs — delivered inside `hl1.7.3`/`3ys`, which rewrite these files anyway. + +## Alternatives Considered + +### Alternative A: Do nothing +- **Why rejected:** the branch currently ships a regression that blocks GUI login. Master does + not have it. Shipping `14c13a0e9` as written is not an option. + *(The first draft rejected A for "the failure mode is intact", which was wrong — the failure + mode does not exist on master `[review: adversarial]`.)* + +### Alternative B: Revert `14c13a0e9` +- **Pros:** restores known-good master behavior exactly. +- **Cons:** reverts two legitimate fixes the commit made (the navigation race; the loading flag). +- **Why rejected:** throws away real work to undo a side effect. + +### Alternative C: Per-component `catch` in the login handlers +- **Why rejected:** covers local + LDAP only. The five OAuth providers and page reload enter via + `CheckForServer`, never through a `Login*` action `[review: architecture, cartographer]`. + +### Alternative D: Full per-view migration now +- **Cons:** four of six consumers need new fetch + loading/error UI; collides with `hl1.7.3` + and `3ys`, which rewrite `store/server.ts` and `store/groups.ts`. +- **Why rejected as Phase 1:** it is the right destination, and it is nearly free inside the + rewrites that are already planned. Note the hazard: `GroupsModule.loading` starts `true` and is + cleared only in `FetchGroupData` (`groups.ts:26,:122`), so removing the prefetch without adding + a view fetch leaves that table spinning forever `[review: architecture]`. + +### Alternative E (chosen): Un-block at the choke point, defer the migration +Stop awaiting the secondary fetches inside `GetUserInfo`, attach `.catch()` so nothing floats +(preserving what `14c13a0e9` was cleaning up), make `FetchGroupData` settled, and read `role` +from the JWT claim. Application data moves per-view later, with `vue-query`, inside the planned +rewrites. +- **Pros:** ~4 small edits; covers every entry path; preserves the commit's legitimate intents; + nothing written now is thrown away by the ports. +- **Cons:** the full-directory prefetch remains until Phase 2 — it can no longer deny a session, + but it still runs on every login. + +### Alternative F: Memoized `ensure*()` store actions +Fetch-once-on-demand behind a shared promise, called from getters/views. +- **Why not now:** a real option, and closer to `vue-query`'s model — which is why Phase 2 uses + the library rather than hand-building this `[review: architecture]`. + +## Consequences + +**Easier:** a failing list degrades to an empty table, not a lockout · failures are diagnosable +at the layer that owns them · `hl1.7.3` has a written target. + +**Harder:** two new states per view ("not loaded" vs "failed") once Phase 2 lands · the JWT role +claim is stale until expiry, which must be documented as UI-only. + +**Risks:** +- *`allSettled` without an error channel yields "loaded and empty".* Real, and **deferred to + Phase 2 rather than mitigated in Phase 1** `[review: adversarial, cartographer — mitigation + re-sited on measurement]`. The review proposed adding a `groups.error` field "shaped like + `store/evaluations.ts:79-92`". Measured at `3090a5f0f`, that citation does not support it: + `:79-92` is the `SET_LOADING(true)` + `try/finally` **loading** pattern and contains no error + field, and **no data store in this repo has one** — the only `error` state is `snackbar.ts:25`, + which is the interceptor channel Decision §5 already routes through. A `groups.error` added now + would have zero consumers, because nothing renders it until Phase 2 gives each view its own + loading/error pair. + - **Phase 1 takes the half that is precedented and load-bearing:** `SET_LOADING(true)` on entry + to `FetchGroupData`, so a refetch stops rendering "loaded, empty" (`groups.ts:26,:122` — the + flag is set `false` once and never back to `true`). + - **Phase 2 owns the error channel**, where `@tanstack/vue-query` supplies per-query error state + natively. That is a further reason to prefer the library over hand-building it here. +- *Phase 2 never gets scheduled.* It is carded standalone, not merely as a dependency of + unscheduled epics `[review: architecture]`. +- *Per-view fetching trades one login fetch for N per-visit fetches.* `vue-query`'s cache is the + mitigation, and is a reason to prefer it over hand-rolled hooks `[review: adversarial]`. + +## Implementation Plan + +### Phase 1 — Un-block login (this PR; fixes a regression this branch introduced) + +**Files** +- Modify: `apps/frontend/src/store/server.ts` — `GetUserInfo:261-262` non-blocking with + `.catch()`; move `axios.get:253` inside the `try`; delete dead `LoginGithub:214-222` + (`CheckForServer` needs no change — Decision §6) +- Modify: `apps/frontend/src/store/groups.ts` — settled semantics at `:121`; `SET_LOADING(true)` + on entry (no `error` field — see Risks; Phase 2 owns the error channel) +- ~~Modify `apps/frontend/src/router.ts` — read `role` from the JWT claim~~ **dropped**, premise + false and the change would be a downgrade (Decision §1) +- `apps/frontend/src/router.ts` + the login components (`LocalLogin.vue`, `LDAPLogin.vue`, + `views/Login.vue`) and a shared `completeLogin()` seam — **only if the `redirect` feature is + scoped in** (Decision §7, pending ruling). Not otherwise touched by Phase 1. +- Modify: `apps/frontend/src/main.ts` — repair the 401 origin gate so auto-logout actually fires +- Test: `apps/frontend/tests/unit/LocalLogin.spec.ts`, `LDAPLogin.spec.ts` (both new) + +**Acceptance criteria** +- [ ] A rejecting `GET /groups/my` does not prevent navigation — asserted for the local **and** + LDAP paths, and for the `CheckForServer` path that OAuth and reload use +- [ ] MUTATION PROOF: each test fails against the current branch code +- [ ] `CheckForServer` never rejects — a 500 during it still completes navigation +- [ ] A **401** on any fetch terminates the session and returns to `/login`; verified by test +- [ ] `redirect` accepts only `^/(?!/)`; `//evil.tld`, `https://evil.tld`, `/\evil.tld` all fall + back to `/`; navigation uses `router.push` +- [ ] No new toast is added at any call site — the interceptor remains the single channel +- [ ] `requiresAdmin` is UNCHANGED, and an admin deep-linking to `/admin` is never bounced — + because the profile commit stays awaited ahead of navigation (Decision §1). Pinned by test, + not implemented +- [ ] `CheckForServer` runs once per page load, not once per navigation — **pinned by test, not + implemented**; the `!this.loading` early return already provides it (Decision §6) +- [ ] TDD; no regressions; live tested (single-theme app — one Playwright capture is complete) + +**Verification** +`yarn workspace @mitre/heimdall-lite vitest run && yarn workspace @mitre/heimdall-lite build` + +### Phase 2 — Per-view application data (deferred into `hl1.7.3` / `3ys`) + +**Files:** `GroupManagement.vue` (add its fetch — rendered by both `views/Groups.vue:23` and +`views/Admin.vue:39`, so it belongs in the component), `Users.vue`, `EditEvaluationModal.vue`, +`EvaluationMixin.ts` (extract the hidden `myGroups` filter at `:14` **before** lists load +lazily), the two stores, plus `@tanstack/vue-query` adoption and an interceptor opt-out so +per-view errors do not double-report `[review: cartographer, framework]`. + +**Acceptance criteria** +- [ ] `GetUserInfo` no longer fetches the user directory or group lists +- [ ] Every consumer loads its own data with loading and error states +- [ ] Bundle-size delta of `@tanstack/vue-query` measured before adoption, not assumed +- [ ] TDD; no regressions + +### Verification Strategy + +- **The regression test that matters:** reject `/groups/my` and assert navigation still occurs — + on all four entry paths. It must fail against this branch and **pass against `origin/master`**, + which is the check the first draft got wrong. +- **Edge cases:** OAuth arrival with a failing list · reload with a failing list · + `CheckForServer` erroring inside the guard · 401 vs 500 on a secondary fetch · admin deep-link + to `/admin` · `redirect=//evil.tld`. +- **Security:** authorization is untouched; the frontend guard is UX and the backend re-derives + authorization per request from the database (verified across all 11 controllers + `[review: security]`). The JWT `role` claim is a UI hint and never an authorization decision. diff --git a/docs/adrs/adr-009-tenable-proxy-ssrf-controls.md b/docs/adrs/adr-009-tenable-proxy-ssrf-controls.md new file mode 100644 index 0000000000..0de5414673 --- /dev/null +++ b/docs/adrs/adr-009-tenable-proxy-ssrf-controls.md @@ -0,0 +1,219 @@ +# ADR-009: The Tenable Proxy Needs Three Independent Controls, Not One + +**Status:** Accepted — implemented: guard `86f6.5`, allowlist `86f6.6`, redirects `86f6.12`, +address filter `86f6.13` +**Date:** 2026-08-15 +**Author:** Aaron Lippold +**Branch:** `feature/fips-compliant-password-hashing` +**PR charter:** `heimdall2-zv9y` +**Related:** `heimdall2-86f6` (epic), `.5` `.6` `.12` `.13`, and ADR-008 (the audit that found this) + +> **Numbering.** ADR-007 is reserved by card `heimdall2-8dy` (API-key credential hashing). + +## Evidence standard + +Every claim cites a file and line, a git object, or an upstream document **fetched in the session +that wrote this ADR**. Claims about *this branch* are checked against `origin/master`, because the +distinction between "we broke it" and "it shipped broken" changes who needs to act and how quickly. + +## Context + +### What the endpoint did + +`POST /api/tenable/login` accepts `{host_url, accesskey, secretkey}`, fetches +`${host_url}/rest/currentUser`, and returns the upstream response body to the caller. A catch-all +`@All('*splat')` proxies every later request to the same host using session-stored credentials. + +That is a server-side request primitive whose destination and whose response are both controlled by +the caller. Three defects made it exploitable: + +1. **No authentication.** The controller carried `@Controller('api/tenable')` with no `@UseGuards`, + and the application registers no global guard. +2. **No destination check.** `host_url` was used exactly as supplied. +3. **Redirects followed.** Both call sites use axios, which follows up to 21 redirects by default. + +### These are pre-existing on master, not introduced by this branch + +This matters for triage, so it was checked rather than assumed: + +```bash +git show origin/master:apps/backend/src/tenable/tenable.controller.ts +# @Controller('api/tenable') — no @UseGuards; no maxRedirects on either call site +``` + +The file was introduced by `a23b7dbef` "Tenable Interface Refactor (#7032)", which is an ancestor of +`origin/master`. The defects are live in shipped code today. By contrast the two login regressions +that ADR-008 addresses (`3bdd1f146`, `14c13a0e9`) are **not** ancestors of `origin/master` — those +this branch introduced and this branch fixed. + +### Why a unit suite never caught it + +Every one of these is invisible to a green test run, which is the property that ties this epic +together: + +- **Guard wiring.** Specs that mount a controller directly never exercise module composition, so a + missing guard and a missing `ConfigModule` import both stay green while the running app is wrong. +- **HTTP client behaviour.** Whether redirects are followed is a property of the client at runtime, + not of any value the code returns. +- **Types.** vitest runs through swc/oxc transpile-only, so a green suite carries no type + information at all; `nest build` is the type gate. + +## Decision + +**Three independent controls, each with its own failure mode, its own test seam, and its own card. +None of them is sufficient alone, and the code says so where a reader will meet it.** + +### 1. Authentication first — `heimdall2-86f6.5` + +A class-level `@UseGuards(JwtAuthGuard)`, not a per-route list. The catch-all route means any future +route on this controller is reachable the moment it is declared, and a per-route list would silently +miss it. + +### 2. An allowlist of permitted destinations — `heimdall2-86f6.6` + +**Allowlist, not host-pinning.** Ruled by Aaron on 2026-08-15. Pinning every request to +`TENABLE_HOST_URL` would have been simpler and would have removed the hostname field from +`AuthStep.vue`; an allowlist keeps multi-instance deployments working by adding a configuration +line instead of removing a feature. + +Sub-decisions, each of which is a place this class of control usually fails: + +- **Compare parsed origins, never strings.** OWASP's SSRF Prevention Cheat Sheet (read 2026-08-15): + "Deny-lists are bypass-prone. Prefer allow-lists", and do not accept complete URLs from the user + because "URL are difficult to validate and the parser can be abused" — validate components. A + `startsWith` or `includes` comparison admits `https://tenable.example.com.attacker.test` against + an allowlist containing `https://tenable.example.com`. +- **WHATWG `URL.origin` is the comparison primitive.** It drops a default port, lowercases the host + and discards the path, which also settles the normalization question for free: `AuthStep.vue` + appends `https://` and `:443` client-side, so the server receives either form and both compare + equal. +- **Only `http:` and `https:` may become allowlist entries**, enforced at config-parse time. +- **An empty allowlist refuses everything.** A deployment with no Tenable host configured has + nothing legitimate to talk to, so the safe reading is "refuse", never "allow anything". +- **Rejections never echo the requested host.** Reflecting it turns the endpoint into a probe + oracle. The caller already knows what it sent. + +### 3. Do not follow redirects — `heimdall2-86f6.12` + +OWASP, same document: "disable the support for the following of the redirection". The allowlist +governs where a request is **sent**; it cannot govern where the **response** sends it next. An +allowlisted host answering `302 Location: http://169.254.169.254/...` walks the server straight past +the check that just approved it. + +`maxRedirects: 0` is set at **both** call sites — the login probe in the controller and the proxy's +own `axios.create` instance in the service. They are two independent configurations and fixing one +does not fix the other. + +**A refused redirect is classified explicitly.** Per the axios documentation (fetched via Context7, +2026-08-15), `settle()` rejects any response failing `validateStatus`, whose default accepts 2xx +only — so a 302 arrives as an ordinary `AxiosError` carrying the 3xx response, and because the code +assignment is `status >= 400 && status < 500 ? ERR_BAD_REQUEST : ERR_BAD_RESPONSE`, a 3xx is +labelled `ERR_BAD_RESPONSE`. Left unclassified it would reach the controller's default branch, which +reports `status: error.response?.status` — Heimdall would answer the **upstream's** 302 as its own +status code, on behalf of a host it had just refused to follow. It is therefore answered as +`502 UPSTREAM_REDIRECT_REFUSED`, before any other classification. + +### 4. Filter the connection's own resolved address — `heimdall2-86f6.13` + +OWASP requires that after validating the domain, the application resolve the A/AAAA records and +apply the same checks to the resolved addresses. A name on the allowlist can still resolve into +link-local or private space, and can resolve differently between the check and the request (DNS +rebinding). + +**The check runs inside the connection's own DNS lookup, not as a separate resolve-then-request +step.** This is the correction that matters, and it was made after the first version of this ADR +was written. Resolving separately and then handing the NAME to the HTTP client leaves the client to +resolve again when it connects, and the second answer can differ from the validated one — that gap +IS the rebinding window the control exists to close. Node lets a connection supply its own resolver +(`lookup` on `socket.connect`, documented as "Custom lookup function. Default: `dns.lookup()`"), so +validating there makes the address that was checked the same address the socket uses. + +This is the established community pattern, taken from `azu/request-filtering-agent`, which +subclasses `http.Agent`, overrides `createConnection`, and injects a filtering `lookup`. Read from +its source on 2026-08-15 rather than recalled. At the network layer the equivalent control is an +egress proxy such as Stripe's `smokescreen`; that is a deployment decision and out of scope here. + +**Implemented in-repo rather than by adding the dependency.** `request-filtering-agent` has ~26 +stars; adding it as a supply-chain dependency of a MITRE product for a security control is a worse +trade than implementing the same ~60-line pattern on the standard library. Classification uses +`node:net` `BlockList`, which parses addresses and understands CIDR, so no dotted-quad regex or +mask arithmetic appears anywhere — and which maps IPv4-mapped IPv6 (`::ffff:169.254.169.254`) onto +the IPv4 rules, verified by test rather than assumed. + +**Both agents are supplied at both call sites**, because axios selects between `httpAgent` and +`httpsAgent` by the target's protocol; supplying one leaves the other scheme unfiltered. + +**Default-deny with an explicit operator opt-out.** The earlier framing of this control — "applies +to request-supplied hosts, not to the operator-configured one" — does not survive contact with +control 2: the allowlist has already reduced every permitted host to a configured origin, so +exempting configured origins would exempt everything and the control would do nothing. What an +operator actually needs is the ability to say "my Security Center really is on private space", which +is `TENABLE_ALLOW_PRIVATE_ADDRESSES`, default false. That matches how the community libraries expose +the same choice (`allowPrivateIPAddress`). + +**Residual exposure, stated precisely.** There is no second resolution to disagree with the first, +so the classic rebinding window is closed for these agents. A name may still resolve to a permitted +address on one connection and a blocked one on the next — each connection is judged on its own +resolution, which is correct behaviour rather than a gap. + +### The rule this ADR exists to state + +**No single one of these closes SSRF, and no card that ships one of them may be read as having +closed it.** Every card says so in its own text, and `tenable-host-allowlist.ts` says so in its +module header, where the next person to change the file will meet it. + +## Alternatives Considered + +### A. Pin every request to `TENABLE_HOST_URL` + +Rejected by Aaron, 2026-08-15. Strictly safer and strictly less capable: it removes the hostname +field from `AuthStep.vue` and breaks any deployment talking to more than one Security Center. The +allowlist reaches the same security property while a second instance costs one configuration line. + +### B. Deny-list private and link-local ranges + +Rejected. OWASP is explicit that deny-lists fail against encoded payloads, nested schemes and +normalization. A regex on `169.254.` looks simpler and is wrong. + +### C. Substring / `startsWith` comparison against the configured host + +Rejected — it admits the suffix attack described above. Pinned by a test, and by a mutation that +weakens the comparison to `startsWith` and is caught. + +### D. One card for all of it + +Rejected after the card was written. A single "fix the SSRF" card produced 21 acceptance criteria at +sp:5 whose first failing test went red only because a module did not exist — an import error, not a +demonstration of the vulnerability. Splitting by failure mode is what surfaced the redirect bypass, +which the combined card had not mentioned at all. + +### E. A boolean-flagged decision type + +The allowlist decision was first modelled as `{allowed: true} | {allowed: false, reason}`. This +repo sets neither `strict` nor `strictNullChecks`, so TypeScript will not narrow that union through +truthiness: `if (!decision.allowed)` compiles under vitest and then fails `nest build`. The working +form, `decision.allowed === false`, then collided with +`unicorn/no-unnecessary-boolean-comparison`. Replaced with a string tag — +`{kind: 'allowed'} | {kind: 'rejected'}` — which narrows correctly regardless of the compiler +setting and is not a comparison against a boolean literal. Both rules satisfied by code, no +`eslint-disable`. + +## Consequences + +- **Operators with more than one Security Center instance must set + `TENABLE_ADDITIONAL_HOST_URLS`** (comma- or space-separated). Single-host deployments need no new + configuration; `TENABLE_HOST_URL` is always permitted. +- **A deployment with no `TENABLE_HOST_URL` cannot use the Tenable integration at all.** This is + deliberate: refuse over allow. It is also a behaviour change for anyone who was relying on the + endpoint accepting an arbitrary host. +- **`502 UPSTREAM_REDIRECT_REFUSED` and `502 UPSTREAM_ADDRESS_REFUSED` are new response codes** on + both Tenable paths, deliberately distinct from each other and from the allowlist's + `400 HOST_NOT_ALLOWED`, so an operator can tell which control refused. +- **`TENABLE_ALLOW_PRIVATE_ADDRESSES` defaults to false.** A deployment whose Security Center runs + on private address space must set it to true, and will otherwise see `UPSTREAM_ADDRESS_REFUSED` + after upgrading. This is the one operator-visible behaviour change in the set. +- **All three controls are now in place.** That is not the same as "SSRF is impossible here" — + it means the three failure modes this ADR names are each closed by a control with its own tests + and its own mutation evidence. Any future change to these paths inherits the same obligation. +- Each control is pinned by mutation testing, and each card carries live evidence against a running + server, because the whole class of defect is one a green unit suite cannot see. diff --git a/docs/development/eslint-config-decisions.md b/docs/development/eslint-config-decisions.md new file mode 100644 index 0000000000..e66d301c0f --- /dev/null +++ b/docs/development/eslint-config-decisions.md @@ -0,0 +1,257 @@ +# ESLint Configuration Decisions + +This document records the reasoning behind `eslint.config.mjs`. It is the design doc referenced by +the lint cards (`heimdall2-4qm` and children) and by `eslint.config.mjs` itself, which cites the +auto-fix hazard numbers in §5 from its own comments. + +**Rewritten 2026-08-14** against the 814-line config. The previous version described a 211-line +config from 2026-06-25 and had gone substantially wrong — see §9 for what changed and why. + +**Rule of thumb for this file:** the config carries its own rationale in comments at each decision +site. That is the primary record. This document explains the *shape* of the configuration and the +decisions that span more than one rule. Where the two ever disagree, the config is authoritative — +it is the executable one. + +## 1. Background + +ESLint 10 with flat config arrived in PR #7919 (Amndeep, 2026-04-10). The upgrade turned on strict +rules but the codebase was never brought into compliance, and `yarn lint:ci` carried `|| true`, +which masked every failure. At the start of the cleanup there were **58,124 problems** across 709 +files. + +Card `heimdall2-4qm` drove that to **zero**. `yarn lint:ci` now runs `eslint --max-warnings 0` and +**exits 0**, the `|| true` is gone from every workspace package, and the `continue-on-error` escape +was removed from `.github/workflows/linter.yml` in `1e57fee7d` — so lint is genuinely blocking in +CI for the first time. + +## 2. How the config is organized + +Flat config is **last-wins**: a later block overrides an earlier one for any file both match. The +file is ordered accordingly — broad presets first, scoped exceptions after, and +`eslint-config-prettier` **last** so formatting rules lose to Prettier rather than fight it. + +Roughly 40 named blocks. Every scoped block carries a `name:` and a comment explaining what it +covers and why, so a reader can tell a deliberate carve-out from an accident. + +### Preset curation, not wholesale extension + +The governing principle: **one generalist plugin, domain specialists where they earn it, and +everything else curated rule by rule.** Wholesale-extending overlapping presets produces the same +finding reported three to five times by different plugins, and the fix for one trips another. + +- Generalist: `unicorn` +- Domain specialists: `regexp`, `security`, `import-x`, `n`, `promise`, `vue`, `vitest`, `cypress`, + `yml`, `json`, `markdown` (+ `markdown-links`, `markdown-preferences`), `@stylistic` +- Type-aware: `typescript-eslint` +- **`perfectionist`'s preset is deliberately NOT extended** (config lines 84 and 107). It remains a + dependency but its `sort-*` rules are not adopted; that overlap is exactly what the curation rule + exists to prevent. +- `e18e` is adopted selectively — its checks that duplicate a specialist are turned off (§3.1). + +## 3. Global rule decisions + +Grouped by the *reason*, because the reason is what generalizes. Every one of these carries its +full rationale inline in `eslint.config.mjs`. + +### 3.1 Duplicate checks — the specialist owns it + +Two plugins reporting the same defect means acting on the cruder signal and fixing things twice. + +| Off | Owned instead by | +|---|---| +| `e18e/prefer-regex-test` | `regexp/prefer-regexp-test` | +| `e18e/prefer-array-some` | `unicorn/prefer-array-some` | +| `n/no-process-exit` | `unicorn/no-process-exit` (also off — see 3.4) | +| `security/detect-unsafe-regex` | `regexp/no-super-linear-backtracking` and `-move` | + +The regex case is the instructive one. `security/detect-unsafe-regex` is safe-regex's *star-height +heuristic*, which cannot distinguish an ambiguous pattern from a merely nested one. The `regexp` +plugin's rules model actual backtracking, are enabled, and are what actually found the real +problems on this branch — the `DATABASE_URL` catch-all tail and the ASFF quantifiers. Keeping both +means acting on the cruder signal. + +### 3.2 Rules ahead of this repo's runtime floor + +The rule is correct in the abstract and wrong for the runtime we ship on. These are revisit-later, +not never. + +- **`unicorn/prefer-uint8array-base64`** — `Uint8Array.fromBase64`/`toBase64` is TC39 Stage 4 but + **undefined** at our Node floor (22.18; 24.x in use). The `Buffer` forms it flags are the correct + code today. +- **`unicorn/prefer-iterator-to-array`** — same class, plus a direct collision: this rule and + `prefer-spread` both fire on iterator-to-array conversions, and the only form satisfying both is + `Iterator.prototype.toArray()`, which the **browser** floor lacks. The frontend, hdf-converters + and inspecjs all ship in the browser bundle, so spread is the correct code today. + +### 3.3 Vocabulary opinions with no behavior gain + +Aaron's 2026-08-13 triage. Each would have forced mass renaming of public-ish surface for zero +correctness value: + +- **`unicorn/name-replacements`** — 281 hits, renaming exported symbols and Vue props. +- **`unicorn/consistent-boolean-name`** — `is`/`has` prefix enforcement across Vue props and mapper + options. +- **`unicorn/consistent-compound-words`** — its three hits rename **exported types of a published + package** (`FileMetaData`, `GenericPayloadWithMetaData`), which every consumer of hdf-converters + would have to follow. + +### 3.4 Style family with dangerous or valueless fixers + +Dropped in the same triage — zero correctness value, and every fixer is `suggestion`-type, meaning +it rewrites the AST: `unicorn/no-for-each`, `no-useless-else` (see hazard #6), `prefer-node-protocol`, +`prefer-ternary`, `prevent-abbreviations`, `switch-case-braces`. Also `unicorn/no-null`. + +`unicorn/no-process-exit` and `n/no-process-exit` are both off because their advice is actively +wrong at the entry points that trip them: throwing inside `bootstrap().catch()` produces exactly the +unhandled rejection those handlers exist to prevent. + +### 3.5 Cost exceeds value on legacy surface + +**`regexp/require-unicode-regexp`** and **`require-unicode-sets-regexp`** — adding `u`/`v` flags to +180 working legacy regexes (144 of them in hdf-converters mappers) is 180 *semantic* changes, each +needing per-pattern equivalence proof against golden fixtures. New code can adopt the flags freely. + +### 3.6 Type-safety relaxations + +`@typescript-eslint/no-explicit-any`, `no-unsafe-argument`, `no-unsafe-assignment`, `no-unsafe-call`, +`no-unsafe-member-access`, `no-unsafe-return`, `no-redundant-type-constituents`, and +`prefer-nullish-coalescing` are off. These fire pervasively on the untyped boundaries this codebase +has by design — parsed scan output, CJS namespace imports, Vuex module internals. Tightening them is +a typed-inputs project, not a lint sweep. + +### 3.7 Rules reconfigured rather than disabled + +- **`unicorn/filename-case: kebabCase`** — the rule's own default, what NestJS generates, and what + the repo already is (`apps/backend/src` alone: 56 kebab-case files, zero snake_case). The previous + `snakeCase` setting matched nothing and produced 343 errors, hidden until `|| true` came out. + Packages with a different *measured* convention get scoped case unions rather than renames. +- **`@typescript-eslint/no-unused-vars`** with `^_` ignore patterns on args, caught errors, + destructured arrays and vars. The underscore prefix is the ecosystem's intentionally-unused + marker and this codebase already uses it; unmarked unused vars still flag. +- **`@typescript-eslint/consistent-type-definitions: ['error', 'type']`** globally, with per-package + scoping where measured usage points the other way. +- `consistent-type-imports` and `consistent-type-exports` are on; `@stylistic/quotes` is single with + `avoidEscape`. + +## 4. Scoped overrides — the pattern + +Framework conventions get a **scoped block with a stated reason**, never an inline disable. Notable +ones: + +| Block | Scope | Why | +|---|---|---| +| `unicorn/nest-dto-parameter-names` | `apps/backend/src/**/*.ts` | NestJS documents naming a DTO parameter after its class (`createCatDto: CreateCatDto`). All 15 hits are parameters, not methods, and the rule offers no suffix or pattern exemption. | +| `promise-off-for-cypress-chainables` | `**/*.cy.ts`, `test/support/**` | Cypress `.then()` is a Chainable continuation, not a Promise — its callbacks assert and return nothing by design. | +| `unicorn/cjs-entry-points` | backend `main.ts`, test-infra, support servers | Plain CJS scripts have no top-level await; `entry().catch(...)` is the idiom that keeps a failed boot exiting non-zero. | +| `unicorn/vue-plugin-installation` | `router.ts`, `store.ts`, one component | Vue 2 installs plugins via `Vue.use()` at module scope, before the instances these modules export. The side effect is the module's purpose. | +| `unicorn/router-push-not-array-push` | router-calling files | `vue-router`'s `push` shares `Array#push`'s name; the syntactic rule cannot see receivers. Real array pushes stay linted everywhere else. | +| `unicorn/vue2-reactive-array-writes` | `apps/frontend/**` | The rule prefers the exact index write Vue 2 cannot observe. | +| `regexp/password-rules-stay-explicit` | `libs/password-complexity/**` | Those regexes ARE the STIG rules; their siblings depend on case being significant and the package ships no tests to catch a later mistake. | +| `n/frontend-bundler-resolution`, `n/lib-source-tsc-resolution` | frontend, `libs/**` | The `n` plugin resolves as Node would; these trees are resolved by the bundler and by `tsc`. | +| `security/spec-fixture-paths`, `security/maintainer-data-tooling` | specs, `hdf-converters/data/**` | Fixture paths and maintainer tooling are not user input. | + +## 5. Auto-fix safety + +**`yarn lint` is wired to `--fix-type layout`**, which cannot change the AST. That is the whole +mitigation: `suggestion`-type fixers rewrite code, and several have shipped real bugs. Verify any +new rule with `--fix-dry-run` before trusting its fixer. + +**Known auto-fix hazards.** These numbers are cited from `eslint.config.mjs` — do not renumber them. + +1. `unicorn/prefer-spread` — converts `.concat()` on a CJS namespace to spread; `_.concat([], a, b)` + became `[..._, a, b]`, spreading the lodash namespace object. Build failure `TS2488`. +2. `e18e/prefer-spread-syntax` — same fixer, same failure. +3. `perfectionist/sort-modules` — reorders type declarations alphabetically and breaks circular type + references (`TS2456`). TypeScript resolves cycles by declaration order. +4. `@typescript-eslint/consistent-type-definitions` — `interface` → `type` breaks circular + references: interfaces are lazily evaluated, type aliases eagerly. +5. `@typescript-eslint/consistent-type-imports` without `inline-type-imports` — splits an import + whose binding is used as both type and value. +6. **`unicorn/no-useless-else` — DESTROYS `continue` statements.** It rewrites + `if (cond) { continue } else { ... }` into a form that drops the control flow entirely, which + caused a stack overflow in inspecjs. Rule disabled globally. +7. `perfectionist/sort-decorators` — decorator order is semantic; sorting it broke every Sequelize + model. +8. `unicorn/prefer-at` — widened a type at one call site. +9. `markdown-preferences/prefer-autolinks` — converts *relative* links (`[SECURITY.md](SECURITY.md)`) + into angle-bracket autolinks, but those require an absolute URI with a scheme, so the output is + not a link at all. A fixer that produces invalid markdown from valid markdown cannot be trusted. + +## 6. Rule-pair collisions + +When two enabled rules disagree, the resolution is **the form both accept** — or, where one rule is +wrong about this repo's runtime floor, turn that one off. + +| Pair | Resolution | +|---|---| +| `prefer-spread` vs `prefer-iterator-to-array` | Browser floor lacks iterator helpers → the latter off | +| `prefer-continue` vs `no-break-in-nested-loop` | Extract the loop body to a function | +| `prefer-switch` vs `no-break-in-nested-loop` | Put the switch in a callback body — a callback is not a loop | +| `prefer-includes` vs `no-unnecessary-boolean-comparison` | Bind the optional first: `const f = x; if (f && !f.includes(y))`. A bare negated `includes` inverts the meaning, because `arr?.indexOf(x) === -1` is **false** when `arr` is undefined | +| `prefer-global-this` vs `no-unnecessary-global-this` | Both accept the bare global | +| template literal → `String()` → `no-useless-coercion` | `JSON.stringify`'s lib type lies (it is `string` but really `string \| undefined`) | +| `floating-promises` vs `return-array-push` on `router.push` | Scoped block (§4) | +| `detect-unsafe-regex` vs the `regexp` analyzers | Keep the precise analyzer (§3.1) | + +`String.prototype.matchAll` is hoist-safe on a shared `/g` regex; `test`/`exec` are not. +`prefer-number-coercion` is semantics-dangerous — `Number('')` is `0` where `parseFloat` gives `NaN`. + +## 7. Inline disables + +An inline disable is a commit-blocking event: show the code fix, or explain why no code fix exists. +This branch added **six**, each carrying an impossibility rationale at its site: + +| Site | Rule | Why no code fix exists | +|---|---|---| +| `apps/backend/config/app-config.ts:17` | `security/detect-non-literal-fs-filename` | Reads an operator-configured TLS path | +| `apps/backend/config/app-config.ts:62` | `security/detect-object-injection` | `process.env` is the platform's exotic object — no `.get()`, and a dynamically named variable requires bracket access | +| `apps/backend/src/authn/ldap.strategy.ts:83` | `unicorn/prefer-at` | `.at()` widened the type | +| `apps/backend/src/casl/casl-exception.filter.ts:10` | `promise/valid-params` | Nest's base filter signature | +| `apps/backend/src/tenable/tenable.controller.ts:25` | `@typescript-eslint/consistent-type-definitions` | Module augmentation requires `interface` | +| `apps/frontend/src/store/search.ts:66` | `security/detect-non-literal-regexp` | Pattern built from hardcoded internal data | + +Pre-existing disables not added by this work (`vue/no-v-html` in the control-table components, +`ban-ts-comment` in two backend test constants) are untouched. + +## 8. Prettier + +Prettier **3.9.6** with `eslint-config-prettier` **10.1.8**, configured last in +`eslint.config.mjs` so formatting rules defer to it. `eslint-plugin-prettier` is deliberately not +used — Prettier's own documentation discourages running it as an ESLint rule. `.prettierrc.json` +sets `singleQuote` only. Scripts are `yarn format` and `yarn format:check`. + +**The repo-wide reformat has not run yet** — roughly 576 files. It is card `heimdall2-fhtn`, and +`.git-blame-ignore-revs` is already scaffolded for that commit. + +## 9. What changed in the 2026-08-14 rewrite + +The previous version of this file was written 2026-06-25 against a 211-line config and had become +misleading. Corrected: + +- **"~405 lint errors remain in hdf-converters"** — now zero, repo-wide. +- **"Remove `|| true` from `lint:ci`"** — already removed, and CI is now blocking. +- **"Write `engines` in lib package.json files"** — done; all workspace packages declare + `>=22.18.0`. +- **"Fix the `@eslint/markdown` crash"** — handled, with an ignore and an upstream note in the + config. +- **`unicorn/consistent-boolean-name` listed as ERROR / "legitimate"** — it is now **off** (§3.3). +- **`unicorn/filename-case` described as a four-case union** — it is now `kebabCase` with scoped + unions only where a different convention was measured. +- **`perfectionist/*` rows described as disabled overrides** — the preset is not extended at all, by + design (§2). +- **`@typescript-eslint/restrict-template-expressions` described as reconfigured** with + `allowNumber`/`allowBoolean`/`allowNullish` — it is not configured in the file at all; it comes + from the preset at its default. +- **`security/detect-object-injection` described as WARN** — it is an error, with narrow scoped + exceptions for specs and maintainer data tooling. +- **Auto-fix hazard #6 was missing entirely** even though `eslint.config.mjs` cites it by number. + Added, along with three further hazards found since (#7–#9). + +## 10. Verifying a claim in this document + +- Effective rules for one file — **use this, not a grep of the config**, because scoped `off` blocks + may not apply to the file you care about: + `npx eslint --print-config ` +- What a disabled rule *would* flag: `npx eslint --rule '{"":"error"}'` +- Whether a fixer is safe: `npx eslint --fix-dry-run` +- The gate itself: `yarn lint:ci` (must exit 0) diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000000..6868d8d129 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,16 @@ +{ + "name": "heimdall2-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Heimdall documentation site (VitePress) — ADR-005", + "scripts": { + "dev": "vitepress dev .", + "build": "vitepress build .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "vitepress": "2.0.0-alpha.19", + "vue": "^3.5.18" + } +} diff --git a/docs/plans/.gitkeep b/docs/plans/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/plans/pr-fips-foundation-plan.md b/docs/plans/pr-fips-foundation-plan.md new file mode 100644 index 0000000000..8450a62aad --- /dev/null +++ b/docs/plans/pr-fips-foundation-plan.md @@ -0,0 +1,247 @@ +# PR Plan — FIPS + RPM + VitePress docs + lint + backend security + +**Status:** active +**Branch:** `feature/fips-compliant-password-hashing` +**Worktree:** `heimdall2-fips` +**Charter card:** `heimdall2-zv9y` · **Label:** `pr:fips-foundation` (32 cards) +**Written:** 2026-08-14 + +--- + +## 1. What this PR is + +One branch, one pull request, five workstreams — deliberately bundled: + +| # | Workstream | Epic | State | +|---|---|---|---| +| 1 | FIPS password hashing (ADR-006) | `heimdall2-e25` | 24/34 closed — **71%** | +| 2 | RPM / packaging | inside `e25` | `.26` `.32` closed; `.27` `.28` `.33` open — **~40%** | +| 3 | VitePress documentation (ADR-005) | `heimdall2-yvx` | 5/17 closed — **29%** | +| 4 | Repo-wide ESLint cleanup | `heimdall2-4qm` | complete by its own subject | +| 5 | Backend security + contract detectability (ADR-008, ADR-009) | `heimdall2-86f6` + `heimdall2-sked` | 4/13 and 2/3 closed — added 2026-08-15 | + +Forked from master at `2e1649c9e` (2026-06-23). Linear history, zero merge commits. +165 commits unpushed. **Push only on Aaron's word.** + +### Why the lint work is here + +FIPS was ~70% done and the docs platform largely built, so the foundation was cleaned inside the +same PR rather than deferred. Recorded three times: + +- bd memory `lint-first-foundation` — "Lint-first … Clean foundation first. **User decision 2026-06-17.**" +- Aaron, 2026-08-13 — *"keep the lint-config repair ON THE BRANCH but make CI lint non-blocking so + the FIPS/docs PR isn't failed."* The escape was later removed in `1e57fee7d` once the repo hit + zero, so CI lint is now **blocking**. +- Aaron, 2026-08-13 20:09 — agreeing the order: after 4qm → vf4 → yvx.17 + docs → `yarn format` → push. + +### Why the security work is here — added 2026-08-15, and it is in scope for review + +This stream was not in the original four. It exists *because of* this PR's own lint work, and it +splits into two halves a reviewer should judge by different standards. + +**Self-inflicted on this branch, and fixed here.** Two ESLint autofixes changed runtime behaviour: + +- `3bdd1f146` alphabetized `GroupsController` members, so `@Get(':id')` was declared before + `@Get('/my')` and swallowed it — GUI login broke for every user. Decorator order is semantic in + NestJS and no linter can see it. +- `14c13a0e9` turned a fire-and-forget call into an awaited chain, coupling login to unrelated + application data. ADR-008 records the decision that came out of it. + +Neither commit is an ancestor of `origin/master` — both are branch-local, introduced here and +fixed here. Check with `git merge-base --is-ancestor origin/master`, which fails for both. + +**Pre-existing on master, found by the audit those breaks triggered.** Asking what else that class +of invisible contract could hide produced epic `heimdall2-86f6`, and the audit found the Tenable +proxy defects. These are not this branch's doing and they are live on master today: + +```bash +git show origin/master:apps/backend/src/tenable/tenable.controller.ts +# @Controller('api/tenable') with NO @UseGuards, and no maxRedirects on either call site +``` + +They shipped in `a23b7dbef` "Tenable Interface Refactor (#7032)". + +**The Tenable chain** is the largest security change in the PR. `POST /api/tenable/login` took a +caller-supplied `host_url`, fetched it, and returned the upstream response to the caller. Three +independent controls, one card each, none sufficient alone: + +- `86f6.5` closed — authentication guard; the endpoint was reachable unauthenticated +- `86f6.6` closed — name allowlist; any authenticated user could aim it at any host +- `86f6.12` closed — no redirect following; an allowlisted host could `302` the request away +- `86f6.13` **open** — resolved-address check; a permitted name can still resolve into blocked + address space (DNS rebinding) + +ADR-009 records the design. **The SSRF is not fully closed until `86f6.13` lands** — do not read +the closed cards as "SSRF fixed"; each card and the module header say so explicitly. + +Also in this stream: `heimdall2-sked` seeds stable dev/test users behind a production guard +(`3a09a5894`, `c7532b7c5`), which is what makes the live-test evidence on these cards reproducible. + +Every card in this stream carries live-test evidence and a mutation run in its notes, because the +defects are all of the form "a green unit suite cannot see this". + +### The rule + +**This PR's purpose is recorded here and on `heimdall2-zv9y`. Do not re-derive it from git-log +statistics, file counts, or commit-message prefixes. Do not propose splitting or re-scoping the +branch.** + +On 2026-08-14 an agent counted commit-message keywords, concluded the lint commits were +"contaminating" a supposedly-clean FIPS branch, wrote that into all three recovery files as +established fact, and proposed branch surgery. It was fabricated — the decision was in bd memory +and in the session transcripts the whole time. It cost an entire evening and nearly cost 150 +commits of sound work. + +--- + +## 2. Current verified state (HEAD `197e3dcc9`) + +- `yarn lint:ci` — **exit 0** across 709 files, 58,124 → 0. Re-verified 2026-08-14 in 89.61 s. +- backend — tsc 0, **370/370** (30 files), against the test DB on `127.0.0.1:5433` +- frontend — vue-cli **build 0** (the only frontend typecheck), **51/51** (12 files) +- inspecjs — build 0, 6/6 +- hdf-converters — tsc 0, build 0, **163 passed / 167** +- generated-artifact guard clean; `yarn.lock` delta zero + +The 4 hdf failures are environmental, not regressions: 1 × `splunk_reverse_mapper` (the +long-standing CI-container-only miss) and 3 × `sonarqube_mapper` failing with +`ECONNREFUSED 127.0.0.1:3001` — that mock is started externally, is not defined in this repo, and +is currently down. It must be on IPv4; the spec hardcodes the v4 literal. + +--- + +## 3. Execution order (wired into the board) + +``` +heimdall2-4qm ── AC-verify round 3 (gate heimdall2-x4uo) → close + │ + └─ heimdall2-fhtn Prettier reformat, ~576 files, isolated commit + blame-ignore + ├─ vf4, kxi the two bug cards unblocked by 4qm + ├─ e25.18, e25.22, e25.24 FIPS code + └─ e25.27, yvx.4, yvx.10–.15, yvx.17 prose (Prettier formats markdown too) + +e25.5 (heimdall-cli, cross-repo) ─→ e25.22 ─→ e25.29 (load test) + └───→ e25.27 ─→ e25.28 (COPR) ─→ yvx.16 + └─→ 7qe8 +e25.24 ─→ e25.25 (admin Migration tab) +yvx.2 + yvx.4 ─→ yvx.5 ─→ yvx.6 (wiki decommission) +``` + +**Why `yarn format` moved earlier.** Aaron's 2026-08-13 order put it after `yvx.17`. Revised +2026-08-14: the only argument for deferring was cross-branch conflict, and no other branch is +being worked. Running it before the remaining code and prose cards avoids writing files that the +reformat then rewrites — which would mean reviewing the same lines twice. + +**Dependencies that existed only in prose until 2026-08-14.** `e25.22`'s description says *"which +is why the CLI card BLOCKS this one"* but the board carried no dependency at all; `.27`, `.28` and +`.29` had none either. Now wired. + +**bd forbids a task depending on an epic.** `yvx.16` ("correct the FIPS posture — AFTER the FIPS +release ships") and `7qe8` are therefore wired to `e25.28`, the COPR distribution card, as the +concrete stand-in for "the release shipped." + +--- + +## 4. Effort remaining + +| Stream | Cards | Estimate | +|---|---|---| +| lint close-out (`.62` `.63` `.64` + AC-verify) | 3 | 70 m | +| format + the two unblocked bug cards | 3 | 75 m | +| FIPS + RPM | 8 | 136 m | +| docs | 12 | 219 m | +| cross-repo tracking (`e25.5`, `e25.33`) | 2 | 25 m — not our throughput | +| **total** | **28** | **~8.8 h Claude-pace** | + +The FIPS **hashing implementation is done**. What remains in `e25` is migration and admin tooling +(`.22` `.24` `.25`), config (`.18`), a runtime-dependency audit (`.3`), the deployment doc (`.27`), +COPR release (`.28`) and a load test (`.29`) — not cryptography. + +**The load test needs no provisioning work.** `packaging/test-infra/fips-ec2/` already holds +Terraform (`main.tf`, `variables.tf`, `outputs.tf`, `user-data.yaml.tftpl`), an applied +`terraform.tfstate`, a `bin/fips-box` launcher and `spike/bench.js`. Benchmarks were already taken: +594 ms p50 at 600k iterations on t3.medium. + +Docs prose was recalibrated 2026-08-14 (304 m → 219 m; minutes only, `sp:` unchanged since it is +relative complexity). The original numbers came from a code-card calibration table and treated +markdown authoring like multi-file code work. Note that a large share of what remains is not +writing but per-card machinery — full suites, the VitePress build, markdown lint and an AC-verify +pass, which cost the same whether the card is a migration or a paragraph. + +--- + +## 5. Known items that are NOT blockers + +**The ten parked cards.** `heimdall2-4qm` has ten children that are hdf-converters work, not lint: +`.3 .4 .5 .7 .8 .44 .52 .53 .54` and `.6` — `DEFAULT_PROFILE_FIELDS`, the regen tool, `BaseResults`. +Their code lives on `feature/attestation-comment-engine`. Under the PR-split plan PR1 was +"foundation = lint + DRY", so they were filed here; the DRY half was then written on the +attestation branch. That branch is dormant until this PR merges and a release ships. Re-parenting +them is a decision for later. + +**Three lint children are verified and waiting on one batched review.** Aaron approved batching for +evidence-only closes (per-card review whenever new code is written): + +- `.62` — 6/6 ACs. `lint:ci` proven to exit non-zero on a real rule violation and zero when clean. +- `.63` — 9/10. AC6 superseded by Aaron's 2026-08-13 ruling disabling `unicorn/consistent-boolean-name`. +- `.64` — 7/8. AC4's premise was factually wrong: the rule flags **DTO parameter names**, never + method names — 15 violations measured, all parameters, per NestJS convention. + +**Six repo-wide git stashes** — inventory and triage plan on `heimdall2-nq8`. `refs/stash` is a +repository-level ref, so all four worktrees see one shared stack. Four of the six hold work that +never landed on any branch. Do not `git stash clear`; do not address them by index. + +**The attestation rebase** — `heimdall2-7qe8`. The two branches rewrote 331 of the same files. The +resolution rule: keep attestation's **content** and re-derive the lint layer by running the tools, +because lint regenerates and feature work does not. + +--- + +## 6. Likely next direction after this PR (or the one after) + +**pnpm + Vitest + Playwright**, retiring Cypress and Yarn 1. + +The Playwright migration is **already largely done and is not in this repo's history** — it lives +in a separate clone: + +``` +/Users/alippold/github/mitre/heimdall-clean + branch feature/vue3-nuxtui-migration + 969cf2db 2025-10-15 ci: modernize GitHub Actions for pnpm + Vitest + Playwright + test/playwright.config.ts + test/e2e/{login,registration,groups,results,database-results,splunk}.spec.ts 399 lines + test/fixtures/ + test/support/ 37 files +``` + +All six Cypress specs are ported, using proper Playwright structure — page objects injected as +test fixtures (`test('...', async ({page, loginPageVerifier, toastVerifier}) => ...)`). **The +selectors are app-agnostic** (`input[name=email]`, `#login_button`), not Nuxt UI internals, so they +should work against the current Vue 2 app despite living on a Vue 3 branch. + +Three things to know before reusing it: + +- **One real gap:** `cy.register` was never ported. `login.spec.ts` carries + `// TODO: Implement register via API call` with `page.FIXME_register(...)` commented out, so + `beforeEach` does not create the user and every spec needing a registered user fails as-is. +- It is a **pnpm** branch — that commit does pnpm, Vitest and Playwright together, so the config + and workflow need translating if this repo is still on Yarn 1 at the time. +- It is ~10 months old and predates everything in this PR. + +Rough size once ported: **1–2 hours**, plus the usual unknown debugging tail of getting e2e green +against a live stack. Retiring Cypress also removes four dependencies and both Cypress carve-outs +from `eslint.config.mjs`. Related open card: `heimdall2-30c.4` (decide package manager — Yarn 1 is +end-of-life). + +## 7. Method notes worth keeping + +- **A lint exit code of zero does not mean the code was fixed.** It can mean the rule was disabled. + Check the *effective* config with `eslint --print-config `, not by grepping + `eslint.config.mjs` — a grep shows scoped `off` blocks that may not apply to the file in question. + That distinction changed the verdict on `.63`. +- **To test whether a rule's AC was really satisfied, force the rule back on** — `eslint --rule` — + and read what it actually flags. That is how `.64`'s AC was found to be false in its premise. +- **`bd show`'s rendered output re-wraps lines.** Extract raw text with `bd show --json` before + writing any anchored edit, and assert the anchor matched before writing. +- **Never route card text through a shell string.** Backticks inside a double-quoted argument are + command substitution — on 2026-08-14 that accidentally executed `npx jest`. Write to a file and + pass it, or run scripts from a file. diff --git a/docs/research/fips-host-spike.md b/docs/research/fips-host-spike.md new file mode 100644 index 0000000000..b81189b500 --- /dev/null +++ b/docs/research/fips-host-spike.md @@ -0,0 +1,263 @@ +# FIPS-Host Spike — ADR-006 §15/§10/§11 Empirical Findings + +**Card:** `heimdall2-e25.1` · **Date:** 2026-08-08 +**Host:** EC2 `i-00b942baf369dc6be`, RHEL 9.4 (`RHEL-9.4.0_HVM-20260217`), t3.medium +(2 vCPU, burstable), kernel FIPS mode enabled. Provisioned by +`packaging/test-infra/fips-ec2/` (cloud-init `fips-mode-setup --enable` + reboot). +**Container:** `registry.access.redhat.com/ubi9/nodejs-22-minimal:1` +(Node v22.23.1) under rootless podman — the exact image `Dockerfile:1` pins. +**Method:** every claim below is a live observation on this host; raw outputs +inline. Evidence markers follow ADR-006's standard: **[V]** verified by +execution here; **[U]** plausible mechanism, not load-bearing. + +Host state, verified before any container work: + +``` +$ cat /proc/sys/crypto/fips_enabled +1 +$ fips-mode-setup --check +FIPS mode is enabled. +$ openssl version +OpenSSL 3.0.7 1 Nov 2022 (Library: OpenSSL 3.0.7 1 Nov 2022) +``` + +--- + +## F1 — The provider ACTIVATES in the container with no container-local config. **[V]** + +**The epic's STOP-gate question (ADR §15 [U] item 1) resolves YES.** + +``` +$ podman run --rm ubi9/nodejs-22-minimal:1 node -p 'require("crypto").getFips()' +1 +$ podman run --rm ... sh -c 'find / -name fipsmodule.cnf 2>/dev/null' +(nothing) +$ podman run --rm ... sh -c 'ls -la /usr/lib64/ossl-modules/' +-rwxr-xr-x. 1 root root 1338392 Jun 3 15:40 fips.so +-rwxr-xr-x. 1 root root 140352 Jul 15 09:44 legacy.so +(abridged — total/./.. lines elided) +``` + +`crypto.getFips()` returns `1` inside the container, and **no `fipsmodule.cnf` +exists anywhere in the image**. Activation is pure host inheritance: kernel +`fips_enabled=1` → RHEL's patched OpenSSL reads it at runtime → Node inherits — +across the container boundary, with zero container-side configuration. This is +the §10 model working exactly as documented. The epic's deployment design +stands; no redesign needed. + +## F2 — Provider identity and version, as deployed. **[V]** + +``` +$ podman run --rm ... sh -c 'openssl list -providers' +Providers: + base name: OpenSSL Base Provider version: 3.5.5 status: active + default name: OpenSSL Default Provider version: 3.5.5 status: active + fips name: Red Hat Enterprise Linux 9 - OpenSSL FIPS Provider + version: 3.0.7-cda111b5812c30d4 status: active +``` + +The running module self-identifies as **`3.0.7-cda111b5812c30d4`** — a Red Hat +maintenance build, exactly as ADR §15 predicted, ≠ certificate #4857's validated +`3.0.7-395c1a240fbfffd8`. This is the observed string for the SSP posture +(cite cert #4857, disclose this build, self-affirm the OE per CMVP MM §7.9). + +Note: the **default provider is active alongside fips** — the container's +OpenSSL config does not restrict to fips-only. Approved-algorithm enforcement +on RHEL comes via crypto-policies + the patched OpenSSL, not provider +exclusivity. Consistent with F5 (nothing blocks pure-JS code either). + +## F3 — "Separate RPM since 9.2" is REFUTED. **[V]** (positive dating: inference) + +Run **on the HOST** (RHEL 9.4). Scope note, reconciling with F1/F2: the +provider actually *loaded* in a container is the **image's own** `fips.so` +(F1's `ls`; F2's version string) — activation comes from the kernel flag, not +from host files crossing the boundary. This query dates when the **el9 package +stream** began shipping `fips.so` as its own RPM — which is precisely what §15 +constraint 4 claims; host and UBI image draw from the same el9 stream: + +``` +$ rpm -qf /usr/lib64/ossl-modules/fips.so +openssl-fips-provider-3.0.7-2.el9.x86_64 +$ rpm -q --changelog openssl-fips-provider | tail -2 +* Wed Jan 24 2024 Simo Sorce - 3.0.7-1 +Initial packaging +``` + +The FIPS provider **is** a separate RPM (`openssl-fips-provider`) — that half +of ADR §15 [U] item 4 is confirmed **[V]**. The "since 9.2" dating is +**refuted [V]**: a package first packaged 2024-01-24 cannot have shipped in +9.2 (GA May 2023). The positive placement — "9.4" — is an **inference [U]** +from the date falling between 9.3 GA (Nov 2023) and 9.4 GA (Apr 2024); the +load-bearing fact is the refutation plus the separate-RPM confirmation. The +pinning insight is unchanged: the provider version is decoupled from +`openssl-libs`, deliberately frozen at the validated module's base version +while the linking OpenSSL moves (3.5.5 in the container, per F2; 3.0.7 on +this 9.4 host, per the pasted `openssl version`). + +## F4 — Performance on target hardware: latency 4× the laptop, throughput = physical cores. **[V]** ⚠️ + +Measured with the concurrency-ladder harness at +`packaging/test-infra/fips-ec2/spike/bench.js` (v2, part of this card's change +set — every published number re-derivable from it; the first harness ran a +single unlabelled concurrency and its **throughput and fs numbers are +retracted as methodology flaws** — its 40-sample sequential-latency phase was +sound and is retained below). Host topology **[V]**: + +``` +$ lscpu | grep -E '^CPU\(s\)|Thread|Core|Model name' +CPU(s): 2 +Model name: Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz +Thread(s) per core: 2 +Core(s) per socket: 1 +``` + +A t3.medium's "2 vCPU" is **two HT siblings of ONE physical core**. + +Sequential latency, 40 samples (produced by the v1 harness's sequential phase +— methodologically sound and retained; the identical procedure is now +bench.js v2's sequential phase, `BENCH_SEQ_N`, same output format): + +``` +latency ms — p50: 594.3, p95: 600.7, min: 592.5, max: 600.7 +``` + +Concurrency ladder (v2 harness; per-level sample count is 2×C, so ladder +latency columns characterize queueing shape — the 40-sample run above is the +latency source of record. These ladder runs predate the harness's added +sequential phase: reproduce byte-identical output with `BENCH_SEQ_N=0`): + +``` +fips=1 node=v22.23.1 iter=600000 nproc=2 threadpool=4 (default) +C ops wall_s ops/sec op_p50_ms op_p95_ms steal% +1 2 1.2 1.68 598 598 0.0 +2 4 2.4 1.65 1214 1215 0.0 +4 8 4.9 1.64 2425 2440 0.0 +8 16 9.7 1.64 4754 4873 0.0 +16 32 18.8 1.70 6941 9416 0.0 +32 64 38.0 1.68 11743 19013 0.0 +fs.readFile ms — baseline p50=0.15 p95=0.29 | under sustained 8-KDF load p50=12145.73 p95=12245.05 + +``` + +Second ladder, `UV_THREADPOOL_SIZE=8` (pasted in full): + +``` +fips=1 node=v22.23.1 iter=600000 nproc=2 threadpool=8 +C ops wall_s ops/sec op_p50_ms op_p95_ms steal% +1 2 1.2 1.67 600 600 0.0 +2 4 2.4 1.65 1214 1215 0.0 +4 8 4.9 1.64 2427 2440 0.0 +8 16 10.0 1.60 4948 5066 0.0 +16 32 19.4 1.65 9658 9698 0.0 +32 64 37.9 1.69 13840 19020 0.0 +fs.readFile ms — baseline p50=0.15 p95=0.29 | under sustained 8-KDF load p50=4143.75 p95=4693.13 +``` + +Two-container discriminator **[V]** — two isolated podman containers each +running continuous C=1 for 15 s, simultaneously +(script: `packaging/test-infra/fips-ec2/spike/two-container.sh`, this change set): + +``` +A ops: 13 in 15.784 s = 0.82 ops/sec +B ops: 13 in 15.746 s = 0.83 ops/sec (aggregate 1.65 = the same ceiling) +``` + +- **Single-op cost: 594.3 ms p50 / 600.7 ms p95** (40-sample sequential run, + pasted above) at 600k — vs 145 ms on the dev laptop (§11's number was real + but not representative). **p95 exceeded the card's 500 ms STOP threshold** — + surfaced; decision recorded below. +- **Throughput is pinned at 1.60–1.70 ops/sec at EVERY concurrency (1→32) + and BOTH threadpool sizes** (the low point, 1.60, is the UV=8 ladder's C=8 + row — pasted above) **while wall time and p95 latency scale ~linearly with + C** (p95: 598 → 1215 → 2440 → 4873 → 9416 → 19013; p50 tracks more loosely + at mid-ladder due to completion-order spread) — + real concurrency, hard ceiling. The two-container test splits the same + 1.65/sec between isolated processes, proving the bound is the **physical + core**, not a threadpool artifact or a FIPS-provider lock. SMT contributes + ~nothing to this ALU-bound SHA-512 loop. **Sizing law: KDF throughput ≈ + 1.7 ops/sec × physical cores (this CPU generation, 600k iterations) — + count cores, never vCPUs.** +- **`fs.readFile` starves catastrophically under SUSTAINED KDF load [V]:** + p50 **12.1 s** at default threadpool, **4.1 s** at UV_THREADPOOL_SIZE=8. + §11's starvation warning is reinstated *stronger* than its laptop numbers, + and both mitigations now carry measured justification: the KDF concurrency + limiter (bounding how many pool slots KDFs may hold) and UV_THREADPOOL_SIZE=8 + (3× less read-wait under saturation). +- **History of this finding:** harness v1 launched 8 one-shot KDFs (a + transient, not sustained, load) and observed no starvation, which this doc + briefly attributed to io_uring **[U]**. v2's continuous-refill load shows + the truth; the io_uring hypothesis is **refuted** — reads demonstrably share + the threadpool. v1's "throughput" run never recorded its concurrency and is + superseded by the ladder. +- **Caveats [U — predictions, not measured]:** t3-class burstable credits + could make sustained production load worse than these short runs; per-op + latency on other CPU generations will differ. The measured sizing law above + is the [V] part. + +## F5 — bcryptjs executes freely under FIPS: the gate must be OURS. **[V]** + +``` +fips: 1 +bcrypt.hashSync under FIPS: EXECUTED, prefix $2b$12$ +bcrypt.compareSync correct pw: true +bcrypt.compareSync wrong pw: false +``` + +With the host in FIPS mode and the provider active, pure-JS bcrypt hashing and +comparison run to completion, unblocked and undetected. The platform will never +enforce ADR §3's prohibition — the application-level FIPS gate in +`verifyPassword` is the only enforcement point. Confirmed by execution. + +## F6 — Node 24 behaves identically. **[V]** + +`ubi9/nodejs-24-minimal:1` exists (Node v24.18.0, current LTS; the Dockerfile +pins 22). Activation checks, same host, outputs pasted: + +``` +$ podman run --rm ubi9/nodejs-24-minimal:1 sh -c 'node -v; node -p "require(\"crypto\").getFips()"' +v24.18.0 +1 +``` + +FIPS activation is identical to Node 22 **[V]**. A v1-methodology benchmark +run showed sequential latency consistent with Node 22 — expected, since pbkdf2 +executes in OpenSSL's C code — but its output is not published here and **no +performance figure is claimed for Node 24 [U]**; re-derive with the v2 harness +against the nodejs-24 image if it ever becomes load-bearing. A future Node +bump requires no FIPS *activation* rework **[V — scoped to the tested +nodejs-24 image]**. + +--- + +## Decision — iteration count: 600,000 stays (Aaron, 2026-08-08) + +Rationale: best-practice default (OWASP's FIPS-context recommendation), and the +target deployment (140 AF PMOs) is Okta/Keycloak-dominant — external-auth users +never touch PBKDF2, so the payers are few and privileged (local break-glass +admins), exactly the accounts to harden hardest. Deployments tune via +`PASSWORD_HASH_ITERATIONS`; parameters live in each hash, so any later change +needs no migration. The real scale bottleneck is the API-key path, addressed +separately (ADR-007 card). Original decision table preserved below for the record. + +### The options as presented + +The card's STOP threshold (p95 > 500 ms) is exceeded at 600k on 2-vCPU cloud +hardware. Scaling from measured numbers: + +| Iterations | p50 est. | Throughput est. (per physical core) | Standing | +|---|---|---|---| +| **600,000** (ADR default) | ~594 ms | ~1.6/sec | OWASP's "600k or more" for FIPS-140 contexts | +| **310,000** | ~307 ms | ~3.1/sec | above OWASP floor, middle path | +| **220,000** (OWASP floor) | ~218 ms | ~4.4/sec | §11's documented fallback | + +All three clear the module's own minimum (1,000) by orders of magnitude, and +parameters live in the PHC string, so the choice is **tunable later without +migration** — hashes verify at their recorded iterations regardless (ADR §8's +no-propagation caveat noted). `PASSWORD_HASH_ITERATIONS` also lets individual +deployments tune per hardware. The decision sets the *default* in `e25.6`/§9. + +## Verdict for the epic + +**Foundation confirmed — proceed.** F1 clears the STOP-gate; F2 gives the SSP +posture string; F5 proves the gate design is necessary. The iteration default +is decided (600k — see Decision above); implementation lands in `e25.6`. diff --git a/docs/site/about/index.md b/docs/site/about/index.md new file mode 100644 index 0000000000..4088fed706 --- /dev/null +++ b/docs/site/about/index.md @@ -0,0 +1,19 @@ +# About + +Project information: attributions, code of conduct and licensing. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Technology attributions | Wiki: `Technology-Attributions` (moved verbatim) | +| Code of conduct | Repo: `CODE_OF_CONDUCT.md` (symlinked, verbatim) | +| License | Repo: `LICENSE.md` (symlinked, verbatim) | + +Legal and attribution content moves without wording changes (ADR-005 §5.1). diff --git a/docs/site/api/index.md b/docs/site/api/index.md new file mode 100644 index 0000000000..a38719ebff --- /dev/null +++ b/docs/site/api/index.md @@ -0,0 +1,19 @@ +# API + +Programmatic access to Heimdall Server. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| API overview | Wiki: `Heimdall-API-Documentation` | + +Rendering a machine-readable OpenAPI specification here is an investigation +item, not a commitment — it depends on a maintained spec existing (ADR-005 +§4.3). diff --git a/docs/site/converters/index.md b/docs/site/converters/index.md new file mode 100644 index 0000000000..c2914bbe99 --- /dev/null +++ b/docs/site/converters/index.md @@ -0,0 +1,18 @@ +# Converters + +`hdf-converters` normalizes security results from many tools into the Heimdall +Data Format, and back out again. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Converter mappings | Wiki: `HDF-Converter-Mappings` | +| How-tos | Wiki: `HDF-Converters-How-Tos` | +| CCI converter | Wiki: `Control-Correlation-Identifier-(CCI)-Converter` | diff --git a/docs/site/deployment/index.md b/docs/site/deployment/index.md new file mode 100644 index 0000000000..b85f9f3bfe --- /dev/null +++ b/docs/site/deployment/index.md @@ -0,0 +1,26 @@ +# Deployment + +Running Heimdall in production: install methods, platform configuration and +release process. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Production checklist | New — TLS posture, registration policy, JWT/API-key secrets | +| Oracle Linux install | Wiki: `Oracle-Linux-Production-Install` | +| Lite and demo configurations | Wiki: `MITRE-Heimdall-Lite-and-Demo-Deployment-Configurations` | +| Heroku | Wiki: `Heimdall-Heroku-Documentation` | +| Releases | Wiki: `How-to-create-a-Heimdall2-release` | + +## Known gaps + +These have no wiki predecessor and are new documentation work: **RPM install** +(see `packaging/rpm/INSTALL.md` in the repository), **Docker install**, and +**Kubernetes / Helm** deployment. diff --git a/docs/site/developers/index.md b/docs/site/developers/index.md new file mode 100644 index 0000000000..9b2097d34a --- /dev/null +++ b/docs/site/developers/index.md @@ -0,0 +1,26 @@ +# Developers + +Architecture, components and day-to-day development practice for contributors. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Architecture | Wiki: `Heimdall-Architecture-Information` | +| Frontend components | Wiki: `Heimdall-Frontend-Components` | +| Class diagrams | Wiki: `Heimdall-Class-Diagrams` | +| Processes | Wiki: `Heimdall-Processes-Documentation` | +| Interface connections | Wiki: `Heimdall-Interface-Connections` | +| Code style | Wiki: `Developers-Code-Style` | +| Tips and tricks | Wiki: `Heimdall-Development-Tips-&-Tricks` | +| Backend | Repo: `apps/backend/README.md` (included, not duplicated) | +| Libraries | Repo: `libs/inspecjs`, `libs/hdf-converters` READMEs | + +Until then, the repository README's *For Developers* section covers local setup +and the two run modes. diff --git a/docs/site/getting-started/configuration.md b/docs/site/getting-started/configuration.md new file mode 100644 index 0000000000..3d1105ba49 --- /dev/null +++ b/docs/site/getting-started/configuration.md @@ -0,0 +1,107 @@ +--- +title: Configuration +description: How Heimdall loads configuration, which file applies to which install method, and what takes precedence. +--- + +# Configuration + +Every Heimdall install is configured the same way — environment variables. What +changes between install methods is only the file those variables live in. + +This page covers the *model*: where configuration comes from and what wins. For +the variables themselves — names, defaults, effects — see the +[Environment Variables reference](/getting-started/environment-variables). That +page is the single source of truth and nothing here repeats it. + +## Where the variables live + +| Install method | File | +| --- | --- | +| Local development | `apps/backend/.env` (start from `apps/backend/.env-example`) | +| Docker Compose | `.env` beside `docker-compose.yml`, or the `environment:` block | +| RPM | `/etc/heimdall-server/backend.env` | +| Kubernetes | your chart's values, projected into the container environment | + +The variable names are identical across all four. A variable that works in +development works in production under the same name. + +## What takes precedence + +Two rules decide which value wins, and both surprise people. + +**The process environment beats the file.** Configuration is read as +`process.env[key] || envConfig[key]`. A variable exported in your shell, set in +a systemd unit, or injected by Kubernetes overrides the same key in the `.env` +file. It is not the other way around — editing the file will not fix a value +that is also set in the environment. + +**The file is read relative to the working directory.** It is loaded with a +relative path, so it is found relative to *where the process was started*, not +where the application is installed. Start the server from a different directory +and no file is loaded at all: the application logs +`Unable to read configuration file .env!` and continues on the process +environment alone. If configuration appears to be ignored entirely, check the +working directory before you check the file. + +A third, smaller rule: **an empty value is not a default.** Most variables are +read with `||`, so an empty string behaves like unset and the default applies. A +few validate instead and refuse to start; those are called out individually in +the reference. + +## The database name is derived + +There is no default database name. When `DATABASE_NAME` is unset, the name is +derived from `NODE_ENV`: + +``` +heimdall-server-${NODE_ENV} +``` + +So `NODE_ENV=development` uses `heimdall-server-development`, and changing +`NODE_ENV` silently points Heimdall at a different database. This is why the +database role needs `CREATEDB`. + +If **both** `DATABASE_NAME` and `NODE_ENV` are unset, the application throws at +startup rather than guessing. + +`DATABASE_URL` is an alternative to the individual settings — when set, it is +parsed into the username, password, host, name and port components at startup. + +## Secrets + +`JWT_SECRET` signs session tokens. When it is unset, a fresh random value is +generated at every start, which invalidates all sessions on every restart — +fine locally, wrong anywhere real. Generate one: + +```bash +openssl rand -hex 64 +``` + +`API_KEY_SECRET` works the same way and controls a feature: API keys are +disabled entirely when it is unset. + +```bash +openssl rand -hex 33 +``` + +::: warning Rotating a secret logs everyone out +Changing `JWT_SECRET` invalidates every existing session. Changing +`API_KEY_SECRET` invalidates every issued API key. Both are sometimes what you +want — neither should be a surprise. +::: + +## Changing configuration + +Configuration is read at startup, so a change takes effect on restart: + +| Method | Apply a change | +| --- | --- | +| Local development | restart `yarn start:dev` | +| Docker Compose | `docker compose up -d` (recreates the container) | +| RPM | `sudo systemctl restart heimdall-server` | +| Kubernetes | roll the deployment | + +## Next steps + +- [Environment Variables](/getting-started/environment-variables) — every variable, with defaults verified against the source +- [Troubleshooting](/getting-started/troubleshooting) — when configuration is right but something still fails diff --git a/docs/site/getting-started/environment-variables.md b/docs/site/getting-started/environment-variables.md new file mode 100644 index 0000000000..911764bbf5 --- /dev/null +++ b/docs/site/getting-started/environment-variables.md @@ -0,0 +1,333 @@ +--- +title: Environment Variables +description: The canonical reference for every environment variable Heimdall reads, with defaults verified against the source that supplies them. +outline: [2, 3] +--- + +# Environment Variables + +This page is the single source of truth for Heimdall's configuration. Every +variable below was derived by reading the code that reads it — not copied from +another document. Other pages link here rather than restating variable +descriptions. + +::: tip Where these go +The variable names are identical across every deployment method; only the file +that holds them changes. + +- **Development** — `apps/backend/.env` (start from `apps/backend/.env-example`) +- **Docker Compose** — the `environment:` block, or a `.env` beside `docker-compose.yml` +- **RPM** — `/etc/heimdall-server/backend.env` +::: + +## How configuration is loaded + +Heimdall reads configuration in `apps/backend/config/app_config.ts`. Three +behaviors surprise people, so they are stated up front. + +**The process environment wins over the `.env` file.** `AppConfig.get()` is +`process.env[key] || envConfig[key]`. A variable exported in the shell, set in a +systemd unit, or injected by Kubernetes overrides the same key in `.env`. It is +not the other way around. + +**`.env` is read from the working directory.** The file is loaded with a +relative `fs.readFileSync('.env')`, so it is found relative to where the process +was started, not relative to the installed application. Starting the server from +a different directory silently loads no file — the application logs +`Unable to read configuration file .env!` and continues on the process +environment alone. + +**An empty value is not the same as a default.** Most reads use `||`, so an +empty string behaves like unset and the default applies. A few variables +validate instead and refuse to start; those are called out individually. + +## Known traps + +These have each cost someone real time. + +::: warning PORT is read by two different servers +`PORT` sets the backend's listen port (default `3000`). The frontend dev server +reads its own configuration from `apps/frontend/.env.development` and +deliberately reads nothing from `apps/backend/.env`. Setting `PORT` in the +backend `.env` to steer the frontend broke local development on 2026-08-10. +Leave `PORT` unset for local development and use `API_PROXY_TARGET` for the +frontend proxy. +::: + +::: warning NODE_ENV selects the database name +When `DATABASE_NAME` is unset, the database name is derived as +`heimdall-server-${NODE_ENV}`. Changing `NODE_ENV` therefore silently points +Heimdall at a different database. If **both** `DATABASE_NAME` and `NODE_ENV` are +unset the application throws at startup rather than guessing. +::: + +::: warning JWT_EXPIRE_TIME is not currently honored in the units it accepts +`JWT_EXPIRE_TIME` is converted to milliseconds and passed to `jsonwebtoken`'s +`expiresIn`, which interprets the number as **seconds**. Sessions therefore last +far longer than configured — the `60s` default yields roughly 16.6 hours, and +`1d` yields roughly 2.7 years. The value is separately clamped to a maximum of +two days before that conversion, so the clamp does not bound the resulting +session either. This is a known open defect; treat the configured value as +advisory until it is fixed. +::: + +## Core server + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `NODE_ENV` | Runtime mode: `development`, `production` or `test`. Also selects the database name when `DATABASE_NAME` is unset. | Yes | none | +| `PORT` | Port the backend listens on. | No | `3000` | +| `EXTERNAL_URL` | Public URL of the deployment, used to build OAuth callback URLs. Required for any external auth provider. | No | empty | +| `MAX_FILE_UPLOAD_SIZE` | Maximum evaluation upload size, in megabytes. | No | `50` | +| `WARNING_BANNER` | Text shown in the login banner. Empty means no banner. | No | empty | + +## Database + +`DATABASE_URL` is parsed at startup into the individual `DATABASE_*` components, +so it can be used instead of setting them separately. It does not appear in +`apps/backend/.env-example`, but the application does read it. + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `DATABASE_URL` | Full connection string. When set, it populates `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_HOST`, `DATABASE_NAME` and `DATABASE_PORT`. | No | none | +| `DATABASE_HOST` | Database hostname. | No | `127.0.0.1` | +| `DATABASE_PORT` | Database port. | No | `5432` | +| `DATABASE_USERNAME` | Database user. | No | `postgres` | +| `DATABASE_PASSWORD` | Database password. | No | empty | +| `DATABASE_NAME` | Database name. When unset, derived as `heimdall-server-${NODE_ENV}`. | No | derived | +| `DATABASE_SSL` | Enable TLS to the database. Any value other than `false` enables it. | No | `false` | +| `DATABASE_SSL_INSECURE` | Set to `true` to skip database certificate verification. A security risk; intended for self-signed development certificates only. | No | `false` | +| `DATABASE_SSL_KEY` | Client key — either an absolute path to the file, or the key material itself (detected by a `-BEGIN` marker). Required when `DATABASE_SSL` is enabled with client certificates. | No | none | +| `DATABASE_SSL_CERT` | Client certificate — path or inline material, same detection. | No | none | +| `DATABASE_SSL_CA` | Certificate authority — path or inline material, same detection. | No | none | + +::: warning +When a `DATABASE_SSL_*` value is given as a path, the file must exist at startup. +A missing file raises `SSL Key file does not exist` (or the `Cert`/`CA` +equivalent) and the application does not start. +::: + +## Password hashing + +Heimdall derives password hashes with PBKDF2 so that hashing is performed by a +FIPS 140-3 validated module. These values are validated at startup and **throw** +on anything out of range — they are never silently clamped. + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `FIPS_MODE` | `true` refuses to start unless the OpenSSL provider reports FIPS active. `false` disables the assertion. Any other value throws. When unset, no assertion runs and the application warns loudly at boot. | No | unset | +| `PASSWORD_HASH_ALGORITHM` | PBKDF2 digest: `sha256`, `sha384` or `sha512`. Any other value throws. | No | `sha512` | +| `PASSWORD_HASH_ITERATIONS` | PBKDF2 iteration count. Accepted range is `100000`–`10000000`; outside it, startup throws. | No | `600000` | +| `PASSWORD_MAX_LENGTH` | Maximum accepted password length when hashing. Accepted range is `1`–`128`. | No | `128` | +| `PASSWORD_KDF_CONCURRENCY` | Number of password derivations allowed to run concurrently. Minimum `1`. | No | `2` | +| `PASSWORD_HASH_WRITE_ENABLED` | `true` or `false`; any other value throws. Gates whether new credentials are written as PBKDF2. Set it `false` during a rolling deploy so older instances can still read newly written credentials, then enable it after cutover. | No | derived — see below | + +::: info How PASSWORD_HASH_WRITE_ENABLED behaves when unset +Leaving it unset is the normal case — the gate is then derived from the state of +the database, and an explicit value always overrides that derivation. + +- A durable marker exists, meaning PBKDF2 writes already began on this database — **enabled**. This is sticky across restarts. +- No marker and the `Users` table is empty, meaning a fresh install — **enabled**, because no older instance can exist. +- No marker and users already exist, meaning an upgrade — **disabled**, because a rolling window with older instances is possible. +::: + +::: danger PASSWORD_HASH_WRITE_ENABLED=false is incompatible with FIPS mode +With writes disabled, new credentials fall back to bcrypt — which generates the +hash outside the validated module. If FIPS mode is active, that combination +throws rather than producing a hash outside the boundary. Enable PBKDF2 writes +before enabling FIPS mode. +::: + +::: info Verification is never gated +Only the hashing path reads these values. Verification reads its parameters from +the stored hash, so credentials written under an earlier algorithm, iteration +count or length limit keep working after you change these settings. +::: + +## Authentication + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `JWT_SECRET` | Signing secret for session tokens. When unset, a value is generated at startup, which invalidates all sessions on every restart. | Yes in production | generated | +| `JWT_EXPIRE_TIME` | Token lifetime, clamped to a maximum of two days. See the trap above regarding units. | No | `60s` | +| `API_KEY_SECRET` | Signing secret for API keys. API keys are disabled entirely when this is unset. | No | none | +| `LOCAL_LOGIN_DISABLED` | `true` disables username/password login, leaving only external providers. | No | `false` | +| `REGISTRATION_DISABLED` | `true` prevents self-registration; only an administrator can create users. | No | `false` | +| `ONE_SESSION_PER_USER` | `true` limits each user to a single active session. | No | `false` | +| `ADMIN_EMAIL` | Email address of the seeded administrator account. | No | `admin@heimdall.local` | +| `ADMIN_PASSWORD` | Password for the seeded administrator. When unset, a random password is generated and printed once, during initial setup. | No | generated | +| `ADMIN_USES_EXTERNAL_AUTH` | `true` seeds the administrator as an external-auth user with no local password. | No | `false` | + +### LDAP + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `LDAP_ENABLED` | `true` enables LDAP authentication. | No | `false` | +| `LDAP_HOST` | LDAP server hostname. | Yes, for LDAP | none | +| `LDAP_PORT` | LDAP server port. | No | `389` | +| `LDAP_BINDDN` | Distinguished name used for lookups. | Yes, for LDAP | none | +| `LDAP_PASSWORD` | Password for the lookup account. | Yes, for LDAP | none | +| `LDAP_SEARCHBASE` | Search base, for example `OU=Users, DC=example, DC=local`. | Yes, for LDAP | none | +| `LDAP_SEARCHFILTER` | Search filter. Active Directory typically uses `sAMAccountName={{username}}`. | No | `(sAMAccountName={{username}})` | +| `LDAP_NAMEFIELD` | Attribute holding the user's full name. | No | `name` | +| `LDAP_MAILFIELD` | Attribute holding the user's email. | No | `mail` | +| `LDAP_SSL` | `true` connects with `ldaps://` instead of `ldap://`. | No | `false` | +| `LDAP_SSL_INSECURE` | `true` skips LDAP certificate verification. A security risk. | No | `false` | +| `LDAP_SSL_CA` | Certificate authority — path or inline material. | No | none | + +### OAuth and OIDC + +Setting a provider's `*_CLIENTID` is what enables that provider; leaving it unset +disables it. Every provider also needs `EXTERNAL_URL` set, because the callback +URL is built from it. + +#### GitHub + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `GITHUB_CLIENTID` | GitHub application client ID. Enables the provider. | Yes, for GitHub | none | +| `GITHUB_CLIENTSECRET` | GitHub application client secret. | Yes, for GitHub | none | +| `GITHUB_ENTERPRISE_INSTANCE_BASE_URL` | Base URL for GitHub Enterprise. | No | `https://github.com/` | +| `GITHUB_ENTERPRISE_INSTANCE_API_URL` | API URL for GitHub Enterprise. | No | `https://api.github.com/` | + +#### GitLab + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `GITLAB_CLIENTID` | GitLab application client ID. Enables the provider. | Yes, for GitLab | none | +| `GITLAB_CLIENTSECRET` | GitLab application client secret. | Yes, for GitLab | none | +| `GITLAB_SECRET` | Legacy name for the client secret, still accepted. | No | none | +| `GITLAB_BASEURL` | GitLab base URL, for self-managed instances. | No | `https://gitlab.com` | + +::: info Two names for the GitLab client secret +`GITLAB_CLIENTSECRET` is canonical — it matches the other providers. Earlier +releases read only `GITLAB_SECRET`, so that name remains supported and existing +deployments need no change. When both are set, `GITLAB_CLIENTSECRET` wins. +::: + +#### Google + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `GOOGLE_CLIENTID` | Google application client ID, usually ending in `.apps.googleusercontent.com`. Enables the provider. | Yes, for Google | none | +| `GOOGLE_CLIENTSECRET` | Google application client secret. | Yes, for Google | none | + +#### Okta + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `OKTA_CLIENTID` | Okta application client ID. Enables the provider. | Yes, for Okta | none | +| `OKTA_CLIENTSECRET` | Okta application client secret. | Yes, for Okta | none | +| `OKTA_DOMAIN` | Okta domain, for example `example.okta.com`. The issuer and endpoint URLs below are derived from it when they are not set explicitly. | Yes, for Okta | none | +| `OKTA_ISSUER_URL` | Override the derived issuer URL. | No | derived from `OKTA_DOMAIN` | +| `OKTA_AUTHORIZATION_URL` | Override the derived authorization endpoint. | No | derived | +| `OKTA_TOKEN_URL` | Override the derived token endpoint. | No | derived | +| `OKTA_USER_INFO_URL` | Override the derived user-info endpoint. | No | derived | +| `OKTA_USE_HTTPS_PROXY` | `true` routes Okta requests through the proxy named by `HTTPS_PROXY`. | No | `false` | + +#### Generic OIDC + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `OIDC_CLIENTID` | OIDC client ID. Enables the provider. | Yes, for OIDC | none | +| `OIDC_CLIENT_SECRET` | OIDC client secret. Note the underscore — this name differs from the other providers. | Yes, for OIDC | none | +| `OIDC_NAME` | Label shown on the login button. | Yes, for OIDC | empty | +| `OIDC_ISSUER` | Issuer URL, for example `https://example.auth0.com`. | Yes, for OIDC | none | +| `OIDC_AUTHORIZATION_URL` | Authorization endpoint. | Yes, for OIDC | none | +| `OIDC_TOKEN_URL` | Token endpoint. | Yes, for OIDC | none | +| `OIDC_USER_INFO_URL` | User-info endpoint. | Yes, for OIDC | none | +| `OIDC_EXTERNAL_GROUPS` | `true` maps groups from the provider. Groups are never created automatically — users are only mapped into groups that already exist. | No | `false` | +| `OIDC_USES_PKCE_S256` | `true` uses PKCE with the `S256` challenge method. | No | `false` | +| `OIDC_USES_PKCE_PLAIN` | `true` uses PKCE with the `plain` challenge method. Ignored when `OIDC_USES_PKCE_S256` is set. | No | `false` | +| `OIDC_USES_VERIFIED_EMAIL` | Set to `false` to accept provider emails that are not marked verified. | No | `true` | +| `OIDC_USE_HTTPS_PROXY` | `true` routes OIDC requests through the proxy named by `HTTPS_PROXY`. | No | `false` | +| `HTTPS_PROXY` | Proxy URL used when `OIDC_USE_HTTPS_PROXY` or `OKTA_USE_HTTPS_PROXY` is enabled. | No | none | + +## External interfaces + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `SPLUNK_HOST_URL` | Splunk host URL, without a port. Enables the Splunk integration in the frontend. | No | empty | +| `TENABLE_HOST_URL` | Tenable.SC host URL, without a port. Enables the Tenable integration in the frontend, and is the first entry on the outbound allowlist. | No | empty | +| `TENABLE_ADDITIONAL_HOST_URLS` | Further Tenable.SC hosts this server may contact, separated by commas or spaces. `TENABLE_HOST_URL` is always permitted; a request naming any other host is refused. | No | empty | +| `TENABLE_ALLOW_PRIVATE_ADDRESSES` | `true` permits outbound Tenable connections to private, loopback and link-local addresses. Leave it `false` unless your Tenable.SC genuinely runs on internal address space — see the note below. | No | `false` | +| `FORCE_TENABLE_FRONTEND` | `true` forces the Tenable interface in the frontend. | No | `false` | + +::: warning Outbound Tenable requests are restricted +The Tenable proxy takes its destination from the request, so it is guarded on three sides. A request +is refused unless the caller has a Heimdall session; unless the host it names is one of the origins +configured above, compared on scheme, host and port rather than by substring; and unless the address +that host resolves to is outside private, loopback and link-local space. Redirects are never +followed, so a permitted host cannot move the request elsewhere. + +Each refusal answers with its own code, so you can tell which check fired: `HOST_NOT_ALLOWED` (400) +for a host that is not configured, `UPSTREAM_ADDRESS_REFUSED` (502) for one that resolves into +blocked address space, and `UPSTREAM_REDIRECT_REFUSED` (502) for a host that tried to redirect. + +**Upgrading with Tenable.SC on an internal network:** the address check is new, and it defaults to +refusing private space. If your Tenable.SC is reachable only on an internal address you must set +`TENABLE_ALLOW_PRIVATE_ADDRESSES=true`, or the integration will answer `UPSTREAM_ADDRESS_REFUSED` +after the upgrade. Design and rationale: ADR-009. +::: + +## Classification banner + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `CLASSIFICATION_BANNER_TEXT` | Banner text, for example `CUI`. No banner is shown when this is empty. | No | empty | +| `CLASSIFICATION_BANNER_COLOR` | Banner background color. | No | `red` | +| `CLASSIFICATION_BANNER_TEXT_COLOR` | Banner text color. | No | `white` | + +## Deployment-method specific + +These are read by installation tooling or the runtime host, not by the +application itself. + +| Variable | Description | Where it applies | Default | +| --- | --- | --- | --- | +| `CYPRESS_TESTING` | `true` enables the end-to-end test support route. See the warning below before setting it. | Development and test only | unset | +| `UV_THREADPOOL_SIZE` | Size of libuv's thread pool, which is what PBKDF2 hashing runs on. Read by the Node runtime, not by Heimdall. Set to `8` by the Dockerfile, `cmd.sh` and the systemd unit. Raising `PASSWORD_HASH_ITERATIONS` without a matching thread pool starves concurrent logins. | Any | `8` where Heimdall's own launchers apply, otherwise Node's default of `4` | +| `NGINX_HOST` | Templated into the bundled NGINX configuration as `server_name`. Read by the setup scripts, never by the application. | Docker Compose, dev setup scripts | `localhost` | +| `LOG_FILE` | When set, the launcher redirects stdout and stderr to this path. Unset means logging to journald. The directory must be writable by the `heimdall` user. | RPM only | unset (journald) | +| `NODE_EXTRA_CA_CERTS` | Path to additional trusted CAs. Read by the Node runtime itself, not by Heimdall. Needed behind a TLS-inspecting proxy. | Any | none | +| `API_PROXY_TARGET` | Backend URL the frontend dev server proxies to. Lives in `apps/frontend/.env.development`. Unset or empty means no proxy, and the frontend runs as standalone Heimdall Lite. | Development only | empty | + +::: danger CYPRESS_TESTING unlocks an endpoint that deletes every user +Setting `CYPRESS_TESTING=true` while `NODE_ENV` is `development` or `test` +enables `POST /users/clear`, which truncates the `Users` table. Both conditions +must hold, and `development` is the value used for ordinary local work — so the +one variable is what stands between a development instance and an unauthenticated +route that empties user accounts. + +Set it only for an end-to-end test run, and never in an environment holding data +you care about. It has no effect when `NODE_ENV=production`. +::: + +## Documentation build + +These affect building this documentation site, not the application. + +| Variable | Description | Required | Default | +| --- | --- | --- | --- | +| `HEIMDALL_DOCS_TARGET` | Build target: `pages`, `local` or `app`. An unrecognized value fails the build. | No | `local` | +| `HEIMDALL_DOCS_BASE` | Base path, honored by the `app` target only. | No | `/docs/` | + +## Not environment variables + +The frontend source refers to `PACKAGE_VERSION`, `DESCRIPTION`, `REPOSITORY`, +`LICENSE`, `CHANGELOG`, `BRANCH` and `ISSUES` through `process.env`. These are +**not** runtime environment variables — they are substituted at build time from +`package.json` by webpack's `DefinePlugin`. Setting them in the environment has +no effect; change `package.json` and rebuild instead. + +## Documented elsewhere but not yet implemented + +The RPM manual page describes three password-complexity variables that this +application does not currently read. They are listed here so the discrepancy is +explicit rather than discovered in production. + +| Variable | Status | +| --- | --- | +| `PASSWORD_MIN_LENGTH` | Not read by Heimdall. Setting it has no effect today. | +| `PASSWORD_REQUIRE_CLASSES` | Not read by Heimdall. Setting it has no effect today. | +| `PASSWORD_MAX_CONSECUTIVE` | Not read by Heimdall. Setting it has no effect today. | diff --git a/docs/site/getting-started/index.md b/docs/site/getting-started/index.md new file mode 100644 index 0000000000..1b2d333374 --- /dev/null +++ b/docs/site/getting-started/index.md @@ -0,0 +1,40 @@ +--- +title: Getting Started +description: Installation, configuration and first steps for Heimdall. +--- + +# Getting Started + +Heimdall visualizes and analyzes security results in the Heimdall Data Format +(HDF). Start with whichever of these matches what you are trying to do. + +## I just want to look at some results + +```bash +npx @mitre/heimdall-lite +``` + +Heimdall Lite is the standalone viewer — no database, no server, nothing to +install. See [Installation](/getting-started/installation) for other ways to +run it. + +## I want to run the server + +Heimdall Server adds a backend and a PostgreSQL database, which is what gives +you accounts, saved evaluations, groups and the API. +[Installation](/getting-started/installation) indexes the supported methods — +Docker Compose, RPM, Kubernetes and source — and links to the full guide for +each. + +## I want to work on Heimdall itself + +[Quick Start](/getting-started/quick-start) gets you running locally from source +in development mode, which rebuilds as you edit. + +## In this section + +- [Quick Start](/getting-started/quick-start) — run locally from source, with the versions and commands this repository actually uses +- [Installation](/getting-started/installation) — the supported ways to deploy, and how to choose +- [Configuration](/getting-started/configuration) — how configuration is loaded and what takes precedence +- [Environment Variables](/getting-started/environment-variables) — every variable, with defaults verified against the source +- [Troubleshooting](/getting-started/troubleshooting) — what the common failures actually mean diff --git a/docs/site/getting-started/installation.md b/docs/site/getting-started/installation.md new file mode 100644 index 0000000000..b464cbfefc --- /dev/null +++ b/docs/site/getting-started/installation.md @@ -0,0 +1,74 @@ +--- +title: Installation +description: Index of the supported ways to install Heimdall, with guidance on choosing one. +--- + +# Installation + +Heimdall ships in two shapes, and picking the right one first saves the most +time. + +**Heimdall Lite** is the standalone viewer. It is a static single-page +application — it loads HDF results in your browser, stores nothing, and needs no +database and no server. If your goal is to look at scan results, this is the +whole answer. + +**Heimdall Server** adds a backend and a PostgreSQL database, which is what +gives you user accounts, saved evaluations, groups, and the API. Choose it when +results need to persist or be shared. + +## Heimdall Lite + +No installation: + +```bash +npx @mitre/heimdall-lite +``` + +Install it locally if you use it often — subsequent `npx` runs then start much +faster: + +```bash +npm install -g @mitre/heimdall-lite +``` + +Or run it as a container: + +```bash +docker run -d -p 8080:80 mitre/heimdall-lite:release-latest +``` + +It is then at `http://localhost:8080`. Substitute the `latest` tag for +`release-latest` if you want the bleeding-edge build rather than the released +one. + +## Heimdall Server + +Every method below installs the same application; they differ in how it is +supervised, upgraded and secured. Each has its own page under Deployment. + +| Method | Choose it when | Guide | +| --- | --- | --- | +| Docker Compose | You want the fastest supported server install. Brings up the database and a TLS-terminating NGINX alongside Heimdall. | [Deployment](/deployment/) | +| RPM | You are deploying to RHEL or a derivative and want systemd supervision, a system user and standard file locations. | [Deployment](/deployment/) | +| Kubernetes / Helm | You already run Kubernetes and want Heimdall managed the same way as everything else. | [Deployment](/deployment/) | +| From source | You are developing Heimdall, or you need a build no release provides. | [Quick Start](/getting-started/quick-start) | + +The Deployment section covers each in full, along with hardening, backup and +upgrade. This page deliberately does not repeat those instructions — one set of +install steps, in one place. + +## Before you install + +Two things are worth settling before any method: + +**Configuration.** All methods read the same environment variables; only the +file holding them changes. Read [Configuration](/getting-started/configuration) +for the model, and the +[Environment Variables reference](/getting-started/environment-variables) for +the variables themselves. + +**TLS.** The Docker Compose path generates a self-signed certificate valid for +**seven days** so a fresh install works immediately. That is fine for a trial +and wrong for anything else — replace it with a real certificate before anyone +depends on the instance. diff --git a/docs/site/getting-started/quick-start.md b/docs/site/getting-started/quick-start.md new file mode 100644 index 0000000000..42b617b338 --- /dev/null +++ b/docs/site/getting-started/quick-start.md @@ -0,0 +1,164 @@ +--- +title: Quick Start +description: Run Heimdall locally from source in development mode, with the versions and commands this repository actually uses. +--- + +# Quick Start + +This page gets Heimdall running **locally from source** in development mode — +the mode that rebuilds as you edit. It is the path to use when you are working +on Heimdall itself. + +If you only want to *look at* HDF results and have no interest in running a +server, skip all of this: + +```bash +npx @mitre/heimdall-lite +``` + +That runs Heimdall Lite, the standalone viewer — no database, no build, no +clone. For production server installs, see [Installation](/getting-started/installation). + +## Prerequisites + +| Requirement | Version | Check | +| --- | --- | --- | +| Node.js | **22.18.0 or newer** | `node --version` | +| Yarn | 1.x (Classic) | `yarn --version` | +| PostgreSQL | any currently supported release | `psql --version` | +| Git | any | `git --version` | + +::: warning Node 18 is out of date +Older installation notes — including parts of the repository README and the +GitHub wiki — tell you to install Node 18. That is wrong for this codebase. +`package.json` declares `"engines": {"node": ">=22.18.0"}` and `.nvmrc` pins +major version 22. Install on Node 18 and the toolchain will fail. + +With `nvm` installed, the repository's own pin does this for you: + +```bash +nvm use +``` +::: + +Yarn Classic is what this repository uses — the lockfile is `yarn.lock` and the +workspaces are Yarn v1 workspaces. Do not substitute npm or pnpm. + +## 1. Clone and install + +```bash +git clone https://github.com/mitre/heimdall2 +cd heimdall2 +yarn install +``` + +`yarn install` bootstraps every workspace — backend, frontend, and the shared +libraries under `libs/`. + +## 2. Create the database + +Heimdall needs a PostgreSQL database and a role that can create databases. The +role must be able to create them because the backend derives separate database +names per environment. + +```bash +# as a superuser, e.g. `sudo -u postgres psql` +CREATE USER heimdall WITH ENCRYPTED PASSWORD 'your-password'; +ALTER USER heimdall CREATEDB; +``` + +You do not create the database itself by hand — the name is derived from +`NODE_ENV`, and the backend creates it on first run. See the note under +[Configuration](/getting-started/configuration#the-database-name-is-derived). + +## 3. Configure + +Copy the template and edit it: + +```bash +cp apps/backend/.env-example apps/backend/.env +``` + +At minimum set `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `JWT_SECRET` and +`NODE_ENV=development`. Generate a secret rather than inventing one: + +```bash +openssl rand -hex 64 +``` + +Every variable, its default and its effect is documented in the +[Environment Variables reference](/getting-started/environment-variables) — +that page is the single source of truth, and nothing here restates it. + +::: tip Leave PORT unset for local development +`PORT` is read by the backend, which already defaults to `3000`. The frontend +dev server owns its own port and proxy settings in +`apps/frontend/.env.development`. Setting `PORT` in the backend's `.env` to +steer the frontend does not work and breaks local development. +::: + +## 4. Run + +```bash +yarn start:dev +``` + +This runs every workspace's `start:dev` in parallel with streamed output, so +backend and frontend rebuild on change. Leave it running. + +You get **two** servers, not one: + +| Server | Port | Use it for | +| --- | --- | --- | +| Frontend dev server | **printed on startup** — see below | **Open this one.** Serves the UI with hot reload and proxies API calls to the backend. | +| Backend API | `3000` unless `PORT` is set | The NestJS API. Hit it directly when working on the API or reading its responses. | + +::: warning Do not assume the frontend port +The dev server does not have a fixed port. It tries `8080` and moves up — +`8081`, `8082`, and so on — until it finds a free one, so the number changes +between machines and between runs depending on what else is listening. Read the +URL it prints in the `yarn start:dev` output and use that. + +The backend is different: it binds `3000` unless you set `PORT`, so it is +predictable. Opening it in a browser gets you the API, not the interface. +::: + +The other root scripts you are likely to want: + +| Command | What it does | +| --- | --- | +| `yarn start:dev` | development mode, rebuilds on change — **use this while developing** | +| `yarn build` | production build of every workspace | +| `yarn start:built` | build, then start the server against the built output | +| `yarn start` | start the backend only, without building first | + +::: danger Do not use development mode to deploy +Development mode rebuilds on change and makes tradeoffs that are wrong for a +real deployment. To run Heimdall for actual use, follow +[Installation](/getting-started/installation). +::: + +## Running this documentation locally + +These docs are **not** part of the application and `yarn start:dev` does not +start them. The site is a separate project with its own `package.json` and +lockfile, deliberately outside the Yarn workspaces, so it never enters the app's +dependency graph. There is no `/docs` route on the running server. + +To work on the documentation, run it on its own: + +```bash +cd docs +yarn install +yarn dev +``` + +`yarn build` in the same directory produces the static site and fails the build +on dead internal links. + +## Next steps + +- [Configuration](/getting-started/configuration) — how config is loaded, and which file applies to which install method +- [Environment Variables](/getting-started/environment-variables) — every variable, with defaults verified against the source +- [Troubleshooting](/getting-started/troubleshooting) — what the common failures actually mean +- [Installation](/getting-started/installation) — the supported ways to deploy for real use diff --git a/docs/site/getting-started/troubleshooting.md b/docs/site/getting-started/troubleshooting.md new file mode 100644 index 0000000000..91bf1b9cfa --- /dev/null +++ b/docs/site/getting-started/troubleshooting.md @@ -0,0 +1,136 @@ +--- +title: Troubleshooting +description: What Heimdall's common failures actually mean, traced to the code that produces them. +--- + +# Troubleshooting + +Each symptom below is tied to the behavior in the code that causes it, so you +can confirm the diagnosis rather than guess at it. + +## Uploads fail on large files + +There are **two independent size limits**, and raising one does not raise the +other. + +`MAX_FILE_UPLOAD_SIZE` controls the evaluation upload limit in megabytes and +defaults to `50`. Separately, the JSON body parser is capped at a hard-coded +`50mb`. + +The consequence: raising `MAX_FILE_UPLOAD_SIZE` above 50 does **not** let you +post a larger JSON body — that request is rejected by the body parser before the +upload limit is ever consulted. Below 50 MB, `MAX_FILE_UPLOAD_SIZE` is the +effective limit and lowering it works as expected. + +If a large HDF file fails to upload, check which limit you are hitting: a +rejection from the body parser is a parser-level error, not a Heimdall +validation message. + +## Splunk or Tenable connections are blocked in the browser + +Symptom: the server is configured correctly, but the browser console shows the +request to your Splunk or Tenable host refused by Content Security Policy. + +Heimdall sends a CSP whose `connect-src` allows only `'self'`, +`https://api.github.com`, `https://sts.amazonaws.com`, and — added at startup — +the values of `TENABLE_HOST_URL` and `SPLUNK_HOST_URL`. + +The important part is **at startup**. Those hosts enter the policy when the +process boots. Setting or changing either variable without restarting leaves the +old policy in place, and the browser blocks the connection no matter how correct +the server-side configuration is. Restart after changing them. + +Empty values are filtered out, so an unset host simply is not in the policy. + +## Content is blocked over HTTPS + +The policy includes `block-all-mixed-content`. Any resource loaded over plain +HTTP by a page served over HTTPS is blocked by the browser. This usually shows +up behind a reverse proxy that terminates TLS while something upstream still +emits `http://` URLs. + +## Heimdall will not load inside an iframe + +`frame-ancestors` is `'self'`. Embedding Heimdall in a page on another origin is +refused by the browser. This is deliberate. + +## Running over plain HTTP + +This works, and is supported. Helmet's default CSP includes +`upgrade-insecure-requests`, which rewrites requests to HTTPS and breaks HTTP +deployments; Heimdall **deliberately removes that directive** for exactly this +reason. + +So if a plain-HTTP deployment is redirecting to HTTPS, Heimdall's CSP is not the +cause — look at your reverse proxy or a browser HSTS entry from a previous HTTPS +visit to the same host. + +## Database errors at startup + +**"NODE_ENV and DATABASE_NAME are undefined."** There is no fallback database +name. When `DATABASE_NAME` is unset the name is derived as +`heimdall-server-${NODE_ENV}`, so if both are unset the application refuses to +start rather than guess. Set `NODE_ENV`. + +**Connecting to the wrong database.** Because the name is derived from +`NODE_ENV`, changing `NODE_ENV` silently moves Heimdall to a different database. +Data that has "disappeared" after a configuration change is usually intact in +the database belonging to the previous `NODE_ENV`. + +**"SSL Key file does not exist"** (or the `Cert` / `CA` equivalent). The +`DATABASE_SSL_*` variables accept either a path or the certificate material +itself, distinguished by a `-BEGIN` marker. Given a path, the file must exist +at startup or the application will not start. Check the path is readable by the +user the service runs as — under the RPM that is the `heimdall` user, not you. + +## Configuration changes have no effect + +Two causes, in order of likelihood. + +**Something in the environment is overriding the file.** Configuration is read +as `process.env[key] || envConfig[key]`, so a shell export, a systemd +`Environment=` line, or an injected Kubernetes variable beats the file. Editing +the file cannot win. + +**The file was never read.** It is loaded on a relative path, so it is resolved +against the process's working directory. Started from elsewhere, no file is +loaded and the application logs `Unable to read configuration file .env!` before +continuing on the environment alone. That log line is the fastest way to confirm +this. + +See [Configuration](/getting-started/configuration) for the full precedence +model. + +## Requests are rejected as rate-limited + +Heimdall applies rate limiting and returns a `Ratelimited` error when a client +exceeds it. Behind a reverse proxy this can appear to affect everyone at once if +the proxy does not pass the real client address — every request then looks like +it comes from one IP. Ensure the proxy forwards the client address. + +## Where the logs are + +| Install method | Logs | +| --- | --- | +| Local development | the terminal running `yarn start:dev` | +| Docker Compose | `docker compose logs -f server` | +| RPM | `journalctl -u heimdall-server -f` by default; if `LOG_FILE` is set, the launcher redirects output to that path instead | +| Kubernetes | `kubectl logs` against the Heimdall pod | + +Under the RPM, `LOG_FILE` changes where output goes — unset means journald. If +it is set, the directory must be writable by the `heimdall` user. + +## The docs site is not served by the application + +`yarn start:dev` does not start this documentation, and there is no `/docs` +route on a running server. The site is a separate project with its own +`package.json`, outside the application's workspaces. Run it with +`cd docs && yarn dev`. Serving it from the application for offline and airgapped +installs is planned but not yet implemented. + +## Node version errors during install or build + +This codebase requires **Node 22.18.0 or newer** (`.nvmrc` pins major version +22). Older instructions — including parts of the repository README and the +GitHub wiki — say Node 18, which is out of date and will fail. Run `nvm use` in +the repository root to pick up the pinned version. diff --git a/docs/site/index.md b/docs/site/index.md new file mode 100644 index 0000000000..5899fe7961 --- /dev/null +++ b/docs/site/index.md @@ -0,0 +1,38 @@ +--- +layout: home + +hero: + name: Heimdall + text: Visualize and analyze your security results + tagline: >- + The MITRE SAF viewer for InSpec results and 30+ other security data formats, + normalized through hdf-converters into a single view. + actions: + - theme: brand + text: Get Started + link: /getting-started/ + - theme: alt + text: Live Demo + link: https://heimdall-demo.mitre.org/ + - theme: alt + text: Deploy + link: /deployment/ + +features: + - title: View & Analyze + details: >- + Upload HDF results, filter and sort controls, and drill into findings with + the detail you need for review and hot-wash. + - title: 30+ Converters + details: >- + hdf-converters normalizes results from scanners, cloud posture tools and + checklists into the Heimdall Data Format, in both directions. + - title: Deploy Anywhere + details: >- + Docker, RPM, and cloud deployments, with enterprise authentication — + LDAP, OIDC, Okta, GitHub, GitLab and Google. + - title: Compliance-Ready + details: >- + NIST 800-53 control views, manual attestations, and exports to checklist, + CAAT and XCCDF formats. +--- diff --git a/docs/site/release-notes/index.md b/docs/site/release-notes/index.md new file mode 100644 index 0000000000..0fb4d27926 --- /dev/null +++ b/docs/site/release-notes/index.md @@ -0,0 +1,13 @@ +# Release Notes + +Versioned upgrade and migration notes — what changes between releases, and what +an operator must do about it. + +::: info Section under construction +This section is new: the wiki has no equivalent. It exists so breaking changes +and upgrade steps have a durable home rather than living only in release +descriptions. +::: + +Until entries are written here, see the +[GitHub releases](https://github.com/mitre/heimdall2/releases). diff --git a/docs/site/security/index.md b/docs/site/security/index.md new file mode 100644 index 0000000000..4552709d43 --- /dev/null +++ b/docs/site/security/index.md @@ -0,0 +1,15 @@ +# Security + +How Heimdall addresses security controls, and how to report a vulnerability. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Security control responses | Wiki: `Heimdall-Server-Security-Control-Responses` | diff --git a/docs/site/user-guide/index.md b/docs/site/user-guide/index.md new file mode 100644 index 0000000000..1840db354a --- /dev/null +++ b/docs/site/user-guide/index.md @@ -0,0 +1,19 @@ +# User Guide + +Using Heimdall day to day: loading results, managing access, and attesting to +controls. + +::: info Section under construction +The Heimdall documentation is moving from the GitHub wiki into this site +(ADR-005). This section is scaffolding — its pages arrive with the content +migration. +::: + +## Pages planned for this section + +| Page | Source | +| --- | --- | +| Overview | Wiki: `Home` (usage half) | +| Groups and users | Wiki: `Group-and-User-Management` | +| Attestations | Wiki: `Manual-Attestations` | +| Authentication methods | Wiki: `Heimdall-Authentication-Methods` | diff --git a/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 0000000000..ccc8223830 --- /dev/null +++ b/docs/yarn.lock @@ -0,0 +1,890 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/parser@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.8.tgz#9653716a2f10c677b98fbc63d4bfb000c302cf17" + integrity sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA== + dependencies: + "@babel/types" "^7.29.8" + +"@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@docsearch/css@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-4.7.0.tgz#d6d93c6ddf5e813a3ea09da719e150c222693a5c" + integrity sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw== + +"@docsearch/js@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@docsearch/js/-/js-4.7.0.tgz#6294b040c7a0e461f61120f54b0dc770af3916bf" + integrity sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA== + +"@docsearch/sidepanel-js@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@docsearch/sidepanel-js/-/sidepanel-js-4.7.0.tgz#d767ca71f72c4673db87229f64634786a255bb08" + integrity sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA== + +"@iconify-json/simple-icons@^1.2.92": + version "1.2.93" + resolved "https://registry.yarnpkg.com/@iconify-json/simple-icons/-/simple-icons-1.2.93.tgz#7c9105fe0d679a32cf846e0e72305899500db3c3" + integrity sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw== + dependencies: + "@iconify/types" "*" + +"@iconify/types@*": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" + integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== + +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@oxc-project/types@=0.143.0": + version "0.143.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.143.0.tgz#c3e4f3178b7b54e4dd194eac6d45258a60f0092b" + integrity sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA== + +"@rolldown/binding-android-arm64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz#001b8b0b01844701efda1bb6bed84b681c4a488b" + integrity sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw== + +"@rolldown/binding-darwin-arm64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz#5e87c602ed634a6fef092e2162e24fbfb881c4ec" + integrity sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA== + +"@rolldown/binding-darwin-x64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz#f32e0b286714bd03a421d693415d05d97d265b77" + integrity sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA== + +"@rolldown/binding-freebsd-x64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz#5f38ad5761b6b7b21b57a99566bb52634c60ab19" + integrity sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz#ab4dcd07f1bd88e8d659ae0c3bb9d2f290adb897" + integrity sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw== + +"@rolldown/binding-linux-arm64-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz#d279b7016039a725fb66d82784b9841f42df83da" + integrity sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw== + +"@rolldown/binding-linux-arm64-musl@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz#d08bbc93d2742214548c5adf7df7788944e5a89a" + integrity sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q== + +"@rolldown/binding-linux-ppc64-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz#6418e63745b3193f26ab3bb88744b3a4a1356d7c" + integrity sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w== + +"@rolldown/binding-linux-s390x-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz#77ec30d0704cf4eb1cb4a63f501c9852c6728cf4" + integrity sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg== + +"@rolldown/binding-linux-x64-gnu@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz#3b9b6e0dd3e86c597f42858748ca25f1dfd58ed8" + integrity sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w== + +"@rolldown/binding-linux-x64-musl@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz#f78033c592c8bd2af48284a45f8e4baaa0befbf5" + integrity sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A== + +"@rolldown/binding-openharmony-arm64@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz#36e951f5a6fca922a5205e283d0a82b9f98199ca" + integrity sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug== + +"@rolldown/binding-win32-arm64-msvc@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz#c1e494ac47e13bd857fca0b3ad59c33580241f7e" + integrity sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw== + +"@rolldown/binding-win32-x64-msvc@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz#b0effffcd6872f8a021373eb437916b1b52283a4" + integrity sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg== + +"@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + +"@shikijs/core@4.4.3", "@shikijs/core@^4.4.1": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-4.4.3.tgz#00a942fa45ad0e4146ac6dbbac32b8b704b42e3f" + integrity sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg== + dependencies: + "@shikijs/primitive" "4.4.3" + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + hast-util-to-html "^9.0.5" + +"@shikijs/engine-javascript@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz#42dbdc18ec2f86003624674839a8f090cdd7cb62" + integrity sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ== + dependencies: + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + oniguruma-to-es "^4.3.6" + +"@shikijs/engine-oniguruma@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz#a1754f9f42e0f35a55cda9a977599041a2ad5b07" + integrity sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w== + dependencies: + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + +"@shikijs/langs@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-4.4.3.tgz#113282396f119dbba8d3b5e86668258fa8df6e7b" + integrity sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A== + dependencies: + "@shikijs/types" "4.4.3" + +"@shikijs/primitive@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/primitive/-/primitive-4.4.3.tgz#86490cea63b3e2c56b8d9163046e010258844d81" + integrity sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ== + dependencies: + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + +"@shikijs/themes@4.4.3": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-4.4.3.tgz#8310a78261f4cf742663e07e2028df046a02bd72" + integrity sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw== + dependencies: + "@shikijs/types" "4.4.3" + +"@shikijs/transformers@^4.4.1": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/transformers/-/transformers-4.4.3.tgz#18869d95b0e2656fa7ae98350e09408c57585018" + integrity sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw== + dependencies: + "@shikijs/core" "4.4.3" + "@shikijs/types" "4.4.3" + +"@shikijs/types@4.4.3", "@shikijs/types@^4.4.1": + version "4.4.3" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-4.4.3.tgz#019aff19f0cbfb21642c59f6f8432ced74e27b45" + integrity sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + +"@shikijs/vscode-textmate@^10.0.2": + version "10.0.2" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" + integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== + +"@types/hast@^3.0.0", "@types/hast@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.5.tgz#48020de4c0e63492f4ca9db42068c108f68b7f8f" + integrity sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== + dependencies: + "@types/unist" "*" + +"@types/linkify-it@^5": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" + integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== + +"@types/markdown-it@^14.1.2": + version "14.1.2" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" + integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== + dependencies: + "@types/linkify-it" "^5" + "@types/mdurl" "^2" + +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/mdurl@^2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" + integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== + +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/web-bluetooth@^0.0.21": + version "0.0.21" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63" + integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== + +"@ungap/structured-clone@^1.0.0": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.3.tgz#094041e1a4cb1987f038335421281ac8be390bcc" + integrity sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg== + +"@vitejs/plugin-vue@^6.0.8": + version "6.0.8" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz#1809d090b7c93b8f4ae83d3e7536655cbbb1c793" + integrity sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew== + dependencies: + "@rolldown/pluginutils" "^1.0.1" + +"@vue/compiler-core@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz#82a4012d8b420f5a62c1658a72d16f7d1edf11aa" + integrity sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg== + dependencies: + "@babel/parser" "^7.29.8" + "@vue/shared" "3.5.41" + entities "^7.0.1" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz#1029f75de09c665a0a92c6156463754192acb361" + integrity sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw== + dependencies: + "@vue/compiler-core" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/compiler-sfc@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz#f9c7c2170ad6ee4bf35c178b5df026485d8a63db" + integrity sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ== + dependencies: + "@babel/parser" "^7.29.8" + "@vue/compiler-core" "3.5.41" + "@vue/compiler-dom" "3.5.41" + "@vue/compiler-ssr" "3.5.41" + "@vue/shared" "3.5.41" + estree-walker "^2.0.2" + magic-string "^0.30.21" + postcss "^8.5.19" + source-map-js "^1.2.1" + +"@vue/compiler-ssr@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz#8e5f9f6a8d21b802fce7373807dd935ed4541083" + integrity sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A== + dependencies: + "@vue/compiler-dom" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/devtools-api@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz#9d95de2b908aa80b9957d737ddca51790e70c3b5" + integrity sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A== + dependencies: + "@vue/devtools-kit" "^8.2.1" + +"@vue/devtools-kit@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz#ad45babb12c51931d32d1b67ffaebc251f550ce9" + integrity sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ== + dependencies: + "@vue/devtools-shared" "^8.2.1" + birpc "^2.6.1" + hookable "^5.5.3" + perfect-debounce "^2.0.0" + +"@vue/devtools-shared@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz#26524e9e12fd205bcd5477cf3b5e9a17b876aeac" + integrity sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g== + +"@vue/reactivity@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.41.tgz#83be61b88b198f21c157d3aa6dcb843a25a11872" + integrity sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA== + dependencies: + "@vue/shared" "3.5.41" + +"@vue/runtime-core@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz#a3e023c9f21809c81f24813dacb7ffa18a2e4161" + integrity sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg== + dependencies: + "@vue/reactivity" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/runtime-dom@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz#428f7a0420402385fae17d413d25169f98f64205" + integrity sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw== + dependencies: + "@vue/reactivity" "3.5.41" + "@vue/runtime-core" "3.5.41" + "@vue/shared" "3.5.41" + csstype "^3.2.3" + +"@vue/server-renderer@3.5.41": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz#035a38c79182f154495238ea83acba24266ac200" + integrity sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ== + dependencies: + "@vue/compiler-ssr" "3.5.41" + "@vue/runtime-dom" "3.5.41" + "@vue/shared" "3.5.41" + +"@vue/shared@3.5.41", "@vue/shared@^3.5.40": + version "3.5.41" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.41.tgz#ac476497f74495f7525087270849841756555cf2" + integrity sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA== + +"@vueuse/core@14.4.0", "@vueuse/core@^14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-14.4.0.tgz#a841da6b3c7d548bdeed5bf611e73f3de62d20fa" + integrity sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ== + dependencies: + "@types/web-bluetooth" "^0.0.21" + "@vueuse/metadata" "14.4.0" + "@vueuse/shared" "14.4.0" + +"@vueuse/integrations@^14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-14.4.0.tgz#32b6854e3d27bfe2cfee966a997f709022a7e3be" + integrity sha512-oJz9qTgczvA7L1nXQFRU7h8tQbOCoiceqvMMhT9XYMyOGTqLJ2rEa09PON+nD2t48sZUfeOmg4eaWJXV4sZb/w== + dependencies: + "@vueuse/core" "14.4.0" + "@vueuse/shared" "14.4.0" + +"@vueuse/metadata@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.4.0.tgz#a2508499b803bac14775a9c9b852b8738fc16f98" + integrity sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g== + +"@vueuse/shared@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-14.4.0.tgz#4e89813c7859d153d48c03d74bff78739f96723b" + integrity sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g== + +birpc@^2.6.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.9.0.tgz#b59550897e4cd96a223e2a6c1475b572236ed145" + integrity sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw== + +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + +csstype@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +focus-trap@^8.2.2: + version "8.2.2" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-8.2.2.tgz#6e8a203f2228ca8b5eb95465433e69a5d5e48387" + integrity sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug== + dependencies: + tabbable "^6.5.0" + +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +hast-util-to-html@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +mark.js@8.11.1: + version "8.11.1" + resolved "https://registry.yarnpkg.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ== + +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +minisearch@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/minisearch/-/minisearch-7.2.0.tgz#3dc30e41e9464b3836553b6d969b656614f8f359" + integrity sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg== + +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + +oniguruma-parser@^0.12.2: + version "0.12.2" + resolved "https://registry.yarnpkg.com/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz#e27ca446f7fcf0969662a3ab9b4f43176d62b139" + integrity sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw== + +oniguruma-to-es@^4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz#43e640280241b0d687a314e7a641d476407a1c4d" + integrity sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA== + dependencies: + oniguruma-parser "^0.12.2" + regex "^6.1.0" + regex-recursion "^6.0.2" + +perfect-debounce@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261" + integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.4, picomatch@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + +postcss@^8.5.19, postcss@^8.5.25: + version "8.5.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" + integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== + dependencies: + nanoid "^3.3.17" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +property-information@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.2.0.tgz#0809b34264e995c0bfcd3227028a1e35210af80a" + integrity sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg== + +regex-recursion@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-6.0.2.tgz#a0b1977a74c87f073377b938dbedfab2ea582b33" + integrity sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg== + dependencies: + regex-utilities "^2.3.0" + +regex-utilities@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280" + integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng== + +regex@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/regex/-/regex-6.1.0.tgz#d7ce98f8ee32da7497c13f6601fca2bc4a6a7803" + integrity sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg== + dependencies: + regex-utilities "^2.3.0" + +rolldown@~1.2.1: + version "1.2.3" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.3.tgz#103bdcbbd575d51265277b8b510f080827b6eb6f" + integrity sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A== + dependencies: + "@oxc-project/types" "=0.143.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.2.3" + "@rolldown/binding-darwin-arm64" "1.2.3" + "@rolldown/binding-darwin-x64" "1.2.3" + "@rolldown/binding-freebsd-x64" "1.2.3" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.3" + "@rolldown/binding-linux-arm64-gnu" "1.2.3" + "@rolldown/binding-linux-arm64-musl" "1.2.3" + "@rolldown/binding-linux-ppc64-gnu" "1.2.3" + "@rolldown/binding-linux-s390x-gnu" "1.2.3" + "@rolldown/binding-linux-x64-gnu" "1.2.3" + "@rolldown/binding-linux-x64-musl" "1.2.3" + "@rolldown/binding-openharmony-arm64" "1.2.3" + "@rolldown/binding-win32-arm64-msvc" "1.2.3" + "@rolldown/binding-win32-x64-msvc" "1.2.3" + +shiki@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-4.4.3.tgz#31fb41c5c82435779a0b5a9b92a3b0377b061e15" + integrity sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g== + dependencies: + "@shikijs/core" "4.4.3" + "@shikijs/engine-javascript" "4.4.3" + "@shikijs/engine-oniguruma" "4.4.3" + "@shikijs/langs" "4.4.3" + "@shikijs/themes" "4.4.3" + "@shikijs/types" "4.4.3" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.5" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + +tabbable@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.0.tgz#a65101385a4fd6cbd580b7546da0170f307b535d" + integrity sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA== + +tinyglobby@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +unist-util-is@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02" + integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz#9a2a28b0aa76a15e0da70a08a5863a2f060e2468" + integrity sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +vfile-message@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + +vite@^8.2.0: + version "8.2.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.1.tgz#6fc8d8bb843bd52353091fac978e194d4de5b31d" + integrity sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.5" + postcss "^8.5.25" + rolldown "~1.2.1" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vitepress@2.0.0-alpha.19: + version "2.0.0-alpha.19" + resolved "https://registry.yarnpkg.com/vitepress/-/vitepress-2.0.0-alpha.19.tgz#c5f1b1597e4199170e909f270849e25c2cadf033" + integrity sha512-WnBsb0Bwr43kXKyiis+lld/7ri3hnMbthS8N3hpFtjjwsdLO4IRmiAE08D7aud4q6oMDf9uwRowxzNqRFe/amw== + dependencies: + "@docsearch/css" "^4.7.0" + "@docsearch/js" "^4.7.0" + "@docsearch/sidepanel-js" "^4.7.0" + "@iconify-json/simple-icons" "^1.2.92" + "@shikijs/core" "^4.4.1" + "@shikijs/transformers" "^4.4.1" + "@shikijs/types" "^4.4.1" + "@types/markdown-it" "^14.1.2" + "@vitejs/plugin-vue" "^6.0.8" + "@vue/devtools-api" "^8.2.1" + "@vue/shared" "^3.5.40" + "@vueuse/core" "^14.4.0" + "@vueuse/integrations" "^14.4.0" + focus-trap "^8.2.2" + mark.js "8.11.1" + minisearch "^7.2.0" + shiki "^4.4.1" + vite "^8.2.0" + vue "^3.5.40" + +vue@^3.5.18, vue@^3.5.40: + version "3.5.41" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.41.tgz#8864bddfe59ce128a28c9bad219cd9248916af73" + integrity sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg== + dependencies: + "@vue/compiler-dom" "3.5.41" + "@vue/compiler-sfc" "3.5.41" + "@vue/runtime-dom" "3.5.41" + "@vue/server-renderer" "3.5.41" + "@vue/shared" "3.5.41" + +zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/eslint.config.mjs b/eslint.config.mjs index e088e8a20d..5ddd0e7145 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,3 @@ -/* eslint-disable n/no-extraneous-import */ import e18e from '@e18e/eslint-plugin'; import comments from '@eslint-community/eslint-plugin-eslint-comments/configs'; @@ -7,11 +6,15 @@ import json from '@eslint/json'; import markdown from '@eslint/markdown'; import stylistic from '@stylistic/eslint-plugin'; import vitest from '@vitest/eslint-plugin'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import { + createTypeScriptImportResolver, + defaultExtensions, +} from 'eslint-import-resolver-typescript'; import { importX } from 'eslint-plugin-import-x'; import markdownLinks from 'eslint-plugin-markdown-links'; import markdownPreferences from 'eslint-plugin-markdown-preferences'; import n from 'eslint-plugin-n'; -import perfectionist from 'eslint-plugin-perfectionist'; import promise from 'eslint-plugin-promise'; import regexp from 'eslint-plugin-regexp'; import security from 'eslint-plugin-security'; @@ -22,11 +25,36 @@ import tseslint from 'typescript-eslint'; import cypress from 'eslint-plugin-cypress'; import vue from 'eslint-plugin-vue'; -/* eslint-enable n/no-extraneous-import */ - export default defineConfig([ { - ignores: ['**/dist', '**/lib', '**/node_modules', 'libs/inspecjs/src/generated_parsers/**'], + // `docs` is the VitePress documentation site (ADR-005 §2.1): an isolated + // project with its own package.json, lockfile and toolchain, deliberately + // outside the workspaces globs. This config's typed linting cannot parse + // its .vitepress sources (they belong to no tsconfig project), and its + // markdown answers to the docs build — `yarn build` inside docs/, with + // dead-link checking — not to the application's markdown rules. + ignores: [ + '**/dist', + '**/lib', + '**/node_modules', + 'libs/inspecjs/src/generated_parsers/**', + 'docs/**', + // Static DATA tables and generated sources — extending the + // generated_parsers precedent directly above. These hold no logic, so + // linting them yields no correctness signal (TypeScript still type-checks + // them); what it did yield was 36k formatting complaints that buried the + // real findings. NiktoNistMappingData.ts alone produced 26,836 messages — + // 17,882 "use single quotes" and 8,941 "don't quote props", i.e. exactly + // 3 per data row. Scoped to the *MappingData.ts suffix on purpose: the + // sibling *Mapping.ts / *MappingItem.ts files ARE logic and stay linted. + '**/*MappingData.ts', + 'libs/hdf-converters/src/ckl-mapper/jsonixMapping.ts', // "Generated by jsonix-schema-compiler" + 'apps/frontend/src/utilities/cci_util.ts', // 14k-line CCI_DESCRIPTIONS table, zero functions + 'libs/hdf-converters/schemas/**/jsonix-compiler-output/**', // same generator as jsonixMapping.ts + 'libs/hdf-converters/data/reverse-html-mapper/tw-elements.min.js', // vendored minified bundle + 'libs/hdf-converters/sample_jsons/**', // mapper fixture corpus — inputs under test, not code + '**/.terraform/**', // provider binaries and docs vendored by tofu/terraform init + ], name: 'global ignores', }, { @@ -49,12 +77,35 @@ export default defineConfig([ }, tseslint.configs.stylisticTypeChecked, comments.recommended, - yml.configs.standard.map((cfg) => ({...cfg, name: 'yml/standard'})), + yml.configs.standard.map((config) => ({...config, name: 'yml/standard'})), security.configs.recommended, importX.flatConfigs.recommended, importX.flatConfigs.typescript, - { ...perfectionist.configs['recommended-natural'], name: 'perfectionist/recommended-natural' }, - { ...e18e.configs.modernization, name: 'e18e/modernization' }, + // perfectionist's preset is deliberately NOT extended. Its sort-* rules + // carry zero correctness value and every one is a `suggestion`-type + // autofix — the AST-rewriting class ESLint's own docs say must not be + // auto-applied when "a fix potentially changes functionality". In this + // repo they did exactly that: sort-decorators reordered sequelize + // decorators and the whole backend suite stopped collecting; + // sort-classes landed in an unsatisfiable conflict with + // unicorn/consistent-class-member-order; sort-objects reordering object + // literals contributed to a typing break in evaluations.service.ts. + // sort-objects is also unsound in principle — key order is semantic + // across a spread ({...defaults, mode} !== {mode, ...defaults}). + // The import-ordering rules were kept at first, then dropped too: + // sort-imports MOVES side-effect imports, which are order-dependent. + // Verified by dry run — it relocated `import '@mdi/font/...css'` behind + // the vuetify import in plugins/vuetify.ts, reordering the CSS cascade, + // and ControlRowDetails.vue has five prismjs imports where the core must + // load before its language components register onto it. No sort-* rule + // here can reach zero without rewriting order that carries meaning. + // e18e is adopted for its UNIQUE value only (2026-08-13 triage, Aaron: + // one generalist plugin, specialists per domain, extras curated + // rule-by-rule). Its modernization category is NOT extended — all ten + // rules re-check what unicorn/typescript-eslint already own, so the + // same site reported 3-5x (prefer-includes: unicorn + ts + e18e). + // Wholesale-extending an overlapping preset was the perfectionist + // mistake repeated. Two perf-category duplicates are disabled below. { ...e18e.configs.performanceImprovements, name: 'e18e/performanceImprovements' }, cypress.configs.recommended, vue.configs['flat/vue2-recommended'], @@ -81,12 +132,44 @@ export default defineConfig([ '@stylistic/eol-last': 'error', '@stylistic/object-curly-newline': ['error', { multiline: true }], '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + // Per-package below (2026-08-13 triage, Aaron) — this global default + // covers packages not scoped there. Measured usage decides direction. '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + // e18e duplicates disabled — the specialist owns each check: + // regexp/prefer-regexp-test (regexp plugin) and unicorn/prefer-array-some. + 'e18e/prefer-regex-test': 'off', + 'e18e/prefer-array-some': 'off', + // Uint8Array.fromBase64/toBase64 is TC39 Stage 4 but UNDEFINED at this + // repo's Node floor (22.18; 24.x in use) — the Buffer forms the rule + // flags are the correct code today. Revisit when the floor includes the + // API (2026-08-13 triage, Aaron). + 'unicorn/prefer-uint8array-base64': 'off', + // Same runtime-floor class, plus a direct conflict: this rule and + // prefer-spread both fire on iterator-to-array conversions, and the + // only form satisfying both is Iterator.prototype.toArray() — which + // the browser floor lacks (frontend, hdf-converters and inspecjs all + // ship in the browser bundle). Spread is the correct code today; + // revisit when the browser floor includes iterator helpers. + 'unicorn/prefer-iterator-to-array': 'off', '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unsafe-argument': 'off', + // The underscore prefix is the ecosystem's explicit intentionally-unused + // marker and this codebase already uses it (interface-conforming params, + // watcher signatures, mock methods). Honoring it is the rule's own + // documented configuration; unmarked unused vars still flag. + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', @@ -94,38 +177,504 @@ export default defineConfig([ '@typescript-eslint/prefer-nullish-coalescing': 'off', curly: 'error', 'n/no-missing-import': ['error', { tryExtensions: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.json'] }], - 'perfectionist/sort-imports': [ - 'error', - { - groups: [ - ['type-builtin', 'value-builtin'], - ['type-external', 'value-external'], - ['type-internal', 'value-internal'], - ['type-parent', 'value-parent'], - ['type-sibling', 'value-sibling'], - ['type-index', 'value-index'], - 'ts-equals-import', - 'unknown', - ], - newlinesBetween: 0, - type: 'natural', - useExperimentalDependencyDetection: true, - }, - ], 'prefer-object-has-own': 'error', - 'unicorn/filename-case': ['error', { case: 'snakeCase' }], + // kebabCase is the rule's own default, what NestJS generates + // (schematics normalizeToKebabOrSnakeCase dasherizes camelCase), and + // what this repo already is: apps/backend/src alone has 56 kebab-case + // files and zero snake_case. The previous snakeCase setting matched + // nothing and produced 343 errors — hidden until `|| true` was removed + // from lint:ci. Packages with a different measured convention get + // scoped case unions below (Aaron, 2026-08-13 triage: zero renames). + 'unicorn/filename-case': ['error', { case: 'kebabCase' }], + // 2026-08-13 triage (Aaron): dropped as vocabulary opinion — 281 hits + // meant mass-renaming exported symbols and Vue props for zero behavior + // gain. + 'unicorn/name-replacements': 'off', + // 2026-08-13 triage (Aaron): same class as name-replacements — is/has + // prefix enforcement renames public-ish booleans (Vue props, mapper + // options) for zero behavior gain. + 'unicorn/consistent-boolean-name': 'off', + // Same class again: its three hits rename EXPORTED types of a published + // package (FileMetaData, GenericPayloadWithMetaData), which every + // consumer of hdf-converters would have to follow, for no behavior gain. + 'unicorn/consistent-compound-words': 'off', 'unicorn/no-null': 'off', + // safe-regex's star-height heuristic, which cannot tell an ambiguous + // pattern from a merely nested one. The regexp plugin's + // no-super-linear-backtracking and no-super-linear-move model actual + // backtracking, are enabled, and report these same patterns clean — the + // DATABASE_URL tail and the ASFF quantifiers in this branch were found + // and fixed by THEM. Keeping both means acting on the cruder signal. + 'security/detect-unsafe-regex': 'off', 'unicorn/no-process-exit': 'off', + // The same check from a second plugin. Leaving it on would silently + // undo the line above, and its advice is wrong at the entry points that + // trip it: throwing inside bootstrap().catch() produces the unhandled + // rejection those handlers exist to prevent. + 'n/no-process-exit': 'off', + // 2026-08-13 triage (Aaron): style family dropped — zero correctness + // value and every fixer is suggestion-type (AST-rewriting). + // no-useless-else's fixer DESTROYS continue statements (inspecjs stack + // overflow; docs/development/eslint-config-decisions.md hazard #6). + 'unicorn/no-for-each': 'off', + 'unicorn/no-useless-else': 'off', 'unicorn/prefer-node-protocol': 'off', + 'unicorn/prefer-ternary': 'off', 'unicorn/prevent-abbreviations': 'off', + 'unicorn/switch-case-braces': 'off', + // 2026-08-13 triage (Aaron): adding u/v flags to 180 working legacy + // regexes (144 in hdf-converters mappers) is 180 semantic changes each + // needing per-pattern equivalence proof against golden fixtures — cost + // far exceeds value. New code can adopt the flags freely. + 'regexp/require-unicode-regexp': 'off', + 'regexp/require-unicode-sets-regexp': 'off', + }, + // Without this, import-x/n resolve imports from the REPO ROOT and never see + // apps/frontend/tsconfig.json, where `@/*` -> `./src/*` is defined. Every + // `@/...` import in the frontend then reports as unresolved — ~744 errors + // for imports that resolve correctly in every real build. Listing each + // package's tsconfig lets the resolver pick the one closest to the file + // being linted (see the resolver's affinity sorting). + settings: { + // lodash is CommonJS and builds its export object dynamically, so + // import-x cannot statically enumerate its members. With 62 files doing + // `import * as _ from 'lodash'`, import-x/namespace reported 797 errors + // of the form "'get' not found in imported namespace '_'" — for + // functions that demonstrably exist (`typeof _.get === 'function'`). + // Every one was a false positive. This is the rule's documented escape + // hatch for modules whose exports can't be analyzed; it is scoped to + // lodash, so namespace checking stays active for every other module. + 'import-x/ignore': ['lodash'], + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ + extensions: [...defaultExtensions, '.vue'], + noWarnOnMultipleProjects: true, + project: [ + 'tsconfig.json', + 'apps/*/tsconfig.json', + 'libs/*/tsconfig.json', + 'test/tsconfig.json', + ], + }), + ], + }, + }, + { + // Sequelize migrations are plain CJS scripts outside every tsconfig, so + // the type-aware projectService fatals on all ~31 of them ("was not found + // by the project service") and they were silently not linted at all. + // disableTypeChecked is typescript-eslint's documented answer for files + // outside the project: full linting minus the rules that need type info. + // Sequelize migrations and seeders are plain CJS scripts outside every + // tsconfig, so the type-aware projectService fatals on all of them ("was + // not found by the project service") and they were silently not linted at + // all. disableTypeChecked is typescript-eslint's documented answer for + // files outside the project: full linting minus the rules needing type + // info. + extends: [tseslint.configs.disableTypeChecked], + files: [ + 'apps/backend/migrations/**/*.js', + // Support modules for the seeders. They live OUTSIDE seeders/ because + // sequelize-cli loads every file in that directory as a seeder and calls + // up() on it, but they are the same class of file for linting: plain CJS + // outside every tsconfig. + 'apps/backend/seed-support/**/*.js', + 'apps/backend/seeders/**/*.js', + ], + languageOptions: { parserOptions: { projectService: false } }, + name: 'ts/sequelize-scripts-untyped', + rules: { + // The standard sequelize signature is (queryInterface, Sequelize) and + // ~31 shipped migrations never use the second param. Editing historical + // migrations is churn on files nobody should touch (Aaron, 2026-08-13 + // triage) — ignore that one name here; real unused vars still flag. + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_|^Sequelize$' }], + // CommonJS scripts: require() IS their module system. + '@typescript-eslint/no-require-imports': 'off', + // A migration's filename is an identifier stored in the SequelizeMeta + // table on every deployment (seeders likewise in SequelizeData) — + // renaming one causes it to re-run. The names are frozen data, not a + // style choice. + 'unicorn/filename-case': 'off', + }, + }, + { + // Same project-service fatal, different remedy: these loose CJS scripts + // (Lite's npm-shipped server, PostCSS config, test support servers, the + // FIPS bench spike) are real code and keep every rule — they just cannot + // have type-aware linting, belonging to no tsconfig project. + extends: [tseslint.configs.disableTypeChecked], + files: [ + 'apps/frontend/src/server.js', + 'packaging/**/*.js', + 'postcss.config.js', + 'test/support/**/*.js', + ], + languageOptions: { parserOptions: { projectService: false } }, + name: 'ts/loose-cjs-scripts-untyped', + }, + { + // The same loose CJS scripts, plus vue.config.js (CJS by webpack + // contract): __dirname/module.exports are correct here — same rationale + // as the backend/libs scope-off above. + files: [ + 'apps/frontend/src/server.js', + 'apps/frontend/vue.config.js', + 'packaging/**/*.js', + 'postcss.config.js', + 'test/support/**/*.js', + ], + name: 'unicorn/loose-cjs-prefer-module', + plugins: { unicorn }, + rules: { + 'unicorn/prefer-module': 'off', + // Same contract: require() IS these scripts' module system. + '@typescript-eslint/no-require-imports': 'off', + }, + }, + { + // consistent-type-definitions per MEASURED package majority (2026-08-13 + // triage, Aaron — zero-churn philosophy, same as filenames): frontend + // 40:16, inspecjs 43:1 and common are interface codebases; backend (21:0 + // type) and hdf-converters (39:29 type) keep the global 'type'. The + // interface->type fixer is the one that broke declare-module before — + // never run it across module augmentations. The cypress tree (test/) is + // interface-mode structurally: its Cypress.Chainable global augmentation + // works ONLY through interface declaration merging. + files: [ + 'apps/frontend/**/*.{ts,vue}', + 'libs/common/**/*.ts', + 'libs/inspecjs/**/*.ts', + 'test/**/*.ts', + ], + name: 'ts/interface-packages', + rules: { + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + }, + }, + { + // Mapper pipelines nest calls as a deliberate style; extracting named + // intermediates at 130 sites is churn, not clarity (2026-08-13 triage, + // Aaron). The handful of hits elsewhere get fixed by hand. + files: ['libs/hdf-converters/**'], + name: 'unicorn/hdf-nested-calls', + plugins: { unicorn }, + rules: { 'unicorn/max-nested-calls': 'off' }, + }, + { + // Filename-case unions per package, from MEASURED conventions (2026-08-13 + // triage, Aaron: zero renames). Each union is that package's existing + // styles — the rule still blocks any NEW style from appearing. Measured: + // frontend .vue 95/95 PascalCase (Vue's own style-guide convention); + // frontend .ts snake/flat/Pascal mix; hdf-converters src kebab-majority + // with PascalCase classes and 3 camel; hdf-converters test 33/35 + // snake_case (*_mapper.spec.ts); inspecjs flat/snake/kebab; test/ + // Pascal-majority. Backend stays strict kebab via the global rule. + files: ['apps/frontend/**/*.vue'], + name: 'unicorn/filename-vue', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, pascalCase: true } }], + }, + }, + { + files: ['apps/frontend/**/*.ts'], + name: 'unicorn/filename-frontend-ts', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, pascalCase: true, snakeCase: true } }], + }, + }, + { + files: ['libs/hdf-converters/src/**'], + name: 'unicorn/filename-hdf-src', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { camelCase: true, kebabCase: true, pascalCase: true } }], + }, + }, + { + files: ['libs/hdf-converters/test/**', 'libs/inspecjs/**'], + name: 'unicorn/filename-snake-test-dirs', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, snakeCase: true } }], + }, + }, + { + files: ['test/**'], + name: 'unicorn/filename-e2e', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': ['error', { cases: { kebabCase: true, pascalCase: true } }], }, }, + { + // Cypress specs assert through cy.should chains, chai expect().to + // matchers, and page-object verifiers — none of which are vitest tests, + // so vitest's expectation heuristics misfire across the whole e2e tree + // (test/ at the repo root), not just the .cy.ts specs. + files: ['**/*.cy.ts', 'test/**'], + name: 'vitest-off-for-cypress', + plugins: { vitest }, + rules: { + 'vitest/expect-expect': 'off', + 'vitest/no-standalone-expect': 'off', + 'vitest/valid-expect': 'off', + 'vitest/valid-title': 'off', + }, + }, + { + // Cypress .then() is a Chainable continuation, not a Promise — its + // callbacks assert and return nothing by design, so the promise + // plugin's then-shape rules misfire on every page-object verifier. + files: ['**/*.cy.ts', 'test/support/**'], + name: 'promise-off-for-cypress-chainables', + rules: { + 'promise/always-return': 'off', + // innerText is the rendered, user-visible text — which is precisely what + // an end-to-end verifier asserts about. textContent would also return + // text from hidden nodes, quietly weakening every one of these checks. + 'unicorn/prefer-dom-node-text-content': 'off', + // chai states some assertions as properties rather than calls + // (`.to.exist`, `.to.be.true`); that is its documented API, not a + // statement someone forgot to finish. + '@typescript-eslint/no-unused-expressions': 'off', + }, + }, + { + // Data, not code (2026-08-13 triage, Aaron: rename code, ignore data): + // inspecjs parse_testbed fixtures and the vendor-shaped .d.ts type files + // keep their upstream-derived names; README.md is the ecosystem + // convention. Renaming data and docs is churn with no value. + files: [ + '**/README.md', + 'libs/hdf-converters/types/**', + 'libs/inspecjs/parse_testbed/**', + ], + name: 'unicorn/filename-data', + plugins: { unicorn }, + rules: { + 'unicorn/filename-case': 'off', + }, + }, + { + // These URLs are data, not endpoints this project calls. The ZAP fixtures + // pass a scanned host through the mapper and compare against stored + // output, so changing the scheme changes the expected result; and the + // CycloneDX schema identifier is http in the upstream specification, which + // is what the type mirrors. + files: ['libs/hdf-converters/test/**', 'libs/hdf-converters/types/**'], + name: 'unicorn/fixture-and-schema-urls', + plugins: { unicorn }, + rules: { + 'unicorn/prefer-https': 'off', + }, + }, + { + // import-x/no-named-as-default-member cautions that `plugin.configs` may + // not survive some CJS/ESM interop paths. In this file the pattern + // (`tseslint.configs...`, `yml.configs...`) is the flat-config idiom every + // plugin's own documentation uses, and the rule's suggested named imports + // would collide — six plugins here all export `configs`. Config file only; + // application code keeps the rule. + files: ['eslint.config.mjs'], + name: 'import-x/flat-config-idiom', + rules: { 'import-x/no-named-as-default-member': 'off' }, + }, + { + // CommonJS entry scripts: nest builds the backend to CJS and the EC2 + // test-infra harnesses are plain CJS scripts, so top-level await does + // not exist for them — `entry().catch(...)` is the entry idiom that + // keeps a failed boot exiting nonzero instead of surfacing as an + // unhandled rejection. File-scoped because that is eslint's granularity; + // these files are thin entry shells, with the real logic in modules + // that keep both rules. + files: [ + 'apps/backend/src/main.ts', + 'packaging/test-infra/**/*.js', + 'test/support/server/**/*.js', + ], + name: 'unicorn/cjs-entry-points', + plugins: { unicorn }, + rules: { + 'unicorn/prefer-await': 'off', + 'unicorn/prefer-top-level-await': 'off', + }, + }, + { + // Vue 2 installs plugins by calling Vue.use() at module scope, and it must + // run before the Router and Store instances these modules construct and + // export. Registering the global navigation guard is likewise what + // router.ts is FOR. The side effect is the module's purpose here, not an + // accident of one; the rest of the frontend keeps the rule. + files: [ + 'apps/frontend/src/router.ts', + 'apps/frontend/src/store/store.ts', + 'apps/frontend/src/components/global/upload-tabs/FileReader.vue', + ], + name: 'unicorn/vue-plugin-installation', + plugins: { unicorn }, + rules: { + 'unicorn/no-top-level-side-effects': 'off', + }, + }, + { + // NestJS's documented convention names a DTO parameter after its class — + // `createCatDto: CreateCatDto` (docs.nestjs.com/controllers) — and every + // generator and sibling Nest codebase reads this way. The rule's only + // option is WHICH verbs to check (no suffix or pattern exemption), so the + // framework convention cannot be carved out more narrowly than the tree + // that follows it. + files: ['apps/backend/src/**/*.ts'], + name: 'unicorn/nest-dto-parameter-names', + plugins: { unicorn }, + rules: { + 'unicorn/no-non-function-verb-prefix': 'off', + }, + }, + { + // security/detect-non-literal-fs-filename flags OWASP path traversal: + // fs calls whose path an attacker might influence. In spec files the + // paths are the test's own fixtures (tmpdir + literals) — there is no + // attacker input by construction, and lint:ci's --max-warnings 0 + // escalates the rule's deliberate warn severity into a hard failure. + // Production code keeps the warning. + files: ['**/*.spec.ts', '**/*.spec.js', 'test/**', '**/test/**', '**/tests/**'], + name: 'security/spec-fixture-paths', + rules: { + 'security/detect-non-literal-fs-filename': 'off', + // Same reasoning: test code indexes its own fixtures with its own loop + // counters — there is no attacker input in a spec by construction. + 'security/detect-object-injection': 'off', + }, + }, + { + // Maintainer-run fixture conversion tooling (csv2json/xml2json): the + // path arguments are the operator's own CLI inputs on their own machine — + // never request data. + files: ['libs/hdf-converters/data/**'], + name: 'security/maintainer-data-tooling', + rules: { + 'security/detect-non-literal-fs-filename': 'off', + }, + }, + { + // unicorn/prefer-module exists to drive ESM migration and forbids + // __dirname/__filename, which "are not available in JavaScript modules". + // These packages all extend the root tsconfig (module: nodenext) with no + // "type": "module", so they COMPILE TO COMMONJS — __dirname is correct + // there and import.meta is a syntax error. The rule is also auto-fixable: + // a --fix pass would rewrite __dirname to import.meta and break the build + // (same hazard class as sort-decorators; see + // docs/development/eslint-config-decisions.md). Scoped off until an ESM + // migration; the bundler-resolved frontend keeps it. + files: [ + 'apps/backend/**/*.{js,cjs,ts,cts}', + 'libs/**/*.{js,cjs,ts,cts}', + ], + name: 'unicorn/cjs-compile-target', + rules: { 'unicorn/prefer-module': 'off' }, + }, + { + // eslint-plugin-n models NODE.JS runtime resolution. apps/frontend is a + // webpack-bundled Vue app whose `@/*` imports are resolved by the BUNDLER + // via tsconfig paths — Node never resolves them, so the rule reports 326 + // failures for imports that are correct. `settings.n.tsconfigPath` does not + // teach it those aliases (verified: relative and absolute both still fail). + // import-x/no-unresolved covers the same ground and, with the resolver + // configured above, reports them correctly — so this is redundant here, + // not merely inconvenient. The backend IS a Node app and keeps the rule. + files: ['apps/frontend/**/*.{js,mjs,cjs,ts,mts,cts,vue}'], + name: 'n/frontend-bundler-resolution', + rules: { 'n/no-missing-import': 'off' }, + }, + { + // Same rule, same reason, for the published libs' TypeScript sources: + // Node only ever resolves their COMPILED output (lib/**/index.js exists + // after build), while the TS-source directory imports and the types-only + // @microsoft/microsoft-graph-types package (no JS entry by design) are + // resolved by tsc/vitest. The rule's Node-runtime model cannot see either, + // and import-x/no-unresolved already checks these files with the TS + // resolver. The backend keeps the rule — it is the actual Node app. + files: ['libs/**/*.ts'], + name: 'n/lib-source-tsc-resolution', + rules: { + 'n/no-missing-import': 'off', + // Same source-vs-compiled mismatch, other direction: these packages + // publish lib/ (see each package.json "files"), so every import of a + // sibling SOURCE file reads as unpublished to the rule. What ships is + // lib/index.js importing lib/compat_wrappers.js, which is published. + 'n/no-unpublished-import': 'off', + }, + }, + { + // eslint-plugin-n models the NODE runtime, where navigator and + // localStorage are recent/experimental builtins. apps/frontend is a + // BROWSER app: these are long-standing DOM APIs there, and the bare + // spelling is the form both prefer-global-this and + // no-unnecessary-global-this accept. The backend keeps the rule — it is + // the actual Node app. + files: ['apps/frontend/**/*.{js,mjs,cjs,ts,mts,cts,vue}'], + name: 'n/frontend-browser-globals', + rules: { 'n/no-unsupported-features/node-builtins': 'off' }, + }, + { + // helmet and vue-cookies are CommonJS packages whose default export IS the + // middleware/plugin, and whose interop also surfaces it under its own + // name. Importing the default is deliberate here — the rule's suggestion + // would swap a value that works for one that only looks tidier. + files: ['apps/backend/src/main.ts', 'apps/frontend/src/main.ts'], + name: 'import-x/cjs-default-plugins', + rules: { 'import-x/no-named-as-default': 'off' }, + }, + { + // The direct element replacement this rule prefers is exactly the bug that + // was fixed here: Vue 2 cannot observe `arr[i] = x`, so the write lands + // silently and nothing re-renders. splice IS the reactive replacement, and + // the call sites say so in their own comments. + files: ['apps/frontend/**/*.{js,ts,vue}'], + name: 'unicorn/vue2-reactive-array-writes', + plugins: { unicorn }, + rules: { 'unicorn/no-confusing-array-splice': 'off' }, + }, + { + // These regexes ARE the STIG password rules, and their siblings depend on + // case being significant (separate lowercase and uppercase checks). Folding + // two of them onto an `i` flag makes the set read as though case does not + // matter, in a published package that ships no tests to catch a later + // mistake. Left explicit on purpose. + files: ['libs/password-complexity/**'], + name: 'regexp/password-rules-stay-explicit', + rules: { 'regexp/use-ignore-case': 'off' }, + }, { extends: [json.configs.recommended], files: ['**/*.json'], ignores: ['package-lock.json', 'parse_testbed/**', 'schemas/**'], language: 'json/json', name: 'json', + }, + { + // Captured scanner output, not authored JSON: real InSpec runs emit a + // control's `check`/`fix` keys more than once, and last-wins is exactly + // what the parser under test sees. Editing a fixture to satisfy the rule + // would stop it matching the tool it was captured from. + files: ['apps/frontend/tests/hdf_data/**/*.json'], + language: 'json/json', + name: 'json/captured-fixtures', + plugins: { json }, + rules: { 'json/no-duplicate-keys': 'off' }, + }, + { + // VS Code config files are JSONC — comments are part of the format. The + // strict json language fatals on the first `//` ("Unexpected character + // '/'"), which silently un-linted .vscode/. This files/language pairing is + // @eslint/json's own documented example for exactly these files. + extends: [json.configs.recommended], + files: ['**/*.jsonc', '.vscode/*.json'], + language: 'json/jsonc', + name: 'jsonc', plugins: { json }, }, { @@ -134,6 +683,14 @@ export default defineConfig([ language: 'json/json', name: 'package.json', plugins: { json }, + rules: { + // Dependency migrations (axios→fetch, lodash→native, moment→dayjs, + // jsonwebtoken→jose, uuid/rimraf/dotenv→native) are engineering + // projects, not lint fixes. Captured as the dependency-modernization + // epic on the board; rule returns when that epic is worked + // (2026-08-13 triage, Aaron). + 'e18e/ban-dependencies': 'off', + }, }, { extends: [ @@ -142,10 +699,107 @@ export default defineConfig([ markdownPreferences.configs.standard, ], files: ['**/*.md'], + // UPSTREAM BUG WORKAROUND (2026-08-12). @eslint/markdown 8.0.2 on ESLint + // 10.5.0 throws "Custom getLoc() method must be implemented in the + // subclass" (from @eslint/plugin-kit 0.7.2) when a GFM email autolink sits + // inside UNDERSCORE emphasis. Minimal reproduction: + // _a@b.com_ -> crash + // *a@b.com* -> fine + // **a@b.com** -> fine + // a@b.com -> fine + // _no-at-sign_ -> fine + // The crash aborts the ENTIRE eslint run, not just the offending file, so a + // single line of markdown silently disabled linting for the whole repo — + // which is how ~56k violations accumulated unnoticed (`lint:ci` also had a + // `|| true` that hid the non-zero exit). + // Only one tracked file trips it: the RPM man page, at + // `**ADMIN_EMAIL**=_admin@heimdall.local_`. + // Remove this ignore once the upstream fix lands and @eslint/markdown is + // bumped; verify with the five-line reproduction above. + ignores: [ + 'packaging/rpm/man/heimdall-server-backend.env.5.md', + // Copies of external standards (MITRE SAF license text, Contributor + // Covenant). Matching upstream BYTE-FOR-BYTE is their requirement, so + // no markdown rule can legitimately fire on them — and fixers must + // never rewrite them. A layout `--fix` pass converted LICENSE.md's + // Apache URL line to a fenced block on 2026-08-13 before this ignore + // existed; the files were restored from HEAD. + '**/LICENSE.md', + 'CODE_OF_CONDUCT.md', + ], language: 'markdown/gfm', name: 'markdown', plugins: { markdown }, + // Formatting belongs to Prettier, which formats markdown too (Aaron, + // 2026-08-13 triage). These are markdown-preferences' pure-formatting + // rules — the exact analogue of the @stylistic rules eslint-config- + // prettier switches off for code, which it cannot do here because it does + // not know this plugin. They accounted for ~800 problems, 85% in + // packaging/. The plugin's correctness/content rules (prefer-fenced-code- + // blocks, prefer-autolinks, link checks) stay on. + rules: { + 'markdown-preferences/bullet-list-marker-style': 'off', + 'markdown-preferences/emphasis-delimiters-style': 'off', + 'markdown-preferences/hard-linebreak-style': 'off', + 'markdown-preferences/indent': 'off', + 'markdown-preferences/no-multi-spaces': 'off', + 'markdown-preferences/no-multiple-empty-lines': 'off', + 'markdown-preferences/no-trailing-spaces': 'off', + 'markdown-preferences/ordered-list-marker-sequence': 'off', + 'markdown-preferences/padding-line-between-blocks': 'off', + 'markdown-preferences/table-pipe-alignment': 'off', + 'markdown-preferences/table-pipe-spacing': 'off', + // Its fixer converts RELATIVE links — [SECURITY.md](SECURITY.md) became + // on 2026-08-13 — but angle-bracket autolinks require an + // absolute URI with a scheme, so the output is not a link at all; + // renderers treat it as a raw HTML tag. A fixer that produces invalid + // markdown from valid markdown cannot be trusted on any file. + 'markdown-preferences/prefer-autolinks': 'off', + }, + }, + { + // vue-router's push shares Array#push's name, and these files' push + // calls are ALL router navigations whose promise handling + // no-floating-promises requires (void for benign duplicate-navigation + // rejections, await inside try/catch) — the two rules collide head-on + // on every site. The syntactic rule cannot see receivers; real array + // pushes stay linted everywhere else. + files: [ + 'apps/frontend/src/mixins/RouteMixin.ts', + 'apps/frontend/src/components/global/RegistrationModal.vue', + 'apps/frontend/src/components/global/login/LDAPLogin.vue', + 'apps/frontend/src/components/global/login/LocalLogin.vue', + 'apps/frontend/src/views/Base.vue', + 'apps/frontend/src/views/Login.vue', + ], + name: 'unicorn/router-push-not-array-push', + plugins: { unicorn }, + rules: { + 'unicorn/no-return-array-push': 'off', + }, + }, + { + // GitHub issue templates use square-bracket placeholders ([e.g. iOS], + // [...]) — GitHub's own stock template convention, which the label-ref + // rule reads as broken reference links. Placed AFTER the markdown block: + // its preset extension would otherwise re-enable the rule. + files: ['.github/ISSUE_TEMPLATE/**/*.md'], + language: 'markdown/gfm', + name: 'markdown/issue-template-placeholders', + plugins: { markdown }, + rules: { + 'markdown/no-missing-label-refs': 'off', + }, }, + // MUST BE LAST. eslint-config-prettier only turns rules OFF — every + // formatting rule that would fight the formatter — so anything placed after + // it would switch those rules back on and reintroduce the conflict. + // Per Prettier's own guidance: formatting belongs to the formatter, code + // quality to the linter, and the two run as separate tools. + // eslint-plugin-prettier (Prettier AS an ESLint rule) is deliberately NOT + // used — Prettier documents it as discouraged: slower, and it reports + // formatting as lint errors, which is exactly the noise this removes. + eslintConfigPrettier, ]); // Should we retain this naming convention for any (i.e. common, hdf-converters projects) / all interfaces (soon to be mostly all types) // "@typescript-eslint/naming-convention": [ diff --git a/libs/common/index.ts b/libs/common/index.ts index e69de29bb2..4d8fa0b472 100644 --- a/libs/common/index.ts +++ b/libs/common/index.ts @@ -0,0 +1,3 @@ +// The package's declared entry point. Everything it offers is a type, so the +// re-export is type-only and emits nothing at runtime. +export type * from './interfaces'; diff --git a/libs/common/interfaces/evaluation/create-evaluation.interface.ts b/libs/common/interfaces/evaluation/create-evaluation.interface.ts index c5338ca58c..24d0381509 100644 --- a/libs/common/interfaces/evaluation/create-evaluation.interface.ts +++ b/libs/common/interfaces/evaluation/create-evaluation.interface.ts @@ -1,4 +1,4 @@ -import {ICreateEvaluationTag} from '..'; +import type {ICreateEvaluationTag} from '..'; export interface ICreateEvaluation { readonly filename: string; diff --git a/libs/common/interfaces/evaluation/evaluation.interface.ts b/libs/common/interfaces/evaluation/evaluation.interface.ts index 46a2023b8f..c763086d9f 100644 --- a/libs/common/interfaces/evaluation/evaluation.interface.ts +++ b/libs/common/interfaces/evaluation/evaluation.interface.ts @@ -1,5 +1,5 @@ -import {IEvaluationTag} from '..'; -import {IGroup} from '../group/group.interface'; +import type {IEvaluationTag} from '..'; +import type {IGroup} from '../group/group.interface'; export interface IEvaluation { id: string; @@ -23,8 +23,8 @@ export interface IEvaluationResponse { export interface IEvalPaginationParams { offset: number; limit: number; - order: Array; + order: string[]; useClause?: boolean; operator?: string; - searchFields?: Array; + searchFields?: string[]; } diff --git a/libs/common/interfaces/group/group.interface.ts b/libs/common/interfaces/group/group.interface.ts index 2c72d9c55e..d5bc2d6568 100644 --- a/libs/common/interfaces/group/group.interface.ts +++ b/libs/common/interfaces/group/group.interface.ts @@ -1,4 +1,4 @@ -import {ISlimUser} from '../user/slim-user.interface'; +import type {ISlimUser} from '../user/slim-user.interface'; export interface IGroup { id: string; readonly name: string; diff --git a/libs/common/interfaces/health/health-details.interface.ts b/libs/common/interfaces/health/health-details.interface.ts new file mode 100644 index 0000000000..fbfa6e949e --- /dev/null +++ b/libs/common/interfaces/health/health-details.interface.ts @@ -0,0 +1,13 @@ +export interface IHealthDetails { + readonly bcryptRemaining: IHealthTableCounts; + readonly fips: boolean; + readonly fipsModeAsserted: boolean; + readonly oldestUnmigratedLogin: null | string; + readonly passwordHashWriteEnabled: boolean; + readonly pbkdf2Migrated: IHealthTableCounts; +} + +export interface IHealthTableCounts { + readonly apiKeys: number; + readonly users: number; +} diff --git a/libs/common/interfaces/health/health.interface.ts b/libs/common/interfaces/health/health.interface.ts new file mode 100644 index 0000000000..df61ca5801 --- /dev/null +++ b/libs/common/interfaces/health/health.interface.ts @@ -0,0 +1,4 @@ +export interface IHealth { + readonly status: string; + readonly version: string; +} diff --git a/libs/common/interfaces/index.ts b/libs/common/interfaces/index.ts index b7fb118700..59d56e0a2e 100644 --- a/libs/common/interfaces/index.ts +++ b/libs/common/interfaces/index.ts @@ -1,24 +1,26 @@ -export * from './apikey/apikey.interface'; -export * from './apikey/create-apikey.interface'; -export * from './apikey/delete-apikey.interface'; -export * from './apikey/regenerate-apikey.interface'; -export * from './apikey/update-apikey.interface'; -export * from './config/startup-settings.interface'; -export * from './evaluation-tag/create-evaluation-tag.interface'; -export * from './evaluation-tag/delete-evaluation-tag.interface'; -export * from './evaluation-tag/evaluation-tag.interface'; -export * from './evaluation/create-evaluation.interface'; -export * from './evaluation/evaluation.interface'; -export * from './evaluation/update-evaluation.interface'; -export * from './group/add-user-to-group.interface'; -export * from './group/create-group.interface'; -export * from './group/evaluation-group.interface'; -export * from './group/group.interface'; -export * from './group/remove-user-from-group.interface'; -export * from './group/update-group-user.interface'; -export * from './statistics/statistics.interface'; -export * from './user/create-user.interface'; -export * from './user/delete-user.interface'; -export * from './user/slim-user.interface'; -export * from './user/update-user.interface'; -export * from './user/user.interface'; +export type * from './apikey/apikey.interface'; +export type * from './apikey/create-apikey.interface'; +export type * from './apikey/delete-apikey.interface'; +export type * from './apikey/regenerate-apikey.interface'; +export type * from './apikey/update-apikey.interface'; +export type * from './config/startup-settings.interface'; +export type * from './evaluation-tag/create-evaluation-tag.interface'; +export type * from './evaluation-tag/delete-evaluation-tag.interface'; +export type * from './evaluation-tag/evaluation-tag.interface'; +export type * from './evaluation/create-evaluation.interface'; +export type * from './evaluation/evaluation.interface'; +export type * from './evaluation/update-evaluation.interface'; +export type * from './group/add-user-to-group.interface'; +export type * from './group/create-group.interface'; +export type * from './group/evaluation-group.interface'; +export type * from './group/group.interface'; +export type * from './group/remove-user-from-group.interface'; +export type * from './group/update-group-user.interface'; +export type * from './health/health-details.interface'; +export type * from './health/health.interface'; +export type * from './statistics/statistics.interface'; +export type * from './user/create-user.interface'; +export type * from './user/delete-user.interface'; +export type * from './user/slim-user.interface'; +export type * from './user/update-user.interface'; +export type * from './user/user.interface'; diff --git a/libs/common/package.json b/libs/common/package.json index aa3f98f73a..23e296eecb 100644 --- a/libs/common/package.json +++ b/libs/common/package.json @@ -1,6 +1,7 @@ { "name": "@heimdall/common", "version": "2.13.0", + "license": "Apache-2.0", "description": "Common utilities and interfaces between the front and backends of Heimdall", "private": true, "repository": { @@ -12,5 +13,8 @@ "scripts": { "lint": "eslint --fix", "lint:ci": "eslint --max-warnings 0" + }, + "engines": { + "node": ">=22.18.0" } } diff --git a/libs/hdf-converters/LICENSE.md b/libs/hdf-converters/LICENSE.md index ff0d8689c9..6a712fb952 100644 --- a/libs/hdf-converters/LICENSE.md +++ b/libs/hdf-converters/LICENSE.md @@ -1,4 +1,18 @@ -Licensed under the Apache-2.0 license. +© 2026 The MITRE Corporation. + +Approved for Public Release; Distribution Unlimited. Case Number 18-3678. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -7,3 +21,13 @@ Redistribution and use in source and binary forms, with or without modification, - Redistributions in binary form must reproduce the above copyright copyright/ digital rights legend, this list of conditions and the following Notice in the documentation and/or other materials provided with the distribution. - Neither the name of The MITRE Corporation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +NOTICE + +MITRE grants permission to reproduce, distribute, modify, and otherwise use this software to the extent permitted by the licensed terms provided in the LICENSE file included with this project. + +This software was produced by The MITRE Corporation for the U. S. Government under contract. As such the U.S. Government has certain use and data rights in this software. No use other than those granted to the U. S. Government, or to those acting on behalf of the U. S. Government, under these contract arrangements is authorized without the express written permission of The MITRE Corporation. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +DISA STIGs are published by DISA IASE, see: https://iase.disa.mil/Pages/privacy_policy.aspx diff --git a/libs/hdf-converters/data/converters/xml2json.ts b/libs/hdf-converters/data/converters/xml2json.ts index 78b61830f2..6a7fe776ab 100644 --- a/libs/hdf-converters/data/converters/xml2json.ts +++ b/libs/hdf-converters/data/converters/xml2json.ts @@ -1,13 +1,13 @@ import fs from 'fs'; import _ from 'lodash'; -import xml2js from 'xml2js'; +import {Parser} from 'xml2js'; -const parser = new xml2js.Parser(); +const parser = new Parser(); const pathToInfile = process.argv[2]; const pathToOutfile = process.argv[3]; // XML Structure after conversion -export interface ICCIList { +export type ICCIList = { cci_list: { cci_items: { cci_item: { @@ -20,7 +20,7 @@ export interface ICCIList { }[]; }[]; }; -} +}; if (!pathToInfile || !pathToOutfile) { console.error(`You must provide the path to both an input and ouput file.`); diff --git a/libs/hdf-converters/data/reverse-html-mapper/style.css b/libs/hdf-converters/data/reverse-html-mapper/style.css index a755de9b9a..3f4671177f 100644 --- a/libs/hdf-converters/data/reverse-html-mapper/style.css +++ b/libs/hdf-converters/data/reverse-html-mapper/style.css @@ -1,2 +1,2 @@ -/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--color-red-700:oklch(50.5% .213 27.518);--color-green-600:oklch(62.7% .194 149.214);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-900:oklch(37.9% .146 265.522);--color-slate-300:oklch(86.9% .022 252.894);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-normal:0em;--leading-normal:1.5;--leading-loose:2;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-webkit-slider-thumb{background:#8faee0}input[type=range]:disabled::-webkit-slider-thumb{background:oklch(70.8% 0 0)}input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(70.8% 0 0)}input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(70.8% 0 0)}.dark input[type=range]:disabled::-webkit-slider-thumb{background:oklch(55.6% 0 0)}.dark input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(55.6% 0 0)}.dark input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(55.6% 0 0)}input[type=range]::-moz-range-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-moz-range-thumb{background:#8faee0}input[type=range]:disabled::-moz-range-thumb{background:oklch(70.8% 0 0)}.dark input[type=range]:disabled::-moz-range-thumb{background:oklch(55.6% 0 0)}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}.dark input[type=range]::-moz-range-progress{background:#6590d5}.dark input[type=range]::-ms-fill-lower{background:#6590d5}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}.dark input[type=range]:focus::-webkit-slider-thumb{background:#6590d5}.dark input[type=range]:active::-webkit-slider-thumb{background:#3061af}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.\!absolute{position:absolute!important}.\!fixed{position:fixed!important}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.-top-\[18px\]{top:-18px}.-top-\[21px\]{top:-21px}.-top-\[35px\]{top:-35px}.top-0{top:calc(var(--spacing) * 0)}.top-1{top:calc(var(--spacing) * 1)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-\[11px\]{top:11px}.top-\[13px\]{top:13px}.top-\[50\%\]{top:50%}.top-\[50px\]{top:50px}.top-full{top:100%}.right-0{right:calc(var(--spacing) * 0)}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:calc(var(--spacing) * 1)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-3{right:calc(var(--spacing) * 3)}.right-9{right:calc(var(--spacing) * 9)}.-bottom-\[47px\]{bottom:-47px}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-1{bottom:calc(var(--spacing) * 1)}.bottom-1\/2{bottom:50%}.-left-\[15px\]{left:-15px}.-left-\[9999px\]{left:-9999px}.left-0{left:calc(var(--spacing) * 0)}.left-1{left:calc(var(--spacing) * 1)}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.left-\[50\%\]{left:50%}.left-\[50px\]{left:50px}.left-\[calc\(50\%-1px\)\]{left:calc(50% - 1px)}.isolate{isolation:isolate}.\!z-40{z-index:40!important}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[2\]{z-index:2}.z-\[999\]{z-index:999}.z-\[1035\]{z-index:1035}.z-\[1040\]{z-index:1040}.z-\[1065\]{z-index:1065}.z-\[1066\]{z-index:1066}.z-\[1070\]{z-index:1070}.z-\[1080\]{z-index:1080}.z-\[1100\]{z-index:1100}.order-1{order:1}.order-2{order:2}.order-3{order:3}.float-left{float:left}.float-right{float:right}.container{width:100%}@media (min-width:320px){.container{max-width:320px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:320px){.container\!{max-width:320px!important}}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.\!-m-px{margin:-1px!important}.-m-px{margin:-1px}.m-0{margin:calc(var(--spacing) * 0)}.m-1{margin:calc(var(--spacing) * 1)}.m-auto{margin:auto}.mx-0{margin-inline:calc(var(--spacing) * 0)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-6{margin-inline:calc(var(--spacing) * 6)}.mx-\[10px\]{margin-inline:10px}.mx-auto{margin-inline:auto}.\!my-0{margin-block:calc(var(--spacing) * 0)!important}.my-0{margin-block:calc(var(--spacing) * 0)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-\[5px\]{margin-block:5px}.me-auto{margin-inline-end:auto}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0{margin-top:calc(var(--spacing) * 0)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-11{margin-top:calc(var(--spacing) * 11)}.mt-\[0\.15rem\]{margin-top:.15rem}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-8{margin-right:calc(var(--spacing) * 8)}.mr-\[6px\]{margin-right:6px}.mr-\[8px\]{margin-right:8px}.mr-auto{margin-right:auto}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-\[0\.125rem\]{margin-bottom:.125rem}.mb-\[10px\]{margin-bottom:10px}.-ml-\[1\.5rem\]{margin-left:-1.5rem}.ml-0{margin-left:calc(var(--spacing) * 0)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-\[3px\]{margin-left:3px}.ml-\[30px\]{margin-left:30px}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.\!block{display:block!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.\!h-0{height:calc(var(--spacing) * 0)!important}.\!h-px{height:1px!important}.h-0{height:calc(var(--spacing) * 0)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\/5{height:40%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-14{height:calc(var(--spacing) * 14)}.h-\[0\.9375rem\]{height:.9375rem}.h-\[1\.4rem\]{height:1.4rem}.h-\[1\.125rem\]{height:1.125rem}.h-\[2px\]{height:2px}.h-\[4px\]{height:4px}.h-\[6px\]{height:6px}.h-\[10px\]{height:10px}.h-\[30px\]{height:30px}.h-\[32px\]{height:32px}.h-\[40px\]{height:40px}.h-\[42px\]{height:42px}.h-\[48px\]{height:48px}.h-\[50px\]{height:50px}.h-\[56px\]{height:56px}.h-\[72px\]{height:72px}.h-\[100px\]{height:100px}.h-\[120px\]{height:120px}.h-\[160px\]{height:160px}.h-\[260px\]{height:260px}.h-\[380px\]{height:380px}.h-\[512px\]{height:512px}.h-\[calc\(100\%-100px\)\]{height:calc(100% - 100px)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[calc\(100\%-64px\)\]{max-height:calc(100% - 64px)}.max-h-full{max-height:100%}.min-h-\[1\.5rem\]{min-height:1.5rem}.min-h-\[40px\]{min-height:40px}.min-h-\[305px\]{min-height:305px}.min-h-\[325px\]{min-height:325px}.min-h-\[auto\]{min-height:auto}.\!w-px{width:1px!important}.w-0{width:calc(var(--spacing) * 0)}.w-1{width:calc(var(--spacing) * 1)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/2{width:50%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\[0\.9375rem\]{width:.9375rem}.w-\[1\.4rem\]{width:1.4rem}.w-\[1\.125rem\]{width:1.125rem}.w-\[2px\]{width:2px}.w-\[4px\]{width:4px}.w-\[6px\]{width:6px}.w-\[15px\]{width:15px}.w-\[30px\]{width:30px}.w-\[32px\]{width:32px}.w-\[45\%\]{width:45%}.w-\[50px\]{width:50px}.w-\[70px\]{width:70px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[260px\]{width:260px}.w-\[300px\]{width:300px}.w-\[304px\]{width:304px}.w-\[328px\]{width:328px}.w-\[calc\(100\%-100px\)\]{width:calc(100% - 100px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-\[90\%\]{max-width:90%}.max-w-\[200px\]{max-width:200px}.max-w-\[267px\]{max-width:267px}.max-w-\[325px\]{max-width:325px}.max-w-\[calc\(100\%-1rem\)\]{max-width:calc(100% - 1rem)}.max-w-full{max-width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[48px\]{min-width:48px}.min-w-\[64px\]{min-width:64px}.min-w-\[100px\]{min-width:100px}.min-w-\[310px\]{min-width:310px}.min-w-full{min-width:100%}.flex-auto{flex:auto}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:calc(var(--spacing) * 2);border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-\[0_0\]{transform-origin:0 0}.origin-\[50\%_50\%\]{transform-origin:50%}.origin-\[center_bottom_0\]{transform-origin:center bottom 0}.origin-bottom{transform-origin:bottom}.-translate-x-1{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[6px\]{--tw-translate-x:calc(6px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[50\%\]{--tw-translate-x:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[150\%\]{--tw-translate-x:150%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-\[50\%\]{--tw-translate-y:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[6px\]{--tw-translate-y:6px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-y-\[0\.8\]{--tw-scale-y:.8;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[0\.25\]{scale:.25}.scale-\[1\.02\]{scale:1.02}.-rotate-45{rotate:-45deg}.rotate-45{rotate:45deg}.rotate-180{rotate:180deg}.rotate-\[-180deg\]{rotate:-180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.transform-none{transform:none}.animate-\[fade-in_0\.3s_both\]{animation:.3s both fade-in}.animate-\[fade-in_0\.15s_both\]{animation:.15s both fade-in}.animate-\[fade-in_350ms_ease-in-out\]{animation:.35s ease-in-out fade-in}.animate-\[fade-out_0\.3s_both\]{animation:.3s both fade-out}.animate-\[fade-out_0\.15s_both\]{animation:.15s both fade-out}.animate-\[fade-out_350ms_ease-in-out\]{animation:.35s ease-in-out fade-out}.animate-\[progress_3s_ease-in-out_infinite\]{animation:3s ease-in-out infinite progress}.animate-\[show-up-clock_350ms_linear\]{animation:.35s linear show-up-clock}.animate-\[slide-in-left_0\.8s_both\]{animation:.8s both slide-in-left}.animate-\[slide-in-right_0\.8s_both\]{animation:.8s both slide-in-right}.animate-\[slide-out-left_0\.8s_both\]{animation:.8s both slide-out-left}.animate-\[slide-out-right_0\.8s_both\]{animation:.8s both slide-out-right}.animate-\[spinner-grow_0\.75s_linear_infinite\]{animation:.75s linear infinite spinner-grow}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-row{grid-auto-flow:row}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\[0\.5rem\]{border-radius:.5rem}.rounded-\[0\.6rem\]{border-radius:.6rem}.rounded-\[0\.25rem\]{border-radius:.25rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[16px\]{border-radius:16px}.rounded-\[50\%\]{border-radius:50%}.rounded-\[100\%\]{border-radius:100%}.rounded-\[999px\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\[0\.6rem\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\[0\.25rem\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\[0\.25rem\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\!border-\[3px\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\[\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[0\.15em\]{border-style:var(--tw-border-style);border-width:.15em}.border-\[0\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[1px\]{border-style:var(--tw-border-style);border-width:1px}.border-\[14px\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\[0\.125rem\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\!border-\[\#14a44d\]{border-color:#14a44d!important}.\!border-\[\#b2b3b4\]{border-color:#b2b3b4!important}.\!border-\[\#dc4c64\]{border-color:#dc4c64!important}.border-\[\#3b71ca\]{border-color:#3b71ca}.border-\[\#14a44d\]{border-color:#14a44d}.border-\[\#dc4c64\]{border-color:#dc4c64}.border-\[\#eee\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\!bg-\[\#858585\]{background-color:#858585!important}.\!bg-danger-100{background-color:#fae5e9!important}.\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\!bg-primary-100{background-color:#e3ebf7!important}.\!bg-success-100{background-color:#d6fae4!important}.bg-\[\#000000e6\]{background-color:#000000e6}.bg-\[\#3b71ca\]{background-color:#3b71ca}.bg-\[\#6d6d6d\]{background-color:#6d6d6d}.bg-\[\#00000012\]{background-color:#00000012}.bg-\[\#00000066\]{background-color:#0006}.bg-\[\#aaa\]{background-color:#aaa}.bg-\[\#eceff1\]{background-color:#eceff1}.bg-\[\#eee\]{background-color:#eee}.bg-\[rgba\(0\,0\,0\,0\.4\)\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\[\#336dec\]{fill:#336dec}.fill-\[\#afafaf\]{fill:#afafaf}.fill-current{fill:currentColor}.\!p-0{padding:calc(var(--spacing) * 0)!important}.p-0{padding:calc(var(--spacing) * 0)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\[1rem\]{padding:1rem}.p-\[5px\]{padding:5px}.p-\[auto\]{padding:auto}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[0\.4rem\]{padding-inline:.4rem}.px-\[1\.4rem\]{padding-inline:1.4rem}.px-\[10px\]{padding-inline:10px}.px-\[12px\]{padding-inline:12px}.px-\[auto\]{padding-inline:auto}.\!py-0{padding-block:calc(var(--spacing) * 0)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:calc(var(--spacing) * 0)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\[0\.4rem\]{padding-block:.4rem}.py-\[0\.32rem\]{padding-block:.32rem}.py-\[0\.33rem\]{padding-block:.33rem}.py-\[0\.4375rem\]{padding-block:.4375rem}.py-\[1px\]{padding-block:1px}.py-\[5px\]{padding-block:5px}.py-\[10px\]{padding-block:10px}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\[0\.37rem\]{padding-top:.37rem}.pt-\[6px\]{padding-top:6px}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\[24px\]{padding-right:24px}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\[5px\]{padding-bottom:5px}.pl-0{padding-left:calc(var(--spacing) * 0)}.pl-1{padding-left:calc(var(--spacing) * 1)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[1\.5rem\]{padding-left:1.5rem}.pl-\[8px\]{padding-left:8px}.pl-\[18px\]{padding-left:18px}.pl-\[50px\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\[-0\.125em\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[2\.5rem\]{font-size:2.5rem}.text-\[3\.75rem\]{font-size:3.75rem}.text-\[10px\]{font-size:10px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[34px\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[2\.15\]{--tw-leading:2.15;line-height:2.15}.leading-\[40px\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.00833em\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\[\.1rem\],.tracking-\[0\.1rem\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\[1\.7px\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-\[\#14a44d\]{color:#14a44d!important}.\!text-\[\#dc4c64\]{color:#dc4c64!important}.\!text-danger-700{color:#b0233a!important}.\!text-gray-50{color:var(--color-gray-50)!important}.\!text-primary{color:#3b71ca!important}.\!text-primary-700{color:#285192!important}.\!text-success-700{color:#0e7537!important}.text-\[\#3b71ca\]{color:#3b71ca}.text-\[\#4f4f4f\]{color:#4f4f4f}.text-\[\#14a44d\]{color:#14a44d}.text-\[\#212529\]{color:#212529}.text-\[\#b3afaf\]{color:#b3afaf}.text-\[\#b3b3b3\]{color:#b3b3b3}.text-\[\#dc4c64\]{color:#dc4c64}.text-\[\#ffffff8a\]{color:#ffffff8a}.text-\[rgb\(220\,76\,100\)\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\/\[64\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\/\[64\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\!opacity-0{opacity:0!important}.\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\[\.53\]{opacity:.53}.opacity-\[\.54\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0px_3px_0_rgba\(0\,0\,0\,0\.07\)\,0_2px_2px_0_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_5px_0_rgba\(0\,0\,0\,0\.16\)\,_0_2px_10px_0_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_9px_-4px_\#3b71ca\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_10px_15px_-3px_rgba\(0\,0\,0\,0\.07\)\,0_4px_6px_-2px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0px_2px_15px_-3px_rgba\(0\,0\,0\,\.07\)\,_0px_10px_20px_-2px_rgba\(0\,0\,0\,\.04\)\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\/login,.shadow\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,_opacity\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,box-shadow\,border\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[height\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,_opacity\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,height\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\[0ms\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\[150ms\]{--tw-duration:.15s;transition-duration:.15s}.duration-\[200ms\]{--tw-duration:.2s;transition-duration:.2s}.duration-\[250ms\]{--tw-duration:.25s;transition-duration:.25s}.duration-\[350ms\]{--tw-duration:.35s;transition-duration:.35s}.duration-\[400ms\]{--tw-duration:.4s;transition-duration:.4s}.duration-\[1000ms\]{--tw-duration:1s;transition-duration:1s}.ease-\[cubic-bezier\(0\,0\,0\.15\,1\)\,_cubic-bezier\(0\,0\,0\.15\,1\)\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\[cubic-bezier\(0\.4\,0\,0\.2\,1\)\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\)\],.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\.0\)\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\[ease\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\!\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)!important}.\[bash\:1221\]{bash:1221}.\[check\:5737\]{check:5737}.\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)}.\[direction\:ltr\]{direction:ltr}.\[drm\:hdmiphy_enable\.part\.0\]{drm:hdmiphy enable.part0}.\[drm\:samsung_dsim_host_attach\]{drm:samsung dsim host attach}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[pid\:5118\,cpu4\,QThread\,0\]{pid:5118,cpu4,QThread,0}.\[pid\:5118\,cpu4\,QThread\,1\]{pid:5118,cpu4,QThread,1}.\[pid\:5118\,cpu4\,QThread\,2\]{pid:5118,cpu4,QThread,2}.\[pid\:5118\,cpu4\,QThread\,3\]{pid:5118,cpu4,QThread,3}.\[pid\:5118\,cpu4\,QThread\,4\]{pid:5118,cpu4,QThread,4}.\[pid\:5118\,cpu4\,QThread\,9\]{pid:5118,cpu4,QThread,9}.\[transition\:background-color_\.2s_linear\,_height_\.2s_ease-in-out\]{transition:background-color .2s linear,height .2s ease-in-out}.\[transition\:background-color_\.2s_linear\,_width_\.2s_ease-in-out\,_opacity\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\[transition\:background-color_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,box-shadow_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,border_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\/ps\:opacity-60:is(:where(.group\/ps):hover *){opacity:.6}.group-hover\/x\:h-\[11px\]:is(:where(.group\/x):hover *){height:11px}.group-hover\/x\:bg-\[\#999\]:is(:where(.group\/x):hover *){background-color:#999}.group-hover\/y\:w-\[11px\]:is(:where(.group\/y):hover *){width:11px}.group-hover\/y\:bg-\[\#999\]:is(:where(.group\/y):hover *){background-color:#999}}.group-focus\/ps\:opacity-60:is(:where(.group\/ps):focus *){opacity:.6}.group-focus\/ps\:opacity-100:is(:where(.group\/ps):focus *){opacity:1}.group-focus\/x\:h-\[0\.6875rem\]:is(:where(.group\/x):focus *){height:.6875rem}.group-focus\/x\:bg-\[\#999\]:is(:where(.group\/x):focus *){background-color:#999}.group-focus\/y\:w-\[0\.6875rem\]:is(:where(.group\/y):focus *){width:.6875rem}.group-focus\/y\:bg-\[\#999\]:is(:where(.group\/y):focus *){background-color:#999}.group-active\/ps\:opacity-100:is(:where(.group\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:calc(var(--spacing) * 0)}.group-data-te-collapse-collapsed\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\:fill-\[\#212529\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\[te-input-focused\]\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-focused\]\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-focused\]\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-focused\]\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-focused\]\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-focused\]\:border-\[\#14a44d\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\[te-input-focused\]\:border-\[\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\[te-input-focused\]\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\[te-input-focused\]\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\[te-input-focused\]\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-state-active\]\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-state-active\]\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-state-active\]\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-state-active\]\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-state-active\]\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-state-active\]\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\[te-select-option-group-ref\]\/opt\:pl-7:is(:where(.group\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\[te-was-validated\]\/validation\:mb-4:is(:where(.group\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\[\&\.ps--active-x\]\/ps\:block:is(:where(.group\/ps).ps--active-x *){display:block}.group-\[\&\.ps--active-x\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-x *){background-color:#0000}.group-\[\&\.ps--active-y\]\/ps\:block:is(:where(.group\/ps).ps--active-y *){display:block}.group-\[\&\.ps--active-y\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-y *){background-color:#0000}.group-\[\&\.ps--clicking\]\/x\:h-\[11px\]:is(:where(.group\/x).ps--clicking *){height:11px}.group-\[\&\.ps--clicking\]\/x\:bg-\[\#999\]:is(:where(.group\/x).ps--clicking *){background-color:#999}.group-\[\&\.ps--clicking\]\/y\:w-\[11px\]:is(:where(.group\/y).ps--clicking *){width:11px}.group-\[\&\.ps--clicking\]\/y\:bg-\[\#999\]:is(:where(.group\/y).ps--clicking *){background-color:#999}.group-\[\&\.ps--scrolling-x\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-x *),.group-\[\&\.ps--scrolling-y\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-y *){opacity:.6}.group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\[\[data-te-datepicker-cell-current\]\]\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\[\[data-te-datepicker-cell-current\]\]\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\[\[data-te-datepicker-cell-current\]\]\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\[\[data-te-datepicker-cell-selected\]\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\[\[data-te-datepicker-cell-selected\]\]\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\[te-was-validated\]\/validation\:peer-valid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-valid\:text-green-600:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:text-\[rgb\(220\,76\,100\)\]:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\:-translate-y-\[0\.9rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[0\.75rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[1\.15rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:scale-\[0\.8\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\:\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\[te-input-focused\]\:\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\[te-input-focused\]\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:scale-\[0\.8\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\:bg-transparent ::selection{background-color:#0000}.selection\:bg-transparent::selection{background-color:#0000}.before\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:h-\[0\.875rem\]:before{content:var(--tw-content);height:.875rem}.before\:w-\[0\.875rem\]:before{content:var(--tw-content);width:.875rem}.before\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\:opacity-0:before{content:var(--tw-content);opacity:0}.before\:shadow-\[0px_0px_0px_13px_transparent\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.odd\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\:\!border-\[\#14a44d\]:checked{border-color:#14a44d!important}.checked\:\!border-\[\#dc4c64\]:checked{border-color:#dc4c64!important}.checked\:border-primary:checked{border-color:#3b71ca}.checked\:\!bg-\[\#14a44d\]:checked{background-color:#14a44d!important}.checked\:\!bg-\[\#dc4c64\]:checked{background-color:#dc4c64!important}.checked\:bg-primary:checked{background-color:#3b71ca}.checked\:before\:opacity-\[0\.16\]:checked:before{content:var(--tw-content);opacity:.16}.checked\:after\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\:after\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\:after\:ml-\[0\.25rem\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\:after\:block:checked:after{content:var(--tw-content);display:block}.checked\:after\:h-\[0\.8125rem\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\:after\:w-\[0\.375rem\]:checked:after{content:var(--tw-content);width:.375rem}.checked\:after\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\:after\:border-\[0\.125rem\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:after\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:after\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:after\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:after\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:after\:\!bg-\[\#14a44d\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\:after\:\!bg-\[\#dc4c64\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\:after\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\:after\:content-\[\'\'\]:checked:after{--tw-content:"";content:var(--tw-content)}.empty\:hidden:empty{display:none}@media (hover:hover){.hover\:z-2:hover{z-index:2}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:rounded-\[50\%\]:hover{border-radius:50%}.hover\:\!bg-\[\#eee\]:hover{background-color:#eee!important}.hover\:bg-\[\#00000014\]:hover{background-color:#00000014}.hover\:bg-\[\#00000026\]:hover{background-color:#00000026}.hover\:bg-\[unset\]:hover{background-color:unset}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-primary-600:hover{background-color:#3061af}.hover\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\:fill-\[\#8b8b8b\]:hover{fill:#8b8b8b}.hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.hover\:text-\[\#8b8b8b\]:hover{color:#8b8b8b}.hover\:text-primary:hover{color:#3b71ca}.hover\:text-primary-600:hover{color:#3061af}.hover\:text-white:hover{color:var(--color-white)}.hover\:\!opacity-90:hover{opacity:.9!important}.hover\:opacity-100:hover{opacity:1}.hover\:\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\:before\:opacity-\[0\.04\]:hover:before{content:var(--tw-content);opacity:.04}.hover\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:z-3:focus{z-index:3}.focus\:rounded-\[50\%\]:focus{border-radius:50%}.focus\:\!border-\[\#14a44d\]:focus{border-color:#14a44d!important}.focus\:\!border-\[\#dc4c64\]:focus{border-color:#dc4c64!important}.focus\:border-primary:focus{border-color:#3b71ca}.focus\:\!bg-\[\#eee\]:focus{background-color:#eee!important}.focus\:bg-\[\#00000014\]:focus{background-color:#00000014}.focus\:bg-\[\#00000026\]:focus{background-color:#00000026}.focus\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\:bg-primary-600:focus{background-color:#3061af}.focus\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.focus\:text-gray-700:focus{color:var(--color-gray-700)}.focus\:text-primary:focus{color:#3b71ca}.focus\:text-primary-600:focus{color:#3061af}.focus\:text-white:focus{color:var(--color-white)}.focus\:\!opacity-90:focus{opacity:.9!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#14a44d\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#dc4c64\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:transition-\[border-color_0\.2s\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:placeholder\:opacity-100:focus::placeholder{opacity:1}.focus\:before\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\:before\:opacity-\[0\.12\]:focus:before{content:var(--tw-content);opacity:.12}.focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:after\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\:after\:z-\[1\]:focus:after{content:var(--tw-content);z-index:1}.focus\:after\:block:focus:after{content:var(--tw-content);display:block}.focus\:after\:h-\[0\.875rem\]:focus:after{content:var(--tw-content);height:.875rem}.focus\:after\:w-\[0\.875rem\]:focus:after{content:var(--tw-content);width:.875rem}.focus\:after\:rounded-\[0\.125rem\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\:after\:content-\[\'\'\]:focus:after{--tw-content:"";content:var(--tw-content)}.checked\:focus\:before\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\:focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\:focus\:after\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\:focus\:after\:ml-\[0\.25rem\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\:focus\:after\:h-\[0\.8125rem\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\:focus\:after\:w-\[0\.375rem\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\:focus\:after\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\:focus\:after\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\:focus\:after\:border-\[0\.125rem\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:focus\:after\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:focus\:after\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:focus\:after\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:focus\:after\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:focus\:after\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\:z-60:active{z-index:60}.active\:bg-\[\#c4d4ef\]:active{background-color:#c4d4ef}.active\:bg-\[\#cacfd1\]:active{background-color:#cacfd1}.active\:bg-primary-700:active{background-color:#285192}.active\:bg-primary-accent-200:active{background-color:#cedbee}.active\:text-primary-700:active{color:#285192}.active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\:grid[data-te-dropdown-show]{display:grid}.data-\[data-te-autocomplete-option-disabled\]\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\[data-te-autocomplete-option-disabled\]\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\[popper-reference-hidden\]\:hidden[data-popper-reference-hidden]{display:none}.data-\[te-active\]\:-top-\[38px\][data-te-active]{top:-38px}.data-\[te-active\]\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-autocomplete-state-open\]\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-state-open\]\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\[te-carousel-fade\]\:z-0[data-te-carousel-fade]{z-index:0}.data-\[te-carousel-fade\]\:z-\[1\][data-te-carousel-fade]{z-index:1}.data-\[te-carousel-fade\]\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\[te-carousel-fade\]\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\[te-carousel-fade\]\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\[te-carousel-fade\]\:duration-\[600ms\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\[te-datepicker-cell-disabled\]\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\[te-datepicker-cell-disabled\]\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\[te-datepicker-cell-disabled\]\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\[te-datepicker-cell-disabled\]\:hover\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\[\[data-te-datepicker-cell-focused\]\]\:data-\[te-datepicker-cell-selected\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\[te-input-disabled\]\:cursor-default[data-te-input-disabled]{cursor:default}.data-\[te-input-disabled\]\:bg-\[\#e9ecef\][data-te-input-disabled]{background-color:#e9ecef}.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:block[data-te-input-state-active]{display:block}.data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:scale-\[0\.8\][data-te-input-state-active]{scale:.8}.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:placeholder\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\[te-select-open\]\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-select-open\]\:opacity-100[data-te-select-open]{opacity:1}.data-\[te-select-option-disabled\]\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\:transform-none{transform:none}.motion-reduce\:animate-\[spin_1\.5s_linear_infinite\]{animation:1.5s linear infinite spin}.motion-reduce\:animate-\[spinner-grow_1\.5s_linear_infinite\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\:block{display:block}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10\%_90\%\]{grid-template-columns:10% 90%}.sm\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\:break-words{overflow-wrap:break-word}.sm\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\:order-none{order:0}.md\:my-0{margin-block:calc(var(--spacing) * 0)}.md\:mb-0{margin-bottom:calc(var(--spacing) * 0)}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:pr-1{padding-right:calc(var(--spacing) * 1)}.md\:pr-\[17px\]{padding-right:17px}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:w-32{width:calc(var(--spacing) * 32)}.lg\:w-36{width:calc(var(--spacing) * 36)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\:w-52{width:calc(var(--spacing) * 52)}.xl\:grid-flow-col{grid-auto-flow:column}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\[320px\]\:max-\[825px\]\:landscape\:h-auto{height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[305px\]{min-height:305px}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[auto\]{min-height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-w-\[auto\]{min-width:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:\!flex-row{flex-direction:row!important}.min-\[320px\]\:max-\[825px\]\:landscape\:flex-col{flex-direction:column}.min-\[320px\]\:max-\[825px\]\:landscape\:\!justify-around{justify-content:space-around!important}.min-\[320px\]\:max-\[825px\]\:landscape\:overflow-y-auto{overflow-y:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-lg{border-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-tr-none{border-top-right-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-none{border-bottom-left-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:p-\[10px\]{padding:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:pr-\[10px\]{padding-right:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\[320px\]\:max-\[825px\]\:landscape\:text-\[3rem\]{font-size:3rem}.min-\[320px\]\:max-\[825px\]\:landscape\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\:max-md\:landscape\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\:max-md\:landscape\:h-8{height:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:h-\[360px\]{height:360px}.xs\:max-md\:landscape\:h-full{height:100%}.xs\:max-md\:landscape\:w-8{width:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:w-\[475px\]{width:475px}.xs\:max-md\:landscape\:flex-row{flex-direction:row}}}}.rtl\:\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\:\!origin-\[50\%_50\%_0\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\:\[direction\:rtl\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\:border-\[\#4f4f4f\]{border-color:#4f4f4f}.dark\:border-\[\#14a44d\]{border-color:#14a44d}.dark\:border-\[\#dc4c64\]{border-color:#dc4c64}.dark\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-primary-400{border-color:#8faee0}.dark\:\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\:bg-\[\#4f4f4f\]{background-color:#4f4f4f}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-primary-600{background-color:#3061af}.dark\:bg-transparent{background-color:#0000}.dark\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\:bg-zinc-600\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\:fill-gray-400{fill:var(--color-gray-400)}.dark\:\!text-primary-400{color:#8faee0!important}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-primary-400{color:#8faee0}.dark\:text-white{color:var(--color-white)}.dark\:shadow-\[0_4px_9px_-4px_rgba\(59\,113\,202\,0\.5\)\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\[data-te-datepicker-cell-current\]\]\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\:group-\[\[data-te-datepicker-cell-disabled\]\]\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\:peer-focus\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\:peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\:placeholder\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\:checked\:border-primary:checked{border-color:#3b71ca}.dark\:checked\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\:hover\:\!bg-\[\#555\]:hover{background-color:#555!important}.dark\:hover\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\:hover\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\:hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.dark\:hover\:text-primary-400:hover{color:#8faee0}.dark\:hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\:focus\:\!bg-\[\#555\]:focus{background-color:#555!important}.dark\:focus\:bg-white\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:focus\:bg-white\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.dark\:focus\:text-primary-400:focus{color:#8faee0}.dark\:focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(255\,255\,255\,0\.4\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:disabled\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\:disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-buttons-timepicker\]\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\:data-\[te-input-disabled\]\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\:block{display:block}.print\:hidden{display:none}.print\:border-none{--tw-border-style:none;border-style:none}.print\:border-black{border-color:var(--color-black)}.print\:bg-white{background-color:var(--color-white)}.print\:text-left{text-align:left}.print\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\[\&\.ps--clicking\]\:\!bg-\[\#eee\].ps--clicking{background-color:#eee!important}.\[\&\.ps--clicking\]\:\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\:\[\&\.ps--clicking\]\:\!bg-\[\#555\].ps--clicking{background-color:#555!important}}.\[\&\:\:-webkit-scrollbar\]\:h-1::-webkit-scrollbar{height:calc(var(--spacing) * 1)}.\[\&\:\:-webkit-scrollbar\]\:w-1::-webkit-scrollbar{width:calc(var(--spacing) * 1)}.\[\&\:\:-webkit-scrollbar-button\]\:block::-webkit-scrollbar-button{display:block}.\[\&\:\:-webkit-scrollbar-button\]\:h-0::-webkit-scrollbar-button{height:calc(var(--spacing) * 0)}.\[\&\:\:-webkit-scrollbar-button\]\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\[\&\:\:-webkit-scrollbar-thumb\]\:h-\[50px\]::-webkit-scrollbar-thumb{height:50px}.\[\&\:\:-webkit-scrollbar-thumb\]\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\[\&\:\:-webkit-scrollbar-thumb\]\:bg-\[\#999\]::-webkit-scrollbar-thumb{background-color:#999}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\[\&\:\:-webkit-scrollbar-track-piece\]\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:\[box-shadow\:inset_0_-1px_0_rgba\(229\,231\,235\)\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\[\&\:not\(\[data-te-input-placeholder-active\]\)\]\:placeholder\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:nth-child\(odd\)\]\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\[\&\:nth-child\(odd\)\]\:dark\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:mx-auto>svg{margin-inline:auto}.\[\&\>svg\]\:h-4>svg{height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:h-5>svg{height:calc(var(--spacing) * 5)}.\[\&\>svg\]\:h-6>svg{height:calc(var(--spacing) * 6)}.\[\&\>svg\]\:w-4>svg{width:calc(var(--spacing) * 4)}.\[\&\>svg\]\:w-5>svg{width:calc(var(--spacing) * 5)}.\[\&\>svg\]\:w-6>svg{width:calc(var(--spacing) * 6)}.\[\&\>svg\]\:rotate-180>svg{rotate:180deg}.\[\&\>svg\]\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\:\[\&\>svg\]\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:"";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:"";inherits:false;initial-value:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}} \ No newline at end of file +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--color-red-700:oklch(50.5% .213 27.518);--color-green-600:oklch(62.7% .194 149.214);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-900:oklch(37.9% .146 265.522);--color-slate-300:oklch(86.9% .022 252.894);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-50:oklch(98.5% 0 none);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-normal:0em;--leading-normal:1.5;--leading-loose:2;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-webkit-slider-thumb{background:#8faee0}input[type=range]:disabled::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-moz-range-thumb{background:#8faee0}input[type=range]:disabled::-moz-range-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-moz-range-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}.dark input[type=range]::-moz-range-progress{background:#6590d5}.dark input[type=range]::-ms-fill-lower{background:#6590d5}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}.dark input[type=range]:focus::-webkit-slider-thumb{background:#6590d5}.dark input[type=range]:active::-webkit-slider-thumb{background:#3061af}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.\!absolute{position:absolute!important}.\!fixed{position:fixed!important}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-\[18px\]{top:-18px}.-top-\[21px\]{top:-21px}.-top-\[35px\]{top:-35px}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-\[11px\]{top:11px}.top-\[13px\]{top:13px}.top-\[50\%\]{top:50%}.top-\[50px\]{top:50px}.top-full{top:100%}.right-0{right:0}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-3{right:calc(var(--spacing) * 3)}.right-9{right:calc(var(--spacing) * 9)}.-bottom-\[47px\]{bottom:-47px}.bottom-0{bottom:0}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-1{bottom:var(--spacing)}.bottom-1\/2{bottom:50%}.-left-\[15px\]{left:-15px}.-left-\[9999px\]{left:-9999px}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.left-\[50\%\]{left:50%}.left-\[50px\]{left:50px}.left-\[calc\(50\%-1px\)\]{left:calc(50% - 1px)}.isolate{isolation:isolate}.\!z-40{z-index:40!important}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[2\]{z-index:2}.z-\[999\]{z-index:999}.z-\[1035\]{z-index:1035}.z-\[1040\]{z-index:1040}.z-\[1065\]{z-index:1065}.z-\[1066\]{z-index:1066}.z-\[1070\]{z-index:1070}.z-\[1080\]{z-index:1080}.z-\[1100\]{z-index:1100}.order-1{order:1}.order-2{order:2}.order-3{order:3}.float-left{float:left}.float-right{float:right}.container{width:100%}@media (min-width:320px){.container{max-width:320px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:320px){.container\!{max-width:320px!important}}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.\!-m-px{margin:-1px!important}.-m-px{margin:-1px}.m-0{margin:0}.m-1{margin:var(--spacing)}.m-auto{margin:auto}.mx-0{margin-inline:0}.mx-1{margin-inline:var(--spacing)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-6{margin-inline:calc(var(--spacing) * 6)}.mx-\[10px\]{margin-inline:10px}.mx-auto{margin-inline:auto}.\!my-0{margin-block:0!important}.my-0{margin-block:0}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-\[5px\]{margin-block:5px}.me-auto{margin-inline-end:auto}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0{margin-top:0}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-11{margin-top:calc(var(--spacing) * 11)}.mt-\[0\.15rem\]{margin-top:.15rem}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-8{margin-right:calc(var(--spacing) * 8)}.mr-\[6px\]{margin-right:6px}.mr-\[8px\]{margin-right:8px}.mr-auto{margin-right:auto}.mb-0{margin-bottom:0}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-\[0\.125rem\]{margin-bottom:.125rem}.mb-\[10px\]{margin-bottom:10px}.-ml-\[1\.5rem\]{margin-left:-1.5rem}.ml-0{margin-left:0}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-\[3px\]{margin-left:3px}.ml-\[30px\]{margin-left:30px}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.\!block{display:block!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.\!h-0{height:0!important}.\!h-px{height:1px!important}.h-0{height:0}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\/5{height:40%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-14{height:calc(var(--spacing) * 14)}.h-\[0\.9375rem\]{height:.9375rem}.h-\[1\.4rem\]{height:1.4rem}.h-\[1\.125rem\]{height:1.125rem}.h-\[2px\]{height:2px}.h-\[4px\]{height:4px}.h-\[6px\]{height:6px}.h-\[10px\]{height:10px}.h-\[30px\]{height:30px}.h-\[32px\]{height:32px}.h-\[40px\]{height:40px}.h-\[42px\]{height:42px}.h-\[48px\]{height:48px}.h-\[50px\]{height:50px}.h-\[56px\]{height:56px}.h-\[72px\]{height:72px}.h-\[100px\]{height:100px}.h-\[120px\]{height:120px}.h-\[160px\]{height:160px}.h-\[260px\]{height:260px}.h-\[380px\]{height:380px}.h-\[512px\]{height:512px}.h-\[calc\(100\%-100px\)\]{height:calc(100% - 100px)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[calc\(100\%-64px\)\]{max-height:calc(100% - 64px)}.max-h-full{max-height:100%}.min-h-\[1\.5rem\]{min-height:1.5rem}.min-h-\[40px\]{min-height:40px}.min-h-\[305px\]{min-height:305px}.min-h-\[325px\]{min-height:325px}.min-h-\[auto\]{min-height:auto}.\!w-px{width:1px!important}.w-0{width:0}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/2{width:50%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\[0\.9375rem\]{width:.9375rem}.w-\[1\.4rem\]{width:1.4rem}.w-\[1\.125rem\]{width:1.125rem}.w-\[2px\]{width:2px}.w-\[4px\]{width:4px}.w-\[6px\]{width:6px}.w-\[15px\]{width:15px}.w-\[30px\]{width:30px}.w-\[32px\]{width:32px}.w-\[45\%\]{width:45%}.w-\[50px\]{width:50px}.w-\[70px\]{width:70px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[260px\]{width:260px}.w-\[300px\]{width:300px}.w-\[304px\]{width:304px}.w-\[328px\]{width:328px}.w-\[calc\(100\%-100px\)\]{width:calc(100% - 100px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-\[90\%\]{max-width:90%}.max-w-\[200px\]{max-width:200px}.max-w-\[267px\]{max-width:267px}.max-w-\[325px\]{max-width:325px}.max-w-\[calc\(100\%-1rem\)\]{max-width:calc(100% - 1rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-\[48px\]{min-width:48px}.min-w-\[64px\]{min-width:64px}.min-w-\[100px\]{min-width:100px}.min-w-\[310px\]{min-width:310px}.min-w-full{min-width:100%}.flex-auto{flex:auto}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:calc(var(--spacing) * 2);border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-\[0_0\]{transform-origin:0 0}.origin-\[50\%_50\%\]{transform-origin:50%}.origin-\[center_bottom_0\]{transform-origin:center bottom 0}.origin-bottom{transform-origin:bottom}.-translate-x-1{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[6px\]{--tw-translate-x:calc(6px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\[50\%\]{--tw-translate-x:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[150\%\]{--tw-translate-x:150%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-\[50\%\]{--tw-translate-y:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[6px\]{--tw-translate-y:6px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-y-\[0\.8\]{--tw-scale-y:.8;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[0\.25\]{scale:.25}.scale-\[1\.02\]{scale:1.02}.-rotate-45{rotate:-45deg}.rotate-45{rotate:45deg}.rotate-180{rotate:180deg}.rotate-\[-180deg\]{rotate:-180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.transform-none{transform:none}.animate-\[fade-in_0\.3s_both\]{animation:.3s both fade-in}.animate-\[fade-in_0\.15s_both\]{animation:.15s both fade-in}.animate-\[fade-in_350ms_ease-in-out\]{animation:.35s ease-in-out fade-in}.animate-\[fade-out_0\.3s_both\]{animation:.3s both fade-out}.animate-\[fade-out_0\.15s_both\]{animation:.15s both fade-out}.animate-\[fade-out_350ms_ease-in-out\]{animation:.35s ease-in-out fade-out}.animate-\[progress_3s_ease-in-out_infinite\]{animation:3s ease-in-out infinite progress}.animate-\[show-up-clock_350ms_linear\]{animation:.35s linear show-up-clock}.animate-\[slide-in-left_0\.8s_both\]{animation:.8s both slide-in-left}.animate-\[slide-in-right_0\.8s_both\]{animation:.8s both slide-in-right}.animate-\[slide-out-left_0\.8s_both\]{animation:.8s both slide-out-left}.animate-\[slide-out-right_0\.8s_both\]{animation:.8s both slide-out-right}.animate-\[spinner-grow_0\.75s_linear_infinite\]{animation:.75s linear infinite spinner-grow}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-row{grid-auto-flow:row}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\[0\.5rem\]{border-radius:.5rem}.rounded-\[0\.6rem\]{border-radius:.6rem}.rounded-\[0\.25rem\]{border-radius:.25rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[16px\]{border-radius:16px}.rounded-\[50\%\]{border-radius:50%}.rounded-\[100\%\]{border-radius:100%}.rounded-\[999px\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\[0\.6rem\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\[0\.25rem\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\[0\.25rem\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\!border-\[3px\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\[\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[0\.15em\]{border-style:var(--tw-border-style);border-width:.15em}.border-\[0\.125rem\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\[1px\]{border-style:var(--tw-border-style);border-width:1px}.border-\[14px\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\[0\.125rem\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\!border-\[\#14a44d\]{border-color:#14a44d!important}.\!border-\[\#b2b3b4\]{border-color:#b2b3b4!important}.\!border-\[\#dc4c64\]{border-color:#dc4c64!important}.border-\[\#3b71ca\]{border-color:#3b71ca}.border-\[\#14a44d\]{border-color:#14a44d}.border-\[\#dc4c64\]{border-color:#dc4c64}.border-\[\#eee\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\!bg-\[\#858585\]{background-color:#858585!important}.\!bg-danger-100{background-color:#fae5e9!important}.\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\!bg-primary-100{background-color:#e3ebf7!important}.\!bg-success-100{background-color:#d6fae4!important}.bg-\[\#000000e6\]{background-color:#000000e6}.bg-\[\#3b71ca\]{background-color:#3b71ca}.bg-\[\#6d6d6d\]{background-color:#6d6d6d}.bg-\[\#00000012\]{background-color:#00000012}.bg-\[\#00000066\]{background-color:#0006}.bg-\[\#aaa\]{background-color:#aaa}.bg-\[\#eceff1\]{background-color:#eceff1}.bg-\[\#eee\]{background-color:#eee}.bg-\[rgba\(0\,0\,0\,0\.4\)\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\[\#336dec\]{fill:#336dec}.fill-\[\#afafaf\]{fill:#afafaf}.fill-current{fill:currentColor}.\!p-0{padding:0!important}.p-0{padding:0}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\[1rem\]{padding:1rem}.p-\[5px\]{padding:5px}.p-\[auto\]{padding:auto}.px-0{padding-inline:0}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[0\.4rem\]{padding-inline:.4rem}.px-\[1\.4rem\]{padding-inline:1.4rem}.px-\[10px\]{padding-inline:10px}.px-\[12px\]{padding-inline:12px}.px-\[auto\]{padding-inline:auto}.\!py-0{padding-block:0!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:0}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\[0\.4rem\]{padding-block:.4rem}.py-\[0\.32rem\]{padding-block:.32rem}.py-\[0\.33rem\]{padding-block:.33rem}.py-\[0\.4375rem\]{padding-block:.4375rem}.py-\[1px\]{padding-block:1px}.py-\[5px\]{padding-block:5px}.py-\[10px\]{padding-block:10px}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\[0\.37rem\]{padding-top:.37rem}.pt-\[6px\]{padding-top:6px}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\[24px\]{padding-right:24px}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\[5px\]{padding-bottom:5px}.pl-0{padding-left:0}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[1\.5rem\]{padding-left:1.5rem}.pl-\[8px\]{padding-left:8px}.pl-\[18px\]{padding-left:18px}.pl-\[50px\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\[-0\.125em\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[2\.5rem\]{font-size:2.5rem}.text-\[3\.75rem\]{font-size:3.75rem}.text-\[10px\]{font-size:10px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[34px\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[2\.15\]{--tw-leading:2.15;line-height:2.15}.leading-\[40px\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.00833em\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\[\.1rem\],.tracking-\[0\.1rem\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\[1\.7px\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-\[\#14a44d\]{color:#14a44d!important}.\!text-\[\#dc4c64\]{color:#dc4c64!important}.\!text-danger-700{color:#b0233a!important}.\!text-gray-50{color:var(--color-gray-50)!important}.\!text-primary{color:#3b71ca!important}.\!text-primary-700{color:#285192!important}.\!text-success-700{color:#0e7537!important}.text-\[\#3b71ca\]{color:#3b71ca}.text-\[\#4f4f4f\]{color:#4f4f4f}.text-\[\#14a44d\]{color:#14a44d}.text-\[\#212529\]{color:#212529}.text-\[\#b3afaf\]{color:#b3afaf}.text-\[\#b3b3b3\]{color:#b3b3b3}.text-\[\#dc4c64\]{color:#dc4c64}.text-\[\#ffffff8a\]{color:#ffffff8a}.text-\[rgb\(220\,76\,100\)\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\/\[64\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\/\[64\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\!opacity-0{opacity:0!important}.\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\[\.53\]{opacity:.53}.opacity-\[\.54\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0px_3px_0_rgba\(0\,0\,0\,0\.07\)\,0_2px_2px_0_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_5px_0_rgba\(0\,0\,0\,0\.16\)\,_0_2px_10px_0_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_9px_-4px_\#3b71ca\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_10px_15px_-3px_rgba\(0\,0\,0\,0\.07\)\,0_4px_6px_-2px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0px_2px_15px_-3px_rgba\(0\,0\,0\,\.07\)\,_0px_10px_20px_-2px_rgba\(0\,0\,0\,\.04\)\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\/login,.shadow\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,_opacity\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,box-shadow\,border\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[height\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,_opacity\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,height\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\[0ms\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\[150ms\]{--tw-duration:.15s;transition-duration:.15s}.duration-\[200ms\]{--tw-duration:.2s;transition-duration:.2s}.duration-\[250ms\]{--tw-duration:.25s;transition-duration:.25s}.duration-\[350ms\]{--tw-duration:.35s;transition-duration:.35s}.duration-\[400ms\]{--tw-duration:.4s;transition-duration:.4s}.duration-\[1000ms\]{--tw-duration:1s;transition-duration:1s}.ease-\[cubic-bezier\(0\,0\,0\.15\,1\)\,_cubic-bezier\(0\,0\,0\.15\,1\)\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\[cubic-bezier\(0\.4\,0\,0\.2\,1\)\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\)\],.ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\.0\)\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\[ease\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\!\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)!important}.\[bash\:1221\]{bash:1221}.\[check\:5737\]{check:5737}.\[clip\:rect\(0\,0\,0\,0\)\]{clip:rect(0,0,0,0)}.\[direction\:ltr\]{direction:ltr}.\[drm\:hdmiphy_enable\.part\.0\]{drm:hdmiphy enable.part0}.\[drm\:samsung_dsim_host_attach\]{drm:samsung dsim host attach}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[pid\:5118\,cpu4\,QThread\,0\]{pid:5118,cpu4,QThread,0}.\[pid\:5118\,cpu4\,QThread\,1\]{pid:5118,cpu4,QThread,1}.\[pid\:5118\,cpu4\,QThread\,2\]{pid:5118,cpu4,QThread,2}.\[pid\:5118\,cpu4\,QThread\,3\]{pid:5118,cpu4,QThread,3}.\[pid\:5118\,cpu4\,QThread\,4\]{pid:5118,cpu4,QThread,4}.\[pid\:5118\,cpu4\,QThread\,9\]{pid:5118,cpu4,QThread,9}.\[transition\:background-color_\.2s_linear\,_height_\.2s_ease-in-out\]{transition:background-color .2s linear,height .2s ease-in-out}.\[transition\:background-color_\.2s_linear\,_width_\.2s_ease-in-out\,_opacity\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\[transition\:background-color_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,box-shadow_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\,border_250ms_cubic-bezier\(0\.4\,0\,0\.2\,1\)_0ms\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\/ps\:opacity-60:is(:where(.group\/ps):hover *){opacity:.6}.group-hover\/x\:h-\[11px\]:is(:where(.group\/x):hover *){height:11px}.group-hover\/x\:bg-\[\#999\]:is(:where(.group\/x):hover *){background-color:#999}.group-hover\/y\:w-\[11px\]:is(:where(.group\/y):hover *){width:11px}.group-hover\/y\:bg-\[\#999\]:is(:where(.group\/y):hover *){background-color:#999}}.group-focus\/ps\:opacity-60:is(:where(.group\/ps):focus *){opacity:.6}.group-focus\/ps\:opacity-100:is(:where(.group\/ps):focus *){opacity:1}.group-focus\/x\:h-\[0\.6875rem\]:is(:where(.group\/x):focus *){height:.6875rem}.group-focus\/x\:bg-\[\#999\]:is(:where(.group\/x):focus *){background-color:#999}.group-focus\/y\:w-\[0\.6875rem\]:is(:where(.group\/y):focus *){width:.6875rem}.group-focus\/y\:bg-\[\#999\]:is(:where(.group\/y):focus *){background-color:#999}.group-active\/ps\:opacity-100:is(:where(.group\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:0}.group-data-te-collapse-collapsed\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\:fill-\[\#212529\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\[te-input-focused\]\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-focused\]\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-focused\]\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-focused\]\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-focused\]\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-focused\]\:border-\[\#14a44d\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\[te-input-focused\]\:border-\[\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\[te-input-focused\]\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\[te-input-focused\]\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\[te-input-focused\]\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#3b71ca\,_0_-1px_0_0_\#3b71ca\,_0_1px_0_0_\#3b71ca\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#14a44d\,_0_-1px_0_0_\#14a44d\,_0_1px_0_0_\#14a44d\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#dc4c64\,_0_-1px_0_0_\#dc4c64\,_0_1px_0_0_\#dc4c64\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-focused\]\:shadow-\[1px_0_0_\#ffffff\,_0_-1px_0_0_\#ffffff\,_0_1px_0_0_\#ffffff\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[te-input-state-active\]\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\[te-input-state-active\]\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\[te-input-state-active\]\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\[te-input-state-active\]\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\[te-input-state-active\]\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\[te-input-state-active\]\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\[te-select-option-group-ref\]\/opt\:pl-7:is(:where(.group\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\[te-was-validated\]\/validation\:mb-4:is(:where(.group\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\[\&\.ps--active-x\]\/ps\:block:is(:where(.group\/ps).ps--active-x *){display:block}.group-\[\&\.ps--active-x\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-x *){background-color:#0000}.group-\[\&\.ps--active-y\]\/ps\:block:is(:where(.group\/ps).ps--active-y *){display:block}.group-\[\&\.ps--active-y\]\/ps\:bg-transparent:is(:where(.group\/ps).ps--active-y *){background-color:#0000}.group-\[\&\.ps--clicking\]\/x\:h-\[11px\]:is(:where(.group\/x).ps--clicking *){height:11px}.group-\[\&\.ps--clicking\]\/x\:bg-\[\#999\]:is(:where(.group\/x).ps--clicking *){background-color:#999}.group-\[\&\.ps--clicking\]\/y\:w-\[11px\]:is(:where(.group\/y).ps--clicking *){width:11px}.group-\[\&\.ps--clicking\]\/y\:bg-\[\#999\]:is(:where(.group\/y).ps--clicking *){background-color:#999}.group-\[\&\.ps--scrolling-x\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-x *),.group-\[\&\.ps--scrolling-y\]\/ps\:opacity-60:is(:where(.group\/ps).ps--scrolling-y *){opacity:.6}.group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\[\[data-te-datepicker-cell-current\]\]\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\[\[data-te-datepicker-cell-current\]\]\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\[\[data-te-datepicker-cell-current\]\]\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\[\[data-te-datepicker-cell-selected\]\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\[\[data-te-datepicker-cell-selected\]\]\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\[te-was-validated\]\/validation\:peer-valid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-valid\:text-green-600:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:block:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\[te-was-validated\]\/validation\:peer-invalid\:text-\[rgb\(220\,76\,100\)\]:is(:where(.group\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\:-translate-y-\[0\.9rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[0\.75rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:-translate-y-\[1\.15rem\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\:scale-\[0\.8\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\:\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\[te-input-focused\]\:\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\[te-input-focused\]\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\[te-input-state-active\]\:scale-\[0\.8\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\:bg-transparent ::selection{background-color:#0000}.selection\:bg-transparent::selection{background-color:#0000}.before\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:h-\[0\.875rem\]:before{content:var(--tw-content);height:.875rem}.before\:w-\[0\.875rem\]:before{content:var(--tw-content);width:.875rem}.before\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\:opacity-0:before{content:var(--tw-content);opacity:0}.before\:shadow-\[0px_0px_0px_13px_transparent\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.odd\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\:\!border-\[\#14a44d\]:checked{border-color:#14a44d!important}.checked\:\!border-\[\#dc4c64\]:checked{border-color:#dc4c64!important}.checked\:border-primary:checked{border-color:#3b71ca}.checked\:\!bg-\[\#14a44d\]:checked{background-color:#14a44d!important}.checked\:\!bg-\[\#dc4c64\]:checked{background-color:#dc4c64!important}.checked\:bg-primary:checked{background-color:#3b71ca}.checked\:before\:opacity-\[0\.16\]:checked:before{content:var(--tw-content);opacity:.16}.checked\:after\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\:after\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\:after\:ml-\[0\.25rem\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\:after\:block:checked:after{content:var(--tw-content);display:block}.checked\:after\:h-\[0\.8125rem\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\:after\:w-\[0\.375rem\]:checked:after{content:var(--tw-content);width:.375rem}.checked\:after\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\:after\:border-\[0\.125rem\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:after\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:after\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:after\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:after\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:after\:\!bg-\[\#14a44d\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\:after\:\!bg-\[\#dc4c64\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\:after\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\:after\:content-\[\'\'\]:checked:after{--tw-content:"";content:var(--tw-content)}.empty\:hidden:empty{display:none}@media (hover:hover){.hover\:z-2:hover{z-index:2}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:rounded-\[50\%\]:hover{border-radius:50%}.hover\:\!bg-\[\#eee\]:hover{background-color:#eee!important}.hover\:bg-\[\#00000014\]:hover{background-color:#00000014}.hover\:bg-\[\#00000026\]:hover{background-color:#00000026}.hover\:bg-\[unset\]:hover{background-color:unset}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-primary-600:hover{background-color:#3061af}.hover\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\:fill-\[\#8b8b8b\]:hover{fill:#8b8b8b}.hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.hover\:text-\[\#8b8b8b\]:hover{color:#8b8b8b}.hover\:text-primary:hover{color:#3b71ca}.hover\:text-primary-600:hover{color:#3061af}.hover\:text-white:hover{color:var(--color-white)}.hover\:\!opacity-90:hover{opacity:.9!important}.hover\:opacity-100:hover{opacity:1}.hover\:\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\:before\:opacity-\[0\.04\]:hover:before{content:var(--tw-content);opacity:.04}.hover\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:z-3:focus{z-index:3}.focus\:rounded-\[50\%\]:focus{border-radius:50%}.focus\:\!border-\[\#14a44d\]:focus{border-color:#14a44d!important}.focus\:\!border-\[\#dc4c64\]:focus{border-color:#dc4c64!important}.focus\:border-primary:focus{border-color:#3b71ca}.focus\:\!bg-\[\#eee\]:focus{background-color:#eee!important}.focus\:bg-\[\#00000014\]:focus{background-color:#00000014}.focus\:bg-\[\#00000026\]:focus{background-color:#00000026}.focus\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\:bg-primary-600:focus{background-color:#3061af}.focus\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.focus\:text-gray-700:focus{color:var(--color-gray-700)}.focus\:text-primary:focus{color:#3b71ca}.focus\:text-primary-600:focus{color:#3061af}.focus\:text-white:focus{color:var(--color-white)}.focus\:\!opacity-90:focus{opacity:.9!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#14a44d\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:\!shadow-\[inset_0_0_0_1px_\#dc4c64\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:transition-\[border-color_0\.2s\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:placeholder\:opacity-100:focus::placeholder{opacity:1}.focus\:before\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\:before\:opacity-\[0\.12\]:focus:before{content:var(--tw-content);opacity:.12}.focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(0\,0\,0\,0\.6\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:after\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\:after\:z-\[1\]:focus:after{content:var(--tw-content);z-index:1}.focus\:after\:block:focus:after{content:var(--tw-content);display:block}.focus\:after\:h-\[0\.875rem\]:focus:after{content:var(--tw-content);height:.875rem}.focus\:after\:w-\[0\.875rem\]:focus:after{content:var(--tw-content);width:.875rem}.focus\:after\:rounded-\[0\.125rem\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\:after\:content-\[\'\'\]:focus:after{--tw-content:"";content:var(--tw-content)}.checked\:focus\:before\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\:focus\:before\:transition-\[box-shadow_0\.2s\,transform_0\.2s\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\:focus\:after\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\:focus\:after\:ml-\[0\.25rem\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\:focus\:after\:h-\[0\.8125rem\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\:focus\:after\:w-\[0\.375rem\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\:focus\:after\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\:focus\:after\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\:focus\:after\:border-\[0\.125rem\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\:focus\:after\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\:focus\:after\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\:focus\:after\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\:focus\:after\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\:focus\:after\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\:z-60:active{z-index:60}.active\:bg-\[\#c4d4ef\]:active{background-color:#c4d4ef}.active\:bg-\[\#cacfd1\]:active{background-color:#cacfd1}.active\:bg-primary-700:active{background-color:#285192}.active\:bg-primary-accent-200:active{background-color:#cedbee}.active\:text-primary-700:active{color:#285192}.active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.3\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.2\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\:grid[data-te-dropdown-show]{display:grid}.data-\[data-te-autocomplete-option-disabled\]\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\[data-te-autocomplete-option-disabled\]\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\[popper-reference-hidden\]\:hidden[data-popper-reference-hidden]{display:none}.data-\[te-active\]\:-top-\[38px\][data-te-active]{top:-38px}.data-\[te-active\]\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-autocomplete-item-active\]\:bg-black\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-autocomplete-state-open\]\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-autocomplete-state-open\]\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\[te-carousel-fade\]\:z-0[data-te-carousel-fade]{z-index:0}.data-\[te-carousel-fade\]\:z-\[1\][data-te-carousel-fade]{z-index:1}.data-\[te-carousel-fade\]\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\[te-carousel-fade\]\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\[te-carousel-fade\]\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\[te-carousel-fade\]\:duration-\[600ms\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\[te-datepicker-cell-disabled\]\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\[te-datepicker-cell-disabled\]\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\[te-datepicker-cell-disabled\]\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\[te-datepicker-cell-disabled\]\:hover\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\[\[data-te-datepicker-cell-focused\]\]\:data-\[te-datepicker-cell-selected\]\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\[te-input-disabled\]\:cursor-default[data-te-input-disabled]{cursor:default}.data-\[te-input-disabled\]\:bg-\[\#e9ecef\][data-te-input-disabled]{background-color:#e9ecef}.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-multiple-active\]\:bg-black\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:block[data-te-input-state-active]{display:block}.data-\[te-input-state-active\]\:-translate-y-\[0\.9rem\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[0\.75rem\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:-translate-y-\[1\.15rem\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[te-input-state-active\]\:scale-\[0\.8\][data-te-input-state-active]{scale:.8}.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-input-state-active\]\:bg-black\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-input-state-active\]\:placeholder\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\[te-select-open\]\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[te-select-open\]\:opacity-100[data-te-select-open]{opacity:1}.data-\[te-select-option-disabled\]\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:bg-black\/\[0\.02\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-black\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\[te-select-selected\]\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\:transform-none{transform:none}.motion-reduce\:animate-\[spin_1\.5s_linear_infinite\]{animation:1.5s linear infinite spin}.motion-reduce\:animate-\[spinner-grow_1\.5s_linear_infinite\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\:block{display:block}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10\%_90\%\]{grid-template-columns:10% 90%}.sm\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\:break-words{overflow-wrap:break-word}.sm\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\:order-none{order:0}.md\:my-0{margin-block:0}.md\:mb-0{margin-bottom:0}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:pr-1{padding-right:var(--spacing)}.md\:pr-\[17px\]{padding-right:17px}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:w-32{width:calc(var(--spacing) * 32)}.lg\:w-36{width:calc(var(--spacing) * 36)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\:w-52{width:calc(var(--spacing) * 52)}.xl\:grid-flow-col{grid-auto-flow:column}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\[320px\]\:max-\[825px\]\:landscape\:h-auto{height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[305px\]{min-height:305px}.min-\[320px\]\:max-\[825px\]\:landscape\:min-h-\[auto\]{min-height:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:min-w-\[auto\]{min-width:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:\!flex-row{flex-direction:row!important}.min-\[320px\]\:max-\[825px\]\:landscape\:flex-col{flex-direction:column}.min-\[320px\]\:max-\[825px\]\:landscape\:\!justify-around{justify-content:space-around!important}.min-\[320px\]\:max-\[825px\]\:landscape\:overflow-y-auto{overflow-y:auto}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-lg{border-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-tr-none{border-top-right-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\[320px\]\:max-\[825px\]\:landscape\:rounded-bl-none{border-bottom-left-radius:0}.min-\[320px\]\:max-\[825px\]\:landscape\:p-\[10px\]{padding:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:pr-\[10px\]{padding-right:10px}.min-\[320px\]\:max-\[825px\]\:landscape\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\[320px\]\:max-\[825px\]\:landscape\:text-\[3rem\]{font-size:3rem}.min-\[320px\]\:max-\[825px\]\:landscape\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\:max-md\:landscape\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\:max-md\:landscape\:h-8{height:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:h-\[360px\]{height:360px}.xs\:max-md\:landscape\:h-full{height:100%}.xs\:max-md\:landscape\:w-8{width:calc(var(--spacing) * 8)}.xs\:max-md\:landscape\:w-\[475px\]{width:475px}.xs\:max-md\:landscape\:flex-row{flex-direction:row}}}}.rtl\:\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\:\!origin-\[50\%_50\%_0\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\:\[direction\:rtl\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\:border-\[\#4f4f4f\]{border-color:#4f4f4f}.dark\:border-\[\#14a44d\]{border-color:#14a44d}.dark\:border-\[\#dc4c64\]{border-color:#dc4c64}.dark\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-primary-400{border-color:#8faee0}.dark\:\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\:bg-\[\#4f4f4f\]{background-color:#4f4f4f}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-primary-600{background-color:#3061af}.dark\:bg-transparent{background-color:#0000}.dark\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\:bg-zinc-600\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\:fill-gray-400{fill:var(--color-gray-400)}.dark\:\!text-primary-400{color:#8faee0!important}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-primary-400{color:#8faee0}.dark\:text-white{color:var(--color-white)}.dark\:shadow-\[0_4px_9px_-4px_rgba\(59\,113\,202\,0\.5\)\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-disabled\]\)\:not\(\[data-te-datepicker-cell-selected\]\)\:hover\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:group-\[\:not\(\[data-te-datepicker-cell-selected\]\)\[data-te-datepicker-cell-focused\]\]\:bg-white\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:group-\[\[data-te-datepicker-cell-current\]\]\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\:group-\[\[data-te-datepicker-cell-disabled\]\]\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\:peer-focus\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\:peer-focus\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\:placeholder\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\:checked\:border-primary:checked{border-color:#3b71ca}.dark\:checked\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\:hover\:\!bg-\[\#555\]:hover{background-color:#555!important}.dark\:hover\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\:hover\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\:hover\:text-\[\#3b71ca\]:hover{color:#3b71ca}.dark\:hover\:text-primary-400:hover{color:#8faee0}.dark\:hover\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\:focus\:\!bg-\[\#555\]:focus{background-color:#555!important}.dark\:focus\:bg-white\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:focus\:bg-white\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:focus\:text-\[\#3b71ca\]:focus{color:#3b71ca}.dark\:focus\:text-primary-400:focus{color:#8faee0}.dark\:focus\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:focus\:before\:shadow-\[0px_0px_0px_13px_rgba\(255\,255\,255\,0\.4\)\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:checked\:focus\:before\:shadow-\[0px_0px_0px_13px_\#3b71ca\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:active\:shadow-\[0_8px_9px_-4px_rgba\(59\,113\,202\,0\.2\)\,0_4px_18px_0_rgba\(59\,113\,202\,0\.1\)\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:disabled\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\:disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-autocomplete-item-active\]\:bg-white\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-buttons-timepicker\]\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\:data-\[te-input-disabled\]\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-multiple-active\]\:bg-white\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-input-state-active\]\:bg-white\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\:data-\[te-select-option-disabled\]\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[te-select-option-selected\]\:data-\[te-input-state-active\]\:bg-white\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\:block{display:block}.print\:hidden{display:none}.print\:border-none{--tw-border-style:none;border-style:none}.print\:border-black{border-color:var(--color-black)}.print\:bg-white{background-color:var(--color-white)}.print\:text-left{text-align:left}.print\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\[\&\.ps--clicking\]\:\!bg-\[\#eee\].ps--clicking{background-color:#eee!important}.\[\&\.ps--clicking\]\:\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\:\[\&\.ps--clicking\]\:\!bg-\[\#555\].ps--clicking{background-color:#555!important}}.\[\&\:\:-webkit-scrollbar\]\:h-1::-webkit-scrollbar{height:var(--spacing)}.\[\&\:\:-webkit-scrollbar\]\:w-1::-webkit-scrollbar{width:var(--spacing)}.\[\&\:\:-webkit-scrollbar-button\]\:block::-webkit-scrollbar-button{display:block}.\[\&\:\:-webkit-scrollbar-button\]\:h-0::-webkit-scrollbar-button{height:0}.\[\&\:\:-webkit-scrollbar-button\]\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\[\&\:\:-webkit-scrollbar-thumb\]\:h-\[50px\]::-webkit-scrollbar-thumb{height:50px}.\[\&\:\:-webkit-scrollbar-thumb\]\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\[\&\:\:-webkit-scrollbar-thumb\]\:bg-\[\#999\]::-webkit-scrollbar-thumb{background-color:#999}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\[\&\:\:-webkit-scrollbar-track-piece\]\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\[\&\:\:-webkit-scrollbar-track-piece\]\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-autocomplete-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:\[box-shadow\:inset_0_-1px_0_rgba\(229\,231\,235\)\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\[\&\:not\(\[data-te-input-placeholder-active\]\)\]\:placeholder\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-black\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:\[\&\:not\(\[data-te-select-option-disabled\]\)\]\:bg-white\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\[\&\:nth-child\(odd\)\]\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\[\&\:nth-child\(odd\)\]\:dark\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:mx-auto>svg{margin-inline:auto}.\[\&\>svg\]\:h-4>svg{height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:h-5>svg{height:calc(var(--spacing) * 5)}.\[\&\>svg\]\:h-6>svg{height:calc(var(--spacing) * 6)}.\[\&\>svg\]\:w-4>svg{width:calc(var(--spacing) * 4)}.\[\&\>svg\]\:w-5>svg{width:calc(var(--spacing) * 5)}.\[\&\>svg\]\:w-6>svg{width:calc(var(--spacing) * 6)}.\[\&\>svg\]\:rotate-180>svg{rotate:180deg}.\[\&\>svg\]\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\:\[\&\>svg\]\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:"";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:"";inherits:false;initial-value:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}} \ No newline at end of file diff --git a/libs/hdf-converters/index.ts b/libs/hdf-converters/index.ts index 6e1d33de26..089c727f22 100644 --- a/libs/hdf-converters/index.ts +++ b/libs/hdf-converters/index.ts @@ -45,9 +45,9 @@ export * from './src/twistlock-mapper'; export * from './src/utils/attestations'; export * from './src/utils/compliance'; export * from './src/utils/fingerprinting'; -export * from './src/utils/result'; +export type * from './src/utils/result'; export * from './src/utils/splunk-tools'; export * from './src/veracode-mapper'; export * from './src/xccdf-results-mapper'; export * from './src/zap-mapper'; -export * from './types/splunk-config-types'; +export type * from './types/splunk-config-types'; diff --git a/libs/hdf-converters/package.json b/libs/hdf-converters/package.json index 9c21cfd4de..6f0cb04b6d 100644 --- a/libs/hdf-converters/package.json +++ b/libs/hdf-converters/package.json @@ -3,11 +3,30 @@ "version": "2.13.0", "license": "Apache-2.0", "description": "Converter util library used to transform various scan results into HDF format", + "keywords": [ + "hdf", + "ohdf", + "inspec", + "converter", + "sarif", + "asff", + "checklist", + "xccdf", + "security", + "compliance", + "mitre-saf" + ], + "homepage": "https://github.com/mitre/heimdall2#readme", + "bugs": "https://github.com/mitre/heimdall2/issues", + "author": "MITRE Corporation", "repository": { "type": "git", - "url": "https://github.com/mitre/heimdall2", + "url": "git+https://github.com/mitre/heimdall2.git", "directory": "libs/hdf-converters" }, + "publishConfig": { + "access": "public" + }, "files": [ "lib" ], @@ -15,6 +34,7 @@ "scripts": { "prebuild": "tailwindcss -i data/reverse-html-mapper/tailwind.css -o data/reverse-html-mapper/style.css --minify && node data/reverse-html-mapper/convert-to-embedded-strings.ts", "build": "tsc -p tsconfig.build.json", + "validate-generated": "git diff --exit-code HEAD -- data/reverse-html-mapper/style.css src/converters-from-hdf/html/embedded-assets.ts", "lint": "eslint --fix", "lint:ci": "eslint --max-warnings 0", "prepack": "run-script-os", @@ -35,7 +55,7 @@ "@microsoft/microsoft-graph-types": "^2.40.0", "@mitre/jsonix": "^3.0.7", "@smithy/node-http-handler": "^4.0.0", - "@tailwindcss/cli": "^4.0.6", + "@tailwindcss/cli": "4.3.3", "@types/csv2json": "^1.4.2", "@types/mustache": "^4.1.2", "@types/papaparse": "^5.3.2", @@ -60,7 +80,7 @@ "run-script-os": "^1.1.6", "sanitize-html": "^2.17.2", "semver": "^7.6.0", - "tailwindcss": "^4.0.6", + "tailwindcss": "4.3.3", "tw-elements": "^2.0.0", "validator": "^13.12.0", "winston": "^3.6.0", @@ -72,5 +92,8 @@ "@types/node": "^26.0.0", "vitest": "^4.0.18", "xml2js": "^0.6.0" + }, + "engines": { + "node": ">=22.18.0" } } diff --git a/libs/hdf-converters/src/anchore-grype-mapper.ts b/libs/hdf-converters/src/anchore-grype-mapper.ts index 4eedfaf21d..5eee4cb68b 100644 --- a/libs/hdf-converters/src/anchore-grype-mapper.ts +++ b/libs/hdf-converters/src/anchore-grype-mapper.ts @@ -1,19 +1,22 @@ import {ExecJSON} from 'inspecjs'; import _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; +import {stringifyOrUndefinedString} from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], ['low', 0.3], - ['negligible', 0.0], + ['negligible', 0], ['unknown', 0.5] ]); @@ -24,17 +27,17 @@ function skipSeverityNegligibleOrUnknown(controls: unknown[]): unknown[] { // Filter to controls whose highest rating severity is either `negligible` or `unknown` .filter((control) => { const rating = _.get(control, 'tags.severity', '') as string; - //console.log(rating) + // console.log(rating) return rating === 'Negligible' || rating === 'Unknown'; }) // For every result contained by that control, set the status to skipped and request a manual review - .map((control) => - control.results.map((result) => { + .forEach((control) => { + control.results.forEach((result) => { result.status = ExecJSON.ControlResultStatus.Skipped; result.skip_message = 'Manual review required because a Anchore Grype rating severity is set to `negligible` or `unknown`.'; - }) - ); + }); + }); } return controls; } @@ -46,9 +49,9 @@ function description(data: Record): string { unknown >[]; if (!vulnerability.description && relatedVulnerabilities.length > 0) { - return relatedVulnerabilities.filter( + return relatedVulnerabilities.find( (relatedVulnerability) => relatedVulnerability.id === vulnerability.id - )[0].description as string; + )!.description as string; } else if (vulnerability.description) { return vulnerability.description as string; } @@ -59,6 +62,14 @@ export class AnchoreGrypeMapper extends BaseConverter { withRaw: boolean; metadata: Record; + constructor(exportJson: string, withRaw = false) { + const temp = JSON.parse(exportJson); + super({wrapper: _.pick(temp, ['matches', 'ignoredMatches'])}); + this.metadata = _.omit(temp, ['matches', 'ignoredMatches']); + this.withRaw = withRaw; + this.setMappings(this.mapping()); + } + controlMatches( matchesPath: string, idTransformer: (value: any) => unknown, @@ -79,7 +90,7 @@ export class AnchoreGrypeMapper extends BaseConverter { path: 'vulnerability.fix', transformer: (data: Record): string => data.state == 'fixed' - ? `vulnerability is ${_.get(data, 'state')} for versions ${(_.get(data, 'versions') as string[]).join(', ')}` + ? `vulnerability is ${String(_.get(data, 'state'))} for versions ${(_.get(data, 'versions') as string[]).join(', ')}` : `vulnerability is not known to be fixed in any versions` }, label: 'fix' @@ -88,7 +99,7 @@ export class AnchoreGrypeMapper extends BaseConverter { data: { path: 'relatedVulnerabilities', transformer: (data: Record): string => - `${JSON.stringify(_.get(data, 'cvss'), null, 2)}` + stringifyOrUndefinedString(_.get(data, 'cvss')) }, label: 'check' } @@ -109,17 +120,16 @@ export class AnchoreGrypeMapper extends BaseConverter { relatedVulnerabilities as Record[] ).map((element) => element.urls) as Record[]; } - return ( - vuln_urls.concat( - ...relatedVulnerabilitiesUrls - ) as unknown as Record[] - ).map((element) => ({url: element})); + return [ + ...vuln_urls, + ...relatedVulnerabilitiesUrls.flat() + ].map((element) => ({url: element})); } } as unknown as ExecJSON.Reference[], source_location: {}, title: { transformer: (data: Record): string => - `Grype found a vulnerability to ${_.get(data, 'vulnerability.id')} in ${_.get(this.metadata, 'source.target.userInput') as string}` + `Grype found a vulnerability to ${String(_.get(data, 'vulnerability.id'))} in ${_.get(this.metadata, 'source.target.userInput') as string}` }, id: { transformer: idTransformer @@ -133,14 +143,14 @@ export class AnchoreGrypeMapper extends BaseConverter { }, code: { transformer: (data: Record): string => - `${JSON.stringify( + JSON.stringify( _.omitBy( _.pick(data, ['vulnerability', 'relatedVulnerabilities']), (value) => value === null || value === '' ), null, 2 - )}` + ) }, arrayTransformer: skipSeverityNegligibleOrUnknown, results: [ @@ -148,7 +158,7 @@ export class AnchoreGrypeMapper extends BaseConverter { status: ExecJSON.ControlResultStatus.Failed, code_desc: { transformer: (data: Record): string => - `${JSON.stringify(_.get(data, 'matchDetails'), null, 2)}` + stringifyOrUndefinedString(_.get(data, 'matchDetails')) }, message: { transformer: resultMessageTransformer @@ -193,17 +203,17 @@ export class AnchoreGrypeMapper extends BaseConverter { ...this.controlMatches( 'wrapper.matches', (data: Record): string => - `Grype/${_.get(data, 'vulnerability.id')}`, + `Grype/${String(_.get(data, 'vulnerability.id'))}`, impactMapping(IMPACT_MAPPING), (data: Record): string => - `${JSON.stringify(_.get(data, 'artifact'), null, 2)}` + stringifyOrUndefinedString(_.get(data, 'artifact')) ) }, { ...this.controlMatches( 'wrapper.ignoredMatches', (data: Record): string => - `Grype-Ignored-Match/${_.get(data, 'vulnerability.id')}`, + `Grype-Ignored-Match/${String(_.get(data, 'vulnerability.id'))}`, () => 0, (data: Record): string => `This finding is ignored due to the following applied ignored rules:\n${JSON.stringify(_.get(data, 'appliedIgnoreRules'), null, 2)}\nArtifact Information:\n${JSON.stringify(_.get(data, 'artifact'), null, 2)}` @@ -216,18 +226,11 @@ export class AnchoreGrypeMapper extends BaseConverter { passthrough: { transformer: (data: Record): Record => { return { - auxiliary_data: [{name: '', data: _.omit([])}], //Insert service name and mapped fields to be removed + auxiliary_data: [{name: '', data: _.omit([])}], // Insert service name and mapped fields to be removed ...(this.withRaw && {raw: data}) }; } } }; } - constructor(exportJson: string, withRaw = false) { - const temp = JSON.parse(exportJson); - super({wrapper: _.pick(temp, ['matches', 'ignoredMatches'])}); - this.metadata = _.omit(temp, ['matches', 'ignoredMatches']); - this.withRaw = withRaw; - this.setMappings(this.mapping()); - } } diff --git a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts index 1c4992cc03..0fd05632db 100644 --- a/libs/hdf-converters/src/asff-mapper/asff-mapper.ts +++ b/libs/hdf-converters/src/asff-mapper/asff-mapper.ts @@ -6,7 +6,7 @@ import {encode} from 'html-entities'; import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from '../base-converter'; +import {BaseConverter} from '../base-converter'; import { DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS, getCCIsForNISTTags @@ -20,12 +20,12 @@ import {getProwler} from './case-prowler'; import {getSecurityHub} from './case-security-hub'; import {getTrivy} from './case-trivy'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['CRITICAL', 0.9], ['HIGH', 0.7], ['MEDIUM', 0.5], ['LOW', 0.3], - ['INFORMATIONAL', 0.0] + ['INFORMATIONAL', 0] ]); const SEVERITY_LABEL = 'Severity.Label'; @@ -44,6 +44,19 @@ export enum SpecialCasing { Default = 'Default' } +const FIREWALL_MANAGER_ARN = + /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/firewall-manager$/; +const GUARDDUTY_ARN = + /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/guardduty$/; +const INSPECTOR_ARN = + /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/inspector$/; +const PROWLER_ARN = + /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/prowler\/prowler$/; +const SECURITY_HUB_ARN = + /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/securityhub$/; +const TRIVY_ARN = + /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aquasecurity\/aquasecurity$/; + // typically you can just look at the ProductArn field to get information on the product type but we also support some custom formats/products that require alternative means of identification function whichSpecialCase(finding: Record): SpecialCasing { const productArn = _.get(finding, 'ProductArn') as string; @@ -52,17 +65,9 @@ function whichSpecialCase(finding: Record): SpecialCasing { _.get(finding, 'GeneratorId') === 'cms.Chef Inspec' ) { return SpecialCasing.CMSInSpec; - } else if ( - productArn.match( - /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/firewall-manager$/ - ) - ) { + } else if (FIREWALL_MANAGER_ARN.test(productArn)) { return SpecialCasing.FirewallManager; - } else if ( - productArn.match( - /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/guardduty$/ - ) - ) { + } else if (GUARDDUTY_ARN.test(productArn)) { return SpecialCasing.GuardDuty; } else if ( _.some( @@ -72,46 +77,29 @@ function whichSpecialCase(finding: Record): SpecialCasing { if (!_.startsWith(type, 'MITRE/SAF/')) { return false; } - const version = type.split('/').pop()?.split('-')[0] ?? ''; + const version = type.split('/').pop()?.split('-', 1)[0] ?? ''; return validate(version) && compare(version, '2.6.20', '>'); // older versions aren't supported by the 'PreviouslyHDF' specialcasing and instead use the default casing } ) ) { return SpecialCasing.PreviouslyHDF; - } else if ( - productArn.match( - /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/inspector$/ - ) - ) { + } else if (INSPECTOR_ARN.test(productArn)) { return SpecialCasing.Inspector; - } else if ( - productArn.match( - /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/prowler\/prowler$/ - ) - ) { + } else if (PROWLER_ARN.test(productArn)) { return SpecialCasing.Prowler; - } else if ( - productArn.match( - /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aws\/securityhub$/ - ) - ) { + } else if (SECURITY_HUB_ARN.test(productArn)) { return SpecialCasing.SecurityHub; - } else if ( - productArn.match( - /^arn:[^:]+:securityhub:[^:]+:[^:]*:product\/aquasecurity\/aquasecurity$/ - ) - ) { + } else if (TRIVY_ARN.test(productArn)) { return SpecialCasing.Trivy; } else { return SpecialCasing.Default; } } -const SPECIAL_CASE_MAPPING: Map< +const SPECIAL_CASE_MAPPING = new Map< SpecialCasing, - // eslint-disable-next-line @typescript-eslint/ban-types - Record -> = new Map([ + Record any> +>([ [SpecialCasing.CMSInSpec, getCMSInSpec()], [SpecialCasing.FirewallManager, getFirewallManager()], [SpecialCasing.GuardDuty, getGuardDuty()], @@ -162,7 +150,7 @@ function handleIdGroup( const productInfo = (_.get(findings[0], 'ProductArn') as string) .split(':') - .slice(-1)[0] + .at(-1)! .split('/'); const productName = externalProductHandler( context, @@ -211,24 +199,22 @@ function handleIdGroup( _.uniq(group.map((d) => d.desc)).join('\n') ), descriptions: group - .map((d) => d.descriptions) - .flat() + .flatMap((d) => d.descriptions) .filter( (element, index, arr) => element && element.data !== '' && index === arr.findIndex( - (e) => e !== null && e !== undefined && e.data === element.data + (e) => e?.data === element.data ) // https://stackoverflow.com/a/36744732/645647 ) as ExecJSON.ControlDescription[], refs: group - .map((d) => d.refs) - .flat() + .flatMap((d) => d.refs) .filter((element) => _.get(element, 'url') !== undefined), source_location: ((): ExecJSON.SourceLocation => { const locs = _.uniq(group.map((d) => d.source_location)).filter( - (loc) => Object.keys(loc || {}).length !== 0 + (loc) => Object.keys(loc || {}).length > 0 ); if (locs.length === 0) { return {}; @@ -238,7 +224,7 @@ function handleIdGroup( return {ref: JSON.stringify(locs)}; } })(), - ...(Object.keys(waiverData || {}).length !== 0 && { + ...(Object.keys(waiverData || {}).length > 0 && { waiver_data: waiverData }), code: externalProductHandler( @@ -248,8 +234,8 @@ function handleIdGroup( 'code', JSON.stringify({Findings: findings}, null, 2) ), - results: group.map((d) => d.results).flat() - } as ExecJSON.Control; + results: group.flatMap((d) => d.results) + }; } // consolidate the array of controls which were generated 1:1 with findings in order to have subfindings/results @@ -306,14 +292,14 @@ function wrapWithFindingsObject( function fixFileInput( asffJson: string ): Record[]> { - let output = {}; + let output: Parameters[0]; try { output = JSON.parse(asffJson); } catch { const fixedInput = `[${asffJson .trim() - .replace(/}\n/g, '},\n') - .replace(/\},\n\$/g, '')}]`; + .replaceAll('}\n', '},\n') + .replaceAll('},\n$', '')}]`; output = JSON.parse(fixedInput); } return wrapWithFindingsObject(output); @@ -333,7 +319,7 @@ export class ASFFMapper extends BaseConverter { _.get(record, 'Findings[0].ProductArn') as string ) .split(':') - .slice(-1)[0] + .at(-1)! .split('/'); const defaultTargetId = `${productInfo[1]} - ${productInfo[2]}`; return externalProductHandler( @@ -362,7 +348,7 @@ export class ASFFMapper extends BaseConverter { version: '', title: { transformer: (): string => { - return (_.get(this.meta, 'title') as string) || 'ASFF Findings'; + return (_.get(this.meta, 'title')!) || 'ASFF Findings'; } }, maintainer: null, @@ -422,7 +408,7 @@ export class ASFFMapper extends BaseConverter { (_.get(finding, SEVERITY_LABEL) as string | undefined) ? (_.get(finding, SEVERITY_LABEL) as string) : (_.get(finding, 'Severity.Normalized') as number) / - 100.0; + 100; impact = externalProductHandler( this, whichSpecialCase(finding), @@ -446,7 +432,7 @@ export class ASFFMapper extends BaseConverter { finding, 'findingTags', {} - ) as Record, + ), cci: { transformer: (finding: Record): string[] => { const tags = externalProductHandler( @@ -548,7 +534,7 @@ export class ASFFMapper extends BaseConverter { finding, 'subfindingsStatus', defaultFunc - ) as ExecJSON.ControlResultStatus; + ); } }, code_desc: { @@ -595,15 +581,15 @@ export class ASFFMapper extends BaseConverter { const statusReason = this.statusReason(finding); switch (_.get(finding, COMPLIANCE_STATUS)) { case undefined: // Possible for Compliance.Status to not be there, in which case it's a skip_message - return undefined; + return; case 'PASSED': return statusReason; case 'WARNING': - return undefined; + return; case 'FAILED': return statusReason; case 'NOT_AVAILABLE': - return undefined; + return; default: return statusReason; } @@ -622,16 +608,16 @@ export class ASFFMapper extends BaseConverter { case undefined: // Possible for Compliance.Status to not be there, in which case it's a skip_message return statusReason; case 'PASSED': - return undefined; + return; case 'WARNING': return statusReason; case 'FAILED': - return undefined; + return; case 'NOT_AVAILABLE': // primary meaning is that the check could not be performed due to a service outage or API error, but it's also overloaded to mean NOT_APPLICABLE so technically 'skipped' or 'error' could be applicable, but AWS seems to do the equivalent of skipped return statusReason; default: - return undefined; + return; } })(); return { @@ -655,6 +641,17 @@ export class ASFFMapper extends BaseConverter { ] }; + constructor( + asff: Record, + supportingDocs: Map>>, + meta?: Record + ) { + super(asff); + this.meta = meta; + this.supportingDocs = supportingDocs; + this.setMappings(); + } + statusReason(finding: unknown): string | undefined { const statusReasons = _.get(finding, 'Compliance.StatusReasons') as | Record[] @@ -686,18 +683,7 @@ export class ASFFMapper extends BaseConverter { this, 'mapping', this.defaultMappings - ) as MappedTransform; - } - - constructor( - asff: Record, - supportingDocs: Map>>, - meta: Record | undefined = undefined - ) { - super(asff); - this.meta = meta; - this.supportingDocs = supportingDocs; - this.setMappings(); + ); } } @@ -709,8 +695,8 @@ export class ASFFResults { supportingDocs: Map>>; constructor( asffJson: string, - securityhubStandardsJsonArray: undefined | string[] = undefined, - meta: Record | undefined = undefined + securityhubStandardsJsonArray?: string[], + meta?: Record ) { this.meta = meta; this.supportingDocs = new Map< @@ -724,7 +710,7 @@ export class ASFFResults { 'securityhubSupportingDocs', (standards: string[] | undefined) => { throw new Error( - `supportingDocs function should've been defined: ${standards}` + `supportingDocs function should've been defined: ${String(standards)}` ); } )(securityhubStandardsJsonArray) @@ -735,7 +721,7 @@ export class ASFFResults { this.data = _.groupBy(findings, (finding) => { const productInfo = (_.get(finding, 'ProductArn') as string) .split(':') - .slice(-1)[0] + .at(-1)! .split('/'); const defaultFilename = `${productInfo[1]} - ${productInfo[2]}.json`; return externalProductHandler( @@ -760,7 +746,7 @@ export class ASFFResults { wrapped, 'preprocessingASFF', wrapped - ) as Record, + ), externalProductHandler( this, whichSpecialCase( @@ -778,7 +764,7 @@ export class ASFFResults { undefined, 'meta', this.meta - ) as Record + ) ).toHdf(); }); } diff --git a/libs/hdf-converters/src/asff-mapper/case-cms-inspec.ts b/libs/hdf-converters/src/asff-mapper/case-cms-inspec.ts index 7e51041fcb..9c78f17864 100644 --- a/libs/hdf-converters/src/asff-mapper/case-cms-inspec.ts +++ b/libs/hdf-converters/src/asff-mapper/case-cms-inspec.ts @@ -5,7 +5,7 @@ function findingId(finding: Record): string { return encode( (_.get(finding, 'ProductFields.aws/securityhub/FindingId') as string) .split('/') - .slice(-1)[0] + .at(-1)! .split('-') .slice(0, -1) .join('-') @@ -16,7 +16,7 @@ function findingTitle(finding: Record): string { return encode( (_.get(finding, 'Description') as string) .slice(`${_.get(finding, 'Title') as string} titled `.length) - .split(' : ')[0] + .split(' : ', 1)[0] ); } @@ -28,7 +28,7 @@ function subfindingsCodeDesc(finding: Record): string { return encode( (_.get(finding, 'Description') as string) .slice(`${_.get(finding, 'Title') as string} titled `.length) - .split(' : ')[1] + .split(' : ', 2)[1] ); } diff --git a/libs/hdf-converters/src/asff-mapper/case-firewall-manager.ts b/libs/hdf-converters/src/asff-mapper/case-firewall-manager.ts index 7e8fa9fe21..0bf470ec8b 100644 --- a/libs/hdf-converters/src/asff-mapper/case-firewall-manager.ts +++ b/libs/hdf-converters/src/asff-mapper/case-firewall-manager.ts @@ -10,9 +10,8 @@ function productName( ): string { const finding = Array.isArray(findings) ? findings[0] : findings; return encode( - `${_.get(finding, 'ProductFields.aws/securityhub/CompanyName')} ${_.get( - finding, - 'ProductFields.aws/securityhub/ProductName' + `${String(_.get(finding, 'ProductFields.aws/securityhub/CompanyName'))} ${String( + _.get(finding, 'ProductFields.aws/securityhub/ProductName') )}` ); } diff --git a/libs/hdf-converters/src/asff-mapper/case-guardduty.ts b/libs/hdf-converters/src/asff-mapper/case-guardduty.ts index 2b159e5858..9bb84379f8 100644 --- a/libs/hdf-converters/src/asff-mapper/case-guardduty.ts +++ b/libs/hdf-converters/src/asff-mapper/case-guardduty.ts @@ -5,7 +5,7 @@ function findingId(finding: Record): string { return encode( (_.get(finding, 'GeneratorId') as string).concat( ' ', - (_.get(finding, 'Id') as string).split('/').slice(-1)[0] + (_.get(finding, 'Id') as string).split('/').at(-1)! ) ); } diff --git a/libs/hdf-converters/src/asff-mapper/case-inspector.ts b/libs/hdf-converters/src/asff-mapper/case-inspector.ts index 06bf690c89..691be2f608 100644 --- a/libs/hdf-converters/src/asff-mapper/case-inspector.ts +++ b/libs/hdf-converters/src/asff-mapper/case-inspector.ts @@ -5,7 +5,7 @@ function findingId(finding: Record): string { return encode( (_.get(finding, 'GeneratorId') as string).concat( ' ', - (_.get(finding, 'Id') as string).split('/').slice(-1)[0] + (_.get(finding, 'Id') as string).split('/').at(-1)! ) ); } diff --git a/libs/hdf-converters/src/asff-mapper/case-previously-hdf.ts b/libs/hdf-converters/src/asff-mapper/case-previously-hdf.ts index ccbdce7e2c..47f96460a2 100644 --- a/libs/hdf-converters/src/asff-mapper/case-previously-hdf.ts +++ b/libs/hdf-converters/src/asff-mapper/case-previously-hdf.ts @@ -1,13 +1,14 @@ import {encode} from 'html-entities'; import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; -import {ILookupPath, MappedTransform} from '../base-converter'; +import type {ILookupPath, MappedTransform} from '../base-converter'; import { conditionallyProvideAttribute, DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS, FROM_ASFF_TYPES_SLASH_REPLACEMENT } from '../utils/global'; -import {ASFFMapper, consolidate, SpecialCasing} from './asff-mapper'; +import type {ASFFMapper} from './asff-mapper'; +import { consolidate, SpecialCasing} from './asff-mapper'; function replaceTypesSlashes(type: T): T | string { if (!_.isString(type)) { @@ -36,7 +37,9 @@ function objectifyTypesArray( ); try { parsed = JSON.parse(parsed); - } catch {} + } catch { + // Not JSON — keep the string exactly as it arrived. + } return {[type]: {[attribute]: parsed}}; })() ); @@ -49,7 +52,7 @@ function findExecutionFindingIndex( asffFindingToMatch?: {Id: string} ): number { if (asffFindingToMatch) { - const targetToMatch = asffFindingToMatch.Id.split('/')[0]; + const targetToMatch = asffFindingToMatch.Id.split('/', 1)[0]; return _.findIndex( Array.isArray(asffOrFindings) ? asffOrFindings @@ -70,7 +73,7 @@ function findExecutionFindingIndex( function preprocessingASFF( asff: Record ): Record { - const clone = _.cloneDeep(asff); + const clone = structuredClone(asff); const index = findExecutionFindingIndex(clone); _.pullAt(_.get(clone, 'Findings') as Record[], index); return clone; @@ -84,7 +87,7 @@ function supportingDocs( ): Map>> { const [asff, docs] = input; const index = findExecutionFindingIndex(asff); - const docsClone = _.cloneDeep(docs); + const docsClone = structuredClone(docs); docsClone.set(SpecialCasing.PreviouslyHDF, { execution: _.get(asff, `Findings[${index}]`) as Record }); @@ -119,18 +122,29 @@ function filename( findingInfo[0] as {Id: string} ); + // Guard BEFORE .at(): findIndex returns -1 on no match, and .at(-1) would + // silently take the LAST finding where the old bracket access crashed on + // undefined. Same failure condition, now stated instead of cryptic. + const executionFinding = index >= 0 ? findingInfo[1].at(index) : undefined; + if (executionFinding === undefined) { + throw new TypeError( + 'PreviouslyHDF data has no execution finding to derive a filename from' + ); + } const target = replaceTypesSlashes( - (_.get(findingInfo[1][index], 'Id') as string).split('/')[0] + (_.get(executionFinding, 'Id') as string).split('/', 1)[0] ); const finding = findingInfo[0]; - return `${_.get(objectifyTypesArray(finding), 'File.Input')}-${target}.json`; + return `${String(_.get(objectifyTypesArray(finding), 'File.Input'))}-${target}.json`; } function getCodeForProfileLayer( finding: Record, profileName: string ) { - const profileLayerToCodeMapping: Record = {}; + // Map, not Record: profile names are parsed out of the ASFF finding, and + // the old `in` membership test consulted the prototype chain. + const profileLayerToCodeMapping = new Map(); ( _.get(finding, 'Resources') as { Type: string; @@ -144,20 +158,17 @@ function getCodeForProfileLayer( ?.Details?.AwsIamRole?.AssumeRolePolicyDocument.split( '=========================================================\n# Profile name: ' ) - .filter((codeLayer) => codeLayer) + .filter(Boolean) .forEach((codeLayer) => { const [profileLevel, code] = codeLayer.split( '\n=========================================================\n\n' ); - profileLayerToCodeMapping[profileLevel] = code - .split('Test Description:')[0] - .trim(); + profileLayerToCodeMapping.set( + profileLevel, + code.split('Test Description:', 1)[0].trim() + ); }); - if (profileName in profileLayerToCodeMapping) { - return profileLayerToCodeMapping[profileName]; - } else { - return ''; - } + return profileLayerToCodeMapping.get(profileName) ?? ''; } function mapping( @@ -168,7 +179,7 @@ function mapping( 'execution' ); const executionTypes = objectifyTypesArray( - execution as Record + execution! ); const profileNames = Object.keys(executionTypes || {}).filter( (type) => @@ -181,7 +192,7 @@ function mapping( target_id: ( context.supportingDocs.get(SpecialCasing.PreviouslyHDF)?.execution .Id as string - ).split('/')[0] + ).split('/', 1)[0] }, version: _.get(executionTypes, 'Execution.version'), statistics: _.get(executionTypes, 'Execution.statistics'), @@ -344,7 +355,8 @@ function mapping( ]; return _.has(findingTypes, 'HDF2ASFF-converter.warning') - ? ret.concat([ + ? [ + ...ret, { code_desc: '', start_time: '', @@ -352,7 +364,7 @@ function mapping( skip_message: 'Warning: Entry was truncated when converted to ASFF (AWS Security Hub)' } - ]) + ] : ret; })() } as ExecJSON.Control; diff --git a/libs/hdf-converters/src/asff-mapper/case-prowler.ts b/libs/hdf-converters/src/asff-mapper/case-prowler.ts index 93b082b9e9..fe5e4d1996 100644 --- a/libs/hdf-converters/src/asff-mapper/case-prowler.ts +++ b/libs/hdf-converters/src/asff-mapper/case-prowler.ts @@ -30,7 +30,6 @@ function meta(): Record { return {name: 'Prowler', title: 'Prowler Findings'}; } -// eslint-disable-next-line @typescript-eslint/ban-types export function getProwler(): Record any> { return { subfindingsCodeDesc, diff --git a/libs/hdf-converters/src/asff-mapper/case-security-hub.ts b/libs/hdf-converters/src/asff-mapper/case-security-hub.ts index f4c47d9433..497d720ac3 100644 --- a/libs/hdf-converters/src/asff-mapper/case-security-hub.ts +++ b/libs/hdf-converters/src/asff-mapper/case-security-hub.ts @@ -2,6 +2,7 @@ import {encode} from 'html-entities'; import * as _ from 'lodash'; import {AwsConfigMapping} from '../mappings/AwsConfigMapping'; const FINDING_STANDARDS_CONTROL_ARN = 'ProductFields.StandardsControlArn'; +const WHITESPACE = /\s+/; function correspondingControl(controls: unknown[], finding: unknown) { return controls.find( @@ -15,15 +16,16 @@ function securityhubSupportingDocs(standards: string[] | undefined) { let controls: null | unknown[]; try { if (Array.isArray(standards)) { - controls = standards - .map((standard) => _.get(JSON.parse(standard), 'Controls')) - .flat(); + controls = standards.flatMap((standard) => + _.get(JSON.parse(standard), 'Controls') + ); } else { controls = null; } } catch (error) { throw new Error( - `Invalid supporting docs for Security Hub:\nException: ${error}` + `Invalid supporting docs for Security Hub:\nException: ${String(error)}`, + {cause: error} ); } const AWS_CONFIG_MAPPING = new AwsConfigMapping(); @@ -51,9 +53,7 @@ function findingId( return encode(_.get(finding, 'ProductFields.RuleId')); } else { return encode( - (_.get(finding, 'GeneratorId') as unknown as string) - .split('/') - .slice(-1)[0] + (_.get(finding, 'GeneratorId') as unknown as string).split('/').at(-1) ); } } @@ -73,7 +73,7 @@ function findingImpact( // severity is required, but must include either 'label' or 'normalized' internally with 'label' being preferred. other values can be in here too such as the original severity rating. impact = _.get(finding, 'Severity.Label') || - (_.get(finding, 'Severity.Normalized') as unknown as number) / 100.0; + (_.get(finding, 'Severity.Normalized') as unknown as number) / 100; // securityhub asff file does not contain accurate severity information by setting things that shouldn't be informational to informational: when additional context, i.e. standards, is not provided, set informational to medium. if (typeof impact === 'string' && impact === 'INFORMATIONAL') { impact = 'MEDIUM'; @@ -125,25 +125,25 @@ function productName( if ( (_.get(finding, 'Types[0]') as string) .split('/') - .slice(-1)[0] - .replace(/-/gi, ' ') + .at(-1)! + .replaceAll('-', ' ') .toLowerCase() === (_.get(finding, FINDING_STANDARDS_CONTROL_ARN) as string) .split('/') .slice(-4)[0] - .replace(/-/gi, ' ') + .replaceAll('-', ' ') .toLowerCase() ) { standardName = (_.get(finding, 'Types[0]') as string) .split('/') - .slice(-1)[0] - .replace(/-/gi, ' '); + .at(-1)! + .replaceAll('-', ' '); } else { standardName = (_.get(finding, FINDING_STANDARDS_CONTROL_ARN) as string) .split('/') .slice(-4)[0] - .replace(/-/gi, ' ') - .split(/\s+/) + .replaceAll('-', ' ') + .split(WHITESPACE) .map((element: string) => { return element.charAt(0).toUpperCase() + element.slice(1); }) diff --git a/libs/hdf-converters/src/asff-mapper/case-trivy.ts b/libs/hdf-converters/src/asff-mapper/case-trivy.ts index a212d1e016..01031997c5 100644 --- a/libs/hdf-converters/src/asff-mapper/case-trivy.ts +++ b/libs/hdf-converters/src/asff-mapper/case-trivy.ts @@ -5,9 +5,11 @@ import {DEFAULT_UPDATE_REMEDIATION_NIST_TAGS} from '../utils/global'; function findingId(finding: unknown): string { const generatorId = _.get(finding, 'GeneratorId'); - const cveId = _.get(finding, 'Resources[0].Details.Other.CVE ID'); + // lodash types _.get on unknown input as undefined, which the typeof + // guard below would narrow to never; unknown lets it narrow to string. + const cveId: unknown = _.get(finding, 'Resources[0].Details.Other.CVE ID'); if (typeof cveId === 'string') { - return encode(`${generatorId}/${cveId}`); + return encode(`${String(generatorId)}/${cveId}`); } else { const id = _.get(finding, 'Id'); return encode(`${generatorId}/${id}`); @@ -15,7 +17,9 @@ function findingId(finding: unknown): string { } function findingNistTag(finding: unknown): string[] { - const cveId = _.get(finding, 'Resources[0].Details.Other.CVE ID'); + // lodash types _.get on unknown input as undefined, which the typeof + // guard below would narrow to never; unknown lets it narrow to string. + const cveId: unknown = _.get(finding, 'Resources[0].Details.Other.CVE ID'); if (typeof cveId === 'string') { return DEFAULT_UPDATE_REMEDIATION_NIST_TAGS; } else { @@ -28,7 +32,9 @@ function subfindingsStatus(): ExecJSON.ControlResultStatus { } function subfindingsMessage(finding: unknown): string | undefined { - const cveId = _.get(finding, 'Resources[0].Details.Other.CVE ID'); + // lodash types _.get on unknown input as undefined, which the typeof + // guard below would narrow to never; unknown lets it narrow to string. + const cveId: unknown = _.get(finding, 'Resources[0].Details.Other.CVE ID'); if (typeof cveId === 'string') { const patchedPackage = _.get( finding, diff --git a/libs/hdf-converters/src/aws-config-mapper.ts b/libs/hdf-converters/src/aws-config-mapper.ts index 3eb84af0d5..beeff5561c 100644 --- a/libs/hdf-converters/src/aws-config-mapper.ts +++ b/libs/hdf-converters/src/aws-config-mapper.ts @@ -1,11 +1,12 @@ -import { +import type { ComplianceByConfigRule, ConfigRule, - ConfigService, ConfigServiceClientConfig, DescribeConfigRulesCommandInput, DescribeConfigRulesResponse, - EvaluationResult, + EvaluationResult} from '@aws-sdk/client-config-service'; +import { + ConfigService, ResourceType } from '@aws-sdk/client-config-service'; import {NodeHttpHandler} from '@smithy/node-http-handler'; @@ -22,6 +23,9 @@ const INSUFFICIENT_DATA_MSG = const NAME = 'AWS Config'; const AWS_CONFIG_MAPPING = new AwsConfigMapping(); +// The consumer reads the whole match (matches[0]), so the digits need no +// capture group. +const CONFIG_RULE_ACCOUNT_ID = /:\d{12}:config-rule/; export class AwsConfigMapper { configService: ConfigService; @@ -62,7 +66,7 @@ export class AwsConfigMapper { if (response.ConfigRules === undefined) { throw new Error('No data was returned'); } else { - while (response !== undefined && response.ConfigRules !== undefined) { + while (response?.ConfigRules !== undefined) { response.ConfigRules.forEach((rule) => { configRules.push(rule); }); @@ -156,35 +160,32 @@ export class AwsConfigMapper { return []; } } else { - return ruleData.push(result); + ruleData.push(result); } }); } return this.appendResourceNamesToResults( - await Promise.all(ruleData), + ruleData, await this.extractResourceNamesFromIds(allRulesResolved) ); } - private async appendResourceNamesToResults( + private appendResourceNamesToResults( completedControlResults: ExecJSON.ControlResult[][], - extractedResourceNames: Record + extractedResourceNames: Map ) { return completedControlResults.map((completedControlResult) => completedControlResult.map((completedControl) => { - for (const extractedResourceName in extractedResourceNames) { + for (const [extractedResourceName, resourceName] of extractedResourceNames) { if ( - completedControl.code_desc.indexOf( - JSON.stringify(extractedResourceName) - .replace(/\"/gi, '') - .replace(/{/gi, '') - .replace(/}/gi, '') - ) !== -1 + completedControl.code_desc.includes( + JSON.stringify(extractedResourceName).replaceAll(/["{}]/g, '') + ) ) { return { ...completedControl, - code_desc: `${completedControl.code_desc}, resource_name: ${extractedResourceNames[extractedResourceName]}` + code_desc: `${completedControl.code_desc}, resource_name: ${resourceName}` }; } } @@ -196,10 +197,13 @@ export class AwsConfigMapper { private async extractResourceNamesFromIds( evaluationResults: EvaluationResult[] ) { - // Map of resource types to resource IDs {resourceType: ResourceId[]} - const resourceMap: Partial> = {}; + // Maps, not Records: resource types and ids arrive from the AWS + // response, and plain-object accumulation is where a hostile key reaches + // prototype state ('__proto__' writes hit the setter; `in` walks the + // prototype chain). + const resourceMap = new Map(); // Map of resource IDs to resource names - const resolvedResourcesMap: Record = {}; + const resolvedResourcesMap = new Map(); // Extract resource Ids evaluationResults.forEach((result) => { const resourceType: ResourceType = @@ -213,21 +217,18 @@ export class AwsConfigMapper { result, 'EvaluationResultIdentifier.EvaluationResultQualifier.ResourceId' ) as unknown as string; - if (resourceType in resourceMap) { - if ( - !resourceMap[resourceType]?.includes(resourceId) && - typeof resourceId === 'string' - ) { - resourceMap[resourceType]?.push(resourceId); + const existingIds = resourceMap.get(resourceType); + if (existingIds) { + if (!existingIds.includes(resourceId) && typeof resourceId === 'string') { + existingIds.push(resourceId); } } else { - resourceMap[resourceType] = [resourceId]; + resourceMap.set(resourceType, [resourceId]); } }); // Resolve resource names from AWS - let resourceType: ResourceType; - for (resourceType in resourceMap) { - const resourceIDSlices = _.chunk(resourceMap[resourceType], 20); + for (const [resourceType, resourceIds] of resourceMap) { + const resourceIDSlices = _.chunk(resourceIds, 20); for (const slice of resourceIDSlices) { await this.delay(150); const resources = await this.configService.listDiscoveredResources({ @@ -236,7 +237,7 @@ export class AwsConfigMapper { }); resources.resourceIdentifiers?.forEach((resource) => { if (resource.resourceId && resource.resourceName) { - resolvedResourcesMap[resource.resourceId] = resource.resourceName; + resolvedResourcesMap.set(resource.resourceId, resource.resourceName); } }); } @@ -247,15 +248,11 @@ export class AwsConfigMapper { private getCodeDesc(result: EvaluationResult): string { let output = ''; if ( - result.EvaluationResultIdentifier !== undefined && - result.EvaluationResultIdentifier.EvaluationResultQualifier !== undefined + result.EvaluationResultIdentifier?.EvaluationResultQualifier !== undefined ) { output = JSON.stringify( result.EvaluationResultIdentifier.EvaluationResultQualifier - ) - .replace(/\"/gi, '') - .replace(/{/gi, '') - .replace(/}/gi, ''); + ).replaceAll(/["{}]/g, ''); } return output; } @@ -312,15 +309,12 @@ export class AwsConfigMapper { if (response.ComplianceByConfigRules === undefined) { throw new Error('No compliance data was returned'); } else { - response.ComplianceByConfigRules?.forEach((compliance) => - complianceResults.push(compliance) - ); + complianceResults.push(...(response.ComplianceByConfigRules ?? [])); } } return complianceResults; } - // eslint-disable-next-line @typescript-eslint/ban-types private hdfTags(configRule: ConfigRule): Record { let result = {}; const sourceIdentifier = configRule.Source?.SourceIdentifier; @@ -329,12 +323,11 @@ export class AwsConfigMapper { if (sourceIdentifier !== undefined) { defaultMatch = AWS_CONFIG_MAPPING.searchNIST([sourceIdentifier]); } - if (Array.isArray(defaultMatch) && defaultMatch.length !== 0) { - result = _.set( - result, - 'nist', - (_.get(result, 'nist') as unknown as string[]).concat(defaultMatch) - ); + if (Array.isArray(defaultMatch) && defaultMatch.length > 0) { + result = _.set(result, 'nist', [ + ...(_.get(result, 'nist') as unknown as string[]), + ...defaultMatch + ]); } return result; } @@ -345,17 +338,14 @@ export class AwsConfigMapper { configRule.InputParameters !== undefined && configRule.InputParameters !== '{}' ) { - params = configRule.InputParameters.replace(/{/gi, '') - .replace(/}/gi, '') - .split(','); + params = configRule.InputParameters.replaceAll(/[{}]/g, '').split(','); } - const checkText = []; - checkText.push(`ARN: ${configRule.ConfigRuleArn || 'N/A'}`); - checkText.push( + const checkText = [ + `ARN: ${configRule.ConfigRuleArn || 'N/A'}`, `Source Identifier: ${configRule.Source?.SourceIdentifier || 'N/A'}` - ); - if (params.length !== 0) { - checkText.push(`${params.join('
').replace(/\"/gi, '')}`); + ]; + if (params.length > 0) { + checkText.push(params.join('
').replaceAll('"', '')); } return checkText.join('
'); } @@ -370,7 +360,7 @@ export class AwsConfigMapper { } private getAccountId(arn: string): string { - const matches = arn.match(/:(\d{12}):config-rule/); + const matches = CONFIG_RULE_ACCOUNT_ID.exec(arn); if (matches === null) { return 'no-account-id'; } else { @@ -379,15 +369,15 @@ export class AwsConfigMapper { } private async getControls(): Promise { - let index = 0; - return (await this.issues).map((issue: ConfigRule) => { + const issues = await this.issues; + return issues.map((issue: ConfigRule, index) => { const control: ExecJSON.Control = { id: issue.ConfigRuleId || '', title: `${this.getAccountId(issue.ConfigRuleArn || '')} - ${ issue.ConfigRuleName }` - .replace(/:/gi, '') - .replace(/config-rule/gi, ''), + .replaceAll(':', '') + .replaceAll(/config-rule/gi, ''), desc: issue.Description || null, impact: this.getImpact(issue), tags: this.hdfTags(issue), @@ -395,9 +385,10 @@ export class AwsConfigMapper { refs: [], source_location: {ref: issue.ConfigRuleArn, line: 1}, code: '', - results: this.results[index] + // Parallel array built from the same source: the map callback's own + // index addresses it; [] can only occur if the arrays ever diverge. + results: this.results.at(index) ?? [] }; - index++; return control; }); } @@ -419,7 +410,7 @@ export class AwsConfigMapper { }, version: HeimdallToolsVersion, statistics: { - //aws_config_sdk_version: ConfigService., // How do i get the sdk version? + // aws_config_sdk_version: ConfigService., // How do i get the sdk version? duration: null }, profiles: [ diff --git a/libs/hdf-converters/src/base-converter.ts b/libs/hdf-converters/src/base-converter.ts index e45e101fbe..365ed7dbcc 100644 --- a/libs/hdf-converters/src/base-converter.ts +++ b/libs/hdf-converters/src/base-converter.ts @@ -1,37 +1,35 @@ import {createHash} from 'crypto'; import {XMLParser} from 'fast-xml-parser'; -import {ExecJSON} from 'inspecjs'; +import type {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; -import Papa from 'papaparse'; +import {parse} from 'papaparse'; -export interface ILookupPath { +export type ILookupPath = { shortcircuit?: boolean; path?: string | string[]; transformer?: (value: any) => unknown; arrayTransformer?: (value: unknown[], file: any) => unknown[]; pathTransform?: (value: unknown, file: any) => unknown; key?: string; -} +}; export type ObjectEntryValue = {[K in keyof T]: readonly [K, T[K]]}[keyof T]; -/* eslint-disable @typescript-eslint/ban-types */ export type MappedTransform = { - [K in keyof T]: Exclude extends Array + [K in keyof T]: Exclude extends any[] ? MappedTransform - : T[K] extends Function + : T[K] extends (...args: never[]) => unknown ? T[K] : T[K] extends object ? MappedTransform : T[K] | U; }; export type MappedReform = { - [K in keyof T]: Exclude extends Array + [K in keyof T]: Exclude extends any[] ? MappedReform : T[K] extends object ? MappedReform : Exclude; }; -/* eslint-enable @typescript-eslint/ban-types */ // Hashing Function export function generateHash(data: string, algorithm = 'sha256'): string { @@ -39,7 +37,12 @@ export function generateHash(data: string, algorithm = 'sha256'): string { return hash.update(data).digest('hex'); } -export async function buildParseHtmlFunc(): Promise<(input: unknown) => string> { +export type ParseHtmlFunc = (input: unknown) => string; + +// Used when a lookup path declares no transformer of its own. +const identityTransformer = (value: unknown): unknown => value; + +export async function buildParseHtmlFunc(): Promise { const htmlparser = await import('htmlparser2'); return (input: unknown): string => { if (!_.isString(input)) { @@ -51,7 +54,7 @@ export async function buildParseHtmlFunc(): Promise<(input: unknown) => string> data.push(text); } }); - parser.write(String(input)); + parser.write(input); parser.end(); return data.join(''); }; @@ -76,10 +79,13 @@ export function parseXml( } export function parseCsv(csv: string): unknown[] { - const result = Papa.parse(csv.trim(), {header: true}); + const result = parse(csv.trim(), {header: true}); - if (result.errors.length) { - throw result.errors; + if (result.errors.length > 0) { + throw new Error( + `Failed to parse CSV: ${result.errors.map((error) => error.message).join('; ')}`, + {cause: result.errors} + ); } return result.data; @@ -97,12 +103,11 @@ export function impactMapping( }; } -// eslint-disable-next-line @typescript-eslint/ban-types function collapseDuplicates( - array: Array, + array: T[], key: string, collapseResults: boolean -): Array { +): T[] { const seen = new Map(); const newArray: T[] = []; let counter = 0; @@ -110,13 +115,16 @@ function collapseDuplicates( const propertyValue = _.get(item, key); if (typeof propertyValue === 'string') { const index = seen.get(propertyValue) || 0; - if (!seen.has(propertyValue)) { - newArray.push(item); - seen.set(propertyValue, counter); - counter++; - } else { + if (seen.has(propertyValue)) { + // The index was recorded by `seen` at push time, so .at() cannot + // miss; the guard states that invariant. _.set mutates the fetched + // object, so working through the reference is identical to indexing. + const existing = newArray.at(index); + if (existing === undefined) { + return; + } const oldResult = _.get( - newArray[index], + existing, 'results' ) as ExecJSON.ControlResult[]; const descriptions = oldResult.map((element) => @@ -124,25 +132,25 @@ function collapseDuplicates( ); if (collapseResults) { if ( - descriptions.indexOf( + !descriptions.includes( _.get(item, 'results[0].code_desc') as string - ) === -1 + ) ) { - _.set( - newArray[index], - 'results', - oldResult.concat( - _.get(item, 'results') as ExecJSON.ControlResult[] - ) - ); + _.set(existing, 'results', [ + ...oldResult, + ...(_.get(item, 'results') as ExecJSON.ControlResult[]) + ]); } } else { - _.set( - newArray[index], - 'results', - oldResult.concat(_.get(item, 'results') as ExecJSON.ControlResult[]) - ); + _.set(existing, 'results', [ + ...oldResult, + ...(_.get(item, 'results') as ExecJSON.ControlResult[]) + ]); } + } else { + newArray.push(item); + seen.set(propertyValue, counter); + counter++; } } }); @@ -180,7 +188,7 @@ export class BaseConverter> { } } - objectMap, V>( + objectMap( obj: T, fn: (v: ObjectEntryValue) => V ): {[K in keyof T]: V} { @@ -188,6 +196,7 @@ export class BaseConverter> { Object.entries(obj).map(([k, v]) => [k, fn(v as ObjectEntryValue)]) ) as Record; } + convertInternal( file: Record, fields: T @@ -212,15 +221,15 @@ export class BaseConverter> { evaluate( file: Record, - v: T | Array - ): T | Array | MappedReform { + v: T | T[] + ): T | T[] | MappedReform { if (v === undefined) { return v; } const hasTransformer = _.has(v, 'transformer') && _.isFunction(_.get(v, 'transformer')); - let transformer = (val: unknown) => val; + let transformer = identityTransformer; if (hasTransformer) { transformer = _.get(v, 'transformer') as any; v = _.omit(v as object, 'transformer') as T; @@ -277,7 +286,7 @@ export class BaseConverter> { } if (hasTransformer) { - return transformer(hasPath ? pathV : (file as T | T[])) as + return transformer(hasPath ? pathV : (file)) as | T | T[] | MappedReform; @@ -293,12 +302,12 @@ export class BaseConverter> { handleArray( file: Record, - v: Array - ): Array { + v: (T & ILookupPath)[] + ): T[] { if (v.length === 0) { return []; } - const resultingData: Array = []; + const resultingData: T[] = []; for (const lookupPath of v) { if (lookupPath.path === undefined) { const arrayTransformer = lookupPath.arrayTransformer?.bind(this); @@ -307,8 +316,7 @@ export class BaseConverter> { ? (_.omit(element, ['arrayTransformer']) as T & ILookupPath) : element; }); - let output: Array = []; - output.push(this.evaluate(file, lookupPath) as T); + let output: T[] = [this.evaluate(file, lookupPath) as T]; if (arrayTransformer !== undefined) { if (Array.isArray(arrayTransformer)) { output = arrayTransformer[0].apply(arrayTransformer[1], [ @@ -316,7 +324,10 @@ export class BaseConverter> { this.data ]); } else { - output = arrayTransformer.apply(null, [output, this.data]) as T[]; + output = Reflect.apply(arrayTransformer, null, [ + output, + this.data + ]) as T[]; } } resultingData.push(...output); @@ -358,7 +369,7 @@ export class BaseConverter> { this.data ]); } else { - v = arrayTransformer.apply(null, [v, this.data]) as any; + v = Reflect.apply(arrayTransformer, null, [v, this.data]) as any; } } if (key !== undefined) { @@ -387,15 +398,19 @@ export class BaseConverter> { const index = _.findIndex(pathArray, (p) => this.hasPath(file, p)); - if (index === -1) { + // Guard BEFORE .at(): findIndex returns -1 on no match, and .at(-1) + // would silently read the LAST path where the old code returned ''. + const matchedPath = index === -1 ? undefined : pathArray.at(index); + if (matchedPath === undefined) { // should probably throw error here, but instead are providing a default value to match current behavior return ''; - } else if (pathArray[index].startsWith('$.')) { - return _.get(this.data, pathArray[index].slice(2)) || ''; // having default values implemented like this also prevents 'null' from being passed through + } else if (matchedPath.startsWith('$.')) { + return _.get(this.data, matchedPath.slice(2)) || ''; // having default values implemented like this also prevents 'null' from being passed through } else { - return _.get(file, pathArray[index]) ?? ''; + return _.get(file, matchedPath) ?? ''; } } + hasPath(file: Record, path: string | string[]): boolean { let pathArray; if (typeof path === 'string') { diff --git a/libs/hdf-converters/src/burpsuite-mapper.ts b/libs/hdf-converters/src/burpsuite-mapper.ts index 266a90f328..09cf412865 100644 --- a/libs/hdf-converters/src/burpsuite-mapper.ts +++ b/libs/hdf-converters/src/burpsuite-mapper.ts @@ -1,11 +1,13 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform, + ParseHtmlFunc} from './base-converter'; import { BaseConverter, - ILookupPath, impactMapping, - MappedTransform, buildParseHtmlFunc, parseXml } from './base-converter'; @@ -16,7 +18,7 @@ import { } from './utils/global'; // Constant -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3], @@ -25,14 +27,12 @@ const IMPACT_MAPPING: Map = new Map([ const NAME = 'BurpSuite Pro Scan'; const CWE_NIST_MAPPING = new CweNistMapping(); -let parseHtml: (input: unknown) => string; - // Transformation Functions -function formatCodeDesc(issue: unknown): string { +function formatCodeDesc(parseHtml: ParseHtmlFunc, issue: unknown): string { const text = []; if (_.has(issue, 'host.ip') && _.has(issue, 'host.text')) { text.push( - `Host: ip: ${_.get(issue, 'host.ip')}, url: ${_.get(issue, 'host.text')}` + `Host: ip: ${String(_.get(issue, 'host.ip'))}, url: ${String(_.get(issue, 'host.text'))}` ); } else { text.push('Host: ip: , url: '); @@ -59,14 +59,14 @@ function idToString(id: unknown): string { return ''; } } -function formatCweId(input: string): string { +function formatCweId(parseHtml: ParseHtmlFunc, input: string): string { return parseHtml(input).slice(1, -1).trimStart(); } -function nistTag(input: string): string[] { - let cwe = formatCweId(input).split('CWE-'); +function nistTag(parseHtml: ParseHtmlFunc, input: string): string[] { + let cwe = formatCweId(parseHtml, input).split('CWE-'); cwe.shift(); - cwe = cwe.map((x) => x.split(':')[0]); + cwe = cwe.map((x) => x.split(':', 1)[0]); return CWE_NIST_MAPPING.nistFilter( cwe, DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS @@ -77,14 +77,15 @@ export class BurpSuiteResults { constructor(readonly burpsXml: string, readonly withRaw = false) {} async toHdf(): Promise { - parseHtml = await buildParseHtmlFunc(); + const parseHtml = await buildParseHtmlFunc(); - return (new BurpSuiteMapper(this.burpsXml, this.withRaw)).toHdf(); + return new BurpSuiteMapper(this.burpsXml, parseHtml, this.withRaw).toHdf(); } } export class BurpSuiteMapper extends BaseConverter { withRaw: boolean; + parseHtml: ParseHtmlFunc; mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, @@ -113,15 +114,17 @@ export class BurpSuiteMapper extends BaseConverter { tags: { nist: { path: 'vulnerabilityClassifications', - transformer: nistTag + transformer: (input: string) => nistTag(this.parseHtml, input) }, cweid: { path: 'vulnerabilityClassifications', - transformer: formatCweId + transformer: (input: string) => + formatCweId(this.parseHtml, input) }, cci: { path: 'vulnerabilityClassifications', - transformer: (data: string) => getCCIsForNISTTags(nistTag(data)) + transformer: (data: string) => + getCCIsForNISTTags(nistTag(this.parseHtml, data)) }, confidence: {path: 'confidence'} }, @@ -129,14 +132,23 @@ export class BurpSuiteMapper extends BaseConverter { source_location: {}, title: {path: 'name'}, id: {path: 'type', transformer: idToString}, - desc: {path: 'issueBackground', transformer: parseHtml}, + desc: { + path: 'issueBackground', + transformer: (input: unknown) => this.parseHtml(input) + }, descriptions: [ { - data: {path: 'issueBackground', transformer: parseHtml}, + data: { + path: 'issueBackground', + transformer: (input: unknown) => this.parseHtml(input) + }, label: 'check' }, { - data: {path: 'remediationBackground', transformer: parseHtml}, + data: { + path: 'remediationBackground', + transformer: (input: unknown) => this.parseHtml(input) + }, label: 'fix' } ], @@ -151,7 +163,10 @@ export class BurpSuiteMapper extends BaseConverter { results: [ { status: ExecJSON.ControlResultStatus.Failed, - code_desc: {transformer: formatCodeDesc}, + code_desc: { + transformer: (issue: unknown) => + formatCodeDesc(this.parseHtml, issue) + }, start_time: {path: '$.issues.exportTime'} } ] @@ -168,8 +183,10 @@ export class BurpSuiteMapper extends BaseConverter { } } }; - constructor(burpsXml: string, withRaw = false) { + + constructor(burpsXml: string, parseHtml: ParseHtmlFunc, withRaw = false) { super(parseXml(burpsXml)); + this.parseHtml = parseHtml; this.withRaw = withRaw; } } diff --git a/libs/hdf-converters/src/checkov-mapper.ts b/libs/hdf-converters/src/checkov-mapper.ts index af7bfa47c7..64a2ed88ea 100644 --- a/libs/hdf-converters/src/checkov-mapper.ts +++ b/libs/hdf-converters/src/checkov-mapper.ts @@ -1,8 +1,15 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import {data as MappingData} from './mappings/CheckovToCciAndNistMappingData'; + +// Map view over the generated table: check_id arrives from the scan file, and +// bracket access on the plain object would resolve prototype keys (a check_id +// of "constructor" is truthy and breaks the string[] contract). Map.get +// answers undefined for unknown and prototype keys alike. +const CHECKOV_MAPPING = new Map(Object.entries(MappingData)); import { conditionallyProvideAttribute, DEFAULT_STATIC_CODE_ANALYSIS_CCI_TAGS, @@ -23,7 +30,7 @@ type CheckovCheck = { file_path: string; file_line_range: number[]; resource: string; - code_block: Array<[number, string]>; + code_block: [number, string][]; check_class: string; file_abs_path: string; repo_file_path: string; @@ -84,7 +91,7 @@ type CheckovReport = { // Severity is only populated when passing in an API key via --bc-api-key, otherwise it is null // Default to medium - treat null/unknown risk as moderate until a formal risk assessment is performed. const MEDIUM_SEVERITY = 0.6; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 1], ['high', 0.8], ['important', 0.8], @@ -202,6 +209,11 @@ export class CheckovMapper extends BaseConverter { } }; + constructor(checkovJson: string, withRaw = false) { + super(JSON.parse(checkovJson) as CheckovReport); + this.withRaw = withRaw; + } + controlMapping(): MappedTransform< ExecJSON.Control & ILookupPath, ILookupPath @@ -212,14 +224,14 @@ controlMapping(): MappedTransform< cci: { path: 'check_id', transformer: (checkId: CheckovCheck['check_id']): string[] => { - const mapping = MappingData[checkId]; + const mapping = CHECKOV_MAPPING.get(checkId); return mapping ? mapping.cci : DEFAULT_STATIC_CODE_ANALYSIS_CCI_TAGS; } }, nist: { path: 'check_id', transformer: (checkId: CheckovCheck['check_id']): string[] => { - const mapping = MappingData[checkId]; + const mapping = CHECKOV_MAPPING.get(checkId); return mapping ? mapping.nist : DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS; } }, @@ -281,9 +293,4 @@ controlMapping(): MappedTransform< ] }; } - - constructor(checkovJson: string, withRaw = false) { - super(JSON.parse(checkovJson) as CheckovReport); - this.withRaw = withRaw; - } } diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts index 98a93c5a9c..fd30483681 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-jsonix-converter.ts @@ -3,22 +3,23 @@ import _ from 'lodash'; import {JsonixIntermediateConverter} from '../jsonix-intermediate-converter'; import {CciNistTwoWayMapper} from '../mappings/CciNistMapping'; import {getDescription} from '../utils/global'; -import { +import type { Asset, - Assettype, Checklist, Istig, - LocalPartEnum, Name, - Role, - Severityoverride, Sidata, Sidname, Status, Stigdata, StigdatumElement, + Vuln} from './checklistJsonix'; +import { + Assettype, + LocalPartEnum, + Role, + Severityoverride, Techarea, - Vuln, Vulnattribute } from './checklistJsonix'; import {coerce} from 'semver'; @@ -87,19 +88,21 @@ export type ChecklistVuln = Omit & { }; // Status mapping for going to and from checklist -enum StatusMapping { +export enum StatusMapping { NotAFinding = 'Passed', Open = 'Failed', Not_Applicable = 'Not Applicable', Not_Reviewed = 'Not Reviewed' } -const IMPACT_MAPPING: Map = new Map([ +const MULTI_VALUE_SEPARATOR = /[,;|]/; + +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], ['low', 0.3], - ['none', 0.0] + ['none', 0] ]); export enum Severity { @@ -202,6 +205,23 @@ export const EmptyChecklistObject: ChecklistObject = { ] }; +function applyProfileMetadataToStig( + stig: ChecklistObject['stigs'][number], + profiles: ChecklistMetadata['profiles'] +): void { + for (const profile of profiles) { + if (stig.header.title !== profile.name) { + continue; + } + stig.header.title = profile.title || profile.name; + stig.header.version = profile.version.toString(); + stig.header.releaseinfo = `Release: ${profile.releasenumber} Benchmark Date: ${profile.releasedate}`; + for (const vuln of stig.vulns) { + vuln.stigRef = `${stig.header.title} :: Version ${stig.header.version}, ${stig.header.releaseinfo}`; + } + } +} + export function updateChecklistWithMetadata( file: ExecJSON.Execution ): ChecklistObject { @@ -229,16 +249,7 @@ export function updateChecklistWithMetadata( checklist.asset.webdbinstance = metadata.webdbinstance; for (const stig of checklist.stigs) { - for (const profile of metadata.profiles) { - if (stig.header.title === profile.name) { - stig.header.title = profile.title || profile.name; - stig.header.version = profile.version.toString(); - stig.header.releaseinfo = `Release: ${profile.releasenumber} Benchmark Date: ${profile.releasedate}`; - for (const vuln of stig.vulns) { - vuln.stigRef = `${stig.header.title} :: Version ${stig.header.version}, ${stig.header.releaseinfo}`; - } - } - } + applyProfileMetadataToStig(stig, metadata.profiles); } return checklist; @@ -283,11 +294,11 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< hostip: _.get(jsonixData, 'value.asset.hostip') as unknown as string, hostmac: _.get(jsonixData, 'value.asset.hostmac') as unknown as string, hostfqdn: _.get(jsonixData, 'value.asset.hostfqdn') as unknown as string, - marking: _.get(jsonixData, 'value.asset.marking') as unknown as string, + marking: _.get(jsonixData, 'value.asset.marking'), targetcomment: _.get( jsonixData, 'value.asset.targetcomment' - ) as unknown as string, + ), techarea: _.get( jsonixData, 'value.asset.techarea' @@ -297,9 +308,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< 'value.asset.targetkey' ) as unknown as string, webordatabase: [true, 'true'].includes( - _.get(jsonixData, 'value.asset.webordatabase', false) as - | string - | boolean + _.get(jsonixData, 'value.asset.webordatabase', false) ), webdbsite: _.get( jsonixData, @@ -320,7 +329,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< const stigInfo: Sidata[] = _.get( stig, 'stiginfo.sidata' - ) as unknown as Sidata[]; + ); const header: StigHeader = { version: this.getValueFromAttributeName(stigInfo, 'version'), classification: this.getValueFromAttributeName( @@ -405,7 +414,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< documentable: this.getValueFromAttributeName( stigdata, 'Documentable' - ) as unknown as string, + ), mitigations: this.getValueFromAttributeName( stigdata, 'Mitigations' @@ -490,15 +499,15 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< } expandVulns(checklistVuln: ChecklistVuln): StigdatumElement[] { - const separateElementNames: string[] = ['CciRef', 'IAControls', 'LegacyID']; + const separateElementNames = new Set(['CciRef', 'IAControls', 'LegacyID']); const stigdata: StigdatumElement[] = []; for (const [attributeName, data] of Object.entries(checklistVuln)) { const keyFoundInVulnattribute: string | undefined = Object.keys( Vulnattribute ).find((key) => key.toLowerCase() === attributeName.toLowerCase()); if (keyFoundInVulnattribute) { - if (separateElementNames.includes(keyFoundInVulnattribute)) { - const dataStrings = data?.toString().split(/[,|;]/) ?? []; + if (separateElementNames.has(keyFoundInVulnattribute)) { + const dataStrings = data?.toString().split(MULTI_VALUE_SEPARATOR) ?? []; for (const dataString of dataStrings) { stigdata.push({ vulnattribute: @@ -515,7 +524,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< Vulnattribute[ keyFoundInVulnattribute as keyof typeof Vulnattribute ], - attributedata: data as string + attributedata: data }); } } @@ -591,7 +600,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< // note: some mappers can produce non-lowercase severity tags switch (severityTag?.toLowerCase()) { case 'none': - // if none, it will be added to Checklist's thirdPartyTools section + // falls through: 'none' is carried in Checklist's thirdPartyTools section case 'low': return Severity.Low; case 'medium': @@ -613,7 +622,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< } getFindingDetails(results: ExecJSON.ControlResult[]): string { - if (typeof results === 'undefined') { + if (results === undefined) { return ''; } else { return results @@ -669,10 +678,10 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< // if severity or severity override don't fit into low, medium, high // denote them in the control specific data if (severityTag === 'none' || severityTag === 'critical') - hdfSpecificData['severity'] = severityTag; + hdfSpecificData.severity = severityTag; if (severityOverrideTag === 'none' || severityOverrideTag === 'critical') - hdfSpecificData['severityoverride'] = severityOverrideTag; + hdfSpecificData.severityoverride = severityOverrideTag; // if impact does not align with what would be computed from the checklist // store it in the hdfSpecificData @@ -683,24 +692,24 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< ((computedImpact !== undefined && computedImpact !== impact) || impact < 0.1 || impact >= 0.9) && - impact !== 0.0 + impact !== 0 ) { - hdfSpecificData['impact'] = control.impact; + hdfSpecificData.impact = control.impact; } // if there is no severity tag, severity is aligned to impact // this must be represented in hdfSpecificData when impact needs to // map to severity none or critical if (severityTag === null) { - if (impact < 0.1) hdfSpecificData['severity'] = 'none'; - else if (impact >= 0.9) hdfSpecificData['severity'] = 'critical'; + if (impact < 0.1) hdfSpecificData.severity = 'none'; + else if (impact >= 0.9) hdfSpecificData.severity = 'critical'; } if (control.code?.startsWith('control')) { - hdfSpecificData['code'] = control.code; + hdfSpecificData.code = control.code; } - const hdfDataExist = Object.keys(hdfSpecificData).length !== 0; + const hdfDataExist = Object.keys(hdfSpecificData).length > 0; return hdfDataExist ? JSON.stringify({hdfSpecificData: hdfSpecificData}, null, 2) @@ -720,23 +729,23 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< addHdfProfileSpecificData(profile: ExecJSON.Profile): string { const hdfSpecificData: Record = {}; - if (profile.attributes.length) { - hdfSpecificData['attributes'] = profile.attributes; + if (profile.attributes.length > 0) { + hdfSpecificData.attributes = profile.attributes; } if (profile.copyright) { - hdfSpecificData['copyright'] = profile.copyright; + hdfSpecificData.copyright = profile.copyright; } if (profile.copyright_email) { - hdfSpecificData['copyright_email'] = profile.copyright_email; + hdfSpecificData.copyright_email = profile.copyright_email; } if (profile.maintainer) { - hdfSpecificData['maintainer'] = profile.maintainer; + hdfSpecificData.maintainer = profile.maintainer; } if (profile.version) { - hdfSpecificData['version'] = profile.version; + hdfSpecificData.version = profile.version; } - const hdfDataExist = Object.keys(hdfSpecificData).length !== 0; + const hdfDataExist = Object.keys(hdfSpecificData).length > 0; return hdfDataExist ? JSON.stringify({hdfSpecificData}) : ''; } @@ -767,16 +776,16 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< checkContent: _.get(control.tags, 'check') ?? (getDescription( - control.descriptions as ExecJSON.ControlDescription[], + control.descriptions!, 'check' - ) as string) ?? + )!) ?? '', fixText: _.get(control.tags, 'fix') ?? (getDescription( - control.descriptions as ExecJSON.ControlDescription[], + control.descriptions!, 'fix' - ) as string) ?? + )!) ?? '', falsePositives: _.get(control.tags, 'False_Positives', ''), falseNegatives: _.get(control.tags, 'False_Negatives', ''), @@ -802,7 +811,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< _.get(control.tags, 'cci') ?? this.matchNistToCcis(_.get(control.tags, 'nist')), comments: this.getComments( - control.descriptions as ExecJSON.ControlDescription[] + control.descriptions! ), findingdetails: this.getFindingDetails(control.results) ?? '', severityjustification: _.get( @@ -846,10 +855,13 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< */ hdfToIntermediateObject(hdf: ExecJSON.Execution): ChecklistObject { const stigs: ChecklistStig[] = []; - const metadata: ChecklistMetadata | undefined = _.get( - hdf, - 'passthrough.metadata' - ) as unknown as ChecklistMetadata | undefined; + // `passthrough` exists on no ExecJSON type — the old code reached it with + // _.get plus an as-unknown-as double cast. One assertion widening hdf to + // carry an optional passthrough is the honest minimum: required by the + // compiler, so the no-unnecessary-type-assertion rule accepts it too. + const metadata: ChecklistMetadata | undefined = ( + hdf as { passthrough?: { metadata?: ChecklistMetadata } } + ).passthrough?.metadata; for (const profile of hdf.profiles) { // if profile is overlay or parent profile, skip if (profile.depends?.length) { @@ -913,9 +925,7 @@ export class ChecklistJsonixConverter extends JsonixIntermediateConverter< webdbinstance: _.get(hdf, 'passthrough.metadata.webdbinstance', ''), webdbsite: _.get(hdf, 'passthrough.metadata.webdbsite', ''), webordatabase: [true, 'true'].includes( - _.get(hdf, 'passthrough.metadata.webordatabase', false) as - | string - | boolean + _.get(hdf, 'passthrough.metadata.webordatabase', false) ) }, stigs: stigs diff --git a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts index 5f4202dcfa..3ea1f7c1a3 100644 --- a/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts +++ b/libs/hdf-converters/src/ckl-mapper/checklist-mapper.ts @@ -2,22 +2,26 @@ import {ExecJSON, severities} from 'inspecjs'; import _ from 'lodash'; import xmlFormat from 'xml-formatter'; import {version as HeimdallToolsVersion} from '../../package.json'; -import { - BaseConverter, - generateHash, +import type { ILookupPath, MappedTransform } from '../base-converter'; +import { + BaseConverter, + generateHash +} from '../base-converter'; import {CciNistTwoWayMapper} from '../mappings/CciNistMapping'; import {DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS} from '../utils/global'; +import type { + ChecklistObject, + ChecklistVuln} from './checklist-jsonix-converter'; import { ChecklistJsonixConverter, - ChecklistObject, - ChecklistVuln, EmptyChecklistObject, + StatusMapping, updateChecklistWithMetadata } from './checklist-jsonix-converter'; -import {Checklist} from './checklistJsonix'; +import type {Checklist} from './checklistJsonix'; import {jsonixMapping} from './jsonixMapping'; import {throwIfInvalidAssetMetadata} from './checklist-metadata-utils'; import {parseJson} from '../utils/parseJson'; @@ -110,7 +114,7 @@ function computeSeverity(vuln: ChecklistVuln): string { let computed = severity; if (severityOverride) computed = severityOverride; - if (!severities.find((severity) => severity === computed)) + if (!(severities as readonly string[]).includes(computed)) throw new Error( `Severity "${computed}" does not match none, low, medium, high, or critical, please check severity for ${ vuln.vulnNum @@ -126,7 +130,7 @@ function computeSeverity(vuln: ChecklistVuln): string { * @returns impact - number */ function transformImpact(vuln: ChecklistVuln): number { - if (vuln.status === 'Not Applicable') return 0.0; + if (vuln.status === StatusMapping.Not_Applicable) return 0; const severity = computeSeverity(vuln); let impact: number = ImpactMapping[severity as keyof typeof ImpactMapping]; const hdfExistingData = parseJson(vuln.thirdPartyTools); @@ -172,11 +176,11 @@ function getStatus(input: string): ExecJSON.ControlResultStatus { function checkMessage( typeCheck: string, - messageType: string, - message: string + messageType: string | undefined, + message: string | undefined ): string | null { if (typeCheck === messageType) { - return message; + return message ?? null; } else { return null; } @@ -189,15 +193,28 @@ function checkMessage( * @param input - array of one element consisting of {code_desc, status, start_time} * @returns ExecJSON.ControlResult */ +// regex of four groups (five if you count the full match) consisting of the +// four possible status values, followed by any number of characters after +// :: TEST which represents the code_desc, followed by an optional :: MESSAGE +// or SKIP_MESSAGE representing the message type, followed by any number of +// characters representing the message +const FINDING_DETAILS_PATTERN = + /^(?error|failed|passed|skipped) :: TEST (?.*?)(?: :: (?MESSAGE|SKIP_MESSAGE) (?.*))?$/s; + +// Comment sections look like "LABEL :: text", one per line. +const COMMENT_SECTION_SEPARATOR = /\n(?=[A-Z]+ ::)/; +// Anchored: the separator above splits immediately before each label, so a +// section's label is always at its start. Searching for one further in would +// rescan every position of a section that has none. +const COMMENT_PATTERN = /^(?
\n \n \n Toggle Result Sets Expansion\n \n \n \n Toggle Results Expansion\n \n \n (To Expand All, Refresh Browser Page)\n \n {{/showResultSets}}\n \n\n \n
\n
\n
Profile Status
\n
\n \n
\n
Count
\n
\n
\n {{{icons.circleCheck}}} Passed: {{statistics.passed}} ({{statistics.passedTests}} individual checks passed)\n
\n
\n {{{icons.circleCross}}} Failed: {{statistics.failed}} ({{statistics.passingTestsFailedResult}} individual checks passed, {{statistics.failedTests}} failed out of {{statistics.totalTests}} total checks)\n
\n
\n {{{icons.circleMinus}}} Not Applicable: {{statistics.notApplicable}}\n
\n
\n {{{icons.circleAlert}}} Not Reviewed: {{statistics.notReviewed}}\n
\n
\n {{{icons.triangleAlert}}} Profile Error: {{statistics.profileError}}\n
\n
\n {{{icons.squareEqual}}} Total: {{statistics.totalResults}}\n
\n
\n
\n\n \n
\n
Severity
\n
\n
\n {{{icons.circleNone}}} None: {{severity.none}}\n
\n
\n {{{icons.circleLow}}} Low: {{severity.low}}\n
\n
\n {{{icons.circleMedium}}} Medium: {{severity.medium}}\n
\n
\n {{{icons.circleHigh}}} High: {{severity.high}}\n
\n
\n {{{icons.circleCritical}}} Critical: {{severity.critical}}\n
\n
\n
\n\n \n
\n
Compliance
\n
\n
\n {{compliance.level}}\n
\n [Passed/(Passed + Failed + Not Reviewed + Profile Error) * 100]\n
\n
\n
\n
\n
\n\n \n
\n
\n
Profile Info
\n
\n \n {{#files}}\n
\n
Filename: {{filename}}
\n
Tool Version: {{toolVersion}}
\n
Platform: {{platform}}
\n
Duration: {{duration}}
\n
\n {{/files}}\n
\n
\n
\n\n
\n \n \n \n {{#showResultSets}} {{#resultSets}}\n \n \n \n \n
\n \n \n {{filename}}\n \n \n \n \n \n \n
\n\n \n \n \n \n
\n
\n \n \n \n \n ID\n
\n \n Status\n
\n \n Severity\n
\n \n 800-53 Controls & CCIs\n
\n \n ID\n \n Severity\n \n Title\n \n 800-53 Controls & CCIs\n
\n
\n\n \n {{#results}}\n
\n
\n \n \n \n \n
\n \n
\n {{hdf.wraps.id}}\n
\n \n
\n {{{resultStatus.icon}}}\n {{resultStatus.status}}\n
\n \n
\n {{{resultSeverity.icon}}}\n {{resultSeverity.severity}}\n
\n \n {{#controlTags}}\n
\n \n {{.}}\n \n
\n {{/controlTags}}\n
\n \n \n {{hdf.wraps.id}}\n \n \n
\n {{{resultSeverity.icon}}}\n {{resultSeverity.severity}}\n
\n \n \n {{{hdf.wraps.title}}}\n \n \n
\n {{#controlTags}}\n
\n \n {{.}}\n \n
\n {{/controlTags}}\n
\n\n \n \n \n \n \n \n
\n\n \n \n \n {{{data.desc}}}\n
\n\n \n
\n \n
Test Results
\n
\n \n
\n \n {{#hdf.segments}}\n
\n Status\n {{status}}\n
\n
\n Test\n {{{code_desc}}}\n
\n
\n Result\n {{message}}\n
\n {{/hdf.segments}}\n
\n\n \n
Result Details
\n
\n \n
\n \n {{#details}}\n
\n {{name}}\n {{{value}}}\n
\n {{/details}}\n
\n
\n\n \n \n {{#showCode}}\n
\n {{full_code}}\n {{/showCode}}\n
\n
\n {{/results}}\n \n \n\n \n
\n \n
\n {{filename}}\n
\n \n {{#results}}\n
\n \n \n \n \n Status\n ID\n Severity\n Title\n \n \n \n \n \n \n
\n {{resultStatus.status}}\n
\n \n \n \n
{{hdf.wraps.id}}
\n \n \n \n
\n {{resultSeverity.severity}}\n
\n \n \n {{{hdf.wraps.title}}}\n \n \n \n
\n \n \n \n \n 800-53 Controls & CCIs\n \n \n \n \n \n \n
\n {{#controlTags}}\n
\n {{.}}\n
\n {{/controlTags}}\n
\n \n \n \n \n
\n\n \n {{{data.desc}}}\n
\n\n \n
\n \n
Test Results
\n
\n \n \n Name\n Value\n \n \n {{#hdf.segments}}\n \n \n Status\n {{status}}\n \n \n Test\n {{{code_desc}}}\n \n \n Result\n {{message}}\n \n \n {{/hdf.segments}}\n \n\n \n
Result Details
\n
\n \n \n Name\n Value\n \n \n \n {{#details}}\n \n {{name}}\n {{{value}}}\n \n {{/details}}\n \n \n
\n\n \n \n {{#showCode}}\n
\n {{full_code}}\n {{/showCode}}\n
\n {{/results}}\n
\n {{/resultSets}} {{/showResultSets}}\n \n \n \n\n" as const; export const js = "/*!\n* TW Elements 1.1.0\n* \n* TW Elements is an open-source UI kit of advanced components for TailwindCSS.\n* Copyright © 2023 MDBootstrap.com\n* \n* Unless a custom, individually assigned license has been granted, this program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.\n* In addition, a custom license may be available upon request, subject to the terms and conditions of that license. Please contact tailwind@mdbootstrap.com for more information on obtaining a custom license.\n* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.\n* \n* If you would like to purchase a COMMERCIAL, non-AGPL license for TWE, please check out our pricing: https://tw-elements.com/pro/\n*/\n(function(N,mt){typeof exports==\"object\"&&typeof module<\"u\"?mt(exports):typeof define==\"function\"&&define.amd?define([\"exports\"],mt):(N=typeof globalThis<\"u\"?globalThis:N||self,mt(N.te={}))})(this,function(N){\"use strict\";var xL=Object.defineProperty;var CL=(N,mt,O)=>mt in N?xL(N,mt,{enumerable:!0,configurable:!0,writable:!0,value:O}):N[mt]=O;var ke=(N,mt,O)=>(CL(N,typeof mt!=\"symbol\"?mt+\"\":mt,O),O);const mt=(()=>{const s={};let t=1;return{set(e,i,n){typeof e[i]>\"u\"&&(e[i]={key:i,id:t},t++),s[e[i].id]=n},get(e,i){if(!e||typeof e[i]>\"u\")return null;const n=e[i];return n.key===i?s[n.id]:null},delete(e,i){if(typeof e[i]>\"u\")return;const n=e[i];n.key===i&&(delete s[n.id],delete e[i])}}})(),O={setData(s,t,e){mt.set(s,t,e)},getData(s,t){return mt.get(s,t)},removeData(s,t){mt.delete(s,t)}},lm=1e6,cm=1e3,xa=\"transitionend\",hm=s=>s==null?`${s}`:{}.toString.call(s).match(/\\s([a-z]+)/i)[1].toLowerCase(),bt=s=>{do s+=Math.floor(Math.random()*lm);while(document.getElementById(s));return s},ch=s=>{let t=s.getAttribute(\"data-te-target\");if(!t||t===\"#\"){let e=s.getAttribute(\"href\");if(!e||!e.includes(\"#\")&&!e.startsWith(\".\"))return null;e.includes(\"#\")&&!e.startsWith(\"#\")&&(e=`#${e.split(\"#\")[1]}`),t=e&&e!==\"#\"?e.trim():null}return t},Ca=s=>{const t=ch(s);return t&&document.querySelector(t)?t:null},Ne=s=>{const t=ch(s);return t?document.querySelector(t):null},oo=s=>{if(!s)return 0;let{transitionDuration:t,transitionDelay:e}=window.getComputedStyle(s);const i=Number.parseFloat(t),n=Number.parseFloat(e);return!i&&!n?0:(t=t.split(\",\")[0],e=e.split(\",\")[0],(Number.parseFloat(t)+Number.parseFloat(e))*cm)},hh=s=>{s.dispatchEvent(new Event(xa))},Wi=s=>!s||typeof s!=\"object\"?!1:(typeof s.jquery<\"u\"&&(s=s[0]),typeof s.nodeType<\"u\"),Be=s=>Wi(s)?s.jquery?s[0]:s:typeof s==\"string\"&&s.length>0?document.querySelector(s):null,L=(s,t,e)=>{Object.keys(e).forEach(i=>{const n=e[i],o=t[i],r=o&&Wi(o)?\"element\":hm(o);if(!new RegExp(n).test(r))throw new Error(`${s.toUpperCase()}: Option \"${i}\" provided type \"${r}\" but expected type \"${n}\".`)})},ae=s=>{if(!s||s.getClientRects().length===0)return!1;if(s.style&&s.parentNode&&s.parentNode.style){const t=getComputedStyle(s),e=getComputedStyle(s.parentNode);return getComputedStyle(s).getPropertyValue(\"visibility\")===\"visible\"||t.display!==\"none\"&&e.display!==\"none\"&&t.visibility!==\"hidden\"}return!1},ci=s=>!s||s.nodeType!==Node.ELEMENT_NODE||s.classList.contains(\"disabled\")?!0:typeof s.disabled<\"u\"?s.disabled:s.hasAttribute(\"disabled\")&&s.getAttribute(\"disabled\")!==\"false\",dh=s=>{if(!document.documentElement.attachShadow)return null;if(typeof s.getRootNode==\"function\"){const t=s.getRootNode();return t instanceof ShadowRoot?t:null}return s instanceof ShadowRoot?s:s.parentNode?dh(s.parentNode):null},ro=()=>function(){},zi=s=>{s.offsetHeight},uh=()=>{const{jQuery:s}=window;return s&&!document.body.hasAttribute(\"data-te-no-jquery\")?s:null},Aa=[],ph=s=>{document.readyState===\"loading\"?(Aa.length||document.addEventListener(\"DOMContentLoaded\",()=>{Aa.forEach(t=>t())}),Aa.push(s)):s()},et=()=>document.documentElement.dir===\"rtl\",dm=s=>Array.from(s),$=s=>document.createElement(s),hi=s=>{typeof s==\"function\"&&s()},fh=(s,t,e=!0)=>{if(!e){hi(s);return}const i=5,n=oo(t)+i;let o=!1;const r=({target:a})=>{a===t&&(o=!0,t.removeEventListener(xa,r),hi(s))};t.addEventListener(xa,r),setTimeout(()=>{o||hh(t)},n)},_h=(s,t,e,i)=>{let n=s.indexOf(t);if(n===-1)return s[!e&&i?s.length-1:0];const o=s.length;return n+=e?1:-1,i&&(n=(n+o)%o),s[Math.max(0,Math.min(n,o-1))]},um=/[^.]*(?=\\..*)\\.|.*/,pm=/\\..*/,fm=/::\\d+$/,wa={};let gh=1;const _m={mouseenter:\"mouseover\",mouseleave:\"mouseout\"},gm=/^(mouseenter|mouseleave)/i,mh=new Set([\"click\",\"dblclick\",\"mouseup\",\"mousedown\",\"contextmenu\",\"mousewheel\",\"DOMMouseScroll\",\"mouseover\",\"mouseout\",\"mousemove\",\"selectstart\",\"selectend\",\"keydown\",\"keypress\",\"keyup\",\"orientationchange\",\"touchstart\",\"touchmove\",\"touchend\",\"touchcancel\",\"pointerdown\",\"pointermove\",\"pointerup\",\"pointerleave\",\"pointercancel\",\"gesturestart\",\"gesturechange\",\"gestureend\",\"focus\",\"blur\",\"change\",\"reset\",\"select\",\"submit\",\"focusin\",\"focusout\",\"load\",\"unload\",\"beforeunload\",\"resize\",\"move\",\"DOMContentLoaded\",\"readystatechange\",\"error\",\"abort\",\"scroll\"]);function bh(s,t){return t&&`${t}::${gh++}`||s.uidEvent||gh++}function vh(s){const t=bh(s);return s.uidEvent=t,wa[t]=wa[t]||{},wa[t]}function mm(s,t){return function e(i){return i.delegateTarget=s,e.oneOff&&_.off(s,i.type,t),t.apply(s,[i])}}function bm(s,t,e){return function i(n){const o=s.querySelectorAll(t);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;\"\")if(o[a]===r)return n.delegateTarget=r,i.oneOff&&_.off(s,n.type,e),e.apply(r,[n]);return null}}function yh(s,t,e=null){const i=Object.keys(s);for(let n=0,o=i.length;nfunction(b){if(!b.relatedTarget||b.relatedTarget!==b.delegateTarget&&!b.delegateTarget.contains(b.relatedTarget))return f.call(this,b)};i?i=p(i):e=p(e)}const[o,r,a]=Th(t,e,i),l=vh(s),c=l[a]||(l[a]={}),h=yh(c,r,o?e:null);if(h){h.oneOff=h.oneOff&&n;return}const d=bh(r,t.replace(um,\"\")),u=o?bm(s,e,i):mm(s,e);u.delegationSelector=o?e:null,u.originalHandler=r,u.oneOff=n,u.uidEvent=d,c[d]=u,s.addEventListener(a,u,o)}function ka(s,t,e,i,n){const o=yh(t[e],i,n);o&&(s.removeEventListener(e,o,!!n),delete t[e][o.uidEvent])}function vm(s,t,e,i){const n=t[e]||{};Object.keys(n).forEach(o=>{if(o.includes(i)){const r=n[o];ka(s,t,e,r.originalHandler,r.delegationSelector)}})}function xh(s){return s=s.replace(pm,\"\"),_m[s]||s}const _={on(s,t,e,i){Eh(s,t,e,i,!1)},one(s,t,e,i){Eh(s,t,e,i,!0)},off(s,t,e,i){if(typeof t!=\"string\"||!s)return;const[n,o,r]=Th(t,e,i),a=r!==t,l=vh(s),c=t.startsWith(\".\");if(typeof o<\"u\"){if(!l||!l[r])return;ka(s,l,r,o,n?e:null);return}c&&Object.keys(l).forEach(d=>{vm(s,l,d,t.slice(1))});const h=l[r]||{};Object.keys(h).forEach(d=>{const u=d.replace(fm,\"\");if(!a||t.includes(u)){const p=h[d];ka(s,l,r,p.originalHandler,p.delegationSelector)}})},trigger(s,t,e){if(typeof t!=\"string\"||!s)return null;const i=uh(),n=xh(t),o=t!==n,r=mh.has(n);let a,l=!0,c=!0,h=!1,d=null;return o&&i&&(a=i.Event(t,e),i(s).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent(\"HTMLEvents\"),d.initEvent(n,l,!0)):d=new CustomEvent(t,{bubbles:l,cancelable:!0}),typeof e<\"u\"&&Object.keys(e).forEach(u=>{Object.defineProperty(d,u,{get(){return e[u]}})}),h&&d.preventDefault(),c&&s.dispatchEvent(d),d.defaultPrevented&&typeof a<\"u\"&&a.preventDefault(),d}},ct={on(s,t,e,i){const n=t.split(\" \");for(let o=0;o{this[t]=null})}_queueCallback(t,e,i=!0){fh(t,e,i)}static getInstance(t){return O.getData(Be(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static get VERSION(){return ym}static get NAME(){throw new Error('You have to implement the static method \"NAME\", for each component!')}static get DATA_KEY(){return`te.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}}const Tm=\"button\",Em=\"active\";class ao extends Mt{static get NAME(){return Tm}toggle(){this._element.setAttribute(\"aria-pressed\",this._element.classList.toggle(Em))}static jQueryInterface(t){return this.each(function(){const e=ao.getOrCreateInstance(this);t===\"toggle\"&&e[t]()})}}var vt=\"top\",Lt=\"bottom\",$t=\"right\",yt=\"left\",Ps=\"auto\",ji=[vt,Lt,$t,yt],di=\"start\",Yi=\"end\",Ch=\"clippingParents\",Sa=\"viewport\",Ki=\"popper\",Ah=\"reference\",Oa=ji.reduce(function(s,t){return s.concat([t+\"-\"+di,t+\"-\"+Yi])},[]),Ia=[].concat(ji,[Ps]).reduce(function(s,t){return s.concat([t,t+\"-\"+di,t+\"-\"+Yi])},[]),wh=\"beforeRead\",kh=\"read\",Sh=\"afterRead\",Oh=\"beforeMain\",Ih=\"main\",Dh=\"afterMain\",Mh=\"beforeWrite\",Lh=\"write\",$h=\"afterWrite\",lo=[wh,kh,Sh,Oh,Ih,Dh,Mh,Lh,$h];function le(s){return s?(s.nodeName||\"\").toLowerCase():null}function Rt(s){if(s==null)return window;if(s.toString()!==\"[object Window]\"){var t=s.ownerDocument;return t&&t.defaultView||window}return s}function ui(s){var t=Rt(s).Element;return s instanceof t||s instanceof Element}function Pt(s){var t=Rt(s).HTMLElement;return s instanceof t||s instanceof HTMLElement}function Da(s){if(typeof ShadowRoot>\"u\")return!1;var t=Rt(s).ShadowRoot;return s instanceof t||s instanceof ShadowRoot}function xm(s){var t=s.state;Object.keys(t.elements).forEach(function(e){var i=t.styles[e]||{},n=t.attributes[e]||{},o=t.elements[e];!Pt(o)||!le(o)||(Object.assign(o.style,i),Object.keys(n).forEach(function(r){var a=n[r];a===!1?o.removeAttribute(r):o.setAttribute(r,a===!0?\"\":a)}))})}function Cm(s){var t=s.state,e={popper:{position:t.options.strategy,left:\"0\",top:\"0\",margin:\"0\"},arrow:{position:\"absolute\"},reference:{}};return Object.assign(t.elements.popper.style,e.popper),t.styles=e,t.elements.arrow&&Object.assign(t.elements.arrow.style,e.arrow),function(){Object.keys(t.elements).forEach(function(i){var n=t.elements[i],o=t.attributes[i]||{},r=Object.keys(t.styles.hasOwnProperty(i)?t.styles[i]:e[i]),a=r.reduce(function(l,c){return l[c]=\"\",l},{});!Pt(n)||!le(n)||(Object.assign(n.style,a),Object.keys(o).forEach(function(l){n.removeAttribute(l)}))})}}const Ma={name:\"applyStyles\",enabled:!0,phase:\"write\",fn:xm,effect:Cm,requires:[\"computeStyles\"]};function Qt(s){return s.split(\"-\")[0]}var pi=Math.max,co=Math.min,Ui=Math.round;function La(){var s=navigator.userAgentData;return s!=null&&s.brands&&Array.isArray(s.brands)?s.brands.map(function(t){return t.brand+\"/\"+t.version}).join(\" \"):navigator.userAgent}function Rh(){return!/^((?!chrome|android).)*safari/i.test(La())}function Xi(s,t,e){t===void 0&&(t=!1),e===void 0&&(e=!1);var i=s.getBoundingClientRect(),n=1,o=1;t&&Pt(s)&&(n=s.offsetWidth>0&&Ui(i.width)/s.offsetWidth||1,o=s.offsetHeight>0&&Ui(i.height)/s.offsetHeight||1);var r=ui(s)?Rt(s):window,a=r.visualViewport,l=!Rh()&&e,c=(i.left+(l&&a?a.offsetLeft:0))/n,h=(i.top+(l&&a?a.offsetTop:0))/o,d=i.width/n,u=i.height/o;return{width:d,height:u,top:h,right:c+d,bottom:h+u,left:c,x:c,y:h}}function $a(s){var t=Xi(s),e=s.offsetWidth,i=s.offsetHeight;return Math.abs(t.width-e)<=1&&(e=t.width),Math.abs(t.height-i)<=1&&(i=t.height),{x:s.offsetLeft,y:s.offsetTop,width:e,height:i}}function Ph(s,t){var e=t.getRootNode&&t.getRootNode();if(s.contains(t))return!0;if(e&&Da(e)){var i=t;do{if(i&&s.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function Jt(s){return Rt(s).getComputedStyle(s)}function Am(s){return[\"table\",\"td\",\"th\"].indexOf(le(s))>=0}function He(s){return((ui(s)?s.ownerDocument:s.document)||window.document).documentElement}function ho(s){return le(s)===\"html\"?s:s.assignedSlot||s.parentNode||(Da(s)?s.host:null)||He(s)}function Nh(s){return!Pt(s)||Jt(s).position===\"fixed\"?null:s.offsetParent}function wm(s){var t=/firefox/i.test(La()),e=/Trident/i.test(La());if(e&&Pt(s)){var i=Jt(s);if(i.position===\"fixed\")return null}var n=ho(s);for(Da(n)&&(n=n.host);Pt(n)&&[\"html\",\"body\"].indexOf(le(n))<0;){var o=Jt(n);if(o.transform!==\"none\"||o.perspective!==\"none\"||o.contain===\"paint\"||[\"transform\",\"perspective\"].indexOf(o.willChange)!==-1||t&&o.willChange===\"filter\"||t&&o.filter&&o.filter!==\"none\")return n;n=n.parentNode}return null}function Ns(s){for(var t=Rt(s),e=Nh(s);e&&Am(e)&&Jt(e).position===\"static\";)e=Nh(e);return e&&(le(e)===\"html\"||le(e)===\"body\"&&Jt(e).position===\"static\")?t:e||wm(s)||t}function Ra(s){return[\"top\",\"bottom\"].indexOf(s)>=0?\"x\":\"y\"}function Bs(s,t,e){return pi(s,co(t,e))}function km(s,t,e){var i=Bs(s,t,e);return i>e?e:i}function Bh(){return{top:0,right:0,bottom:0,left:0}}function Hh(s){return Object.assign({},Bh(),s)}function Vh(s,t){return t.reduce(function(e,i){return e[i]=s,e},{})}var Sm=function(t,e){return t=typeof t==\"function\"?t(Object.assign({},e.rects,{placement:e.placement})):t,Hh(typeof t!=\"number\"?t:Vh(t,ji))};function Om(s){var t,e=s.state,i=s.name,n=s.options,o=e.elements.arrow,r=e.modifiersData.popperOffsets,a=Qt(e.placement),l=Ra(a),c=[yt,$t].indexOf(a)>=0,h=c?\"height\":\"width\";if(!(!o||!r)){var d=Sm(n.padding,e),u=$a(o),p=l===\"y\"?vt:yt,f=l===\"y\"?Lt:$t,b=e.rects.reference[h]+e.rects.reference[l]-r[l]-e.rects.popper[h],v=r[l]-e.rects.reference[l],y=Ns(o),T=y?l===\"y\"?y.clientHeight||0:y.clientWidth||0:0,x=b/2-v/2,E=d[p],C=T-u[h]-d[f],A=T/2-u[h]/2+x,w=Bs(E,A,C),S=l;e.modifiersData[i]=(t={},t[S]=w,t.centerOffset=w-A,t)}}function Im(s){var t=s.state,e=s.options,i=e.element,n=i===void 0?\"[data-popper-arrow]\":i;if(n!=null&&!(typeof n==\"string\"&&(n=t.elements.popper.querySelector(n),!n))){if({}.NODE_ENV!==\"production\"&&(Pt(n)||console.error(['Popper: \"arrow\" element must be an HTMLElement (not an SVGElement).',\"To use an SVG arrow, wrap it in an HTMLElement that will be used as\",\"the arrow.\"].join(\" \"))),!Ph(t.elements.popper,n)){({}).NODE_ENV!==\"production\"&&console.error(['Popper: \"arrow\" modifier\\'s `element` must be a child of the popper',\"element.\"].join(\" \"));return}t.elements.arrow=n}}const Fh={name:\"arrow\",enabled:!0,phase:\"main\",fn:Om,effect:Im,requires:[\"popperOffsets\"],requiresIfExists:[\"preventOverflow\"]};function Gi(s){return s.split(\"-\")[1]}var Dm={top:\"auto\",right:\"auto\",bottom:\"auto\",left:\"auto\"};function Mm(s,t){var e=s.x,i=s.y,n=t.devicePixelRatio||1;return{x:Ui(e*n)/n||0,y:Ui(i*n)/n||0}}function Wh(s){var t,e=s.popper,i=s.popperRect,n=s.placement,o=s.variation,r=s.offsets,a=s.position,l=s.gpuAcceleration,c=s.adaptive,h=s.roundOffsets,d=s.isFixed,u=r.x,p=u===void 0?0:u,f=r.y,b=f===void 0?0:f,v=typeof h==\"function\"?h({x:p,y:b}):{x:p,y:b};p=v.x,b=v.y;var y=r.hasOwnProperty(\"x\"),T=r.hasOwnProperty(\"y\"),x=yt,E=vt,C=window;if(c){var A=Ns(e),w=\"clientHeight\",S=\"clientWidth\";if(A===Rt(e)&&(A=He(e),Jt(A).position!==\"static\"&&a===\"absolute\"&&(w=\"scrollHeight\",S=\"scrollWidth\")),A=A,n===vt||(n===yt||n===$t)&&o===Yi){E=Lt;var k=d&&A===C&&C.visualViewport?C.visualViewport.height:A[w];b-=k-i.height,b*=l?1:-1}if(n===yt||(n===vt||n===Lt)&&o===Yi){x=$t;var D=d&&A===C&&C.visualViewport?C.visualViewport.width:A[S];p-=D-i.width,p*=l?1:-1}}var I=Object.assign({position:a},c&&Dm),M=h===!0?Mm({x:p,y:b},Rt(e)):{x:p,y:b};if(p=M.x,b=M.y,l){var P;return Object.assign({},I,(P={},P[E]=T?\"0\":\"\",P[x]=y?\"0\":\"\",P.transform=(C.devicePixelRatio||1)<=1?\"translate(\"+p+\"px, \"+b+\"px)\":\"translate3d(\"+p+\"px, \"+b+\"px, 0)\",P))}return Object.assign({},I,(t={},t[E]=T?b+\"px\":\"\",t[x]=y?p+\"px\":\"\",t.transform=\"\",t))}function Lm(s){var t=s.state,e=s.options,i=e.gpuAcceleration,n=i===void 0?!0:i,o=e.adaptive,r=o===void 0?!0:o,a=e.roundOffsets,l=a===void 0?!0:a;if({}.NODE_ENV!==\"production\"){var c=Jt(t.elements.popper).transitionProperty||\"\";r&&[\"transform\",\"top\",\"right\",\"bottom\",\"left\"].some(function(d){return c.indexOf(d)>=0})&&console.warn([\"Popper: Detected CSS transitions on at least one of the following\",'CSS properties: \"transform\", \"top\", \"right\", \"bottom\", \"left\".',`\n\n`,'Disable the \"computeStyles\" modifier\\'s `adaptive` option to allow',\"for smooth transitions, or remove these properties from the CSS\",\"transition declaration on the popper element if only transitioning\",\"opacity or background-color for example.\",`\n\n`,\"We recommend using the popper element as a wrapper around an inner\",\"element that can have any CSS property transitioned for animations.\"].join(\" \"))}var h={placement:Qt(t.placement),variation:Gi(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:n,isFixed:t.options.strategy===\"fixed\"};t.modifiersData.popperOffsets!=null&&(t.styles.popper=Object.assign({},t.styles.popper,Wh(Object.assign({},h,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:r,roundOffsets:l})))),t.modifiersData.arrow!=null&&(t.styles.arrow=Object.assign({},t.styles.arrow,Wh(Object.assign({},h,{offsets:t.modifiersData.arrow,position:\"absolute\",adaptive:!1,roundOffsets:l})))),t.attributes.popper=Object.assign({},t.attributes.popper,{\"data-popper-placement\":t.placement})}const Pa={name:\"computeStyles\",enabled:!0,phase:\"beforeWrite\",fn:Lm,data:{}};var uo={passive:!0};function $m(s){var t=s.state,e=s.instance,i=s.options,n=i.scroll,o=n===void 0?!0:n,r=i.resize,a=r===void 0?!0:r,l=Rt(t.elements.popper),c=[].concat(t.scrollParents.reference,t.scrollParents.popper);return o&&c.forEach(function(h){h.addEventListener(\"scroll\",e.update,uo)}),a&&l.addEventListener(\"resize\",e.update,uo),function(){o&&c.forEach(function(h){h.removeEventListener(\"scroll\",e.update,uo)}),a&&l.removeEventListener(\"resize\",e.update,uo)}}const Na={name:\"eventListeners\",enabled:!0,phase:\"write\",fn:function(){},effect:$m,data:{}};var Rm={left:\"right\",right:\"left\",bottom:\"top\",top:\"bottom\"};function po(s){return s.replace(/left|right|bottom|top/g,function(t){return Rm[t]})}var Pm={start:\"end\",end:\"start\"};function zh(s){return s.replace(/start|end/g,function(t){return Pm[t]})}function Ba(s){var t=Rt(s),e=t.pageXOffset,i=t.pageYOffset;return{scrollLeft:e,scrollTop:i}}function Ha(s){return Xi(He(s)).left+Ba(s).scrollLeft}function Nm(s,t){var e=Rt(s),i=He(s),n=e.visualViewport,o=i.clientWidth,r=i.clientHeight,a=0,l=0;if(n){o=n.width,r=n.height;var c=Rh();(c||!c&&t===\"fixed\")&&(a=n.offsetLeft,l=n.offsetTop)}return{width:o,height:r,x:a+Ha(s),y:l}}function Bm(s){var t,e=He(s),i=Ba(s),n=(t=s.ownerDocument)==null?void 0:t.body,o=pi(e.scrollWidth,e.clientWidth,n?n.scrollWidth:0,n?n.clientWidth:0),r=pi(e.scrollHeight,e.clientHeight,n?n.scrollHeight:0,n?n.clientHeight:0),a=-i.scrollLeft+Ha(s),l=-i.scrollTop;return Jt(n||e).direction===\"rtl\"&&(a+=pi(e.clientWidth,n?n.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}function Va(s){var t=Jt(s),e=t.overflow,i=t.overflowX,n=t.overflowY;return/auto|scroll|overlay|hidden/.test(e+n+i)}function jh(s){return[\"html\",\"body\",\"#document\"].indexOf(le(s))>=0?s.ownerDocument.body:Pt(s)&&Va(s)?s:jh(ho(s))}function Hs(s,t){var e;t===void 0&&(t=[]);var i=jh(s),n=i===((e=s.ownerDocument)==null?void 0:e.body),o=Rt(i),r=n?[o].concat(o.visualViewport||[],Va(i)?i:[]):i,a=t.concat(r);return n?a:a.concat(Hs(ho(r)))}function Fa(s){return Object.assign({},s,{left:s.x,top:s.y,right:s.x+s.width,bottom:s.y+s.height})}function Hm(s,t){var e=Xi(s,!1,t===\"fixed\");return e.top=e.top+s.clientTop,e.left=e.left+s.clientLeft,e.bottom=e.top+s.clientHeight,e.right=e.left+s.clientWidth,e.width=s.clientWidth,e.height=s.clientHeight,e.x=e.left,e.y=e.top,e}function Yh(s,t,e){return t===Sa?Fa(Nm(s,e)):ui(t)?Hm(t,e):Fa(Bm(He(s)))}function Vm(s){var t=Hs(ho(s)),e=[\"absolute\",\"fixed\"].indexOf(Jt(s).position)>=0,i=e&&Pt(s)?Ns(s):s;return ui(i)?t.filter(function(n){return ui(n)&&Ph(n,i)&&le(n)!==\"body\"}):[]}function Fm(s,t,e,i){var n=t===\"clippingParents\"?Vm(s):[].concat(t),o=[].concat(n,[e]),r=o[0],a=o.reduce(function(l,c){var h=Yh(s,c,i);return l.top=pi(h.top,l.top),l.right=co(h.right,l.right),l.bottom=co(h.bottom,l.bottom),l.left=pi(h.left,l.left),l},Yh(s,r,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function Kh(s){var t=s.reference,e=s.element,i=s.placement,n=i?Qt(i):null,o=i?Gi(i):null,r=t.x+t.width/2-e.width/2,a=t.y+t.height/2-e.height/2,l;switch(n){case vt:l={x:r,y:t.y-e.height};break;case Lt:l={x:r,y:t.y+t.height};break;case $t:l={x:t.x+t.width,y:a};break;case yt:l={x:t.x-e.width,y:a};break;default:l={x:t.x,y:t.y}}var c=n?Ra(n):null;if(c!=null){var h=c===\"y\"?\"height\":\"width\";switch(o){case di:l[c]=l[c]-(t[h]/2-e[h]/2);break;case Yi:l[c]=l[c]+(t[h]/2-e[h]/2);break}}return l}function qi(s,t){t===void 0&&(t={});var e=t,i=e.placement,n=i===void 0?s.placement:i,o=e.strategy,r=o===void 0?s.strategy:o,a=e.boundary,l=a===void 0?Ch:a,c=e.rootBoundary,h=c===void 0?Sa:c,d=e.elementContext,u=d===void 0?Ki:d,p=e.altBoundary,f=p===void 0?!1:p,b=e.padding,v=b===void 0?0:b,y=Hh(typeof v!=\"number\"?v:Vh(v,ji)),T=u===Ki?Ah:Ki,x=s.rects.popper,E=s.elements[f?T:u],C=Fm(ui(E)?E:E.contextElement||He(s.elements.popper),l,h,r),A=Xi(s.elements.reference),w=Kh({reference:A,element:x,strategy:\"absolute\",placement:n}),S=Fa(Object.assign({},x,w)),k=u===Ki?S:A,D={top:C.top-k.top+y.top,bottom:k.bottom-C.bottom+y.bottom,left:C.left-k.left+y.left,right:k.right-C.right+y.right},I=s.modifiersData.offset;if(u===Ki&&I){var M=I[n];Object.keys(D).forEach(function(P){var X=[$t,Lt].indexOf(P)>=0?1:-1,R=[vt,Lt].indexOf(P)>=0?\"y\":\"x\";D[P]+=M[R]*X})}return D}function Wm(s,t){t===void 0&&(t={});var e=t,i=e.placement,n=e.boundary,o=e.rootBoundary,r=e.padding,a=e.flipVariations,l=e.allowedAutoPlacements,c=l===void 0?Ia:l,h=Gi(i),d=h?a?Oa:Oa.filter(function(f){return Gi(f)===h}):ji,u=d.filter(function(f){return c.indexOf(f)>=0});u.length===0&&(u=d,{}.NODE_ENV!==\"production\"&&console.error([\"Popper: The `allowedAutoPlacements` option did not allow any\",\"placements. Ensure the `placement` option matches the variation\",\"of the allowed placements.\",'For example, \"auto\" cannot be used to allow \"bottom-start\".','Use \"auto-start\" instead.'].join(\" \")));var p=u.reduce(function(f,b){return f[b]=qi(s,{placement:b,boundary:n,rootBoundary:o,padding:r})[Qt(b)],f},{});return Object.keys(p).sort(function(f,b){return p[f]-p[b]})}function zm(s){if(Qt(s)===Ps)return[];var t=po(s);return[zh(s),t,zh(t)]}function jm(s){var t=s.state,e=s.options,i=s.name;if(!t.modifiersData[i]._skip){for(var n=e.mainAxis,o=n===void 0?!0:n,r=e.altAxis,a=r===void 0?!0:r,l=e.fallbackPlacements,c=e.padding,h=e.boundary,d=e.rootBoundary,u=e.altBoundary,p=e.flipVariations,f=p===void 0?!0:p,b=e.allowedAutoPlacements,v=t.options.placement,y=Qt(v),T=y===v,x=l||(T||!f?[po(v)]:zm(v)),E=[v].concat(x).reduce(function(we,Zt){return we.concat(Qt(Zt)===Ps?Wm(t,{placement:Zt,boundary:h,rootBoundary:d,padding:c,flipVariations:f,allowedAutoPlacements:b}):Zt)},[]),C=t.rects.reference,A=t.rects.popper,w=new Map,S=!0,k=E[0],D=0;D=0,R=X?\"width\":\"height\",z=qi(t,{placement:I,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),Y=X?P?$t:yt:P?Lt:vt;C[R]>A[R]&&(Y=po(Y));var Gt=po(Y),oe=[];if(o&&oe.push(z[M]<=0),a&&oe.push(z[Y]<=0,z[Gt]<=0),oe.every(function(we){return we})){k=I,S=!1;break}w.set(I,oe)}if(S)for(var re=f?3:1,li=function(Zt){var Pe=E.find(function(Ta){var Vi=w.get(Ta);if(Vi)return Vi.slice(0,Zt).every(function(rh){return rh})});if(Pe)return k=Pe,\"break\"},qt=re;qt>0;qt--){var Ae=li(qt);if(Ae===\"break\")break}t.placement!==k&&(t.modifiersData[i]._skip=!0,t.placement=k,t.reset=!0)}}const Uh={name:\"flip\",enabled:!0,phase:\"main\",fn:jm,requiresIfExists:[\"offset\"],data:{_skip:!1}};function Xh(s,t,e){return e===void 0&&(e={x:0,y:0}),{top:s.top-t.height-e.y,right:s.right-t.width+e.x,bottom:s.bottom-t.height+e.y,left:s.left-t.width-e.x}}function Gh(s){return[vt,$t,Lt,yt].some(function(t){return s[t]>=0})}function Ym(s){var t=s.state,e=s.name,i=t.rects.reference,n=t.rects.popper,o=t.modifiersData.preventOverflow,r=qi(t,{elementContext:\"reference\"}),a=qi(t,{altBoundary:!0}),l=Xh(r,i),c=Xh(a,n,o),h=Gh(l),d=Gh(c);t.modifiersData[e]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},t.attributes.popper=Object.assign({},t.attributes.popper,{\"data-popper-reference-hidden\":h,\"data-popper-escaped\":d})}const qh={name:\"hide\",enabled:!0,phase:\"main\",requiresIfExists:[\"preventOverflow\"],fn:Ym};function Km(s,t,e){var i=Qt(s),n=[yt,vt].indexOf(i)>=0?-1:1,o=typeof e==\"function\"?e(Object.assign({},t,{placement:s})):e,r=o[0],a=o[1];return r=r||0,a=(a||0)*n,[yt,$t].indexOf(i)>=0?{x:a,y:r}:{x:r,y:a}}function Um(s){var t=s.state,e=s.options,i=s.name,n=e.offset,o=n===void 0?[0,0]:n,r=Ia.reduce(function(h,d){return h[d]=Km(d,t.rects,o),h},{}),a=r[t.placement],l=a.x,c=a.y;t.modifiersData.popperOffsets!=null&&(t.modifiersData.popperOffsets.x+=l,t.modifiersData.popperOffsets.y+=c),t.modifiersData[i]=r}const Zh={name:\"offset\",enabled:!0,phase:\"main\",requires:[\"popperOffsets\"],fn:Um};function Xm(s){var t=s.state,e=s.name;t.modifiersData[e]=Kh({reference:t.rects.reference,element:t.rects.popper,strategy:\"absolute\",placement:t.placement})}const Wa={name:\"popperOffsets\",enabled:!0,phase:\"read\",fn:Xm,data:{}};function Gm(s){return s===\"x\"?\"y\":\"x\"}function qm(s){var t=s.state,e=s.options,i=s.name,n=e.mainAxis,o=n===void 0?!0:n,r=e.altAxis,a=r===void 0?!1:r,l=e.boundary,c=e.rootBoundary,h=e.altBoundary,d=e.padding,u=e.tether,p=u===void 0?!0:u,f=e.tetherOffset,b=f===void 0?0:f,v=qi(t,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),y=Qt(t.placement),T=Gi(t.placement),x=!T,E=Ra(y),C=Gm(E),A=t.modifiersData.popperOffsets,w=t.rects.reference,S=t.rects.popper,k=typeof b==\"function\"?b(Object.assign({},t.rects,{placement:t.placement})):b,D=typeof k==\"number\"?{mainAxis:k,altAxis:k}:Object.assign({mainAxis:0,altAxis:0},k),I=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,M={x:0,y:0};if(A){if(o){var P,X=E===\"y\"?vt:yt,R=E===\"y\"?Lt:$t,z=E===\"y\"?\"height\":\"width\",Y=A[E],Gt=Y+v[X],oe=Y-v[R],re=p?-S[z]/2:0,li=T===di?w[z]:S[z],qt=T===di?-S[z]:-w[z],Ae=t.elements.arrow,we=p&&Ae?$a(Ae):{width:0,height:0},Zt=t.modifiersData[\"arrow#persistent\"]?t.modifiersData[\"arrow#persistent\"].padding:Bh(),Pe=Zt[X],Ta=Zt[R],Vi=Bs(0,w[z],we[z]),rh=x?w[z]/2-re-Vi-Pe-D.mainAxis:li-Vi-Pe-D.mainAxis,mL=x?-w[z]/2+re+Vi+Ta+D.mainAxis:qt+Vi+Ta+D.mainAxis,ah=t.elements.arrow&&Ns(t.elements.arrow),bL=ah?E===\"y\"?ah.clientTop||0:ah.clientLeft||0:0,Zg=(P=I==null?void 0:I[E])!=null?P:0,vL=Y+rh-Zg-bL,yL=Y+mL-Zg,Qg=Bs(p?co(Gt,vL):Gt,Y,p?pi(oe,yL):oe);A[E]=Qg,M[E]=Qg-Y}if(a){var Jg,TL=E===\"x\"?vt:yt,EL=E===\"x\"?Lt:$t,Fi=A[C],Ea=C===\"y\"?\"height\":\"width\",tm=Fi+v[TL],em=Fi-v[EL],lh=[vt,yt].indexOf(y)!==-1,im=(Jg=I==null?void 0:I[C])!=null?Jg:0,sm=lh?tm:Fi-w[Ea]-S[Ea]-im+D.altAxis,nm=lh?Fi+w[Ea]+S[Ea]-im-D.altAxis:em,om=p&&lh?km(sm,Fi,nm):Bs(p?sm:tm,Fi,p?nm:em);A[C]=om,M[C]=om-Fi}t.modifiersData[i]=M}}const Qh={name:\"preventOverflow\",enabled:!0,phase:\"main\",fn:qm,requiresIfExists:[\"offset\"]};function Zm(s){return{scrollLeft:s.scrollLeft,scrollTop:s.scrollTop}}function Qm(s){return s===Rt(s)||!Pt(s)?Ba(s):Zm(s)}function Jm(s){var t=s.getBoundingClientRect(),e=Ui(t.width)/s.offsetWidth||1,i=Ui(t.height)/s.offsetHeight||1;return e!==1||i!==1}function tb(s,t,e){e===void 0&&(e=!1);var i=Pt(t),n=Pt(t)&&Jm(t),o=He(t),r=Xi(s,n,e),a={scrollLeft:0,scrollTop:0},l={x:0,y:0};return(i||!i&&!e)&&((le(t)!==\"body\"||Va(o))&&(a=Qm(t)),Pt(t)?(l=Xi(t,!0),l.x+=t.clientLeft,l.y+=t.clientTop):o&&(l.x=Ha(o))),{x:r.left+a.scrollLeft-l.x,y:r.top+a.scrollTop-l.y,width:r.width,height:r.height}}function eb(s){var t=new Map,e=new Set,i=[];s.forEach(function(o){t.set(o.name,o)});function n(o){e.add(o.name);var r=[].concat(o.requires||[],o.requiresIfExists||[]);r.forEach(function(a){if(!e.has(a)){var l=t.get(a);l&&n(l)}}),i.push(o)}return s.forEach(function(o){e.has(o.name)||n(o)}),i}function ib(s){var t=eb(s);return lo.reduce(function(e,i){return e.concat(t.filter(function(n){return n.phase===i}))},[])}function sb(s){var t;return function(){return t||(t=new Promise(function(e){Promise.resolve().then(function(){t=void 0,e(s())})})),t}}function Ve(s){for(var t=arguments.length,e=new Array(t>1?t-1:0),i=1;i100)){console.error(lb);break}if(h.reset===!0){h.reset=!1,C=-1;continue}var A=h.orderedModifiers[C],w=A.fn,S=A.options,k=S===void 0?{}:S,D=A.name;typeof w==\"function\"&&(h=w({state:h,options:k,name:D,instance:p})||h)}}},update:sb(function(){return new Promise(function(v){p.forceUpdate(),v(h)})}),destroy:function(){b(),u=!0}};if(!id(a,l))return{}.NODE_ENV!==\"production\"&&console.error(td),p;p.setOptions(c).then(function(v){!u&&c.onFirstUpdate&&c.onFirstUpdate(v)});function f(){h.orderedModifiers.forEach(function(v){var y=v.name,T=v.options,x=T===void 0?{}:T,E=v.effect;if(typeof E==\"function\"){var C=E({state:h,name:y,instance:p,options:x}),A=function(){};d.push(C||A)}})}function b(){d.forEach(function(v){return v()}),d=[]}return p}}var cb=fo(),hb=[Na,Wa,Pa,Ma],db=fo({defaultModifiers:hb}),ub=[Na,Wa,Pa,Ma,Zh,Uh,Qh,Fh,qh],Fe=fo({defaultModifiers:ub});const sd=Object.freeze(Object.defineProperty({__proto__:null,afterMain:Dh,afterRead:Sh,afterWrite:$h,applyStyles:Ma,arrow:Fh,auto:Ps,basePlacements:ji,beforeMain:Oh,beforeRead:wh,beforeWrite:Mh,bottom:Lt,clippingParents:Ch,computeStyles:Pa,createPopper:Fe,createPopperBase:cb,createPopperLite:db,detectOverflow:qi,end:Yi,eventListeners:Na,flip:Uh,hide:qh,left:yt,main:Ih,modifierPhases:lo,offset:Zh,placements:Ia,popper:Ki,popperGenerator:fo,popperOffsets:Wa,preventOverflow:Qh,read:kh,reference:Ah,right:$t,start:di,top:vt,variationPlacements:Oa,viewport:Sa,write:Lh},Symbol.toStringTag,{value:\"Module\"}));function za(s){return s===\"true\"?!0:s===\"false\"?!1:s===Number(s).toString()?Number(s):s===\"\"||s===\"null\"?null:s}function ja(s){return s.replace(/[A-Z]/g,t=>`-${t.toLowerCase()}`)}const g={setDataAttribute(s,t,e){s.setAttribute(`data-te-${ja(t)}`,e)},removeDataAttribute(s,t){s.removeAttribute(`data-te-${ja(t)}`)},getDataAttributes(s){if(!s)return{};const t={};return Object.keys(s.dataset).filter(e=>e.startsWith(\"te\")).forEach(e=>{if(e.startsWith(\"teClass\"))return;let i=e.replace(/^te/,\"\");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),t[i]=za(s.dataset[e])}),t},getDataClassAttributes(s){if(!s)return{};const t={...s.dataset};return Object.keys(t).filter(e=>e.startsWith(\"teClass\")).forEach(e=>{let i=e.replace(/^teClass/,\"\");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),t[i]=za(t[e])}),t},getDataAttribute(s,t){return za(s.getAttribute(`data-te-${ja(t)}`))},offset(s){const t=s.getBoundingClientRect();return{top:t.top+document.body.scrollTop,left:t.left+document.body.scrollLeft}},position(s){return{top:s.offsetTop,left:s.offsetLeft}},style(s,t){Object.assign(s.style,t)},toggleClass(s,t){s&&Ya(t).forEach(e=>{s.classList.contains(e)?s.classList.remove(e):s.classList.add(e)})},addClass(s,t){Ya(t).forEach(e=>!s.classList.contains(e)&&s.classList.add(e))},addStyle(s,t){Object.keys(t).forEach(e=>{s.style[e]=t[e]})},removeClass(s,t){Ya(t).forEach(e=>s.classList.contains(e)&&s.classList.remove(e))},hasClass(s,t){return s.classList.contains(t)},maxOffset(s){const t=s.getBoundingClientRect();return{top:t.top+Math.max(document.body.scrollTop,document.documentElement.scrollTop,window.scrollY),left:t.left+Math.max(document.body.scrollLeft,document.documentElement.scrollLeft,window.scrollX)}}};function Ya(s){return typeof s==\"string\"?s.split(\" \"):Array.isArray(s)?s:!1}const pb=3,m={closest(s,t){return s.closest(t)},matches(s,t){return s.matches(t)},find(s,t=document.documentElement){return[].concat(...Element.prototype.querySelectorAll.call(t,s))},findOne(s,t=document.documentElement){return Element.prototype.querySelector.call(t,s)},children(s,t){return[].concat(...s.children).filter(i=>i.matches(t))},parents(s,t){const e=[];let i=s.parentNode;for(;i&&i.nodeType===Node.ELEMENT_NODE&&i.nodeType!==pb;)this.matches(i,t)&&e.push(i),i=i.parentNode;return e},prev(s,t){let e=s.previousElementSibling;for(;e;){if(e.matches(t))return[e];e=e.previousElementSibling}return[]},next(s,t){let e=s.nextElementSibling;for(;e;){if(this.matches(e,t))return[e];e=e.nextElementSibling}return[]},focusableChildren(s){const t=[\"a\",\"button\",\"input\",\"textarea\",\"select\",\"details\",\"[tabindex]\",'[contenteditable=\"true\"]'].map(e=>`${e}:not([tabindex^=\"-\"])`).join(\", \");return this.find(t,s).filter(e=>!ci(e)&&ae(e))}},Ka=\"dropdown\",_i=\".te.dropdown\",Ua=\".data-api\",_o=\"Escape\",nd=\"Space\",od=\"Tab\",Xa=\"ArrowUp\",go=\"ArrowDown\",fb=2,_b=new RegExp(`${Xa}|${go}|${_o}`),gb=`hide${_i}`,mb=`hidden${_i}`,bb=`show${_i}`,vb=`shown${_i}`,yb=`click${_i}${Ua}`,rd=`keydown${_i}${Ua}`,Tb=`keyup${_i}${Ua}`,We=\"show\",Eb=\"dropup\",xb=\"dropend\",Cb=\"dropstart\",Ab=\"[data-te-navbar-ref]\",mo=\"[data-te-dropdown-toggle-ref]\",Ga=\"[data-te-dropdown-menu-ref]\",wb=\"[data-te-navbar-nav-ref]\",kb=\"[data-te-dropdown-menu-ref] [data-te-dropdown-item-ref]:not(.disabled):not(:disabled)\",Sb=et()?\"top-end\":\"top-start\",Ob=et()?\"top-start\":\"top-end\",Ib=et()?\"bottom-end\":\"bottom-start\",Db=et()?\"bottom-start\":\"bottom-end\",Mb=et()?\"left-start\":\"right-start\",Lb=et()?\"right-start\":\"left-start\",$b=[{opacity:\"0\"},{opacity:\"1\"}],Rb=[{opacity:\"1\"},{opacity:\"0\"}],ad={iterations:1,easing:\"ease\",fill:\"both\"},Pb={offset:[0,2],boundary:\"clippingParents\",reference:\"toggle\",display:\"dynamic\",popperConfig:null,autoClose:!0,dropdownAnimation:\"on\",animationDuration:550},Nb={offset:\"(array|string|function)\",boundary:\"(string|element)\",reference:\"(string|element|object)\",display:\"string\",popperConfig:\"(null|object|function)\",autoClose:\"(boolean|string)\",dropdownAnimation:\"string\",animationDuration:\"number\"};class Ft extends Mt{constructor(t,e){super(t),this._popper=null,this._config=this._getConfig(e),this._menu=this._getMenuElement(),this._inNavbar=this._detectNavbar(),this._fadeOutAnimate=null;const i=window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;this._animationCanPlay=this._config.dropdownAnimation===\"on\"&&!i,this._didInit=!1,this._init()}static get Default(){return Pb}static get DefaultType(){return Nb}static get NAME(){return Ka}toggle(){return this._isShown()?this.hide():this.show()}show(){if(ci(this._element)||this._isShown(this._menu))return;const t={relatedTarget:this._element};if(_.trigger(this._element,bb,t).defaultPrevented)return;const i=Ft.getParentFromElement(this._element);this._inNavbar?g.setDataAttribute(this._menu,\"popper\",\"none\"):this._createPopper(i),\"ontouchstart\"in document.documentElement&&!i.closest(wb)&&[].concat(...document.body.children).forEach(n=>_.on(n,\"mouseover\",ro)),this._element.focus(),this._element.setAttribute(\"aria-expanded\",!0),this._menu.setAttribute(`data-te-dropdown-${We}`,\"\"),this._animationCanPlay&&this._menu.animate($b,{...ad,duration:this._config.animationDuration}),this._element.setAttribute(`data-te-dropdown-${We}`,\"\"),setTimeout(()=>{_.trigger(this._element,vb,t)},this._animationCanPlay?this._config.animationDuration:0)}hide(){if(ci(this._element)||!this._isShown(this._menu))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_init(){this._didInit||(_.on(document,rd,mo,Ft.dataApiKeydownHandler),_.on(document,rd,Ga,Ft.dataApiKeydownHandler),_.on(document,yb,Ft.clearMenus),_.on(document,Tb,Ft.clearMenus),this._didInit=!0)}_completeHide(t){this._fadeOutAnimate&&this._fadeOutAnimate.playState===\"running\"||_.trigger(this._element,gb,t).defaultPrevented||(\"ontouchstart\"in document.documentElement&&[].concat(...document.body.children).forEach(i=>_.off(i,\"mouseover\",ro)),this._animationCanPlay&&(this._fadeOutAnimate=this._menu.animate(Rb,{...ad,duration:this._config.animationDuration})),setTimeout(()=>{this._popper&&this._popper.destroy(),this._menu.removeAttribute(`data-te-dropdown-${We}`),this._element.removeAttribute(`data-te-dropdown-${We}`),this._element.setAttribute(\"aria-expanded\",\"false\"),g.removeDataAttribute(this._menu,\"popper\"),_.trigger(this._element,mb,t)},this._animationCanPlay?this._config.animationDuration:0))}_getConfig(t){if(t={...this.constructor.Default,...g.getDataAttributes(this._element),...t},L(Ka,t,this.constructor.DefaultType),typeof t.reference==\"object\"&&!Wi(t.reference)&&typeof t.reference.getBoundingClientRect!=\"function\")throw new TypeError(`${Ka.toUpperCase()}: Option \"reference\" provided type \"object\" without a required \"getBoundingClientRect\" method.`);return t}_createPopper(t){if(typeof sd>\"u\")throw new TypeError(\"Bootstrap's dropdowns require Popper (https://popper.js.org)\");let e=this._element;this._config.reference===\"parent\"?e=t:Wi(this._config.reference)?e=Be(this._config.reference):typeof this._config.reference==\"object\"&&(e=this._config.reference);const i=this._getPopperConfig(),n=i.modifiers.find(o=>o.name===\"applyStyles\"&&o.enabled===!1);this._popper=Fe(e,this._menu,i),n&&g.setDataAttribute(this._menu,\"popper\",\"static\")}_isShown(t=this._element){return t.dataset[`teDropdown${We.charAt(0).toUpperCase()+We.slice(1)}`]===\"\"}_getMenuElement(){return m.next(this._element,Ga)[0]}_getPlacement(){const t=this._element.parentNode;if(t.dataset.teDropdownPosition===xb)return Mb;if(t.dataset.teDropdownPosition===Cb)return Lb;const e=t.dataset.teDropdownAlignment===\"end\";return t.dataset.teDropdownPosition===Eb?e?Ob:Sb:e?Db:Ib}_detectNavbar(){return this._element.closest(Ab)!==null}_getOffset(){const{offset:t}=this._config;return typeof t==\"string\"?t.split(\",\").map(e=>Number.parseInt(e,10)):typeof t==\"function\"?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:\"preventOverflow\",options:{boundary:this._config.boundary}},{name:\"offset\",options:{offset:this._getOffset()}}]};return this._config.display===\"static\"&&(t.modifiers=[{name:\"applyStyles\",enabled:!1}]),{...t,...typeof this._config.popperConfig==\"function\"?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=m.find(kb,this._menu).filter(ae);i.length&&_h(i,e,t===go,!i.includes(e)).focus()}static jQueryInterface(t){return this.each(function(){const e=Ft.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}static clearMenus(t){if(t&&(t.button===fb||t.type===\"keyup\"&&t.key!==od))return;const e=m.find(mo);for(let i=0,n=e.length;ih===this._element);l!==null&&c.length&&(this._selector=l,this._triggerArray.push(a))}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return cd}static get NAME(){return qa}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[],e;if(this._config.parent){const h=m.find(dd,this._config.parent);t=m.find(Kb,this._config.parent).filter(d=>!h.includes(d))}const i=m.findOne(this._selector);if(t.length){const h=t.find(d=>i!==d);if(e=h?ce.getInstance(h):null,e&&e._isTransitioning)return}if(_.trigger(this._element,Hb).defaultPrevented)return;t.forEach(h=>{i!==h&&ce.getOrCreateInstance(h,{toggle:!1}).hide(),e||O.setData(h,ld,null)});const o=this._getDimension(),r=o===\"height\"?this._classes.collapsing:this._classes.collapsingHorizontal;g.removeClass(this._element,this._classes.visible),g.removeClass(this._element,this._classes.hidden),g.addClass(this._element,r),this._element.removeAttribute(Zi),this._element.setAttribute(vo,\"\"),this._element.style[o]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const a=()=>{this._isTransitioning=!1,g.removeClass(this._element,this._classes.hidden),g.removeClass(this._element,r),g.addClass(this._element,this._classes.visible),this._element.removeAttribute(vo),this._element.setAttribute(Zi,\"\"),this._element.setAttribute(Za,\"\"),this._element.style[o]=\"\",_.trigger(this._element,Vb)},c=`scroll${o[0].toUpperCase()+o.slice(1)}`;this._queueCallback(a,this._element,!0),this._element.style[o]=`${this._element[c]}px`}hide(){if(this._isTransitioning||!this._isShown()||_.trigger(this._element,Fb).defaultPrevented)return;const e=this._getDimension(),i=e===\"height\"?this._classes.collapsing:this._classes.collapsingHorizontal;this._element.style[e]=`${this._element.getBoundingClientRect()[e]}px`,zi(this._element),g.addClass(this._element,i),g.removeClass(this._element,this._classes.visible),g.removeClass(this._element,this._classes.hidden),this._element.setAttribute(vo,\"\"),this._element.removeAttribute(Zi),this._element.removeAttribute(Za);const n=this._triggerArray.length;for(let r=0;r{this._isTransitioning=!1,g.removeClass(this._element,i),g.addClass(this._element,this._classes.visible),g.addClass(this._element,this._classes.hidden),this._element.removeAttribute(vo),this._element.setAttribute(Zi,\"\"),_.trigger(this._element,Wb)};this._element.style[e]=\"\",this._queueCallback(o,this._element,!0)}_isShown(t=this._element){return t.hasAttribute(Za)}_getConfig(t){return t={...cd,...g.getDataAttributes(this._element),...t},t.toggle=!!t.toggle,t.parent=Be(t.parent),L(qa,t,Bb),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Ub,...e,...t},L(qa,t,Xb),t}_getDimension(){return this._element.hasAttribute(zb)?jb:Yb}_initializeChildren(){if(!this._config.parent)return;const t=m.find(dd,this._config.parent);m.find(ud,this._config.parent).filter(e=>!t.includes(e)).forEach(e=>{const i=Ne(e);i&&this._addAriaAndCollapsedClass([e],this._isShown(i))})}_addAriaAndCollapsedClass(t,e){t.length&&t.forEach(i=>{e?i.removeAttribute(hd):i.setAttribute(`${hd}`,\"\"),i.setAttribute(\"aria-expanded\",e)})}static jQueryInterface(t){return this.each(function(){const e={};typeof t==\"string\"&&/show|hide/.test(t)&&(e.toggle=!1);const i=ce.getOrCreateInstance(this,e);if(typeof t==\"string\"){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t]()}})}}const pd=\".fixed-top, .fixed-bottom, .is-fixed, .sticky-top\",fd=\".sticky-top\";class Qi{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,\"paddingRight\",e=>e+t),this._setElementAttributes(pd,\"paddingRight\",e=>e+t),this._setElementAttributes(fd,\"marginRight\",e=>e-t)}_disableOverFlow(){this._saveInitialAttribute(this._element,\"overflow\"),this._element.style.overflow=\"hidden\"}_setElementAttributes(t,e,i){const n=this.getWidth(),o=r=>{if(r!==this._element&&window.innerWidth>r.clientWidth+n)return;this._saveInitialAttribute(r,e);const a=window.getComputedStyle(r)[e];r.style[e]=`${i(Number.parseFloat(a))}px`};this._applyManipulationCallback(t,o)}reset(){this._resetElementAttributes(this._element,\"overflow\"),this._resetElementAttributes(this._element,\"paddingRight\"),this._resetElementAttributes(pd,\"paddingRight\"),this._resetElementAttributes(fd,\"marginRight\")}_saveInitialAttribute(t,e){const i=t.style[e];i&&g.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){const i=n=>{const o=g.getDataAttribute(n,e);typeof o>\"u\"?n.style.removeProperty(e):(g.removeDataAttribute(n,e),n.style[e]=o)};this._applyManipulationCallback(t,i)}_applyManipulationCallback(t,e){Wi(t)?e(t):m.find(t,this._element).forEach(e)}isOverflowing(){return this.getWidth()>0}}const Gb={isVisible:!0,isAnimated:!1,rootElement:\"body\",clickCallback:null,backdropClasses:null},qb={isVisible:\"boolean\",isAnimated:\"boolean\",rootElement:\"(element|string)\",clickCallback:\"(function|null)\",backdropClasses:\"(array|string|null)\"},_d=\"backdrop\",gd=`mousedown.te.${_d}`;class Qa{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){if(!this._config.isVisible){hi(t);return}this._append(),this._config.isAnimated&&zi(this._getElement());const e=this._config.backdropClasses||[\"opacity-50\",\"transition-all\",\"duration-300\",\"ease-in-out\",\"fixed\",\"top-0\",\"left-0\",\"z-[1040]\",\"bg-black\",\"w-screen\",\"h-screen\"];g.removeClass(this._getElement(),\"opacity-0\"),g.addClass(this._getElement(),e),this._element.setAttribute(\"data-te-backdrop-show\",\"\"),this._emulateAnimation(()=>{hi(t)})}hide(t){if(!this._config.isVisible){hi(t);return}this._element.removeAttribute(\"data-te-backdrop-show\"),this._getElement().classList.add(\"opacity-0\"),this._getElement().classList.remove(\"opacity-50\"),this._emulateAnimation(()=>{this.dispose(),hi(t)})}_getElement(){if(!this._element){const t=document.createElement(\"div\");t.className=this._config.className,this._config.isAnimated&&t.classList.add(\"opacity-50\"),this._element=t}return this._element}_getConfig(t){return t={...Gb,...typeof t==\"object\"?t:{}},t.rootElement=Be(t.rootElement),L(_d,t,qb),t}_append(){this._isAppended||(this._config.rootElement.append(this._getElement()),_.on(this._getElement(),gd,()=>{hi(this._config.clickCallback)}),this._isAppended=!0)}dispose(){this._isAppended&&(_.off(this._element,gd),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){fh(t,this._getElement(),this._config.isAnimated)}}class Vs{constructor(t,e={},i){this._element=t,this._toggler=i,this._event=e.event||\"blur\",this._condition=e.condition||(()=>!0),this._selector=e.selector||'button, a, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])',this._onlyVisible=e.onlyVisible||!1,this._focusableElements=[],this._firstElement=null,this._lastElement=null,this.handler=n=>{this._condition(n)&&!n.shiftKey&&n.target===this._lastElement?(n.preventDefault(),this._firstElement.focus()):this._condition(n)&&n.shiftKey&&n.target===this._firstElement&&(n.preventDefault(),this._lastElement.focus())}}trap(){this._setElements(),this._init(),this._setFocusTrap()}disable(){this._focusableElements.forEach(t=>{t.removeEventListener(this._event,this.handler)}),this._toggler&&this._toggler.focus()}update(){this._setElements(),this._setFocusTrap()}_init(){const t=e=>{!this._firstElement||e.key!==\"Tab\"||this._focusableElements.includes(e.target)||(e.preventDefault(),this._firstElement.focus(),window.removeEventListener(\"keydown\",t))};window.addEventListener(\"keydown\",t)}_filterVisible(t){return t.filter(e=>{if(!ae(e))return!1;const i=m.parents(e,\"*\");for(let n=0;n{e===this._focusableElements.length-1||e===0?t.addEventListener(this._event,this.handler):t.removeEventListener(this._event,this.handler)})}}let md=[];const yo=(s,t=\"hide\")=>{const e=`click.dismiss${s.EVENT_KEY}`,i=s.NAME;md.includes(i)||(md.push(i),_.on(document,e,`[data-te-${i}-dismiss]`,function(n){if([\"A\",\"AREA\"].includes(this.tagName)&&n.preventDefault(),ci(this))return;const o=Ne(this)||this.closest(`.${i}`)||this.closest(`[data-te-${i}-init]`);if(!o)return;s.getOrCreateInstance(o)[t]()}))},bd=\"offcanvas\",Ji=\".te.offcanvas\",Zb=`load${Ji}.data-api`,Qb=\"Escape\",vd={backdrop:!0,keyboard:!0,scroll:!1},Jb={backdrop:\"boolean\",keyboard:\"boolean\",scroll:\"boolean\"},yd=\"show\",tv=\"[data-te-offcanvas-init][data-te-offcanvas-show]\",ev=`show${Ji}`,iv=`shown${Ji}`,sv=`hide${Ji}`,nv=`hidden${Ji}`,ov=`keydown.dismiss${Ji}`;class ts extends Mt{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners(),this._didInit=!1,this._init()}static get NAME(){return bd}static get Default(){return vd}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){if(this._isShown||_.trigger(this._element,ev,{relatedTarget:t}).defaultPrevented)return;this._isShown=!0,this._element.style.visibility=\"visible\",this._backdrop.show(),this._config.scroll||new Qi().hide(),this._element.removeAttribute(\"aria-hidden\"),this._element.setAttribute(\"aria-modal\",!0),this._element.setAttribute(\"role\",\"dialog\"),this._element.setAttribute(`data-te-offcanvas-${yd}`,\"\");const i=()=>{this._config.scroll||this._focustrap.trap(),_.trigger(this._element,iv,{relatedTarget:t})};this._queueCallback(i,this._element,!0)}hide(){if(!this._isShown||_.trigger(this._element,sv).defaultPrevented)return;this._focustrap.disable(),this._element.blur(),this._isShown=!1,this._element.removeAttribute(`data-te-offcanvas-${yd}`),this._backdrop.hide();const e=()=>{this._element.setAttribute(\"aria-hidden\",!0),this._element.removeAttribute(\"aria-modal\"),this._element.removeAttribute(\"role\"),this._element.style.visibility=\"hidden\",this._config.scroll||new Qi().reset(),_.trigger(this._element,nv)};this._queueCallback(e,this._element,!0)}dispose(){this._backdrop.dispose(),this._focustrap.disable(),super.dispose()}_init(){this._didInit||(_.on(window,Zb,()=>m.find(tv).forEach(t=>ts.getOrCreateInstance(t).show())),this._didInit=!0,yo(ts))}_getConfig(t){return t={...vd,...g.getDataAttributes(this._element),...typeof t==\"object\"?t:{}},L(bd,t,Jb),t}_initializeBackDrop(){return new Qa({isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_initializeFocusTrap(){return new Vs(this._element,{event:\"keydown\",condition:t=>t.key===\"Tab\"})}_addEventListeners(){_.on(this._element,ov,t=>{this._config.keyboard&&t.key===Qb&&this.hide()})}static jQueryInterface(t){return this.each(function(){const e=ts.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(e[t]===void 0||t.startsWith(\"_\")||t===\"constructor\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Ja=\"alert\",Td=\".te.alert\",rv=`close${Td}`,av=`closed${Td}`,Fs=\"data-te-alert-show\",lv={animation:\"boolean\",autohide:\"boolean\",autoclose:\"boolean\",delay:\"number\"},Ed={animation:!0,autohide:!0,autoclose:!1,delay:1e3},cv={fadeIn:\"animate-[fade-in_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\",fadeOut:\"animate-[fade-out_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\"},hv={fadeIn:\"string\",fadeOut:\"string\"};class Ws extends Mt{constructor(t,e,i){super(t),this._element=t,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._didInit=!1,this._init()}static get DefaultType(){return lv}static get Default(){return Ed}static get NAME(){return Ja}close(){if(_.trigger(this._element,rv).defaultPrevented)return;let e=0;this._config.animation&&(e=300,g.addClass(this._element,this._classes.fadeOut)),this._element.removeAttribute(Fs),setTimeout(()=>{this._queueCallback(()=>this._destroyElement(),this._element,this._config.animation)},e)}show(){if(this._element){if(this._config.autohide&&this._setupAutohide(),(this._config.autoclose||this._config.autoclose&&this._config.autohide)&&this._setupAutoclose(),!this._element.hasAttribute(Fs)&&(g.removeClass(this._element,\"hidden\"),g.addClass(this._element,\"block\"),ae(this._element))){const t=e=>{g.removeClass(this._element,\"hidden\"),g.addClass(this._element,\"block\"),_.off(e.target,\"animationend\",t)};this._element.setAttribute(Fs,\"\"),_.on(this._element,\"animationend\",t)}this._config.animation&&(g.removeClass(this._element,this._classes.fadeOut),g.addClass(this._element,this._classes.fadeIn))}}hide(){if(this._element&&this._element.hasAttribute(Fs)){this._element.removeAttribute(Fs);const t=e=>{g.addClass(this._element,\"hidden\"),g.removeClass(this._element,\"block\"),this._timeout!==null&&(clearTimeout(this._timeout),this._timeout=null),_.off(e.target,\"animationend\",t)};_.on(this._element,\"animationend\",t),g.removeClass(this._element,this._classes.fadeIn),g.addClass(this._element,this._classes.fadeOut)}}_init(){this._didInit||(yo(Ws,\"close\"),this._didInit=!0)}_getConfig(t){return t={...Ed,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},L(Ja,t,this.constructor.DefaultType),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...cv,...e,...t},L(Ja,t,hv),t}_setupAutohide(){this._timeout=setTimeout(()=>{this.hide()},this._config.delay)}_setupAutoclose(){this._timeout=setTimeout(()=>{this.close()},this._config.delay)}_destroyElement(){this._element.remove(),_.trigger(this._element,av),this.dispose()}static jQueryInterface(t){return this.each(function(){const e=Ws.getOrCreateInstance(this);if(typeof t==\"string\"){if(e[t]===void 0||t.startsWith(\"_\")||t===\"constructor\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const tl=\"carousel\",Nt=\".te.carousel\",xd=\".data-api\",dv=\"ArrowLeft\",uv=\"ArrowRight\",pv=500,fv=40,Cd={interval:5e3,keyboard:!0,ride:!1,pause:\"hover\",wrap:!0,touch:!0},_v={interval:\"(number|boolean)\",keyboard:\"boolean\",ride:\"(boolean|string)\",pause:\"(string|boolean)\",wrap:\"boolean\",touch:\"boolean\"},gv={pointer:\"touch-pan-y\",block:\"!block\",visible:\"data-[te-carousel-fade]:opacity-100 data-[te-carousel-fade]:z-[1]\",invisible:\"data-[te-carousel-fade]:z-0 data-[te-carousel-fade]:opacity-0 data-[te-carousel-fade]:duration-[600ms] data-[te-carousel-fade]:delay-600\",slideRight:\"translate-x-full\",slideLeft:\"-translate-x-full\"},mv={pointer:\"string\",block:\"string\",visible:\"string\",invisible:\"string\",slideRight:\"string\",slideLeft:\"string\"},gi=\"next\",mi=\"prev\",bi=\"left\",zs=\"right\",bv={[dv]:zs,[uv]:bi},vv=`slide${Nt}`,el=`slid${Nt}`,yv=`keydown${Nt}`,Tv=`mouseenter${Nt}`,Ev=`mouseleave${Nt}`,xv=`touchstart${Nt}`,Cv=`touchmove${Nt}`,Av=`touchend${Nt}`,wv=`pointerdown${Nt}`,kv=`pointerup${Nt}`,Sv=`dragstart${Nt}`,Ov=`load${Nt}${xd}`,Iv=`click${Nt}${xd}`,Ad=\"data-te-carousel-init\",vi=\"data-te-carousel-active\",Dv=\"data-te-carousel-item-end\",il=\"data-te-carousel-item-start\",Mv=\"data-te-carousel-item-next\",Lv=\"data-te-carousel-item-prev\",$v=\"data-te-carousel-pointer-event\",Rv=\"[data-te-carousel-init]\",wd=\"[data-te-carousel-active]\",sl=\"[data-te-carousel-item]\",es=`${wd}${sl}`,Pv=`${sl} img`,Nv=\"[data-te-carousel-item-next], [data-te-carousel-item-prev]\",Bv=\"[data-te-carousel-indicators]\",Hv=\"[data-te-target]\",Vv=\"[data-te-slide], [data-te-slide-to]\",Fv=\"touch\",Wv=\"pen\";class he extends Mt{constructor(t,e,i){super(t),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._indicatorsElement=m.findOne(Bv,this._element),this._touchSupported=\"ontouchstart\"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=!!window.PointerEvent,this._setActiveElementClass(),this._addEventListeners(),this._didInit=!1,this._init(),this._config.ride===\"carousel\"&&this.cycle()}static get Default(){return Cd}static get NAME(){return tl}next(){this._slide(gi)}nextWhenVisible(){!document.hidden&&ae(this._element)&&this.next()}prev(){this._slide(mi)}pause(t){t||(this._isPaused=!0),m.findOne(Nv,this._element)&&(hh(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(t){this._activeElement=m.findOne(es,this._element);const e=this._getItemIndex(this._activeElement);if(t>this._items.length-1||t<0)return;if(this._isSliding){_.one(this._element,el,()=>this.to(t));return}if(e===t){this.pause(),this.cycle();return}const i=t>e?gi:mi;this._slide(i,this._items[t])}_init(){this._didInit||(_.on(document,Iv,Vv,he.dataApiClickHandler),_.on(window,Ov,()=>{const t=m.find(Rv);for(let e=0,i=t.length;ethis.cycle());return}this.cycle()}}_applyInitialClasses(){const t=m.findOne(es,this._element);t.classList.add(this._classes.block,...this._classes.visible.split(\" \")),this._setActiveIndicatorElement(t)}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=fv)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?zs:bi)}_setActiveElementClass(){this._activeElement=m.findOne(es,this._element),g.addClass(this._activeElement,\"hidden\")}_addEventListeners(){this._config.keyboard&&_.on(this._element,yv,t=>this._keydown(t)),this._config.pause===\"hover\"&&(_.on(this._element,Tv,t=>this.pause(t)),_.on(this._element,Ev,t=>this._enableCycle(t))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners(),this._applyInitialClasses()}_addTouchEventListeners(){const t=o=>this._pointerEvent&&(o.pointerType===Wv||o.pointerType===Fv),e=o=>{t(o)?this.touchStartX=o.clientX:this._pointerEvent||(this.touchStartX=o.touches[0].clientX)},i=o=>{this.touchDeltaX=o.touches&&o.touches.length>1?0:o.touches[0].clientX-this.touchStartX},n=o=>{t(o)&&(this.touchDeltaX=o.clientX-this.touchStartX),this._handleSwipe(),this._config.pause===\"hover\"&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(r=>this._enableCycle(r),pv+this._config.interval))};m.find(Pv,this._element).forEach(o=>{_.on(o,Sv,r=>r.preventDefault())}),this._pointerEvent?(_.on(this._element,wv,o=>e(o)),_.on(this._element,kv,o=>n(o)),this._element.classList.add(this._classes.pointer),this._element.setAttribute(`${$v}`,\"\")):(_.on(this._element,xv,o=>e(o)),_.on(this._element,Cv,o=>i(o)),_.on(this._element,Av,o=>n(o)))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=bv[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(t){return this._items=t&&t.parentNode?m.find(sl,t.parentNode):[],this._items.indexOf(t)}_getItemByOrder(t,e){const i=t===gi;return _h(this._items,e,i,this._config.wrap)}_triggerSlideEvent(t,e){const i=this._getItemIndex(t),n=this._getItemIndex(m.findOne(es,this._element));return _.trigger(this._element,vv,{relatedTarget:t,direction:e,from:n,to:i})}_setActiveIndicatorElement(t){if(this._indicatorsElement){const e=m.findOne(wd,this._indicatorsElement);e.removeAttribute(vi),e.removeAttribute(\"aria-current\"),e.classList.remove(\"!opacity-100\");const i=m.find(Hv,this._indicatorsElement);for(let n=0;n{_.trigger(this._element,el,{relatedTarget:r,direction:u,from:o,to:a})};if(this._element.hasAttribute(Ad)){r.setAttribute(`${d}`,\"\"),r.classList.add(this._classes.block,f),zi(r),n.setAttribute(`${h}`,\"\"),n.classList.add(p,...this._classes.invisible.split(\" \")),n.classList.remove(...this._classes.visible.split(\" \")),r.setAttribute(`${h}`,\"\"),r.classList.add(...this._classes.visible.split(\" \")),r.classList.remove(this._classes.slideRight,this._classes.slideLeft);const y=()=>{r.removeAttribute(h),r.removeAttribute(d),r.setAttribute(`${vi}`,\"\"),n.removeAttribute(vi),n.classList.remove(p,...this._classes.invisible.split(\" \"),this._classes.block),n.removeAttribute(d),n.removeAttribute(h),this._isSliding=!1,setTimeout(v,0)};this._queueCallback(y,n,!0)}else n.removeAttribute(vi),n.classList.remove(this._classes.block),r.setAttribute(`${vi}`,\"\"),r.classList.add(this._classes.block),this._isSliding=!1,v();l&&this.cycle()}_directionToOrder(t){return[zs,bi].includes(t)?et()?t===bi?mi:gi:t===bi?gi:mi:t}_orderToDirection(t){return[gi,mi].includes(t)?et()?t===mi?bi:zs:t===mi?zs:bi:t}static carouselInterface(t,e){const i=he.getOrCreateInstance(t,e);let{_config:n}=i;typeof e==\"object\"&&(n={...n,...e});const o=typeof e==\"string\"?e:e.slide;if(typeof e==\"number\"){i.to(e);return}if(typeof o==\"string\"){if(typeof i[o]>\"u\")throw new TypeError(`No method named \"${o}\"`);i[o]()}else n.interval&&n.ride===!0&&i.pause()}static jQueryInterface(t){return this.each(function(){he.carouselInterface(this,t)})}static dataApiClickHandler(t){const e=Ne(this);if(!e||!e.hasAttribute(Ad))return;const i={...g.getDataAttributes(e),...g.getDataAttributes(this)},n=this.getAttribute(\"data-te-slide-to\");n&&(i.interval=!1),he.carouselInterface(e,i),n&&he.getInstance(e).to(n),t.preventDefault()}}const nl=\"modal\",te=\".te.modal\",kd=\"Escape\",Sd={backdrop:!0,keyboard:!0,focus:!0,modalNonInvasive:!1},zv={backdrop:\"(boolean|string)\",keyboard:\"boolean\",focus:\"boolean\",modalNonInvasive:\"boolean\"},jv={show:\"transform-none\",static:\"scale-[1.02]\",staticProperties:\"transition-scale duration-300 ease-in-out\",backdrop:\"opacity-50 transition-all duration-300 ease-in-out fixed top-0 left-0 z-[1040] bg-black w-screen h-screen\"},Yv={show:\"string\",static:\"string\",staticProperties:\"string\",backdrop:\"string\"},Kv=`hide${te}`,Uv=`hidePrevented${te}`,Xv=`hidden${te}`,Gv=`show${te}`,qv=`shown${te}`,Od=`resize${te}`,Id=`click.dismiss${te}`,Dd=`keydown.dismiss${te}`,Zv=`mouseup.dismiss${te}`,Md=`mousedown.dismiss${te}`,Ld=\"data-te-modal-open\",$d=\"data-te-open\",js=\"[data-te-modal-dialog-ref]\",Qv=\"[data-te-modal-body-ref]\";class Ys extends Mt{constructor(t,e,i){super(t),this._config=this._getConfig(e),this._classes=this._getClasses(i),this._dialog=m.findOne(js,this._element),this._backdrop=this._config.modalNonInvasive?null:this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new Qi,this._didInit=!1,this._init()}static get Default(){return Sd}static get NAME(){return nl}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||_.trigger(this._element,Gv,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),!this._config.modalNonInvasive&&this._scrollBar.hide(),document.body.setAttribute(Ld,\"true\"),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),_.on(this._dialog,Md,()=>{_.one(this._element,Zv,i=>{i.target===this._element&&(this._ignoreBackdropClick=!0)})}),this._showElement(t),!this._config.modalNonInvasive&&this._showBackdrop())}hide(){if(!this._isShown||this._isTransitioning||_.trigger(this._element,Kv).defaultPrevented)return;this._isShown=!1;const e=this._isAnimated();e&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),this._focustrap.disable(),m.findOne(js,this._element).classList.remove(this._classes.show),_.off(this._element,Id),_.off(this._dialog,Md),this._queueCallback(()=>this._hideModal(),this._element,e),this._element.removeAttribute($d)}dispose(){[window,document,this._dialog].forEach(t=>_.off(t,te)),this._backdrop&&this._backdrop.dispose(),this._focustrap.disable(),super.dispose()}handleUpdate(){this._adjustDialog()}_init(){this._didInit||(yo(Ys),this._didInit=!0)}_initializeBackDrop(){return new Qa({isVisible:!!this._config.backdrop,isAnimated:this._isAnimated(),backdropClasses:this._classes.backdrop})}_initializeFocusTrap(){return new Vs(this._element,{event:\"keydown\",condition:t=>t.key===\"Tab\"})}_getConfig(t){return t={...Sd,...g.getDataAttributes(this._element),...typeof t==\"object\"?t:{}},L(nl,t,zv),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...jv,...e,...t},L(nl,t,Yv),t}_showElement(t){const e=this._isAnimated(),i=m.findOne(Qv,this._dialog);(!this._element.parentNode||this._element.parentNode.nodeType!==Node.ELEMENT_NODE)&&document.body.append(this._element),this._element.style.display=\"block\",this._element.classList.remove(\"hidden\"),this._element.removeAttribute(\"aria-hidden\"),this._element.setAttribute(\"aria-modal\",!0),this._element.setAttribute(\"role\",\"dialog\"),this._element.setAttribute(`${$d}`,\"true\"),this._element.scrollTop=0;const n=m.findOne(js,this._element);n.classList.add(this._classes.show),n.classList.remove(\"opacity-0\"),n.classList.add(\"opacity-100\"),i&&(i.scrollTop=0),e&&zi(this._element);const o=()=>{this._config.focus&&this._focustrap.trap(),this._isTransitioning=!1,_.trigger(this._element,qv,{relatedTarget:t})};this._queueCallback(o,this._dialog,e)}_setEscapeEvent(){this._isShown?_.on(document,Dd,t=>{this._config.keyboard&&t.key===kd?(t.preventDefault(),this.hide()):!this._config.keyboard&&t.key===kd&&this._triggerBackdropTransition()}):_.off(this._element,Dd)}_setResizeEvent(){this._isShown?_.on(window,Od,()=>this._adjustDialog()):_.off(window,Od)}_hideModal(){const t=m.findOne(js,this._element);t.classList.remove(this._classes.show),t.classList.remove(\"opacity-100\"),t.classList.add(\"opacity-0\");const e=oo(t);setTimeout(()=>{this._element.style.display=\"none\"},e),this._element.setAttribute(\"aria-hidden\",!0),this._element.removeAttribute(\"aria-modal\"),this._element.removeAttribute(\"role\"),this._isTransitioning=!1,this._backdrop&&this._backdrop.hide(()=>{document.body.removeAttribute(Ld),this._resetAdjustments(),!this._config.modalNonInvasive&&this._scrollBar.reset(),_.trigger(this._element,Xv)})}_showBackdrop(t){_.on(this._element,Id,e=>{if(this._ignoreBackdropClick){this._ignoreBackdropClick=!1;return}e.target===e.currentTarget&&(this._config.backdrop===!0?this.hide():this._config.backdrop===\"static\"&&this._triggerBackdropTransition())}),this._backdrop&&this._backdrop.show(t)}_isAnimated(){return!!m.findOne(js,this._element)}_triggerBackdropTransition(){if(_.trigger(this._element,Uv).defaultPrevented)return;const{classList:e,scrollHeight:i,style:n}=this._element,o=i>document.documentElement.clientHeight;if(!o&&n.overflowY===\"hidden\"||e.contains(this._classes.static))return;o||(n.overflowY=\"hidden\"),e.add(...this._classes.static.split(\" \")),e.add(...this._classes.staticProperties.split(\" \"));const r=oo(this._element);this._queueCallback(()=>{e.remove(this._classes.static),setTimeout(()=>{e.remove(...this._classes.staticProperties.split(\" \"))},r),o||this._queueCallback(()=>{n.overflowY=\"\"},this._dialog)},this._dialog),this._element.focus()}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!et()||i&&!t&&et())&&(this._element.style.paddingLeft=`${e}px`),(i&&!t&&!et()||!i&&t&&et())&&(this._element.style.paddingRight=`${e}px`)}_resetAdjustments(){this._element.style.paddingLeft=\"\",this._element.style.paddingRight=\"\"}static jQueryInterface(t,e){return this.each(function(){const i=Ys.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}}const Jv=new Set([\"background\",\"cite\",\"href\",\"itemtype\",\"longdesc\",\"poster\",\"src\",\"xlink:href\"]),Rd=/^aria-[\\w-]*$/i,t0=/^data-te-[\\w-]*$/i,e0=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,i0=/^data:(?:image\\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\\/(?:mpeg|mp4|ogg|webm)|audio\\/(?:mp3|oga|ogg|opus));base64,[\\d+/a-z]+=*$/i,s0=(s,t)=>{const e=s.nodeName.toLowerCase();if(t.includes(e))return Jv.has(e)?!!(e0.test(s.nodeValue)||i0.test(s.nodeValue)):!0;const i=t.filter(n=>n instanceof RegExp);for(let n=0,o=i.length;n{s0(u,d)||l.removeAttribute(u.nodeName)})}return n.body.innerHTML}const Nd=\"tooltip\",de=\".te.tooltip\",o0=\"te-tooltip\",r0=new Set([\"sanitize\",\"allowList\",\"sanitizeFn\"]),a0={animation:\"boolean\",template:\"string\",title:\"(string|element|function)\",trigger:\"string\",delay:\"(number|object)\",html:\"boolean\",selector:\"(string|boolean)\",placement:\"(string|function)\",offset:\"(array|string|function)\",container:\"(string|element|boolean)\",fallbackPlacements:\"array\",boundary:\"(string|element)\",customClass:\"(string|function)\",sanitize:\"boolean\",sanitizeFn:\"(null|function)\",allowList:\"object\",popperConfig:\"(null|object|function)\"},l0={AUTO:\"auto\",TOP:\"top\",RIGHT:et()?\"left\":\"right\",BOTTOM:\"bottom\",LEFT:et()?\"right\":\"left\"},c0={animation:!0,template:'
',trigger:\"hover focus\",title:\"\",delay:0,html:!1,selector:!1,placement:\"top\",offset:[0,0],container:!1,fallbackPlacements:[\"top\",\"right\",\"bottom\",\"left\"],boundary:\"clippingParents\",customClass:\"\",sanitize:!0,sanitizeFn:null,allowList:n0,popperConfig:{hide:!0}},h0={HIDE:`hide${de}`,HIDDEN:`hidden${de}`,SHOW:`show${de}`,SHOWN:`shown${de}`,INSERTED:`inserted${de}`,CLICK:`click${de}`,FOCUSIN:`focusin${de}`,FOCUSOUT:`focusout${de}`,MOUSEENTER:`mouseenter${de}`,MOUSELEAVE:`mouseleave${de}`},d0=\"fade\",u0=\"modal\",ol=\"show\",Ks=\"show\",rl=\"out\",Bd=\".tooltip-inner\",Hd=`.${u0}`,Vd=\"hide.te.modal\",Us=\"hover\",al=\"focus\",p0=\"click\",f0=\"manual\";let is=class rm extends Mt{constructor(t,e){if(typeof sd>\"u\")throw new TypeError(\"Bootstrap's tooltips require Popper (https://popper.js.org)\");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState=\"\",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return c0}static get NAME(){return Nd}static get Event(){return h0}static get DefaultType(){return a0}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains(ol)){this._leave(null,this);return}this._enter(null,this)}}dispose(){clearTimeout(this._timeout),_.off(this._element.closest(Hd),Vd,this._hideModalHandler),this.tip&&this.tip.remove(),this._disposePopper(),super.dispose()}show(){if(this._element.style.display===\"none\")throw new Error(\"Please use show on visible elements\");if(!(this.isWithContent()&&this._isEnabled))return;const t=_.trigger(this._element,this.constructor.Event.SHOW),e=dh(this._element),i=e===null?this._element.ownerDocument.documentElement.contains(this._element):e.contains(this._element);if(t.defaultPrevented||!i)return;this.constructor.NAME===\"tooltip\"&&this.tip&&this.getTitle()!==this.tip.querySelector(Bd).innerHTML&&(this._disposePopper(),this.tip.remove(),this.tip=null);const n=this.getTipElement(),o=bt(this.constructor.NAME);n.setAttribute(\"id\",o),this._element.setAttribute(\"aria-describedby\",o),this._config.animation&&setTimeout(()=>{this.tip.classList.add(\"opacity-100\"),this.tip.classList.remove(\"opacity-0\")},100);const r=typeof this._config.placement==\"function\"?this._config.placement.call(this,n,this._element):this._config.placement,a=this._getAttachment(r);this._addAttachmentClass(a);const{container:l}=this._config;if(O.setData(n,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(l.append(n),_.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=Fe(this._element,n,this._getPopperConfig(a)),n.getAttribute(\"id\").includes(\"tooltip\"))switch(r){case\"bottom\":n.classList.add(\"py-[0.4rem]\");break;case\"left\":n.classList.add(\"px-[0.4rem]\");break;case\"right\":n.classList.add(\"px-[0.4rem]\");break;default:n.classList.add(\"py-[0.4rem]\");break}const h=this._resolvePossibleFunction(this._config.customClass);h&&n.classList.add(...h.split(\" \")),\"ontouchstart\"in document.documentElement&&[].concat(...document.body.children).forEach(p=>{_.on(p,\"mouseover\",ro)});const d=()=>{const p=this._hoverState;this._hoverState=null,_.trigger(this._element,this.constructor.Event.SHOWN),p===rl&&this._leave(null,this)},u=this.tip.classList.contains(\"transition-opacity\");this._queueCallback(d,this.tip,u)}hide(){if(!this._popper)return;const t=this.getTipElement(),e=()=>{this._isWithActiveTrigger()||(this._hoverState!==Ks&&t.remove(),this._cleanTipClass(),this._element.removeAttribute(\"aria-describedby\"),_.trigger(this._element,this.constructor.Event.HIDDEN),this._disposePopper())};if(_.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.add(\"opacity-0\"),t.classList.remove(\"opacity-100\"),\"ontouchstart\"in document.documentElement&&[].concat(...document.body.children).forEach(o=>_.off(o,\"mouseover\",ro)),this._activeTrigger[p0]=!1,this._activeTrigger[al]=!1,this._activeTrigger[Us]=!1;const n=this.tip.classList.contains(\"opacity-0\");this._queueCallback(e,this.tip,n),this._hoverState=\"\"}update(){this._popper!==null&&this._popper.update()}isWithContent(){return!!this.getTitle()}getTipElement(){if(this.tip)return this.tip;const t=document.createElement(\"div\");t.innerHTML=this._config.template;const e=t.children[0];return this.setContent(e),e.classList.remove(d0,ol),this.tip=e,this.tip}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),Bd)}_sanitizeAndSetContent(t,e,i){const n=m.findOne(i,t);if(!e&&n){n.remove();return}this.setElementContent(n,e)}setElementContent(t,e){if(t!==null){if(Wi(e)){e=Be(e),this._config.html?e.parentNode!==t&&(t.innerHTML=\"\",t.append(e)):t.textContent=e.textContent;return}this._config.html?(this._config.sanitize&&(e=To(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e}}getTitle(){const t=this._element.getAttribute(\"data-te-original-title\")||this._config.title;return this._resolvePossibleFunction(t)}updateAttachment(t){return t===\"right\"?\"end\":t===\"left\"?\"start\":t}_initializeOnDelegatedTarget(t,e){return e||this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_getOffset(){const{offset:t}=this._config;return typeof t==\"string\"?t.split(\",\").map(e=>Number.parseInt(e,10)):typeof t==\"function\"?e=>t(e,this._element):t}_resolvePossibleFunction(t){return typeof t==\"function\"?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:\"flip\",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:\"offset\",options:{offset:this._getOffset()}},{name:\"preventOverflow\",options:{boundary:this._config.boundary}},{name:\"arrow\",options:{element:`.${this.constructor.NAME}-arrow`}},{name:\"onChange\",enabled:!0,phase:\"afterWrite\",fn:i=>this._handlePopperPlacementChange(i)}],onFirstUpdate:i=>{i.options.placement!==i.placement&&this._handlePopperPlacementChange(i)}};return{...e,...typeof this._config.popperConfig==\"function\"?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(t)}`)}_getAttachment(t){return l0[t.toUpperCase()]}_setListeners(){this._config.trigger.split(\" \").forEach(e=>{if(e===\"click\")_.on(this._element,this.constructor.Event.CLICK,this._config.selector,i=>this.toggle(i));else if(e!==f0){const i=e===Us?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,n=e===Us?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;_.on(this._element,i,this._config.selector,o=>this._enter(o)),_.on(this._element,n,this._config.selector,o=>this._leave(o))}}),this._hideModalHandler=()=>{this._element&&this.hide()},_.on(this._element.closest(Hd),Vd,this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:\"manual\",selector:\"\"}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute(\"title\"),e=typeof this._element.getAttribute(\"data-te-original-title\");(t||e!==\"string\")&&(this._element.setAttribute(\"data-te-original-title\",t||\"\"),t&&!this._element.getAttribute(\"aria-label\")&&!this._element.textContent&&this._element.setAttribute(\"aria-label\",t),this._element.setAttribute(\"title\",\"\"))}_enter(t,e){if(e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger[t.type===\"focusin\"?al:Us]=!0),e.getTipElement().classList.contains(ol)||e._hoverState===Ks){e._hoverState=Ks;return}if(clearTimeout(e._timeout),e._hoverState=Ks,!e._config.delay||!e._config.delay.show){e.show();return}e._timeout=setTimeout(()=>{e._hoverState===Ks&&e.show()},e._config.delay.show)}_leave(t,e){if(e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger[t.type===\"focusout\"?al:Us]=e._element.contains(t.relatedTarget)),!e._isWithActiveTrigger()){if(clearTimeout(e._timeout),e._hoverState=rl,!e._config.delay||!e._config.delay.hide){e.hide();return}e._timeout=setTimeout(()=>{e._hoverState===rl&&e.hide()},e._config.delay.hide)}}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=g.getDataAttributes(this._element);return Object.keys(e).forEach(i=>{r0.has(i)&&delete e[i]}),t={...this.constructor.Default,...e,...typeof t==\"object\"&&t?t:{}},t.container=t.container===!1?document.body:Be(t.container),typeof t.delay==\"number\"&&(t.delay={show:t.delay,hide:t.delay}),typeof t.title==\"number\"&&(t.title=t.title.toString()),typeof t.content==\"number\"&&(t.content=t.content.toString()),L(Nd,t,this.constructor.DefaultType),t.sanitize&&(t.template=To(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=new RegExp(`(^|\\\\s)${this._getBasicClassPrefix()}\\\\S+`,\"g\"),i=t.getAttribute(\"class\").match(e);i!==null&&i.length>0&&i.map(n=>n.trim()).forEach(n=>t.classList.remove(n))}_getBasicClassPrefix(){return o0}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each(function(){const e=rm.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}};const _0=\"popover\",ue=\".te.popover\",g0=\"te-popover\",m0={...is.Default,placement:\"right\",offset:[0,8],trigger:\"click\",content:\"\",template:'

'},b0={...is.DefaultType,content:\"(string|element|function)\"},v0={HIDE:`hide${ue}`,HIDDEN:`hidden${ue}`,SHOW:`show${ue}`,SHOWN:`shown${ue}`,INSERTED:`inserted${ue}`,CLICK:`click${ue}`,FOCUSIN:`focusin${ue}`,FOCUSOUT:`focusout${ue}`,MOUSEENTER:`mouseenter${ue}`,MOUSELEAVE:`mouseleave${ue}`},y0=\".popover-header\",T0=\".popover-body\";class Eo extends is{static get Default(){return m0}static get NAME(){return _0}static get Event(){return v0}static get DefaultType(){return b0}isWithContent(){return this.getTitle()||this._getContent()}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),y0),this._sanitizeAndSetContent(t,this._getContent(),T0)}_getContent(){return this._resolvePossibleFunction(this._config.content)}_getBasicClassPrefix(){return g0}static jQueryInterface(t){return this.each(function(){const e=Eo.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}}const ll=\"scrollspy\",cl=\".te.scrollspy\",Fd={offset:10,method:\"auto\",target:\"\"},E0={offset:\"number\",method:\"string\",target:\"(string|element)\"},x0={active:\"!text-primary dark:!text-primary-400 font-semibold border-l-[0.125rem] border-solid border-primary dark:border-primary-400\"},C0={active:\"string\"},A0=`activate${cl}`,w0=`scroll${cl}`,hl=\"data-te-nav-link-active\",Wd=\"[data-te-dropdown-item-ref]\",k0=\"[data-te-nav-list-ref]\",dl=\"[data-te-nav-link-ref]\",S0=\"[data-te-nav-item-ref]\",zd=\"[data-te-list-group-item-ref]\",ul=`${dl}, ${zd}, ${Wd}`,O0=\"[data-te-dropdown-ref]\",I0=\"[data-te-dropdown-toggle-ref]\",D0=\"maxOffset\",jd=\"position\";class xo extends Mt{constructor(t,e,i){super(t),this._scrollElement=this._element.tagName===\"BODY\"?window:this._element,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,_.on(this._scrollElement,w0,()=>this._process()),this.refresh(),this._process()}static get Default(){return Fd}static get NAME(){return ll}refresh(){const t=this._scrollElement===this._scrollElement.window?D0:jd,e=this._config.method===\"auto\"?t:this._config.method,i=e===jd?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),m.find(ul,this._config.target).map(o=>{const r=Ca(o),a=r?m.findOne(r):null;if(a){const l=a.getBoundingClientRect();if(l.width||l.height)return[g[e](a).top+i,r]}return null}).filter(o=>o).sort((o,r)=>o[0]-r[0]).forEach(o=>{this._offsets.push(o[0]),this._targets.push(o[1])})}dispose(){_.off(this._scrollElement,cl),super.dispose()}_getConfig(t){return t={...Fd,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},t.target=Be(t.target)||document.documentElement,L(ll,t,E0),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...x0,...e,...t},L(ll,t,C0),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const n=this._targets[this._targets.length-1];this._activeTarget!==n&&this._activate(n);return}if(this._activeTarget&&t0){this._activeTarget=null,this._clear();return}for(let n=this._offsets.length;n--;)this._activeTarget!==this._targets[n]&&t>=this._offsets[n]&&(typeof this._offsets[n+1]>\"u\"||t`${n}[data-te-target=\"${t}\"],${n}[href=\"${t}\"]`),i=m.findOne(e.join(\",\"),this._config.target);i.classList.add(...this._classes.active.split(\" \")),i.setAttribute(hl,\"\"),i.getAttribute(Wd)?m.findOne(I0,i.closest(O0)).classList.add(...this._classes.active.split(\" \")):m.parents(i,k0).forEach(n=>{m.prev(n,`${dl}, ${zd}`).forEach(o=>{o.classList.add(...this._classes.active.split(\" \")),o.setAttribute(hl,\"\")}),m.prev(n,S0).forEach(o=>{m.children(o,dl).forEach(r=>r.classList.add(...this._classes.active.split(\" \")))})}),_.trigger(this._scrollElement,A0,{relatedTarget:t})}_clear(){m.find(ul,this._config.target).filter(t=>t.classList.contains(...this._classes.active.split(\" \"))).forEach(t=>{t.classList.remove(...this._classes.active.split(\" \")),t.removeAttribute(hl)})}static jQueryInterface(t){return this.each(function(){const e=xo.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}}const Yd=\"tab\",Co=\".te.tab\",M0=`hide${Co}`,L0=`hidden${Co}`,$0=`show${Co}`,R0=`shown${Co}`,P0=\"data-te-dropdown-menu-ref\",ss=\"data-te-tab-active\",Ao=\"data-te-nav-active\",N0=\"[data-te-dropdown-ref]\",B0=\"[data-te-nav-ref]\",Kd=`[${ss}]`,H0=`[${Ao}]`,Ud=\":scope > li > .active\",V0=\"[data-te-dropdown-toggle-ref]\",F0=\":scope > [data-te-dropdown-menu-ref] [data-te-dropdown-show]\",W0={show:\"opacity-100\",hide:\"opacity-0\"},z0={show:\"string\",hide:\"string\"};class wo extends Mt{constructor(t,e){super(t),this._classes=this._getClasses(e)}static get NAME(){return Yd}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.getAttribute(Ao)===\"\")return;let t;const e=Ne(this._element),i=this._element.closest(B0),n=m.findOne(H0,i);if(i){const l=i.nodeName===\"UL\"||i.nodeName===\"OL\"?Ud:Kd;t=m.find(l,i),t=t[t.length-1]}const o=t?_.trigger(t,M0,{relatedTarget:this._element}):null;if(_.trigger(this._element,$0,{relatedTarget:t}).defaultPrevented||o!==null&&o.defaultPrevented)return;this._activate(this._element,i,null,n,this._element);const a=()=>{_.trigger(t,L0,{relatedTarget:this._element}),_.trigger(this._element,R0,{relatedTarget:t})};e?this._activate(e,e.parentNode,a,n,this._element):a()}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...W0,...e,...t},L(Yd,t,z0),t}_activate(t,e,i,n,o){const a=(e&&(e.nodeName===\"UL\"||e.nodeName===\"OL\")?m.find(Ud,e):m.children(e,Kd))[0],l=i&&a&&a.hasAttribute(ss),c=()=>this._transitionComplete(t,a,i,n,o);a&&l?(g.removeClass(a,this._classes.show),g.addClass(a,this._classes.hide),this._queueCallback(c,t,!0)):c()}_transitionComplete(t,e,i,n,o){if(e&&n){e.removeAttribute(ss),n.removeAttribute(Ao);const a=m.findOne(F0,e.parentNode);a&&a.removeAttribute(ss),e.getAttribute(\"role\")===\"tab\"&&e.setAttribute(\"aria-selected\",!1)}t.setAttribute(ss,\"\"),o.setAttribute(Ao,\"\"),t.getAttribute(\"role\")===\"tab\"&&t.setAttribute(\"aria-selected\",!0),zi(t),t.classList.contains(this._classes.hide)&&(g.removeClass(t,this._classes.hide),g.addClass(t,this._classes.show));let r=t.parentNode;if(r&&r.nodeName===\"LI\"&&(r=r.parentNode),r&&r.hasAttribute(P0)){const a=t.closest(N0);a&&m.find(V0,a).forEach(l=>l.setAttribute(ss,\"\")),t.setAttribute(\"aria-expanded\",!0)}i&&i()}static jQueryInterface(t){return this.each(function(){const e=wo.getOrCreateInstance(this);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}}const pl=\"toast\",ze=\".te.toast\",j0=`mouseover${ze}`,Y0=`mouseout${ze}`,K0=`focusin${ze}`,U0=`focusout${ze}`,X0=`hide${ze}`,G0=`hidden${ze}`,q0=`show${ze}`,Z0=`shown${ze}`,Xd=\"data-te-toast-hide\",fl=\"data-te-toast-show\",ko=\"data-te-toast-showing\",Q0={animation:\"boolean\",autohide:\"boolean\",delay:\"number\"},Gd={animation:!0,autohide:!0,delay:5e3},J0={fadeIn:\"animate-[fade-in_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\",fadeOut:\"animate-[fade-out_0.3s_both] p-[auto] motion-reduce:transition-none motion-reduce:animate-none\"},ty={fadeIn:\"string\",fadeOut:\"string\"};class Xs extends Mt{constructor(t,e,i){super(t),this._config=this._getConfig(e),this._classes=this._getClasses(i),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners(),this._didInit=!1,this._init()}static get DefaultType(){return Q0}static get Default(){return Gd}static get NAME(){return pl}show(){if(_.trigger(this._element,q0).defaultPrevented)return;this._clearTimeout(),this._config.animation&&(g.removeClass(this._element,this._classes.fadeOut),g.addClass(this._element,this._classes.fadeIn));const e=()=>{this._element.removeAttribute(ko),_.trigger(this._element,Z0),this._maybeScheduleHide()};this._element.removeAttribute(Xd),zi(this._element),this._element.setAttribute(fl,\"\"),this._element.setAttribute(ko,\"\"),this._queueCallback(e,this._element,this._config.animation)}hide(){if(!this._element||this._element.dataset.teToastShow===void 0||_.trigger(this._element,X0).defaultPrevented)return;const e=()=>{let i=0;this._config.animation&&(i=300,g.removeClass(this._element,this._classes.fadeIn),g.addClass(this._element,this._classes.fadeOut)),setTimeout(()=>{this._element.setAttribute(Xd,\"\"),this._element.removeAttribute(ko),this._element.removeAttribute(fl),_.trigger(this._element,G0)},i)};this._element.setAttribute(ko,\"\"),this._queueCallback(e,this._element,this._config.animation)}dispose(){this._clearTimeout(),this._element.dataset.teToastShow!==void 0&&this._element.removeAttribute(fl),super.dispose()}_init(){this._didInit||(yo(Xs),this._didInit=!0)}_getConfig(t){return t={...Gd,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},L(pl,t,this.constructor.DefaultType),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...J0,...e,...t},L(pl,t,ty),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case\"mouseover\":case\"mouseout\":this._hasMouseInteraction=e;break;case\"focusin\":case\"focusout\":this._hasKeyboardInteraction=e;break}if(e){this._clearTimeout();return}const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){_.on(this._element,j0,t=>this._onInteraction(t,!0)),_.on(this._element,Y0,t=>this._onInteraction(t,!1)),_.on(this._element,K0,t=>this._onInteraction(t,!0)),_.on(this._element,U0,t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each(function(){const e=Xs.getOrCreateInstance(this,t);if(typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}(()=>{var s={454:(i,n,o)=>{o.d(n,{Z:()=>l});var r=o(645),a=o.n(r)()(function(c){return c[1]});a.push([i.id,\"INPUT:-webkit-autofill,SELECT:-webkit-autofill,TEXTAREA:-webkit-autofill{animation-name:onautofillstart}INPUT:not(:-webkit-autofill),SELECT:not(:-webkit-autofill),TEXTAREA:not(:-webkit-autofill){animation-name:onautofillcancel}@keyframes onautofillstart{}@keyframes onautofillcancel{}\",\"\"]);const l=a},645:i=>{i.exports=function(n){var o=[];return o.toString=function(){return this.map(function(r){var a=n(r);return r[2]?\"@media \".concat(r[2],\" {\").concat(a,\"}\"):a}).join(\"\")},o.i=function(r,a,l){typeof r==\"string\"&&(r=[[null,r,\"\"]]);var c={};if(l)for(var h=0;h{(function(){if(typeof window<\"u\")try{var i=new window.CustomEvent(\"test\",{cancelable:!0});if(i.preventDefault(),i.defaultPrevented!==!0)throw new Error(\"Could not prevent default\")}catch{var n=function(r,a){var l,c;return(a=a||{}).bubbles=!!a.bubbles,a.cancelable=!!a.cancelable,(l=document.createEvent(\"CustomEvent\")).initCustomEvent(r,a.bubbles,a.cancelable,a.detail),c=l.preventDefault,l.preventDefault=function(){c.call(this);try{Object.defineProperty(this,\"defaultPrevented\",{get:function(){return!0}})}catch{this.defaultPrevented=!0}},l};n.prototype=window.Event.prototype,window.CustomEvent=n}})()},379:(i,n,o)=>{var r,a=function(){var x={};return function(E){if(x[E]===void 0){var C=document.querySelector(E);if(window.HTMLIFrameElement&&C instanceof window.HTMLIFrameElement)try{C=C.contentDocument.head}catch{C=null}x[E]=C}return x[E]}}(),l=[];function c(x){for(var E=-1,C=0;C{var n=i&&i.__esModule?()=>i.default:()=>i;return e.d(n,{a:n}),n},e.d=(i,n)=>{for(var o in n)e.o(n,o)&&!e.o(i,o)&&Object.defineProperty(i,o,{enumerable:!0,get:n[o]})},e.o=(i,n)=>Object.prototype.hasOwnProperty.call(i,n),(()=>{var i=e(379),n=e.n(i),o=e(454);function r(l){if(!l.hasAttribute(\"autocompleted\")){l.setAttribute(\"autocompleted\",\"\");var c=new window.CustomEvent(\"onautocomplete\",{bubbles:!0,cancelable:!0,detail:null});l.dispatchEvent(c)||(l.value=\"\")}}function a(l){l.hasAttribute(\"autocompleted\")&&(l.removeAttribute(\"autocompleted\"),l.dispatchEvent(new window.CustomEvent(\"onautocomplete\",{bubbles:!0,cancelable:!1,detail:null})))}n()(o.Z,{insert:\"head\",singleton:!1}),o.Z.locals,e(810),document.addEventListener(\"animationstart\",function(l){l.animationName===\"onautofillstart\"?r(l.target):a(l.target)},!0),document.addEventListener(\"input\",function(l){l.inputType!==\"insertReplacementText\"&&\"data\"in l?a(l.target):r(l.target)},!0)})()})();const _l=\"input\",So=\"te.input\",qd=\"data-te-input-wrapper-init\",Zd=\"data-te-input-notch-ref\",Qd=\"data-te-input-notch-leading-ref\",Jd=\"data-te-input-notch-middle-ref\",ey=\"data-te-input-notch-trailing-ref\",iy=\"data-te-input-helper-ref\",sy=\"data-te-input-placeholder-active\",je=\"data-te-input-state-active\",tu=\"data-te-input-focused\",eu=\"data-te-input-form-counter\",Oo=`[${qd}] input`,Io=`[${qd}] textarea`,ns=`[${Zd}]`,iu=`[${Qd}]`,su=`[${Jd}]`,ny=`[${iy}]`,oy={inputFormWhite:!1},ry={inputFormWhite:\"(boolean)\"},nu={notch:\"group flex absolute left-0 top-0 w-full max-w-full h-full text-left pointer-events-none\",notchLeading:\"pointer-events-none border border-solid box-border bg-transparent transition-all duration-200 ease-linear motion-reduce:transition-none left-0 top-0 h-full w-2 border-r-0 rounded-l-[0.25rem] group-data-[te-input-focused]:border-r-0 group-data-[te-input-state-active]:border-r-0\",notchLeadingNormal:\"border-neutral-300 dark:border-neutral-600 group-data-[te-input-focused]:shadow-[-1px_0_0_#3b71ca,_0_1px_0_0_#3b71ca,_0_-1px_0_0_#3b71ca] group-data-[te-input-focused]:border-primary\",notchLeadingWhite:\"border-neutral-200 group-data-[te-input-focused]:shadow-[-1px_0_0_#ffffff,_0_1px_0_0_#ffffff,_0_-1px_0_0_#ffffff] group-data-[te-input-focused]:border-white\",notchMiddle:\"pointer-events-none border border-solid box-border bg-transparent transition-all duration-200 ease-linear motion-reduce:transition-none grow-0 shrink-0 basis-auto w-auto max-w-[calc(100%-1rem)] h-full border-r-0 border-l-0 group-data-[te-input-focused]:border-x-0 group-data-[te-input-state-active]:border-x-0 group-data-[te-input-focused]:border-t group-data-[te-input-state-active]:border-t group-data-[te-input-focused]:border-solid group-data-[te-input-state-active]:border-solid group-data-[te-input-focused]:border-t-transparent group-data-[te-input-state-active]:border-t-transparent\",notchMiddleNormal:\"border-neutral-300 dark:border-neutral-600 group-data-[te-input-focused]:shadow-[0_1px_0_0_#3b71ca] group-data-[te-input-focused]:border-primary\",notchMiddleWhite:\"border-neutral-200 group-data-[te-input-focused]:shadow-[0_1px_0_0_#ffffff] group-data-[te-input-focused]:border-white\",notchTrailing:\"pointer-events-none border border-solid box-border bg-transparent transition-all duration-200 ease-linear motion-reduce:transition-none grow h-full border-l-0 rounded-r-[0.25rem] group-data-[te-input-focused]:border-l-0 group-data-[te-input-state-active]:border-l-0\",notchTrailingNormal:\"border-neutral-300 dark:border-neutral-600 group-data-[te-input-focused]:shadow-[1px_0_0_#3b71ca,_0_-1px_0_0_#3b71ca,_0_1px_0_0_#3b71ca] group-data-[te-input-focused]:border-primary\",notchTrailingWhite:\"border-neutral-200 group-data-[te-input-focused]:shadow-[1px_0_0_#ffffff,_0_-1px_0_0_#ffffff,_0_1px_0_0_#ffffff] group-data-[te-input-focused]:border-white\",counter:\"text-right leading-[1.6]\"},ay={notch:\"string\",notchLeading:\"string\",notchLeadingNormal:\"string\",notchLeadingWhite:\"string\",notchMiddle:\"string\",notchMiddleNormal:\"string\",notchMiddleWhite:\"string\",notchTrailing:\"string\",notchTrailingNormal:\"string\",notchTrailingWhite:\"string\",counter:\"string\"};class Z{constructor(t,e,i){this._config=this._getConfig(e,t),this._element=t,this._classes=this._getClasses(i),this._label=null,this._labelWidth=0,this._labelMarginLeft=0,this._notchLeading=null,this._notchMiddle=null,this._notchTrailing=null,this._initiated=!1,this._helper=null,this._counter=!1,this._counterElement=null,this._maxLength=0,this._leadingIcon=null,this._element&&(O.setData(t,So,this),this.init())}static get NAME(){return _l}get input(){return m.findOne(\"input\",this._element)||m.findOne(\"textarea\",this._element)}init(){this._initiated||(this._getLabelData(),this._applyDivs(),this._applyNotch(),this._activate(),this._getHelper(),this._getCounter(),this._getEvents(),this._initiated=!0)}update(){this._getLabelData(),this._getNotchData(),this._applyNotch(),this._activate(),this._getHelper(),this._getCounter()}forceActive(){this.input.setAttribute(je,\"\"),m.findOne(ns,this.input.parentNode).setAttribute(je,\"\")}forceInactive(){this.input.removeAttribute(je),m.findOne(ns,this.input.parentNode).removeAttribute(je)}dispose(){this._removeBorder(),O.removeData(this._element,So),this._element=null}_getConfig(t,e){return t={...oy,...g.getDataAttributes(e),...typeof t==\"object\"?t:{}},L(_l,t,ry),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...nu,...e,...t},L(_l,t,ay),t}_getLabelData(){this._label=m.findOne(\"label\",this._element),this._label===null?this._showPlaceholder():(this._getLabelWidth(),this._getLabelPositionInInputGroup(),this._toggleDefaultDatePlaceholder())}_getHelper(){this._helper=m.findOne(ny,this._element)}_getCounter(){this._counter=g.getDataAttribute(this.input,\"inputShowcounter\"),this._counter&&(this._maxLength=this.input.maxLength,this._showCounter())}_getEvents(){_.on(this._element,\"focus\",\"input\",Z.activate(new Z)),_.on(this._element,\"input\",\"input\",Z.activate(new Z)),_.on(this._element,\"blur\",\"input\",Z.deactivate(new Z)),_.on(this._element,\"focus\",\"textarea\",Z.activate(new Z)),_.on(this._element,\"input\",\"textarea\",Z.activate(new Z)),_.on(this._element,\"blur\",\"textarea\",Z.deactivate(new Z)),_.on(window,\"shown.te.modal\",t=>{m.find(Oo,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.update()}),m.find(Io,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.update()})}),_.on(window,\"shown.te.dropdown\",t=>{const e=t.target.parentNode.querySelector(\"[data-te-dropdown-menu-ref]\");e&&(m.find(Oo,e).forEach(i=>{const n=Z.getInstance(i.parentNode);n&&n.update()}),m.find(Io,e).forEach(i=>{const n=Z.getInstance(i.parentNode);n&&n.update()}))}),_.on(window,\"shown.te.tab\",t=>{let e;t.target.href?e=t.target.href.split(\"#\")[1]:e=g.getDataAttribute(t.target,\"target\").split(\"#\")[1];const i=m.findOne(`#${e}`);m.find(Oo,i).forEach(n=>{const o=Z.getInstance(n.parentNode);o&&o.update()}),m.find(Io,i).forEach(n=>{const o=Z.getInstance(n.parentNode);o&&o.update()})}),_.on(window,\"reset\",t=>{m.find(Oo,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.forceInactive()}),m.find(Io,t.target).forEach(e=>{const i=Z.getInstance(e.parentNode);i&&i.forceInactive()})}),_.on(window,\"onautocomplete\",t=>{const e=Z.getInstance(t.target.parentNode);!e||!t.cancelable||e.forceActive()})}_showCounter(){if(m.find(`[${eu}]`,this._element).length>0)return;this._counterElement=document.createElement(\"div\"),g.addClass(this._counterElement,this._classes.counter),this._counterElement.setAttribute(eu,\"\");const e=this.input.value.length;this._counterElement.innerHTML=`${e} / ${this._maxLength}`,this._helper.appendChild(this._counterElement),this._bindCounter()}_bindCounter(){_.on(this.input,\"input\",()=>{const t=this.input.value.length;this._counterElement.innerHTML=`${t} / ${this._maxLength}`})}_toggleDefaultDatePlaceholder(t=this.input){if(!(t.getAttribute(\"type\")===\"date\"))return;!(document.activeElement===t)&&!t.value?t.style.opacity=0:t.style.opacity=1}_showPlaceholder(){this.input.setAttribute(sy,\"\")}_getNotchData(){this._notchMiddle=m.findOne(su,this._element),this._notchLeading=m.findOne(iu,this._element)}_getLabelWidth(){this._labelWidth=this._label.clientWidth*.8+8}_getLabelPositionInInputGroup(){if(this._labelMarginLeft=0,!this._element.hasAttribute(\"data-te-input-group-ref\"))return;const t=this.input,e=m.prev(t,\"[data-te-input-group-text-ref]\")[0];e===void 0?this._labelMarginLeft=0:this._labelMarginLeft=e.offsetWidth-1}_applyDivs(){const t=this._config.inputFormWhite?this._classes.notchLeadingWhite:this._classes.notchLeadingNormal,e=this._config.inputFormWhite?this._classes.notchMiddleWhite:this._classes.notchMiddleNormal,i=this._config.inputFormWhite?this._classes.notchTrailingWhite:this._classes.notchTrailingNormal,n=m.find(ns,this._element),o=$(\"div\");g.addClass(o,this._classes.notch),o.setAttribute(Zd,\"\"),this._notchLeading=$(\"div\"),g.addClass(this._notchLeading,`${this._classes.notchLeading} ${t}`),this._notchLeading.setAttribute(Qd,\"\"),this._notchMiddle=$(\"div\"),g.addClass(this._notchMiddle,`${this._classes.notchMiddle} ${e}`),this._notchMiddle.setAttribute(Jd,\"\"),this._notchTrailing=$(\"div\"),g.addClass(this._notchTrailing,`${this._classes.notchTrailing} ${i}`),this._notchTrailing.setAttribute(ey,\"\"),!(n.length>=1)&&(o.append(this._notchLeading),o.append(this._notchMiddle),o.append(this._notchTrailing),this._element.append(o))}_applyNotch(){this._notchMiddle.style.width=`${this._labelWidth}px`,this._notchLeading.style.width=`${this._labelMarginLeft+9}px`,this._label!==null&&(this._label.style.marginLeft=`${this._labelMarginLeft}px`)}_removeBorder(){const t=m.findOne(ns,this._element);t&&t.remove()}_activate(t){ph(()=>{this._getElements(t);const e=t?t.target:this.input,i=m.findOne(ns,this._element);t&&t.type===\"focus\"&&i&&i.setAttribute(tu,\"\"),e.value!==\"\"&&(e.setAttribute(je,\"\"),i&&i.setAttribute(je,\"\")),this._toggleDefaultDatePlaceholder(e)})}_getElements(t){if(t&&(this._element=t.target.parentNode,this._label=m.findOne(\"label\",this._element)),t&&this._label){const e=this._labelWidth;this._getLabelData(),e!==this._labelWidth&&(this._notchMiddle=m.findOne(su,t.target.parentNode),this._notchLeading=m.findOne(iu,t.target.parentNode),this._applyNotch())}}_deactivate(t){const e=t?t.target:this.input,i=m.findOne(ns,e.parentNode);i.removeAttribute(tu),e.value===\"\"&&(e.removeAttribute(je),i.removeAttribute(je)),this._toggleDefaultDatePlaceholder(e)}static activate(t){return function(e){t._activate(e)}}static deactivate(t){return function(e){t._deactivate(e)}}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,So);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Z(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,So)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const ou=\"animation\",gl=\"te.animation\",ly={animation:\"string\",animationStart:\"string\",animationShowOnLoad:\"boolean\",onStart:\"(null|function)\",onEnd:\"(null|function)\",onHide:\"(null|function)\",onShow:\"(null|function)\",animationOnScroll:\"(string)\",animationWindowHeight:\"number\",animationOffset:\"(number|string)\",animationDelay:\"(number|string)\",animationReverse:\"boolean\",animationInterval:\"(number|string)\",animationRepeat:\"(number|boolean)\",animationReset:\"boolean\"},cy={animation:\"fade\",animationStart:\"onClick\",animationShowOnLoad:!0,onStart:null,onEnd:null,onHide:null,onShow:null,animationOnScroll:\"once\",animationWindowHeight:0,animationOffset:0,animationDelay:0,animationReverse:!1,animationInterval:0,animationRepeat:!1,animationReset:!1};class Gs{constructor(t,e){this._element=t,this._animateElement=this._getAnimateElement(),this._isFirstScroll=!0,this._repeatAnimateOnScroll=!0,this._options=this._getConfig(e),this._element&&(O.setData(t,gl,this),this._init())}static get NAME(){return ou}init(){this._init()}startAnimation(){this._startAnimation()}stopAnimation(){this._clearAnimationClass()}changeAnimationType(t){this._options.animation=t}dispose(){_.off(this._element,\"mousedown\"),_.off(this._animateElement,\"animationend\"),_.off(window,\"scroll\"),_.off(this._element,\"mouseover\"),O.removeData(this._element,gl),this._element=null,this._animateElement=null,this._isFirstScroll=null,this._repeatAnimateOnScroll=null,this._options=null}_init(){switch(this._options.animationStart){case\"onHover\":this._bindHoverEvents();break;case\"onLoad\":this._startAnimation();break;case\"onScroll\":this._bindScrollEvents();break;case\"onClick\":this._bindClickEvents();break}this._bindTriggerOnEndCallback(),this._options.animationReset&&this._bindResetAnimationAfterFinish()}_getAnimateElement(){const t=g.getDataAttribute(this._element,\"animation-target\");return t?m.find(t)[0]:this._element}_getConfig(t){const e=g.getDataAttributes(this._animateElement);return t={...cy,...e,...t},L(ou,t,ly),t}_animateOnScroll(){const t=g.offset(this._animateElement).top,e=this._animateElement.offsetHeight,i=window.innerHeight,n=t+this._options.animationOffset<=i&&t+this._options.animationOffset+e>=0,o=this._animateElement.style.visibility===\"visible\";switch(!0){case(n&&this._isFirstScroll):this._isFirstScroll=!1,this._startAnimation();break;case(!n&&this._isFirstScroll):this._isFirstScroll=!1,this._hideAnimateElement();break;case(n&&!o&&this._repeatAnimateOnScroll):this._options.animationOnScroll!==\"repeat\"&&(this._repeatAnimateOnScroll=!1),this._callback(this._options.onShow),this._showAnimateElement(),this._startAnimation();break;case(!n&&o&&this._repeatAnimateOnScroll):this._hideAnimateElement(),this._clearAnimationClass(),this._callback(this._options.onHide);break}}_addAnimatedClass(){g.addClass(this._animateElement,`animate-${this._options.animation}`)}_clearAnimationClass(){this._animateElement.classList.remove(`animate-${this._options.animation}`)}_startAnimation(){this._callback(this._options.onStart),this._addAnimatedClass(),this._options.animationRepeat&&!this._options.animationInterval&&this._setAnimationRepeat(),this._options.animationReverse&&this._setAnimationReverse(),this._options.animationDelay&&this._setAnimationDelay(),this._options.animationDuration&&this._setAnimationDuration(),this._options.animationInterval&&this._setAnimationInterval()}_setAnimationReverse(){g.style(this._animateElement,{animationIterationCount:this._options.animationRepeat===!0?\"infinite\":\"2\",animationDirection:\"alternate\"})}_setAnimationDuration(){g.style(this._animateElement,{animationDuration:`${this._options.animationDuration}ms`})}_setAnimationDelay(){g.style(this._animateElement,{animationDelay:`${this._options.animationDelay}ms`})}_setAnimationRepeat(){g.style(this._animateElement,{animationIterationCount:this._options.animationRepeat===!0?\"infinite\":this._options.animationRepeat})}_setAnimationInterval(){_.on(this._animateElement,\"animationend\",()=>{this._clearAnimationClass(),setTimeout(()=>{this._addAnimatedClass()},this._options.animationInterval)})}_hideAnimateElement(){g.style(this._animateElement,{visibility:\"hidden\"})}_showAnimateElement(){g.style(this._animateElement,{visibility:\"visible\"})}_bindResetAnimationAfterFinish(){_.on(this._animateElement,\"animationend\",()=>{this._clearAnimationClass()})}_bindTriggerOnEndCallback(){_.on(this._animateElement,\"animationend\",()=>{this._callback(this._options.onEnd)})}_bindScrollEvents(){this._options.animationShowOnLoad||this._animateOnScroll(),_.on(window,\"scroll\",()=>{this._animateOnScroll()})}_bindClickEvents(){_.on(this._element,\"mousedown\",()=>{this._startAnimation()})}_bindHoverEvents(){_.one(this._element,\"mouseover\",()=>{this._startAnimation()}),_.one(this._animateElement,\"animationend\",()=>{setTimeout(()=>{this._bindHoverEvents()},100)})}_callback(t){t instanceof Function&&t()}static autoInit(t){t._init()}static jQueryInterface(t){new Gs(this[0],t).init()}static getInstance(t){return O.getData(t,gl)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const hy={property:\"color\",defaultValue:null,inherit:!0},os=(s,t)=>{const{property:e,defaultValue:i,inherit:n}={...hy,...t},o=document.createElement(\"div\");o.classList.add(s),document.body.appendChild(o);const a=window.getComputedStyle(o)[e]||i,c=window.getComputedStyle(o.parentElement)[e];return document.body.removeChild(o),!n&&c&&a===c?i:a||i},ml=\"ripple\",Do=\"te.ripple\",dy=\"rgba({{color}}, 0.2) 0, rgba({{color}}, 0.3) 40%, rgba({{color}}, 0.4) 50%, rgba({{color}}, 0.5) 60%, rgba({{color}}, 0) 70%\",uy=[\"[data-te-ripple-init]\"],Mo=[0,0,0],py=[{name:\"primary\",gradientColor:os(\"text-primary\",{defaultValue:\"#3B71CA\",inherit:!1})},{name:\"secondary\",gradientColor:os(\"text-secondary\",{defaultValue:\"#9FA6B2\",inherit:!1})},{name:\"success\",gradientColor:os(\"text-success\",{defaultValue:\"#14A44D\",inherit:!1})},{name:\"danger\",gradientColor:os(\"text-danger\",{defaultValue:\"#DC4C64\",inherit:!1})},{name:\"warning\",gradientColor:os(\"text-warning\",{defaultValue:\"#E4A11B\",inherit:!1})},{name:\"info\",gradientColor:os(\"text-info\",{defaultValue:\"#54B4D3\",inherit:!1})},{name:\"light\",gradientColor:\"#fbfbfb\"},{name:\"dark\",gradientColor:\"#262626\"}],ru=.5,fy={rippleCentered:!1,rippleColor:\"\",rippleColorDark:\"\",rippleDuration:\"500ms\",rippleRadius:0,rippleUnbound:!1},_y={rippleCentered:\"boolean\",rippleColor:\"string\",rippleColorDark:\"string\",rippleDuration:\"string\",rippleRadius:\"number\",rippleUnbound:\"boolean\"},gy={ripple:\"relative overflow-hidden inline-block align-bottom\",rippleWave:\"rounded-[50%] opacity-50 pointer-events-none absolute touch-none scale-0 transition-[transform,_opacity] ease-[cubic-bezier(0,0,0.15,1),_cubic-bezier(0,0,0.15,1)] z-[999]\",unbound:\"overflow-visible\"},my={ripple:\"string\",rippleWave:\"string\",unbound:\"string\"};class Ye{constructor(t,e,i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._element&&(O.setData(t,Do,this),g.addClass(this._element,this._classes.ripple)),this._clickHandler=this._createRipple.bind(this),this._rippleTimer=null,this._isMinWidthSet=!1,this._initialClasses=null,this.init()}static get NAME(){return ml}init(){this._addClickEvent(this._element)}dispose(){O.removeData(this._element,Do),_.off(this._element,\"click\",this._clickHandler),this._element=null,this._options=null}_autoInit(t){uy.forEach(e=>{m.closest(t.target,e)&&(this._element=m.closest(t.target,e))}),this._element.style.minWidth||(g.style(this._element,{\"min-width\":getComputedStyle(this._element).width}),this._isMinWidthSet=!0),this._options=this._getConfig(),this._classes=this._getClasses(),this._initialClasses=[...this._element.classList],g.addClass(this._element,this._classes.ripple),this._createRipple(t)}_addClickEvent(t){_.on(t,\"mousedown\",this._clickHandler)}_createRipple(t){this._element.className.indexOf(this._classes.ripple)<0&&g.addClass(this._element,this._classes.ripple);const{layerX:e,layerY:i}=t,n=t.offsetX||e,o=t.offsetY||i,r=this._element.offsetHeight,a=this._element.offsetWidth,l=this._durationToMsNumber(this._options.rippleDuration),c={offsetX:this._options.rippleCentered?r/2:n,offsetY:this._options.rippleCentered?a/2:o,height:r,width:a},h=this._getDiameter(c),d=this._options.rippleRadius||h/2,u={delay:l*ru,duration:l-l*ru},p={left:this._options.rippleCentered?`${a/2-d}px`:`${n-d}px`,top:this._options.rippleCentered?`${r/2-d}px`:`${o-d}px`,height:`${this._options.rippleRadius*2||h}px`,width:`${this._options.rippleRadius*2||h}px`,transitionDelay:`0s, ${u.delay}ms`,transitionDuration:`${l}ms, ${u.duration}ms`},f=$(\"div\");this._createHTMLRipple({wrapper:this._element,ripple:f,styles:p}),this._removeHTMLRipple({ripple:f,duration:l})}_createHTMLRipple({wrapper:t,ripple:e,styles:i}){Object.keys(i).forEach(n=>e.style[n]=i[n]),g.addClass(e,this._classes.rippleWave),e.setAttribute(\"data-te-ripple-ref\",\"\"),this._addColor(e,t),this._toggleUnbound(t),this._appendRipple(e,t)}_removeHTMLRipple({ripple:t,duration:e}){this._rippleTimer&&(clearTimeout(this._rippleTimer),this._rippleTimer=null),t&&setTimeout(()=>{t.classList.add(\"!opacity-0\")},10),this._rippleTimer=setTimeout(()=>{if(t&&(t.remove(),this._element)){m.find(\"[data-te-ripple-ref]\",this._element).forEach(n=>{n.remove()}),this._isMinWidthSet&&(g.style(this._element,{\"min-width\":\"\"}),this._isMinWidthSet=!1);const i=this._initialClasses?this._addedNewRippleClasses(this._classes.ripple,this._initialClasses):this._classes.ripple.split(\" \");g.removeClass(this._element,i)}},e)}_addedNewRippleClasses(t,e){return t.split(\" \").filter(i=>e.findIndex(n=>i===n)===-1)}_durationToMsNumber(t){return Number(t.replace(\"ms\",\"\").replace(\"s\",\"000\"))}_getConfig(t={}){const e=g.getDataAttributes(this._element);return t={...fy,...e,...t},L(ml,t,_y),t}_getClasses(t={}){const e=g.getDataClassAttributes(this._element);return t={...gy,...e,...t},L(ml,t,my),t}_getDiameter({offsetX:t,offsetY:e,height:i,width:n}){const o=e<=i/2,r=t<=n/2,a=(u,p)=>Math.sqrt(u**2+p**2),l=e===i/2&&t===n/2,c={first:o===!0&&r===!1,second:o===!0&&r===!0,third:o===!1&&r===!0,fourth:o===!1&&r===!1},h={topLeft:a(t,e),topRight:a(n-t,e),bottomLeft:a(t,i-e),bottomRight:a(n-t,i-e)};let d=0;return l||c.fourth?d=h.topLeft:c.third?d=h.topRight:c.second?d=h.bottomRight:c.first&&(d=h.bottomLeft),d*2}_appendRipple(t,e){e.appendChild(t),setTimeout(()=>{g.addClass(t,\"opacity-0 scale-100\")},50)}_toggleUnbound(t){this._options.rippleUnbound===!0?g.addClass(t,this._classes.unbound):g.removeClass(t,this._classes.unbound)}_addColor(t){let e=this._options.rippleColor||\"rgb(0,0,0)\";(localStorage.theme===\"dark\"||!(\"theme\"in localStorage)&&window.matchMedia(\"(prefers-color-scheme: dark)\").matches)&&(e=this._options.rippleColorDark||this._options.rippleColor);const i=py.find(r=>r.name===e.toLowerCase()),n=i?this._colorToRGB(i.gradientColor).join(\",\"):this._colorToRGB(e).join(\",\"),o=dy.split(\"{{color}}\").join(`${n}`);t.style.backgroundImage=`radial-gradient(circle, ${o})`}_colorToRGB(t){function e(o){return o.length<7&&(o=`#${o[1]}${o[1]}${o[2]}${o[2]}${o[3]}${o[3]}`),[parseInt(o.substr(1,2),16),parseInt(o.substr(3,2),16),parseInt(o.substr(5,2),16)]}function i(o){const r=document.body.appendChild(document.createElement(\"fictum\")),a=\"rgb(1, 2, 3)\";return r.style.color=a,r.style.color!==a||(r.style.color=o,r.style.color===a||r.style.color===\"\")?Mo:(o=getComputedStyle(r).color,document.body.removeChild(r),o)}function n(o){return o=o.match(/[.\\d]+/g).map(r=>+Number(r)),o.length=3,o}return t.toLowerCase()===\"transparent\"?Mo:t[0]===\"#\"?e(t):(t.indexOf(\"rgb\")===-1&&(t=i(t)),t.indexOf(\"rgb\")===0?n(t):Mo)}static autoInitial(t){return function(e){t._autoInit(e)}}static jQueryInterface(t){return this.each(function(){return O.getData(this,Do)?null:new Ye(this,t)})}static getInstance(t){return O.getData(t,Do)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}function Tt(s){return s.getDate()}function Lo(s){return s.getDay()}function ot(s){return s.getMonth()}function K(s){return s.getFullYear()}function by(s,t,e){const i=e.startDay,n=i>0?7-i:0,r=new Date(s,t).getDay()+n;return r>=7?r-7:r}function bl(s){return vy(s).getDate()}function vy(s){return ee(s.getFullYear(),s.getMonth()+1,0)}function rs(){return new Date}function kt(s,t){return St(s,t*12)}function St(s,t){const e=ee(s.getFullYear(),s.getMonth()+t,s.getDate()),i=Tt(s),n=Tt(e);return i!==n&&e.setDate(0),e}function as(s,t){return ee(s.getFullYear(),s.getMonth(),s.getDate()+t)}function ee(s,t,e){const i=new Date(s,t,e);return s>=0&&s<100&&i.setFullYear(i.getFullYear()-1900),i}function au(s){const t=s.split(\"-\"),e=t[0],i=t[1],n=t[2];return ee(e,i,n)}function yy(s){return!Number.isNaN(s.getTime())}function ls(s,t){return K(s)-K(t)||ot(s)-ot(t)||Tt(s)-Tt(t)}function yi(s,t){return s.setHours(0,0,0,0),t.setHours(0,0,0,0),s.getTime()===t.getTime()}function $o(s,t){const i=K(s)-Ey();return Ty(i,t)}function Ty(s,t){return(s%t+t)%t}function Ey(s,t,e){let i=0;return e?i=K(e)-s+1:t&&(i=K(t)),i}function Ro(s,t,e,i,n,o){const r=new Date;r.setHours(0,0,0,0);const a=t&&ls(s,t)<=-1,l=e&&ls(s,e)>=1,c=n&&ls(s,r)<=-1,h=o&&ls(s,r)>=1,d=i&&i(s)===!1;return a||l||d||c||h}function lu(s,t,e,i,n,o){const r=new Date,a=i&&K(i),l=i&&ot(i),c=e&&K(e),h=e&&ot(e),d=K(r),u=ot(r),p=l&&a&&(t>a||t===a&&s>l),f=h&&c&&(td||t===d&&s>u);return p||f||b||v}function vl(s,t,e,i,n){const o=t&&K(t),r=e&&K(e),a=K(new Date),l=r&&s>r,c=o&&sa;return l||c||h||d}function xy(s,t,e,i,n,o,r,a){const l=new Date;return l.setHours(0,0,0,0),(s&&o&&ls(o,l)<0||s)&&(o=l),o&&qs(t,o,e,i,n,o,r,a)}function Cy(s,t,e,i,n,o,r,a){const l=new Date;return l.setHours(0,0,0,0),(s&&n&&ls(n,l)<0||s)&&(n=l),n&&qs(t,n,e,i,n,o,r,a)}function qs(s,t,e,i,n,o,r,a){return e===\"days\"?K(s)===K(t)&&ot(s)===ot(t):e===\"months\"?K(s)===K(t):e===\"years\"?K(t)>=a&&K(t)<=r:!1}const Ay=\"data-te-datepicker-modal-container-ref\",wy=\"data-te-datepicker-dropdown-container-ref\",ky=\"data-te-dropdown-backdrop-ref\",Sy=\"data-te-datepicker-date-text-ref\",cu=\"data-te-datepicker-view-ref\",Oy=\"data-te-datepicker-previous-button-ref\",Iy=\"data-te-datepicker-next-button-ref\",Dy=\"data-te-datepicker-ok-button-ref\",My=\"data-te-datepicker-cancel-button-ref\",Ly=\"data-te-datepicker-clear-button-ref\",$y=\"data-te-datepicker-view-change-button-ref\";function Ry(s,t,e,i,n,o,r,a,l,c){const h=ot(s),d=K(s),u=Tt(s),p=Lo(s),f=$(\"div\"),b=`\n ${hu(s,h,d,t,e,i,n,o,r,a,c)}\n `,v=`\n ${Ny(u,p,h,n,c)}\n ${hu(s,h,d,t,e,i,n,o,r,a,c)}\n `;return n.inline?(g.addClass(f,c.datepickerDropdownContainer),f.setAttribute(wy,l),f.innerHTML=b):(g.addClass(f,c.modalContainer),f.setAttribute(Ay,l),f.innerHTML=v),f}function Py(s){const t=$(\"div\");return g.addClass(t,s),t.setAttribute(ky,\"\"),t}function Ny(s,t,e,i,n){return`\n
\n
\n ${i.title}\n
\n
\n ${i.weekdaysShort[t]}, ${i.monthsShort[e]} ${s}\n
\n
\n `}function hu(s,t,e,i,n,o,r,a,l,c,h){let d;return r.inline?d=`\n
\n ${uu(t,e,r,h)}\n
\n ${du(s,e,i,n,o,r,a,l,c,h)}\n
\n
\n `:d=`\n
\n ${uu(t,e,r,h)}\n
\n ${du(s,e,i,n,o,r,a,l,c,h)}\n
\n ${By(r,h)}\n
\n `,d}function du(s,t,e,i,n,o,r,a,l,c){let h;return o.view===\"days\"?h=Po(s,e,o,c):o.view===\"months\"?h=No(t,i,n,o,r,c):h=Bo(s,i,o,a,l,c),h}function uu(s,t,e,i){return`\n
\n \n
\n \n \n
\n
\n `}function pe(s,t){return`\n \n ${s.viewChangeIconTemplate}\n \n `}function By(s,t){const e=``,i=``,n=``;return`\n
\n \n ${s.removeClearBtn?\"\":n}\n ${s.removeCancelBtn?\"\":i}\n ${s.removeOkBtn?\"\":e}\n
\n `}function Po(s,t,e,i){const n=Hy(s,t,e),r=`\n \n ${e.weekdaysNarrow.map((l,c)=>`${l}`).join(\"\")}\n \n `,a=n.map(l=>`\n \n ${l.map(c=>`\n \n \n ${c.dayNumber}\n \n \n `).join(\"\")}\n \n `).join(\"\");return`\n \n \n ${r}\n \n \n ${a}\n \n
\n `}function Hy(s,t,e){const i=[],n=ot(s),o=ot(St(s,-1)),r=ot(St(s,1)),a=K(s),l=by(a,n,e),c=bl(s),h=bl(St(s,-1)),d=7;let u=1,p=!1;for(let f=1;fc&&(u=1,p=!1);const y=ee(a,p?n:r,u);b.push({date:y,currentMonth:p,isSelected:t&&yi(y,t),isToday:yi(y,rs()),dayNumber:Tt(y),disabled:Ro(y,e.min,e.max,e.filter,e.disablePast,e.disableFuture)}),u++}i.push(b)}return i}function No(s,t,e,i,n,o){const r=Vy(i,n),a=ot(rs()),l=K(rs()),c=`\n ${r.map(h=>`\n \n ${h.map(d=>{const u=i.monthsShort.indexOf(d);return`\n \n
${d}
\n \n `}).join(\"\")}\n \n `).join(\"\")}\n `;return`\n \n \n ${c}\n \n
\n `}function Vy(s,t){const e=[];let i=[];for(let n=0;n`\n \n ${c.map(h=>`\n \n
${h}
\n \n `).join(\"\")}\n \n `).join(\"\")}\n `;return`\n \n \n ${l}\n \n
\n `}function Fy(s,t,e){const i=[],n=K(s),o=$o(s,t),r=n-o;let a=[];for(let l=0;l\n \n \n \n \n `}const cs=37,ut=38,hs=39,ht=40,Ti=36,Ei=35,yl=33,Tl=34,Et=13,Ho=32,xi=27,Ci=9,zy=8,jy=46,ie=24,Vo=4,Fo=4,El=\"datepicker\",Wo=\"te.datepicker\",zo=`.${Wo}`,Yy=\".data-api\",Ky=`close${zo}`,Uy=`open${zo}`,Xy=`dateChange${zo}`,jo=`click${zo}${Yy}`,pu=\"data-te-datepicker-modal-container-ref\",fu=\"data-te-datepicker-dropdown-container-ref\",Yo=\"[data-te-datepicker-toggle-ref]\",Gy=`[${pu}]`,qy=`[${fu}]`,Zy=\"[data-te-datepicker-view-change-button-ref]\",Qy=\"[data-te-datepicker-previous-button-ref]\",Jy=\"[data-te-datepicker-next-button-ref]\",tT=\"[data-te-datepicker-ok-button-ref]\",eT=\"[data-te-datepicker-cancel-button-ref]\",iT=\"[data-te-datepicker-clear-button-ref]\",sT=\"[data-te-datepicker-view-ref]\",nT=\"[data-te-datepicker-toggle-button-ref]\",oT=\"[data-te-datepicker-date-text-ref]\",rT=\"[data-te-dropdown-backdrop-ref]\",aT=\"animate-[fade-in_0.3s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",lT=\"animate-[fade-out_0.3s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",cT=\"animate-[fade-in_0.15s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",hT=\"animate-[fade-out_0.15s_both] px-[auto] motion-reduce:transition-none motion-reduce:animate-none\",dT=\"flex flex-col fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[328px] h-[512px] bg-white rounded-[0.6rem] shadow-lg z-[1066] xs:max-md:landscape:w-[475px] xs:max-md:landscape:h-[360px] xs:max-md:landscape:flex-row dark:bg-zinc-700\",uT=\"w-full h-full fixed top-0 right-0 left-0 bottom-0 bg-black/40 z-[1065]\",pT=\"relative h-full\",fT=\"xs:max-md:landscape:h-full h-[120px] px-6 bg-primary flex flex-col rounded-t-lg dark:bg-zinc-800\",_T=\"h-8 flex flex-col justify-end\",gT=\"text-[10px] font-normal uppercase tracking-[1.7px] text-white\",mT=\"xs:max-md:landscape:mt-24 h-[72px] flex flex-col justify-end\",bT=\"text-[34px] font-normal text-white\",vT=\"outline-none px-3\",yT=\"px-3 pt-2.5 pb-0 flex justify-between text-black/[64]\",TT=\"flex items-center outline-none p-2.5 text-neutral-500 font-medium text-[0.9rem] rounded-xl shadow-none bg-transparent m-0 border-none hover:bg-neutral-200 focus:bg-neutral-200 dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10\",ET=\"mt-2.5\",xT=\"p-0 w-10 h-10 leading-10 border-none outline-none m-0 text-gray-600 bg-transparent mr-6 hover:bg-neutral-200 hover:rounded-[50%] focus:bg-neutral-200 focus:rounded-[50%] dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:mx-auto\",CT=\"p-0 w-10 h-10 leading-10 border-none outline-none m-0 text-gray-600 bg-transparent hover:bg-neutral-200 hover:rounded-[50%] focus:bg-neutral-200 focus:rounded-[50%] dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:rotate-180 [&>svg]:mx-auto\",AT=\"h-14 flex absolute w-full bottom-0 justify-end items-center px-3\",wT=\"outline-none bg-white text-primary border-none cursor-pointer py-0 px-2.5 uppercase text-[0.8rem] leading-10 font-medium h-10 tracking-[.1rem] rounded-[10px] mb-2.5 hover:bg-neutral-200 focus:bg-neutral-200 dark:bg-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10\",kT=\"mr-auto\",ST=\"w-10 h-10 text-center text-[12px] font-normal dark:text-white\",OT=\"text-center data-[te-datepicker-cell-disabled]:text-neutral-300 data-[te-datepicker-cell-disabled]:cursor-default data-[te-datepicker-cell-disabled]:pointer-events-none data-[te-datepicker-cell-disabled]:hover:cursor-default hover:cursor-pointer group\",IT=\"w-10 h-10 xs:max-md:landscape:w-8 xs:max-md:landscape:h-8\",DT=\"w-[76px] h-[42px]\",MT=\"mx-auto group-[:not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover]:bg-neutral-300 group-[[data-te-datepicker-cell-selected]]:bg-primary group-[[data-te-datepicker-cell-selected]]:text-white group-[:not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused]]:bg-neutral-100 group-[[data-te-datepicker-cell-focused]]:data-[te-datepicker-cell-selected]:bg-primary group-[[data-te-datepicker-cell-current]]:border-solid group-[[data-te-datepicker-cell-current]]:border-black group-[[data-te-datepicker-cell-current]]:border dark:group-[:not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover]:bg-white/10 dark:group-[[data-te-datepicker-cell-current]]:border-white dark:text-white dark:group-[:not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused]]:bg-white/10 dark:group-[[data-te-datepicker-cell-disabled]]:text-neutral-500\",LT=\"w-9 h-9 leading-9 rounded-[50%] text-[13px]\",$T=\"w-[72px] h-10 leading-10 py-[1px] px-0.5 rounded-[999px]\",RT=\"mx-auto w-[304px]\",PT=\"flex items-center justify-content-center [&>svg]:w-5 [&>svg]:h-5 absolute outline-none border-none bg-transparent right-0.5 top-1/2 -translate-x-1/2 -translate-y-1/2 hover:text-primary focus:text-primary dark:hover:text-primary-400 dark:focus:text-primary-400 dark:text-neutral-200\",NT=\"inline-block pointer-events-none ml-[3px] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:fill-neutral-500 dark:[&>svg]:fill-white\",BT=\"w-[328px] h-[380px] bg-white rounded-lg shadow-[0px_2px_15px_-3px_rgba(0,0,0,.07),_0px_10px_20px_-2px_rgba(0,0,0,.04)] z-[1066] dark:bg-zinc-700\",HT={title:\"Select date\",container:\"body\",disablePast:!1,disableFuture:!1,monthsFull:[\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"],monthsShort:[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"],weekdaysFull:[\"Sunday\",\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\",\"Saturday\"],weekdaysShort:[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"],weekdaysNarrow:[\"S\",\"M\",\"T\",\"W\",\"T\",\"F\",\"S\"],okBtnText:\"Ok\",clearBtnText:\"Clear\",cancelBtnText:\"Cancel\",okBtnLabel:\"Confirm selection\",clearBtnLabel:\"Clear selection\",cancelBtnLabel:\"Cancel selection\",nextMonthLabel:\"Next month\",prevMonthLabel:\"Previous month\",nextYearLabel:\"Next year\",prevYearLabel:\"Previous year\",changeMonthIconTemplate:`\n \n \n `,nextMultiYearLabel:\"Next 24 years\",prevMultiYearLabel:\"Previous 24 years\",switchToMultiYearViewLabel:\"Choose year and month\",switchToMonthViewLabel:\"Choose date\",switchToDayViewLabel:\"Choose date\",startDate:null,startDay:0,format:\"dd/mm/yyyy\",view:\"days\",viewChangeIconTemplate:`\n \n \n `,min:null,max:null,filter:null,inline:!1,toggleButton:!0,disableToggleButton:!1,disableInput:!1,animations:!0,confirmDateOnSelect:!1,removeOkBtn:!1,removeCancelBtn:!1,removeClearBtn:!1},VT={title:\"string\",container:\"string\",disablePast:\"boolean\",disableFuture:\"boolean\",monthsFull:\"array\",monthsShort:\"array\",weekdaysFull:\"array\",weekdaysShort:\"array\",weekdaysNarrow:\"array\",okBtnText:\"string\",clearBtnText:\"string\",cancelBtnText:\"string\",okBtnLabel:\"string\",clearBtnLabel:\"string\",cancelBtnLabel:\"string\",nextMonthLabel:\"string\",prevMonthLabel:\"string\",nextYearLabel:\"string\",prevYearLabel:\"string\",nextMultiYearLabel:\"string\",prevMultiYearLabel:\"string\",changeMonthIconTemplate:\"string\",switchToMultiYearViewLabel:\"string\",switchToMonthViewLabel:\"string\",switchToDayViewLabel:\"string\",startDate:\"(null|string|date)\",startDay:\"number\",format:\"string\",view:\"string\",viewChangeIconTemplate:\"string\",min:\"(null|string|date)\",max:\"(null|string|date)\",filter:\"(null|function)\",inline:\"boolean\",toggleButton:\"boolean\",disableToggleButton:\"boolean\",disableInput:\"boolean\",animations:\"boolean\",confirmDateOnSelect:\"boolean\",removeOkBtn:\"boolean\",removeCancelBtn:\"boolean\",removeClearBtn:\"boolean\"},FT={fadeIn:aT,fadeOut:lT,fadeInShort:cT,fadeOutShort:hT,modalContainer:dT,datepickerBackdrop:uT,datepickerMain:pT,datepickerHeader:fT,datepickerTitle:_T,datepickerTitleText:gT,datepickerDate:mT,datepickerDateText:bT,datepickerView:vT,datepickerDateControls:yT,datepickerViewChangeButton:TT,datepickerViewChangeIcon:NT,datepickerArrowControls:ET,datepickerPreviousButton:xT,datepickerNextButton:CT,datepickerFooter:AT,datepickerFooterBtn:wT,datepickerClearBtn:kT,datepickerDayHeading:ST,datepickerCell:OT,datepickerCellSmall:IT,datepickerCellLarge:DT,datepickerCellContent:MT,datepickerCellContentSmall:LT,datepickerCellContentLarge:$T,datepickerTable:RT,datepickerToggleButton:PT,datepickerDropdownContainer:BT},WT={fadeIn:\"string\",fadeOut:\"string\",fadeInShort:\"string\",fadeOutShort:\"string\",modalContainer:\"string\",datepickerBackdrop:\"string\",datepickerMain:\"string\",datepickerHeader:\"string\",datepickerTitle:\"string\",datepickerTitleText:\"string\",datepickerDate:\"string\",datepickerDateText:\"string\",datepickerView:\"string\",datepickerDateControls:\"string\",datepickerViewChangeButton:\"string\",datepickerArrowControls:\"string\",datepickerPreviousButton:\"string\",datepickerNextButton:\"string\",datepickerFooter:\"string\",datepickerFooterBtn:\"string\",datepickerClearBtn:\"string\",datepickerDayHeading:\"string\",datepickerCell:\"string\",datepickerCellSmall:\"string\",datepickerCellLarge:\"string\",datepickerCellContent:\"string\",datepickerCellContentSmall:\"string\",datepickerCellContentLarge:\"string\",datepickerTable:\"string\",datepickerToggleButton:\"string\",datepickerDropdownContainer:\"string\"};class xl{constructor(t,e,i){this._element=t,this._input=m.findOne(\"input\",this._element),this._options=this._getConfig(e),this._classes=this._getClasses(i),this._activeDate=new Date,this._selectedDate=null,this._selectedYear=null,this._selectedMonth=null,this._headerDate=null,this._headerYear=null,this._headerMonth=null,this._view=this._options.view,this._popper=null,this._focusTrap=null,this._isOpen=!1,this._toggleButtonId=bt(\"datepicker-toggle-\"),this._animations=!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches&&this._options.animations,this._scrollBar=new Qi,this._element&&O.setData(t,Wo,this),this._init(),this.toggleButton&&this._options.disableToggle&&(this.toggleButton.disabled=\"true\"),this._options.disableInput&&(this._input.disabled=\"true\")}static get NAME(){return El}get container(){return m.findOne(`[${pu}='${this._toggleButtonId}']`)||m.findOne(`[${fu}='${this._toggleButtonId}']`)}get options(){return this._options}get activeCell(){let t;return this._view===\"days\"&&(t=this._getActiveDayCell()),this._view===\"months\"&&(t=this._getActiveMonthCell()),this._view===\"years\"&&(t=this._getActiveYearCell()),t}get activeDay(){return Tt(this._activeDate)}get activeMonth(){return ot(this._activeDate)}get activeYear(){return K(this._activeDate)}get firstYearInView(){return this.activeYear-$o(this._activeDate,ie)}get lastYearInView(){return this.firstYearInView+ie-1}get viewChangeButton(){return m.findOne(Zy,this.container)}get previousButton(){return m.findOne(Qy,this.container)}get nextButton(){return m.findOne(Jy,this.container)}get okButton(){return m.findOne(tT,this.container)}get cancelButton(){return m.findOne(eT,this.container)}get clearButton(){return m.findOne(iT,this.container)}get datesContainer(){return m.findOne(sT,this.container)}get toggleButton(){return m.findOne(nT,this._element)}update(t={}){this._options=this._getConfig({...this._options,...t})}_getConfig(t){const e=g.getDataAttributes(this._element);if(t={...HT,...e,...t},L(El,t,VT),t.max&&typeof t.max==\"string\"&&(t.max=new Date(t.max)),t.min&&typeof t.min==\"string\"&&(t.min=new Date(t.min)),t.startDay&&t.startDay!==0){const i=this._getNewDaysOrderArray(t);t.weekdaysNarrow=i}return t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...FT,...e,...t},L(El,t,WT),t}_getContainer(){return m.findOne(this._options.container)}_getNewDaysOrderArray(t){const e=t.startDay,i=t.weekdaysNarrow;return i.slice(e).concat(i.slice(0,e))}_init(){!this.toggleButton&&this._options.toggleButton&&(this._appendToggleButton(),(this._input.readOnly||this._input.disabled)&&(this.toggleButton.style.pointerEvents=\"none\")),this._listenToUserInput(),this._listenToToggleClick(),this._listenToToggleKeydown()}_appendToggleButton(){const t=Wy(this._toggleButtonId,this._classes.datepickerToggleButton);this._element.insertAdjacentHTML(\"beforeend\",t)}open(){if(this._input.readOnly||this._input.disabled)return;const t=_.trigger(this._element,Uy);if(this._isOpen||t.defaultPrevented)return;this._setInitialDate();const e=Py(this._classes.datepickerBackdrop),i=Ry(this._activeDate,this._selectedDate,this._selectedYear,this._selectedMonth,this._options,Fo,ie,Vo,this._toggleButtonId,this._classes);this._options.inline?this._openDropdown(i):(this._openModal(e,i),this._scrollBar.hide()),this._animations&&(g.addClass(this.container,this._classes.fadeIn),g.addClass(e,this._classes.fadeInShort)),this._setFocusTrap(this.container),this._listenToDateSelection(),this._addControlsListeners(),this._updateControlsDisabledState(),this._listenToEscapeClick(),this._listenToKeyboardNavigation(),this._listenToDatesContainerFocus(),this._listenToDatesContainerBlur(),this._asyncFocusDatesContainer(),this._updateViewControlsAndAttributes(this._view),this._isOpen=!0,setTimeout(()=>{this._listenToOutsideClick()},0)}_openDropdown(t){this._popper=Fe(this._input,t,{placement:\"bottom-start\"}),this._getContainer().appendChild(t)}_openModal(t,e){const i=this._getContainer();i.appendChild(t),i.appendChild(e)}_setFocusTrap(t){this._focusTrap=new Vs(t,{event:\"keydown\",condition:e=>e.key===\"Tab\"}),this._focusTrap.trap()}_listenToUserInput(){_.on(this._input,\"input\",t=>{this._handleUserInput(t.target.value)})}_listenToToggleClick(){_.on(this._element,jo,Yo,t=>{t.preventDefault(),this.open()})}_listenToToggleKeydown(){_.on(this._element,\"keydown\",Yo,t=>{t.keyCode===Et&&!this._isOpen&&this.open()})}_listenToDateSelection(){_.on(this.datesContainer,\"click\",t=>{this._handleDateSelection(t)})}_handleDateSelection(t){const e=t.target.nodeName===\"DIV\"?t.target.parentNode.dataset:t.target.dataset,i=t.target.nodeName===\"DIV\"?t.target.parentNode:t.target;if(e.teDate&&this._pickDay(e.teDate,i),e.teMonth&&e.teYear){const n=parseInt(e.teMonth,10),o=parseInt(e.teYear,10);this._pickMonth(n,o)}if(e.teYear&&!e.teMonth){const n=parseInt(e.teYear,10);this._pickYear(n)}this._options.inline||this._updateHeaderDate(this._activeDate,this._options.monthsShort,this._options.weekdaysShort)}_updateHeaderDate(t,e,i){const n=m.findOne(oT,this.container),o=ot(t),r=Tt(t),a=Lo(t);n.innerHTML=`${i[a]}, ${e[o]} ${r}`}_addControlsListeners(){_.on(this.nextButton,\"click\",()=>{this._view===\"days\"?this.nextMonth():this._view===\"years\"?this.nextYears():this.nextYear(),this._updateControlsDisabledState()}),_.on(this.previousButton,\"click\",()=>{this._view===\"days\"?this.previousMonth():this._view===\"years\"?this.previousYears():this.previousYear(),this._updateControlsDisabledState()}),_.on(this.viewChangeButton,\"click\",()=>{this._view===\"days\"?this._changeView(\"years\"):(this._view===\"years\"||this._view===\"months\")&&this._changeView(\"days\")}),this._options.inline||this._listenToFooterButtonsClick()}_listenToFooterButtonsClick(){_.on(this.okButton,\"click\",()=>this.handleOk()),_.on(this.cancelButton,\"click\",()=>this.handleCancel()),_.on(this.clearButton,\"click\",()=>this.handleClear())}_listenToOutsideClick(){_.on(document,jo,t=>{const e=t.target===this.container,i=this.container&&this.container.contains(t.target);!e&&!i&&this.close()})}_listenToEscapeClick(){_.on(document,\"keydown\",t=>{t.keyCode===xi&&this._isOpen&&this.close()})}_listenToKeyboardNavigation(){_.on(this.datesContainer,\"keydown\",t=>{this._handleKeydown(t)})}_listenToDatesContainerFocus(){_.on(this.datesContainer,\"focus\",()=>{this._focusActiveCell(this.activeCell)})}_listenToDatesContainerBlur(){_.on(this.datesContainer,\"blur\",()=>{this._removeCurrentFocusStyles()})}_handleKeydown(t){this._view===\"days\"&&this._handleDaysViewKeydown(t),this._view===\"months\"&&this._handleMonthsViewKeydown(t),this._view===\"years\"&&this._handleYearsViewKeydown(t)}_handleDaysViewKeydown(t){const e=this._activeDate,i=this.activeCell;switch(t.keyCode){case cs:this._activeDate=as(this._activeDate,et()?1:-1);break;case hs:this._activeDate=as(this._activeDate,et()?-1:1);break;case ut:this._activeDate=as(this._activeDate,-7);break;case ht:this._activeDate=as(this._activeDate,7);break;case Ti:this._activeDate=as(this._activeDate,1-Tt(this._activeDate));break;case Ei:this._activeDate=as(this._activeDate,bl(this._activeDate)-Tt(this._activeDate));break;case yl:this._activeDate=St(this._activeDate,-1);break;case Tl:this._activeDate=St(this._activeDate,1);break;case Et:case Ho:this._selectDate(this._activeDate),this._handleDateSelection(t),t.preventDefault();return;default:return}qs(e,this._activeDate,this._view,ie,this._options.min,this._options.max)||this._changeView(\"days\"),this._removeHighlightFromCell(i),this._focusActiveCell(this.activeCell),t.preventDefault()}_asyncFocusDatesContainer(){setTimeout(()=>{this.datesContainer.focus()},0)}_focusActiveCell(t){t&&t.setAttribute(\"data-te-datepicker-cell-focused\",\"\")}_removeHighlightFromCell(t){t&&t.removeAttribute(\"data-te-datepicker-cell-focused\")}_getActiveDayCell(){const t=m.find(\"td\",this.datesContainer);return Array.from(t).find(i=>{const n=au(i.dataset.teDate);return yi(n,this._activeDate)})}_handleMonthsViewKeydown(t){const e=this._activeDate,i=this.activeCell;switch(t.keyCode){case cs:this._activeDate=St(this._activeDate,et()?1:-1);break;case hs:this._activeDate=St(this._activeDate,et()?-1:1);break;case ut:this._activeDate=St(this._activeDate,-4);break;case ht:this._activeDate=St(this._activeDate,4);break;case Ti:this._activeDate=St(this._activeDate,-this.activeMonth);break;case Ei:this._activeDate=St(this._activeDate,11-this.activeMonth);break;case yl:this._activeDate=kt(this._activeDate,-1);break;case Tl:this._activeDate=kt(this._activeDate,1);break;case Et:case Ho:this._selectMonth(this.activeMonth);return;default:return}qs(e,this._activeDate,this._view,ie,this._options.min,this._options.max)||this._changeView(\"months\"),this._removeHighlightFromCell(i),this._focusActiveCell(this.activeCell),t.preventDefault()}_getActiveMonthCell(){const t=m.find(\"td\",this.datesContainer);return Array.from(t).find(i=>{const n=parseInt(i.dataset.teYear,10),o=parseInt(i.dataset.teMonth,10);return n===this.activeYear&&o===this.activeMonth})}_handleYearsViewKeydown(t){const e=this._activeDate,i=this.activeCell,n=4,o=24;switch(t.keyCode){case cs:this._activeDate=kt(this._activeDate,et()?1:-1);break;case hs:this._activeDate=kt(this._activeDate,et()?-1:1);break;case ut:this._activeDate=kt(this._activeDate,-n);break;case ht:this._activeDate=kt(this._activeDate,n);break;case Ti:this._activeDate=kt(this._activeDate,-$o(this._activeDate,o));break;case Ei:this._activeDate=kt(this._activeDate,o-$o(this._activeDate,o)-1);break;case yl:this._activeDate=kt(this._activeDate,-o);break;case Tl:this._activeDate=kt(this._activeDate,o);break;case Et:case Ho:this._selectYear(this.activeYear);return;default:return}qs(e,this._activeDate,this._view,ie,this._options.min,this._options.max)||this._changeView(\"years\"),this._removeHighlightFromCell(i),this._focusActiveCell(this.activeCell),t.preventDefault()}_getActiveYearCell(){const t=m.find(\"td\",this.datesContainer);return Array.from(t).find(i=>parseInt(i.dataset.teYear,10)===this.activeYear)}_setInitialDate(){this._input.value?this._handleUserInput(this._input.value):this._options.startDate?this._activeDate=new Date(this._options.startDate):this._activeDate=new Date}close(){const t=_.trigger(this._element,Ky);!this._isOpen||t.defaultPrevented||(this._removeDatepickerListeners(),this._animations&&g.addClass(this.container,this._classes.fadeOut),this._options.inline?this._closeDropdown():this._closeModal(),this._isOpen=!1,this._view=this._options.view,this.toggleButton?this.toggleButton.focus():this._input.focus())}_closeDropdown(){const t=m.findOne(qy),e=this._getContainer();window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches&&(t&&e.removeChild(t),this._popper&&this._popper.destroy()),t.addEventListener(\"animationend\",()=>{t&&e.removeChild(t),this._popper&&this._popper.destroy()}),this._removeFocusTrap()}_closeModal(){const t=m.findOne(rT),e=m.findOne(Gy);!e||!t||(this._animations?(g.addClass(t,this._classes.fadeOutShort),t.addEventListener(\"animationend\",()=>{this._removePicker(t,e),this._scrollBar.reset()})):(this._removePicker(t,e),this._scrollBar.reset()))}_removePicker(t,e){const i=this._getContainer();i.removeChild(t),i.removeChild(e)}_removeFocusTrap(){this._focusTrap&&(this._focusTrap.disable(),this._focusTrap=null)}_removeDatepickerListeners(){_.off(this.nextButton,\"click\"),_.off(this.previousButton,\"click\"),_.off(this.viewChangeButton,\"click\"),_.off(this.okButton,\"click\"),_.off(this.cancelButton,\"click\"),_.off(this.clearButton,\"click\"),_.off(this.datesContainer,\"click\"),_.off(this.datesContainer,\"keydown\"),_.off(this.datesContainer,\"focus\"),_.off(this.datesContainer,\"blur\"),_.off(document,jo)}dispose(){this._isOpen&&this.close(),this._removeInputAndToggleListeners();const t=m.findOne(`#${this._toggleButtonId}`);t&&this._element.removeChild(t),O.removeData(this._element,Wo),this._element=null,this._input=null,this._options=null,this._activeDate=null,this._selectedDate=null,this._selectedYear=null,this._selectedMonth=null,this._headerDate=null,this._headerYear=null,this._headerMonth=null,this._view=null,this._popper=null,this._focusTrap=null}_removeInputAndToggleListeners(){_.off(this._input,\"input\"),_.off(this._element,jo,Yo),_.off(this._element,\"keydown\",Yo)}handleOk(){this._confirmSelection(this._headerDate),this.close()}_selectDate(t,e=this.activeCell){const{min:i,max:n,filter:o,disablePast:r,disableFuture:a}=this._options;Ro(t,i,n,o,r,a)||(this._removeCurrentSelectionStyles(),this._removeCurrentFocusStyles(),this._addSelectedStyles(e),this._selectedDate=t,this._selectedYear=K(t),this._selectedMonth=ot(t),this._headerDate=t,(this._options.inline||this.options.confirmDateOnSelect)&&(this._confirmSelection(t),this.close()))}_selectYear(t,e=this.activeCell){this._removeCurrentSelectionStyles(),this._removeCurrentFocusStyles(),this._addSelectedStyles(e),this._headerYear=t,this._asyncChangeView(\"months\")}_selectMonth(t,e=this.activeCell){this._removeCurrentSelectionStyles(),this._removeCurrentFocusStyles(),this._addSelectedStyles(e),this._headerMonth=t,this._asyncChangeView(\"days\")}_removeSelectedStyles(t){t&&t.removeAttribute(\"data-te-datepicker-cell-selected\")}_addSelectedStyles(t){t&&t.setAttribute(\"data-te-datepicker-cell-selected\",\"\")}_confirmSelection(t){if(t){const e=this.formatDate(t);this._input.value=e,_.trigger(this._element,Xy,{date:t}),_.trigger(this._input,\"input\")}}handleCancel(){this._selectedDate=null,this._selectedYear=null,this._selectedMonth=null,this.close()}handleClear(){this._selectedDate=null,this._selectedMonth=null,this._selectedYear=null,this._headerDate=null,this._headerMonth=null,this._headerYear=null,this._removeCurrentSelectionStyles(),this._input.value=\"\",this._setInitialDate(),this._changeView(\"days\"),this._updateHeaderDate(this._activeDate,this._options.monthsShort,this._options.weekdaysShort)}_removeCurrentSelectionStyles(){const t=m.findOne(\"[data-te-datepicker-cell-selected]\",this.container);t&&t.removeAttribute(\"data-te-datepicker-cell-selected\")}_removeCurrentFocusStyles(){const t=m.findOne(\"[data-te-datepicker-cell-focused]\",this.container);t&&t.removeAttribute(\"data-te-datepicker-cell-focused\")}formatDate(t){const e=Tt(t),i=this._addLeadingZero(Tt(t)),n=this._options.weekdaysShort[Lo(t)],o=this._options.weekdaysFull[Lo(t)],r=ot(t)+1,a=this._addLeadingZero(ot(t)+1),l=this._options.monthsShort[ot(t)],c=this._options.monthsFull[ot(t)],h=K(t).toString().length===2?K(t):K(t).toString().slice(2,4),d=K(t),u=this._options.format.split(/(d{1,4}|m{1,4}|y{4}|yy|!.)/g);let p=\"\";return u.forEach(f=>{switch(f){case\"dddd\":f=f.replace(f,o);break;case\"ddd\":f=f.replace(f,n);break;case\"dd\":f=f.replace(f,i);break;case\"d\":f=f.replace(f,e);break;case\"mmmm\":f=f.replace(f,c);break;case\"mmm\":f=f.replace(f,l);break;case\"mm\":f=f.replace(f,a);break;case\"m\":f=f.replace(f,r);break;case\"yyyy\":f=f.replace(f,d);break;case\"yy\":f=f.replace(f,h);break}p+=f}),p}_addLeadingZero(t){return parseInt(t,10)<10?`0${t}`:t}_pickDay(t,e){const i=au(t),{min:n,max:o,filter:r,disablePast:a,disableFuture:l}=this._options;Ro(i,n,o,r,a,l)||(this._activeDate=i,this._selectDate(i,e))}_pickYear(t){const{min:e,max:i,disablePast:n,disableFuture:o}=this._options;if(vl(t,e,i,n,o))return;const r=ee(t,this.activeMonth,this.activeDay);this._activeDate=r,this._selectedDate=r,this._selectYear(t)}_pickMonth(t,e){const{min:i,max:n,disablePast:o,disableFuture:r}=this._options;if(lu(t,e,i,n,o,r)||vl(e,i,n,o,r))return;const a=ee(e,t,this.activeDay);this._activeDate=a,this._selectMonth(t)}nextMonth(){const t=St(this._activeDate,1),e=Po(t,this._headerDate,this._options,this._classes);this._activeDate=t,this.viewChangeButton.textContent=`${this._options.monthsFull[this.activeMonth]} ${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}previousMonth(){const t=St(this._activeDate,-1);this._activeDate=t;const e=Po(t,this._headerDate,this._options,this._classes);this.viewChangeButton.textContent=`${this._options.monthsFull[this.activeMonth]} ${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}nextYear(){const t=kt(this._activeDate,1);this._activeDate=t,this.viewChangeButton.textContent=`${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes);const e=No(this.activeYear,this._selectedYear,this._selectedMonth,this._options,Fo,this._classes);this.datesContainer.innerHTML=e}previousYear(){const t=kt(this._activeDate,-1);this._activeDate=t,this.viewChangeButton.textContent=`${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes);const e=No(this.activeYear,this._selectedYear,this._selectedMonth,this._options,Fo,this._classes);this.datesContainer.innerHTML=e}nextYears(){const t=kt(this._activeDate,24);this._activeDate=t;const e=Bo(t,this._selectedYear,this._options,ie,Vo,this._classes);this.viewChangeButton.textContent=`${this.firstYearInView} - ${this.lastYearInView}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}previousYears(){const t=kt(this._activeDate,-24);this._activeDate=t;const e=Bo(t,this._selectedYear,this._options,ie,Vo,this._classes);this.viewChangeButton.textContent=`${this.firstYearInView} - ${this.lastYearInView}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.datesContainer.innerHTML=e}_asyncChangeView(t){setTimeout(()=>{this._changeView(t)},0)}_changeView(t){this._view=t,this.datesContainer.blur(),t===\"days\"&&(this.datesContainer.innerHTML=Po(this._activeDate,this._headerDate,this._options,this._classes)),t===\"months\"&&(this.datesContainer.innerHTML=No(this.activeYear,this._selectedYear,this._selectedMonth,this._options,Fo,this._classes)),t===\"years\"&&(this.datesContainer.innerHTML=Bo(this._activeDate,this._selectedYear,this._options,ie,Vo,this._classes)),this.datesContainer.focus(),this._updateViewControlsAndAttributes(t),this._updateControlsDisabledState()}_updateViewControlsAndAttributes(t){t===\"days\"&&(this.viewChangeButton.textContent=`${this._options.monthsFull[this.activeMonth]} ${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.viewChangeButton.setAttribute(\"aria-label\",this._options.switchToMultiYearViewLabel),this.previousButton.setAttribute(\"aria-label\",this._options.prevMonthLabel),this.nextButton.setAttribute(\"aria-label\",this._options.nextMonthLabel)),t===\"months\"&&(this.viewChangeButton.textContent=`${this.activeYear}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.viewChangeButton.setAttribute(\"aria-label\",this._options.switchToDayViewLabel),this.previousButton.setAttribute(\"aria-label\",this._options.prevYearLabel),this.nextButton.setAttribute(\"aria-label\",this._options.nextYearLabel)),t===\"years\"&&(this.viewChangeButton.textContent=`${this.firstYearInView} - ${this.lastYearInView}`,this.viewChangeButton.innerHTML+=pe(this._options,this._classes),this.viewChangeButton.setAttribute(\"aria-label\",this._options.switchToMonthViewLabel),this.previousButton.setAttribute(\"aria-label\",this._options.prevMultiYearLabel),this.nextButton.setAttribute(\"aria-label\",this._options.nextMultiYearLabel))}_updateControlsDisabledState(){xy(this._options.disableFuture,this._activeDate,this._view,ie,this._options.min,this._options.max,this.lastYearInView,this.firstYearInView)?this.nextButton.disabled=!0:this.nextButton.disabled=!1,Cy(this._options.disablePast,this._activeDate,this._view,ie,this._options.min,this._options.max,this.lastYearInView,this.firstYearInView)?this.previousButton.disabled=!0:this.previousButton.disabled=!1}_handleUserInput(t){const e=this._getDelimeters(this._options.format),i=this._parseDate(t,this._options.format,e);yy(i)?(this._activeDate=i,this._selectedDate=i,this._selectedYear=K(i),this._selectedMonth=ot(i),this._headerDate=i):(this._activeDate=new Date,this._selectedDate=null,this._selectedMonth=null,this._selectedYear=null,this._headerDate=null,this._headerMonth=null,this._headerYear=null)}_getDelimeters(t){return t.match(/[^(dmy)]{1,}/g)}_parseDate(t,e,i){let n;i[0]!==i[1]?n=i[0]+i[1]:n=i[0];const o=new RegExp(`[${n}]`),r=t.split(o),a=e.split(o),l=e.indexOf(\"mmm\")!==-1,c=[];for(let b=0;bi===t)}static getInstance(t){return O.getData(t,Wo)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const zT=({format24:s,okLabel:t,cancelLabel:e,headID:i,footerID:n,bodyID:o,pickerID:r,clearLabel:a,inline:l,showClearBtn:c,amLabel:h,pmLabel:d},u)=>{const p=`
\n
\n
\n
\n
\n
\n \n \n \n \n \n \n \n
\n ${s?\"\":`
\n \n \n
`}\n
\n
\n ${l?\"\":`
\n
\n \n
\n
\n
\n ${s?'
':\"\"}\n
\n
`}\n
\n
\n
\n ${c?``:\"\"}\n \n \n
\n
\n
\n
`,f=`
\n
\n
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${s?\"\":`
\n \n \n \n
`}\n ${s?``:\"\"}\n
\n
\n
\n
\n
`;return l?f:p},jT=(s,t,e)=>{const{iconSVG:i}=s;return`\n \n`},Ko=\"data-te-timepicker-disabled\",Uo=\"data-te-timepicker-active\",Ai=s=>{if(s===\"\")return;let t,e,i,n;return _u(s)?(t=s.getHours(),n=t,e=s.getMinutes(),t%=12,n===0&&t===0&&(i=\"AM\"),t=t||12,i===void 0&&(i=Number(n)>=12?\"PM\":\"AM\"),e=e<10?`0${e}`:e):([t,e,i]=j(s,!1),n=t,t%=12,n===0&&t===0&&(i=\"AM\"),t=t||12,i===void 0&&(i=Number(n)>=12?\"PM\":\"AM\")),{hours:t,minutes:e,amOrPm:i}},_u=s=>s&&Object.prototype.toString.call(s)===\"[object Date]\"&&!Number.isNaN(s),gu=s=>{if(s===\"\")return;let t,e;return _u(s)?(t=s.getHours(),e=s.getMinutes()):[t,e]=j(s,!1),e=Number(e)<10?`0${Number(e)}`:e,{hours:t,minutes:e}},YT=(s,t,e)=>_.on(document,s,t,({target:i})=>{if(i.hasAttribute(Uo))return;document.querySelectorAll(t).forEach(o=>{o.hasAttribute(Uo)&&(g.removeClass(o,e.opacity),o.removeAttribute(Uo))}),g.addClass(i,e.opacity),i.setAttribute(Uo,\"\")}),mu=({clientX:s,clientY:t,touches:e},i,n=!1)=>{const{left:o,top:r}=i.getBoundingClientRect();let a={};return!n||!e?a={x:s-o,y:t-r}:n&&Object.keys(e).length>0&&(a={x:e[0].clientX-o,y:e[0].clientY-r}),a},Xo=()=>navigator.maxTouchPoints&&navigator.maxTouchPoints>2&&/MacIntel/.test(navigator.platform)||/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),j=(s,t=!0)=>t?s.value.replace(/:/gi,\" \").split(\" \"):s.replace(/:/gi,\" \").split(\" \"),bu=(s,t)=>{const[e,i,n]=j(s,!1),[o,r,a]=j(t,!1);return n===\"PM\"&&a===\"AM\"||n===a&&e>o||i>r},vu=()=>{const s=new Date,t=s.getHours(),e=s.getMinutes();return`${t}:${e<10?`0${e}`:e}`},Ke=(s,t,e)=>{if(!t)return s;let i=vu();return e&&(i=`${Ai(i).hours}:${Ai(i).minutes} ${Ai(i).amOrPm}`),(s!==\"\"&&bu(i,s)||s===\"\")&&(s=i),s},Ue=(s,t,e)=>{if(!t)return s;let i=vu();return e&&(i=`${Ai(i).hours}:${Ai(i).minutes} ${Ai(i).amOrPm}`),(s!==\"\"&&!bu(i,s)||s===\"\")&&(s=i),s},KT=({format12:s,maxTime:t,minTime:e,disablePast:i,disableFuture:n},o,r)=>{const a=j(o)[1];e=Ke(e,i,s),t=Ue(t,n,s);const[l,c,h]=j(t,!1),[d,u,p]=j(e,!1);if(h!==void 0||p!==void 0)return[r,a];if(!(l!==\"\"&&d===\"\"&&Number(r)>Number(l))&&!(l===\"\"&&d!==\"\"&&c===void 0&&u!==\"\"&&Number(r){s.forEach(n=>{t=t===\"12\"&&i?\"0\":t,(n.textContent===\"00\"||Number(n.textContent===\"12\"&&i?\"0\":n.textContent)>t)&&(g.addClass(n,e.tipsDisabled),n.setAttribute(Ko,\"\"))})},Tu=(s,t,e,i)=>{s.forEach(n=>{t=t===\"12\"&&i?\"0\":t,n.textContent!==\"00\"&&Number(n.textContent===\"12\"&&i?\"0\":n.textContent){if(t===\"12\"||t===\"24\")return;const n=e?12:24;return i===\"max\"?(Number(s)===n?0:Number(s))>Number(t):(Number(s)===n?0:Number(s)){s.forEach(r=>{(Eu(i,e,o,\"max\")||Number(r.textContent)>t&&Number(i)===Number(e))&&(g.addClass(r,n.tipsDisabled),r.setAttribute(Ko,\"\"))})},XT=(s,t,e,i,n,o)=>{s.forEach(r=>{(Eu(i,e,o,\"min\")||Number(r.textContent)s.startsWith(\"0\")?Number(s.slice(1)):Number(s),Zs=\"timepicker\",W=`data-te-${Zs}`,xu=\"[data-te-toggle]\",Go=`te.${Zs}`,fe=`.${Go}`,_e=\".data-api\",Cu=`click${fe}${_e}`,qo=`keydown${fe}${_e}`,Au=`mousedown${fe}${_e}`,wu=`mouseup${fe}${_e}`,ku=`mousemove${fe}${_e}`,Su=`mouseleave${fe}${_e}`,Ou=`mouseover${fe}${_e}`,Iu=`touchmove${fe}${_e}`,Du=`touchend${fe}${_e}`,Mu=`touchstart${fe}${_e}`,qT=`[${W}-am]`,ZT=`[${W}-pm]`,QT=`[${W}-format24]`,Zo=`[${W}-current]`,Qo=`[${W}-hour-mode]`,JT=`[${W}-toggle-button]`,Cl=`${W}-cancel`,Lu=`${W}-clear`,Al=`${W}-submit`,tE=`${W}-icon`,wl=`${W}-icon-up`,kl=`${W}-icon-down`,eE=`${W}-icon-inline-hour`,iE=`${W}-icon-inline-minute`,$u=`${W}-inline-hour-icons`,sE=`${W}-current-inline`,nE=\"readonly\",oE=`${W}-invalid-feedback`,Sl=`${W}-is-invalid`,Xe=`${W}-disabled`,J=`${W}-active`,rE=`${W}-input`,wi=`${W}-clock`,Qs=`${W}-clock-inner`,Ol=`${W}-wrapper`,Ru=`${W}-clock-wrapper`,Jo=`${W}-hour`,Il=`${W}-minute`,tr=`${W}-tips-element`,_t=`${W}-tips-hours`,xt=`${W}-tips-minutes`,Bt=`${W}-tips-inner`,er=`${W}-tips-inner-element`,Pu=`${W}-middle-dot`,Dl=`${W}-hand-pointer`,Ml=`${W}-circle`,Nu=`${W}-modal`,aE={appendValidationInfo:!0,bodyID:\"\",cancelLabel:\"Cancel\",clearLabel:\"Clear\",closeModalOnBackdropClick:!0,closeModalOnMinutesClick:!1,container:\"body\",defaultTime:\"\",disabled:!1,disablePast:!1,disableFuture:!1,enableValidation:!0,focusInputAfterApprove:!1,footerID:\"\",format12:!0,format24:!1,headID:\"\",increment:!1,inline:!1,invalidLabel:\"Invalid Time Format\",maxTime:\"\",minTime:\"\",modalID:\"\",okLabel:\"Ok\",overflowHidden:!0,pickerID:\"\",readOnly:!1,showClearBtn:!0,switchHoursToMinutesOnClick:!0,iconSVG:`\n \n`,withIcon:!0,pmLabel:\"PM\",amLabel:\"AM\",animations:!0},lE={appendValidationInfo:\"boolean\",bodyID:\"string\",cancelLabel:\"string\",clearLabel:\"string\",closeModalOnBackdropClick:\"boolean\",closeModalOnMinutesClick:\"boolean\",container:\"string\",disabled:\"boolean\",disablePast:\"boolean\",disableFuture:\"boolean\",enableValidation:\"boolean\",footerID:\"string\",format12:\"boolean\",format24:\"boolean\",headID:\"string\",increment:\"boolean\",inline:\"boolean\",invalidLabel:\"string\",modalID:\"string\",okLabel:\"string\",overflowHidden:\"boolean\",pickerID:\"string\",readOnly:\"boolean\",showClearBtn:\"boolean\",switchHoursToMinutesOnClick:\"boolean\",defaultTime:\"(string|date|number)\",iconSVG:\"string\",withIcon:\"boolean\",pmLabel:\"string\",amLabel:\"string\",animations:\"boolean\"},cE={tips:\"absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] rounded-[100%] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent\",tipsActive:\"text-white bg-[#3b71ca] font-normal\",tipsDisabled:\"text-[#b3afaf] pointer-events-none bg-transparent\",transform:\"transition-[transform,height] ease-in-out duration-[400ms]\",modal:\"z-[1065]\",clockAnimation:\"animate-[show-up-clock_350ms_linear]\",opacity:\"!opacity-100\",timepickerWrapper:\"touch-none opacity-100 z-[1065] inset-0 bg-[#00000066] h-full flex items-center justify-center flex-col fixed\",timepickerContainer:\"flex items-center justify-center flex-col max-h-[calc(100%-64px)] overflow-y-auto shadow-[0_10px_15px_-3px_rgba(0,0,0,0.07),0_4px_6px_-2px_rgba(0,0,0,0.05)] min-[320px]:max-[825px]:landscape:rounded-lg\",timepickerElements:\"flex flex-col min-w-[310px] min-h-[325px] bg-white rounded-t-[0.6rem] min-[320px]:max-[825px]:landscape:!flex-row min-[320px]:max-[825px]:landscape:min-w-[auto] min-[320px]:max-[825px]:landscape:min-h-[auto] min-[320px]:max-[825px]:landscape:overflow-y-auto justify-around\",timepickerHead:\"bg-[#3b71ca] dark:bg-zinc-700 h-[100px] rounded-t-lg pr-[24px] pl-[50px] py-[10px] min-[320px]:max-[825px]:landscape:rounded-tr-none min-[320px]:max-[825px]:landscape:rounded-bl-none min-[320px]:max-[825px]:landscape:p-[10px] min-[320px]:max-[825px]:landscape:pr-[10px] min-[320px]:max-[825px]:landscape:h-auto min-[320px]:max-[825px]:landscape:min-h-[305px] flex flex-row items-center justify-center\",timepickerHeadContent:\"min-[320px]:max-[825px]:landscape:flex-col flex w-full justify-evenly\",timepickerCurrentWrapper:\"[direction:ltr] rtl:[direction:rtl]\",timepickerCurrentButtonWrapper:\"relative h-full\",timepickerCurrentButton:\"text-[3.75rem] font-light leading-[1.2] tracking-[-0.00833em] text-white opacity-[.54] border-none bg-transparent p-0 min-[320px]:max-[825px]:landscape:text-5xl min-[320px]:max-[825px]:landscape:font-normal cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none \",timepickerDot:\"font-light leading-[1.2] tracking-[-0.00833em] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white min-[320px]:max-[825px]:landscape:text-[3rem] min-[320px]:max-[825px]:landscape:font-normal\",timepickerModeWrapper:\"flex flex-col justify-center text-[18px] text-[#ffffff8a] min-[320px]:max-[825px]:landscape:!justify-around min-[320px]:max-[825px]:landscape:!flex-row\",timepickerModeAm:\"p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none\",timepickerModePm:\"p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none\",timepickerClockWrapper:\"min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center flex-col items-center dark:bg-zinc-500\",timepickerClock:\"relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50\",timepickerMiddleDot:\"top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute\",timepickerHandPointer:\"bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute\",timepickerPointerCircle:\"-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute\",timepickerClockInner:\"absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 w-[160px] h-[160px] rounded-[100%]\",timepickerFooterWrapper:\"rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500\",timepickerFooter:\"w-full flex justify-between\",timepickerFooterButton:\"text-[0.8rem] min-w-[64px] box-border font-medium leading-[40px] rounded-[10px] tracking-[0.1rem] uppercase text-[#3b71ca] dark:text-white border-none bg-transparent transition-[background-color,box-shadow,border] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] delay-[0ms] outline-none py-0 px-[10px] h-[40px] mb-[10px] hover:bg-[#00000014] focus:bg-[#00000014] focus:outline-none\",timepickerInlineWrapper:\"touch-none opacity-100 z-[1065] inset-0 bg-[#00000066] h-full flex items-center justify-center flex-col rounded-lg\",timepickerInlineContainer:\"flex items-center justify-center flex-col max-h-[calc(100%-64px)] overflow-y-auto shadow-[0_10px_15px_-3px_rgba(0,0,0,0.07),0_4px_6px_-2px_rgba(0,0,0,0.05)]\",timepickerInlineElements:\"flex flex-col min-h-[auto] min-w-[310px] bg-white rounded-[0.6rem] min-[320px]:max-[825px]:landscape:!flex-row min-[320px]:max-[825px]:landscape:rounded-bl-lg min-[320px]:max-[825px]:landscape:min-w-[auto] min-[320px]:max-[825px]:landscape::min-h-[auto] min-[320px]:max-[825px]:landscape:overflow-y-auto justify-around\",timepickerInlineHead:\"bg-[#3b71ca] dark:bg-zinc-700 h-[100px] rounded-t-lg min-[320px]:max-[825px]:landscape:rounded-tr-none min-[320px]:max-[825px]:landscape:rounded-bl-none min-[320px]:max-[825px]:landscape:p-[10px] min-[320px]:max-[825px]:landscape:pr-[10px] min-[320px]:max-[825px]:landscape:h-auto min-[320px]:max-[825px]:landscape:min-h-[305px] flex flex-row items-center justify-center p-0 rounded-b-lg\",timepickerInlineHeadContent:\"min-[320px]:max-[825px]:landscape:flex-col flex w-full justify-evenly items-center\",timepickerInlineHourWrapper:\"relative h-full !opacity-100\",timepickerCurrentMinuteWrapper:\"relative h-full\",timepickerInlineIconUp:\"absolute text-white -top-[35px] opacity-0 hover:opacity-100 transition-all duration-200 ease-[ease] cursor-pointer -translate-x-1/2 -translate-y-1/2 left-1/2 w-[30px] h-[30px] flex justify-center items-center\",timepickerInlineIconSvg:\"h-4 w-4\",timepickerInlineCurrentButton:\"font-light leading-[1.2] tracking-[-0.00833em] text-white border-none bg-transparent p-0 min-[320px]:max-[825px]:landscape:text-5xl min-[320px]:max-[825px]:landscape:font-normal !opacity-100 cursor-pointer focus:bg-[#00000026] hover:outline-none focus:outline-none text-[2.5rem] hover:bg-[unset]\",timepickerInlineIconDown:\"absolute text-white -bottom-[47px] opacity-0 hover:opacity-100 transition-all duration-200 ease-[ease] cursor-pointer -translate-x-1/2 -translate-y-1/2 left-1/2 w-[30px] h-[30px] flex justify-center items-center\",timepickerInlineDot:\"font-light leading-[1.2] tracking-[-0.00833em] opacity-[.54] border-none bg-transparent p-0 text-white min-[320px]:max-[825px]:landscape:text-[3rem] min-[320px]:max-[825px]:landscape:font-normal text-[2.5rem]\",timepickerInlineModeWrapper:\"flex justify-center text-[18px] text-[#ffffff8a] min-[320px]:max-[825px]:landscape:!justify-around min-[320px]:max-[825px]:landscape:!flex-row\",timepickerInlineModeAm:\"hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer mr-2 ml-6\",timepickerInlineModePm:\"hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none p-0 bg-transparent border-none text-white opacity-[.54] cursor-pointer\",timepickerInlineSubmitButton:\"hover:bg-[#00000014] focus:bg-[#00000014] focus:outline-none text-[0.8rem] box-border font-medium leading-[40px] tracking-[.1rem] uppercase border-none bg-transparent [transition:background-color_250ms_cubic-bezier(0.4,0,0.2,1)_0ms,box-shadow_250ms_cubic-bezier(0.4,0,0.2,1)_0ms,border_250ms_cubic-bezier(0.4,0,0.2,1)_0ms] outline-none rounded-[100%] h-[48px] min-w-[48px] inline-block ml-[30px] text-white py-1 px-2 mb-0\",timepickerToggleButton:\"h-4 w-4 ml-auto absolute outline-none border-none bg-transparent right-1.5 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer hover:text-[#3b71ca] focus:text-[#3b71ca] dark:hover:text-[#3b71ca] dark:focus:text-[#3b71ca] dark:text-white\"},hE={tips:\"string\",tipsActive:\"string\",tipsDisabled:\"string\",transform:\"string\",modal:\"string\",clockAnimation:\"string\",opacity:\"string\",timepickerWrapper:\"string\",timepickerContainer:\"string\",timepickerElements:\"string\",timepickerHead:\"string\",timepickerHeadContent:\"string\",timepickerCurrentWrapper:\"string\",timepickerCurrentButtonWrapper:\"string\",timepickerCurrentButton:\"string\",timepickerDot:\"string\",timepickerModeWrapper:\"string\",timepickerModeAm:\"string\",timepickerModePm:\"string\",timepickerClockWrapper:\"string\",timepickerClock:\"string\",timepickerMiddleDot:\"string\",timepickerHandPointer:\"string\",timepickerPointerCircle:\"string\",timepickerClockInner:\"string\",timepickerFooterWrapper:\"string\",timepickerFooterButton:\"string\",timepickerInlineWrapper:\"string\",timepickerInlineContainer:\"string\",timepickerInlineElements:\"string\",timepickerInlineHead:\"string\",timepickerInlineHeadContent:\"string\",timepickerInlineHourWrapper:\"string\",timepickerCurrentMinuteWrapper:\"string\",timepickerInlineIconUp:\"string\",timepickerInlineIconSvg:\"string\",timepickerInlineCurrentButton:\"string\",timepickerInlineIconDown:\"string\",timepickerInlineDot:\"string\",timepickerInlineModeWrapper:\"string\",timepickerInlineModeAm:\"string\",timepickerInlineModePm:\"string\",timepickerInlineSubmitButton:\"string\",timepickerToggleButton:\"string\"};class Ll{constructor(t,e={},i){ke(this,\"_toggleAmPm\",t=>{t===\"PM\"?(this._isPmEnabled=!0,this._isAmEnabled=!1):t===\"AM\"&&(this._isPmEnabled=!1,this._isAmEnabled=!0)});ke(this,\"_toggleBackgroundColorCircle\",t=>{if(this._modal.querySelector(`${t}[${J}]`)!==null){g.addStyle(this._circle,{backgroundColor:\"#1976d2\"});return}g.addStyle(this._circle,{backgroundColor:\"transparent\"})});ke(this,\"_toggleClassActive\",(t,{textContent:e},i)=>{const n=[...t].find(o=>Number(o)===Number(e));return i.forEach(o=>{if(!o.hasAttribute(Xe)){if(o.textContent===n){g.addClass(o,this._classes.tipsActive),o.setAttribute(J,\"\");return}g.removeClass(o,this._classes.tipsActive),o.removeAttribute(J)}})});ke(this,\"_makeMinutesDegrees\",(t,e)=>{const{increment:i}=this._options;return t<0?(e=Math.round(360+t/6)%60,t=360+Math.round(t/6)*6):(e=Math.round(t/6)%60,t=Math.round(t/6)*6),i&&(t=Math.round(t/30)*30,e=Math.round(t/6)*6/6,e===60&&(e=\"00\")),t>=360&&(t=0),{degrees:t,minute:e,addDegrees:i?30:6}});ke(this,\"_makeHourDegrees\",(t,e,i)=>{if(t)return this._hasTargetInnerClass(t)?e<0?(i=Math.round(360+e/30)%24,e=360+e):(i=Math.round(e/30)+12,i===12&&(i=\"00\")):e<0?(i=Math.round(360+e/30)%12,e=360+e):(i=Math.round(e/30)%12,(i===0||i>12)&&(i=12)),e>=360&&(e=0),{degrees:e,hour:i,addDegrees:30}});ke(this,\"_makeInnerHoursDegrees\",(t,e)=>(t<0?(e=Math.round(360+t/30)%24,t=360+t):(e=Math.round(t/30)+12,e===12&&(e=\"00\")),{degrees:t,hour:e,addDegrees:30}));ke(this,\"_getAppendClock\",(t=[],e=`[${wi}]`,i)=>{let{minTime:n,maxTime:o}=this._options;const{inline:r,format12:a,disablePast:l,disableFuture:c}=this._options;n=Ke(n,l,a),o=Ue(o,c,a);const[h,d,u]=j(o,!1),[p,f,b]=j(n,!1);!r&&a&&this._isInvalidTimeFormat&&!this._AM.hasAttribute(J)&&(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\"));const v=m.findOne(e),y=360/t.length;function T(A){return A*(Math.PI/180)}if(v===null)return;const x=(v.offsetWidth-32)/2,E=(v.offsetHeight-32)/2,C=x-4;setTimeout(()=>{let A;a&&(A=m.findOne(`${Qo}[${J}]`).textContent),this._handleDisablingTipsMinTime(A,b,f,p),this._handleDisablingTipsMaxTime(A,u,d,h)},0),[...t].forEach((A,w)=>{const S=T(w*y),k=$(\"span\"),D=$(\"span\");D.innerHTML=A,g.addClass(k,this._classes.tips),k.setAttribute(i,\"\");const I=k.offsetWidth,M=k.offsetHeight;return g.addStyle(k,{left:`${x+Math.sin(S)*C-I}px`,bottom:`${E+Math.cos(S)*C-M}px`}),t.includes(\"05\")&&k.setAttribute(xt,\"\"),t.includes(\"13\")?D.setAttribute(er,\"\"):D.setAttribute(tr,\"\"),k.appendChild(D),v.appendChild(k)})});this._element=t,this._element&&O.setData(t,Go,this),this._document=document,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._currentTime=null,this._toggleButtonId=bt(\"timepicker-toggle-\"),this.hoursArray=[\"12\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\",\"10\",\"11\"],this.innerHours=[\"00\",\"13\",\"14\",\"15\",\"16\",\"17\",\"18\",\"19\",\"20\",\"21\",\"22\",\"23\"],this.minutesArray=[\"00\",\"05\",\"10\",\"15\",\"20\",\"25\",\"30\",\"35\",\"40\",\"45\",\"50\",\"55\"],this.input=m.findOne(\"input\",this._element),this.dataWithIcon=t.dataset.withIcon,this.dataToggle=t.dataset.toggle,this.customIcon=m.findOne(JT,this._element),this._checkToggleButton(),this.inputFormatShow=m.findOne(QT,this._element),this.inputFormat=this.inputFormatShow===null?\"\":Object.values(this.inputFormatShow.dataset)[0],this.elementToggle=m.findOne(xu,this._element),this.toggleElement=Object.values(t.querySelector(xu).dataset)[0],this._hour=null,this._minutes=null,this._AM=null,this._PM=null,this._wrapper=null,this._modal=null,this._hand=null,this._circle=null,this._focusTrap=null,this._popper=null,this._interval=null,this._timeoutInterval=null,this._inputValue=this._options.defaultTime!==\"\"?this._options.defaultTime:this.input.value,this._options.format24&&(this._options.format12=!1,this._currentTime=gu(this._inputValue)),this._options.format12&&(this._options.format24=!1,this._currentTime=Ai(this._inputValue)),this._options.readOnly&&this.input.setAttribute(nE,!0),this.inputFormat===\"true\"&&this.inputFormat!==\"\"&&(this._options.format12=!1,this._options.format24=!0,this._currentTime=gu(this._inputValue)),this._animations=!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches&&this._options.animations,this.init(),this._isHours=!0,this._isMinutes=!1,this._isInvalidTimeFormat=!1,this._isMouseMove=!1,this._isInner=!1,this._isAmEnabled=!1,this._isPmEnabled=!1,this._options.format12&&!this._options.defaultTime&&(this._isPmEnabled=!0),this._objWithDataOnChange={degrees:null},this._scrollBar=new Qi}static get NAME(){return Zs}init(){const{format12:t,format24:e,enableValidation:i}=this._options;let n,o,r;if(this.input.setAttribute(rE,\"\"),this._currentTime!==void 0){const{hours:a,minutes:l,amOrPm:c}=this._currentTime;n=Number(a)<10?0:\"\",o=`${n}${Number(a)}:${l}`,r=c,t?this.input.value=`${o} ${r}`:e&&(this.input.value=`${o}`)}else n=\"\",o=\"\",r=\"\",this.input.value=\"\";this.input.value.length>0&&this.input.value!==\"\"&&(this.input.setAttribute(J,\"\"),_.trigger(this.input,\"input\")),!(this._options===null&&this._element===null)&&(i&&this._getValidate(\"keydown change blur focus\"),this._handleOpen(),this._listenToToggleKeydown())}dispose(){this._removeModal(),this._element!==null&&O.removeData(this._element,Go),setTimeout(()=>{this._element=null,this._options=null,this.input=null,this._focusTrap=null},350),_.off(this._element,\"click\",`[data-te-toggle='${this.toggleElement}']`),_.off(this._element,\"keydown\",`[data-te-toggle='${this.toggleElement}']`)}update(t={}){this._options=this._getConfig({...this._options,...t})}_checkToggleButton(){this.customIcon===null&&(this.dataWithIcon!==void 0&&(this._options.withIcon=null,this.dataWithIcon===\"true\"&&this._appendToggleButton(this._options)),this._options.withIcon&&this._appendToggleButton(this._options))}_appendToggleButton(){const t=jT(this._options,this._toggleButtonId,this._classes);this.input.insertAdjacentHTML(\"afterend\",t)}_getDomElements(){this._hour=m.findOne(`[${Jo}]`),this._minutes=m.findOne(`[${Il}]`),this._AM=m.findOne(qT),this._PM=m.findOne(ZT),this._wrapper=m.findOne(`[${Ol}]`),this._modal=m.findOne(`[${Nu}]`),this._hand=m.findOne(`[${Dl}]`),this._circle=m.findOne(`[${Ml}]`),this._clock=m.findOne(`[${wi}]`),this._clockInner=m.findOne(`[${Qs}]`)}_handlerMaxMinHoursOptions(t,e,i,n,o,r){if(!e&&!i)return!0;const{format24:a,format12:l,disablePast:c,disableFuture:h}=this._options,{_isAmEnabled:d,_isPmEnabled:u}=this,p=r.keyCode,f=r.target.hasAttribute(Qs)||r.target.hasAttribute(Bt)||r.target.hasAttribute(er);i=Ke(i,c,l),e=Ue(e,h,l),typeof e!=\"number\"&&(e=j(e,!1)[0]);const b=e!==\"\"?e*30:\"\",v=i!==\"\"?i*30:\"\";t<0&&(t=360+t),t=t===360?0:t;const y=()=>{const w=document.querySelectorAll(`[${tr}]`),S=document.querySelectorAll(`[${er}]`),k=GT(this._hour.innerText);let D,I,M;return p===ut?I=1:p===ht&&(I=-1),k===12&&p===ut?M=1:k===0&&p===ut?M=13:k===0&&p===ht?M=23:k===13&&p===ht?M=0:k===1&&p===ht?M=12:M=k+I,w.forEach(P=>{Number(P.textContent)===M&&(D=P)}),S.forEach(P=>{Number(P.textContent)===M&&(D=P)}),!D.parentElement.hasAttribute(Xe)},T=()=>{const w=i!==\"\"&&i>12?(i-12)*30:\"\",S=e!==\"\"&&e>12?(e-12)*30:\"\";if(!(w&&tS||e&&e<12))return!0};if(a&&r.type!==\"keydown\"&&f)return T();if(r.type===\"keydown\")return y();const x=!o||o===\"PM\"&&u||i!==\"\"&&o===\"AM\"&&d,E=!n||n===\"PM\"&&u||e!==\"\"&&n===\"AM\"&&d,C=()=>{const w=v===360&&l?0:v;if(i){if(o===\"PM\"&&d||x&&t{const w=b===360&&l?0:b;if(e){if(n===\"AM\"&&u||E&&t>w)return}else return!0;return!0};return C()&&A()}_handleKeyboard(){_.on(this._document,qo,\"\",t=>{let e,i,n;const{increment:o,maxTime:r,minTime:a,format12:l,disablePast:c,disableFuture:h}=this._options;let d=j(a,!1)[0],u=j(r,!1)[0];const p=j(a,!1)[2],f=j(r,!1)[2];d=Ke(d,c,l),u=Ue(u,h,l),typeof u!=\"number\"&&(u=j(u,!1)[0]);const b=m.findOne(`[${xt}]`)===null,v=m.findOne(`[${Bt}]`)!==null,y=Number(this._hand.style.transform.replace(/[^\\d-]/g,\"\")),T=m.find(`[${xt}]`,this._modal),x=m.find(`[${_t}]`,this._modal),E=m.find(`[${Bt}]`,this._modal);let C=this._makeHourDegrees(t.target,y,e).hour;const{degrees:A,addDegrees:w}=this._makeHourDegrees(t.target,y,e);let{minute:S,degrees:k}=this._makeMinutesDegrees(y,i);const D=this._makeMinutesDegrees(y,i).addDegrees;let{hour:I}=this._makeInnerHoursDegrees(y,n);if(t.keyCode===xi){const M=m.findOne(`[${Cl}]`,this._modal);_.trigger(M,\"click\")}else if(b){if(v&&(t.keyCode===hs&&(this._isInner=!1,g.addStyle(this._hand,{height:\"calc(40% + 1px)\"}),this._hour.textContent=this._setHourOrMinute(C>12?1:C),this._toggleClassActive(this.hoursArray,this._hour,x),this._toggleClassActive(this.innerHours,this._hour,E)),t.keyCode===cs&&(this._isInner=!0,g.addStyle(this._hand,{height:\"21.5%\"}),this._hour.textContent=this._setHourOrMinute(I>=24||I===\"00\"?0:I),this._toggleClassActive(this.innerHours,this._hour,E),this._toggleClassActive(this.hoursArray,this._hour-1,x))),t.keyCode===ut){if(!this._handlerMaxMinHoursOptions(A+30,u,d,f,p,t))return;g.addStyle(this._hand,{transform:`rotateZ(${A+w}deg)`}),this._isInner?(I+=1,I===24?I=0:(I===25||I===\"001\")&&(I=13),this._hour.textContent=this._setHourOrMinute(I),this._toggleClassActive(this.innerHours,this._hour,E)):(C+=1,this._hour.textContent=this._setHourOrMinute(C>12?1:C),this._toggleClassActive(this.hoursArray,this._hour,x))}if(t.keyCode===ht){if(!this._handlerMaxMinHoursOptions(A-30,u,d,f,p,t))return;g.addStyle(this._hand,{transform:`rotateZ(${A-w}deg)`}),this._isInner?(I-=1,I===12?I=0:I===-1&&(I=23),this._hour.textContent=this._setHourOrMinute(I),this._toggleClassActive(this.innerHours,this._hour,E)):(C-=1,this._hour.textContent=this._setHourOrMinute(C===0?12:C),this._toggleClassActive(this.hoursArray,this._hour,x))}}else t.keyCode===ut&&(k+=D,g.addStyle(this._hand,{transform:`rotateZ(${k}deg)`}),S+=1,o&&(S+=4,S===\"0014\"&&(S=5)),this._minutes.textContent=this._setHourOrMinute(S>59?0:S),this._toggleClassActive(this.minutesArray,this._minutes,T),this._toggleBackgroundColorCircle(`[${xt}]`)),t.keyCode===ht&&(k-=D,g.addStyle(this._hand,{transform:`rotateZ(${k}deg)`}),o?S-=5:S-=1,S===-1?S=59:S===-5&&(S=55),this._minutes.textContent=this._setHourOrMinute(S),this._toggleClassActive(this.minutesArray,this._minutes,T),this._toggleBackgroundColorCircle(`[${xt}]`))})}_setActiveClassToTipsOnOpen(t,...e){if(!this._isInvalidTimeFormat)if(this._options.format24){const i=m.find(`[${_t}]`,this._modal),n=m.find(`[${Bt}]`,this._modal);this._addActiveClassToTip(i,t),this._addActiveClassToTip(n,t)}else{[...e].filter(n=>(n.toLowerCase()===\"pm\"?(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\")):n.toLowerCase()===\"am\"?(g.addClass(this._AM,this._classes.opacity),this._AM.setAttribute(J,\"\")):(g.removeClass(this._AM,this._classes.opacity),g.removeClass(this._PM,this._classes.opacity),this._AM.removeAttribute(J),this._PM.removeAttribute(J)),n));const i=m.find(`[${_t}]`,this._modal);this._addActiveClassToTip(i,t)}}_setTipsAndTimesDependOnInputValue(t,e){const{inline:i,format12:n}=this._options;if(this._isInvalidTimeFormat)this._hour.textContent=\"12\",this._minutes.textContent=\"00\",i||g.addStyle(this._hand,{transform:\"rotateZ(0deg)\"}),n&&(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\"));else{const o=t>12?t*30-360:t*30;this._hour.textContent=t,this._minutes.textContent=e,i||(g.addStyle(this._hand,{transform:`rotateZ(${o}deg)`}),g.addStyle(this._circle,{backgroundColor:\"#1976d2\"}),(Number(t)>12||t===\"00\")&&g.addStyle(this._hand,{height:\"21.5%\"}))}}_listenToToggleKeydown(){_.on(this._element,\"keydown\",`[data-te-toggle='${this.toggleElement}']`,t=>{t.keyCode===Et&&(t.preventDefault(),_.trigger(this.elementToggle,\"click\"))})}_handleOpen(){const t=this._getContainer();ct.on(this._element,\"click\",`[data-te-toggle='${this.toggleElement}']`,e=>{if(this._options===null)return;const i=g.getDataAttribute(this.input,\"toggle\")!==null?200:0;setTimeout(()=>{g.addStyle(this.elementToggle,{pointerEvents:\"none\"}),this.elementToggle.blur();let n;j(this.input)[0]===\"\"?n=[\"12\",\"00\",\"PM\"]:n=j(this.input);const{modalID:o,inline:r,format12:a}=this._options,[l,c,h]=n,d=$(\"div\");if((Number(l)>12||l===\"00\")&&(this._isInner=!0),this.input.blur(),e.target.blur(),d.innerHTML=zT(this._options,this._classes),g.addClass(d,this._classes.modal),d.setAttribute(Nu,\"\"),d.setAttribute(\"role\",\"dialog\"),d.setAttribute(\"tabIndex\",\"-1\"),d.setAttribute(\"id\",o),r?(this._popper=Fe(this.input,d,{placement:\"bottom-start\"}),t.appendChild(d)):(t.appendChild(d),this._scrollBar.hide()),this._getDomElements(),this._animations?this._toggleBackdropAnimation():g.addClass(this._wrapper,this._classes.opacity),this._setActiveClassToTipsOnOpen(l,c,h),this._appendTimes(),this._setActiveClassToTipsOnOpen(l,c,h),this._setTipsAndTimesDependOnInputValue(l,c),this.input.value===\"\"){const u=m.find(`[${_t}]`,this._modal);a&&(g.addClass(this._PM,this._classes.opacity),this._PM.setAttribute(J,\"\")),this._hour.textContent=\"12\",this._minutes.textContent=\"00\",this._addActiveClassToTip(u,Number(this._hour.textContent))}if(this._handleSwitchTimeMode(),this._handleOkButton(),this._handleClose(),r)this._handleHoverInlineBtn(),this._handleDocumentClickInline(),this._handleInlineClicks();else{this._handleSwitchHourMinute(),this._handleClockClick(),this._handleKeyboard();const u=document.querySelector(`${Zo}[${J}]`);g.addClass(u,this._classes.opacity),g.addStyle(this._hour,{pointerEvents:\"none\"}),g.addStyle(this._minutes,{pointerEvents:\"\"})}this._focusTrap=new Vs(this._wrapper,{event:\"keydown\",condition:({key:u})=>u===\"Tab\"}),this._focusTrap.trap()},i)})}_handleInlineClicks(){let t,e;const i=p=>{let f=p;return f>59?f=0:f<0&&(f=59),f},n=p=>{let f=p;return this._options.format24?(f>24?f=1:f<0&&(f=23),f>23&&(f=0)):(f>12?f=1:f<1&&(f=12),f>12&&(f=1)),f},o=p=>{const f=n(p);this._hour.textContent=this._setHourOrMinute(f)},r=p=>{const f=i(p);this._minutes.textContent=this._setHourOrMinute(f)},a=()=>{t=n(t)+1,o(t)},l=()=>{e=i(e)+1,r(e)},c=()=>{t=n(t)-1,o(t)},h=()=>{e=i(e)-1,r(e)},d=()=>{clearInterval(this._interval),clearTimeout(this._timeoutInterval)},u=p=>{d(),this._timeoutInterval=setTimeout(()=>{this._interval=setInterval(p,100)},500)};ct.on(this._modal,\"click mousedown mouseup touchstart touchend contextmenu\",`[${wl}], [${kl}]`,p=>{t=Number(this._hour.textContent),e=Number(this._minutes.textContent);const{target:f,type:b}=p,v=b===\"mousedown\"||b===\"touchstart\";f.closest(`[${wl}]`)?f.closest(`[${wl}]`).parentNode.hasAttribute($u)?v?u(a):b===\"mouseup\"||b===\"touchend\"||b===\"contextmenu\"?d():a():v?u(l):b===\"mouseup\"||b===\"touchend\"||b===\"contextmenu\"?d():l():f.closest(`[${kl}]`)&&(f.closest(`[${kl}]`).parentNode.hasAttribute($u)?v?u(c):b===\"mouseup\"||b===\"touchend\"?d():c():v?u(h):b===\"mouseup\"||b===\"touchend\"?d():h())}),_.on(window,qo,p=>{const f=p.code,b=document.activeElement.hasAttribute(Jo),v=document.activeElement.hasAttribute(Il),y=document.activeElement===document.body;switch(t=Number(this._hour.textContent),e=Number(this._minutes.textContent),f){case\"ArrowUp\":p.preventDefault(),y||b?(this._hour.focus(),a()):v&&l();break;case\"ArrowDown\":p.preventDefault(),y||b?(this._hour.focus(),c()):v&&h();break}})}_handleClose(){_.on(this._modal,\"click\",`[${Ol}], [${Cl}], [${Lu}]`,({target:t})=>{const{closeModalOnBackdropClick:e}=this._options,i=()=>{var n;g.addStyle(this.elementToggle,{pointerEvents:\"auto\"}),this._animations&&this._toggleBackdropAnimation(!0),this._removeModal(),(n=this._focusTrap)==null||n.disable(),this._focusTrap=null,this.elementToggle?this.elementToggle.focus():this.input&&this.input.focus()};if(t.hasAttribute(Lu)){this._toggleAmPm(\"PM\"),this.input.value=\"\",this.input.removeAttribute(J);let n;j(this.input)[0]===\"\"?n=[\"12\",\"00\",\"PM\"]:n=j(this.input);const[o,r,a]=n;this._setTipsAndTimesDependOnInputValue(\"12\",\"00\"),this._setActiveClassToTipsOnOpen(o,r,a),this._hour.click()}else(t.hasAttribute(Cl)||t.hasAttribute(Al)||t.hasAttribute(Ol)&&e)&&i()})}showValueInput(){return this.input.value}_handleOkButton(){ct.on(this._modal,\"click\",`[${Al}]`,()=>{let{maxTime:t,minTime:e}=this._options;const{format12:i,format24:n,readOnly:o,focusInputAfterApprove:r,disablePast:a,disableFuture:l}=this._options,c=this._document.querySelector(`${Qo}[${J}]`),h=`${this._hour.textContent}:${this._minutes.textContent}`,d=Number(this._hour.textContent),u=d===12&&i?0:d,p=Number(this._minutes.textContent);e=Ke(e,a,i),t=Ue(t,l,i);let[f,b,v]=j(t,!1),[y,T,x]=j(e,!1);y=y===\"12\"&&i?\"00\":y,f=f===\"12\"&&i?\"00\":f;const E=uNumber(f);let A=!0;c&&(A=v===c.textContent);let w=!0;c&&(w=x===c.textContent);const S=p>b&&u===Number(f),k=p{const i=m.find(`[${eE}]`,this._modal),n=m.find(`[${iE}]`,this._modal),o=(l,c)=>l.forEach(h=>{if(c){g.addClass(h,this._classes.opacity),h.setAttribute(J,\"\");return}g.removeClass(h,this._classes.opacity),h.removeAttribute(J)}),a=e.hasAttribute(Jo)?i:n;o(a,t===\"mouseover\")})}_handleDocumentClickInline(){_.on(document,Cu,({target:t})=>{if(this._modal&&!this._modal.contains(t)&&!t.hasAttribute(tE)){if(clearInterval(this._interval),g.addStyle(this.elementToggle,{pointerEvents:\"auto\"}),this._removeModal(),!this._animations)return;this._toggleBackdropAnimation(!0)}})}_handleSwitchHourMinute(){YT(\"click\",Zo,this._classes),_.on(this._modal,\"click\",Zo,()=>{const{format24:t}=this._options,e=m.find(Zo,this._modal),i=m.find(`[${xt}]`,this._modal),n=m.find(`[${_t}]`,this._modal),o=m.find(`[${Bt}]`,this._modal),r=Number(this._hour.textContent),a=Number(this._minutes.textContent),l=(c,h)=>{n.forEach(u=>u.remove()),i.forEach(u=>u.remove()),g.addClass(this._hand,this._classes.transform),setTimeout(()=>{g.removeClass(this._hand,this._classes.transform)},401),this._getAppendClock(c,`[${wi}]`,h);const d=()=>{const u=m.find(`[${_t}]`,this._modal),p=m.find(`[${xt}]`,this._modal);this._addActiveClassToTip(u,r),this._addActiveClassToTip(p,a)};if(!t)setTimeout(()=>{d()},401);else{const u=m.find(`[${Bt}]`,this._modal);setTimeout(()=>{this._addActiveClassToTip(u,r),d()},401)}};e.forEach(c=>{c.hasAttribute(J)&&(c.hasAttribute(Il)?(g.addClass(this._hand,this._classes.transform),g.addStyle(this._hand,{transform:`rotateZ(${this._minutes.textContent*6}deg)`,height:\"calc(40% + 1px)\"}),t&&o.length>0&&o.forEach(h=>h.remove()),l(this.minutesArray,xt),this._hour.style.pointerEvents=\"\",this._minutes.style.pointerEvents=\"none\"):c.hasAttribute(Jo)&&(g.addStyle(this._hand,{transform:`rotateZ(${this._hour.textContent*30}deg)`}),Number(this._hour.textContent)>12?(g.addStyle(this._hand,{transform:`rotateZ(${this._hour.textContent*30-360}deg)`,height:\"21.5%\"}),Number(this._hour.textContent)>12&&g.addStyle(this._hand,{height:\"21.5%\"})):g.addStyle(this._hand,{height:\"calc(40% + 1px)\"}),t&&this._getAppendClock(this.innerHours,`[${Qs}]`,Bt),o.length>0&&o.forEach(h=>h.remove()),l(this.hoursArray,_t),g.addStyle(this._hour,{pointerEvents:\"none\"}),g.addStyle(this._minutes,{pointerEvents:\"\"})))})})}_handleDisablingTipsMaxTime(t,e,i,n){if(!this._options.maxTime&&!this._options.disableFuture)return;const o=m.find(`[${_t}]`),r=m.find(`[${Bt}]`),a=m.find(`[${xt}]`);if(!e||e===t){yu(r,n,this._classes,this._options.format12),yu(o,n,this._classes,this._options.format12),UT(a,i,n,this._hour.textContent,this._classes,this._options.format12);return}e===\"AM\"&&t===\"PM\"&&(o.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}),a.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}))}_handleDisablingTipsMinTime(t,e,i,n){if(!this._options.minTime&&!this._options.disablePast)return;const o=m.find(`[${_t}]`),r=m.find(`[${Bt}]`),a=m.find(`[${xt}]`);!e||e===t?(Tu(o,n,this._classes,this._options.format12),Tu(r,n,this._classes,this._options.format12),XT(a,i,n,this._hour.textContent,this._classes,this._options.format12)):e===\"PM\"&&t===\"AM\"&&(o.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}),a.forEach(l=>{g.addClass(l,this._classes.tipsDisabled),l.setAttribute(Xe,\"\")}))}_handleSwitchTimeMode(){_.on(document,\"click\",Qo,({target:t})=>{let{maxTime:e,minTime:i}=this._options;const{disablePast:n,disableFuture:o,format12:r}=this._options;i=Ke(i,n,r),e=Ue(e,o,r);const[a,l,c]=j(e,!1),[h,d,u]=j(i,!1),p=m.find(`[${_t}]`),f=m.find(`[${xt}]`);(()=>{p.forEach(v=>{g.removeClass(v,this._classes.tipsDisabled),v.removeAttribute(Xe)}),f.forEach(v=>{g.removeClass(v,this._classes.tipsDisabled),v.removeAttribute(Xe)})})(),this._handleDisablingTipsMinTime(t.textContent,u,d,h),this._handleDisablingTipsMaxTime(t.textContent,c,l,a),this._toggleAmPm(t.textContent),t.hasAttribute(J)||(m.find(Qo).forEach(y=>{y.hasAttribute(J)&&(g.removeClass(y,this._classes.opacity),y.removeAttribute(J))}),g.addClass(t,this._classes.opacity),t.setAttribute(J,\"\"))})}_handleClockClick(){let{maxTime:t,minTime:e}=this._options;const{disablePast:i,disableFuture:n,format12:o}=this._options;e=Ke(e,i,o),t=Ue(t,n,o);const r=j(t,!1)[2],a=j(e,!1)[2],l=j(t,!1)[0],c=j(e,!1)[0],h=m.findOne(`[${Ru}]`);ct.on(document,`${Au} ${wu} ${ku} ${Su} ${Ou} ${Mu} ${Iu} ${Du}`,\"\",d=>{Xo()||d.preventDefault();const{type:u,target:p}=d,{closeModalOnMinutesClick:f,switchHoursToMinutesOnClick:b}=this._options,v=m.findOne(`[${xt}]`,this._modal)!==null,y=m.findOne(`[${_t}]`,this._modal)!==null,T=m.findOne(`[${Bt}]`,this._modal)!==null,x=m.find(`[${xt}]`,this._modal),E=mu(d,h),C=h.offsetWidth/2;let A=Math.atan2(E.y-C,E.x-C);if(Xo()){const D=mu(d,h,!0);A=Math.atan2(D.y-C,D.x-C)}let w=null,S=null,k=null;if(u===\"mousedown\"||u===\"mousemove\"||u===\"touchmove\"||u===\"touchstart\")(u===\"mousedown\"||u===\"touchstart\"||u===\"touchmove\")&&(this._hasTargetInnerClass(p)||p.hasAttribute(Ru)||p.hasAttribute(wi)||p.hasAttribute(xt)||p.hasAttribute(_t)||p.hasAttribute(Ml)||p.hasAttribute(Dl)||p.hasAttribute(Pu)||p.hasAttribute(tr))&&(this._isMouseMove=!0,Xo()&&d.touches&&(w=d.touches[0].clientX,S=d.touches[0].clientY,k=document.elementFromPoint(w,S)));else if(u===\"mouseup\"||u===\"touchend\"){if(this._isMouseMove=!1,this._hasTargetInnerClass(p)||p.hasAttribute(wi)||p.hasAttribute(_t)||p.hasAttribute(Ml)||p.hasAttribute(Dl)||p.hasAttribute(Pu)||p.hasAttribute(tr)){if((y||T)&&b){const D=Number(this._hour.textContent)>l||Number(this._hour.textContent)R>=10||R===\"00\"?R:`0${R}`;this._minutes.textContent=z(),this._toggleClassActive(this.minutesArray,this._minutes,x),this._toggleBackgroundColorCircle(`[${xt}]`),this._objWithDataOnChange.degreesMinutes=X,this._objWithDataOnChange.minutes=R}}if(y||T){let D,I=Math.trunc(A*180/Math.PI)+90;if(I=Math.round(I/30)*30,g.addStyle(this._circle,{backgroundColor:\"#1976d2\"}),this._makeHourDegrees(p,I,D)===void 0)return;const M=()=>{if(Xo()&&I&&k){const{degrees:P,hour:X}=this._makeHourDegrees(k,I,D);return this._handleMoveHand(k,X,P)}else{const{degrees:P,hour:X}=this._makeHourDegrees(p,I,D);return this._handleMoveHand(p,X,P)}};this._objWithDataOnChange.degreesHours=I,this._handlerMaxMinHoursOptions(I,l,c,r,a,d)&&M()}d.stopPropagation()})}_hasTargetInnerClass(t){return t.hasAttribute(Qs)||t.hasAttribute(Bt)||t.hasAttribute(er)}_handleMoveHand(t,e,i){const n=m.find(`[${_t}]`,this._modal),o=m.find(`[${Bt}]`,this._modal);this._isMouseMove&&(this._hasTargetInnerClass(t)?g.addStyle(this._hand,{height:\"21.5%\"}):g.addStyle(this._hand,{height:\"calc(40% + 1px)\"}),g.addStyle(this._hand,{transform:`rotateZ(${i}deg)`}),this._hour.textContent=e>=10||e===\"00\"?e:`0${e}`,this._toggleClassActive(this.hoursArray,this._hour,n),this._toggleClassActive(this.innerHours,this._hour,o),this._objWithDataOnChange.hour=e>=10||e===\"00\"?e:`0${e}`)}_handlerMaxMinMinutesOptions(t,e){let{maxTime:i,minTime:n}=this._options;const{format12:o,increment:r,disablePast:a,disableFuture:l}=this._options;n=Ke(n,a,o),i=Ue(i,l,o);const c=j(i,!1)[1],h=j(n,!1)[1],d=j(i,!1)[0],u=j(n,!1)[0],p=u===\"12\"&&o?\"0\":u,f=d===\"12\"&&o?\"0\":d,b=j(i,!1)[2],v=j(n,!1)[2],y=c!==\"\"?c*6:\"\",T=h!==\"\"?h*6:\"\",x=Number(this._hour.textContent),E=x===12&&o?0:x;if(!b&&!v){if(i!==\"\"&&n!==\"\"){if(Number(f)===E&&t>y||Number(p)===E&&t=Number(f)&&t>=y+6)return t}else{if(n!==\"\"){if(v===\"PM\"&&this._isAmEnabled)return;if(v===\"PM\"&&this._isPmEnabled){if(E=Number(f)&&t>=y+6)return t}else if(b===\"AM\"&&this._isAmEnabled&&E>=Number(f)&&t>=y+6)return t}}return r&&(t=Math.round(t/30)*30),t<0?t=360+t:t>=360&&(t=0),{degrees:t,minute:e}}_removeModal(){this._animations?setTimeout(()=>{this._removeModalElements(),this._scrollBar.reset()},300):(this._removeModalElements(),this._scrollBar.reset()),ct.off(this._document,`${Cu} ${qo} ${Au} ${wu} ${ku} ${Su} ${Ou} ${Mu} ${Iu} ${Du}`),_.off(window,qo)}_removeModalElements(){this._modal&&this._modal.remove()}_toggleBackdropAnimation(t=!1){t?this._wrapper.classList.add(\"animate-[fade-out_350ms_ease-in-out]\"):(this._wrapper.classList.add(\"animate-[fade-in_350ms_ease-in-out]\"),this._options.inline||g.addClass(this._clock,this._classes.clockAnimation)),setTimeout(()=>{this._wrapper.classList.remove(\"animate-[fade-out_350ms_ease-in-out]\",\"animate-[fade-in_350ms_ease-in-out]\")},351)}_addActiveClassToTip(t,e){t.forEach(i=>{Number(i.textContent)===Number(e)&&(g.addClass(i,this._classes.tipsActive),i.setAttribute(J,\"\"))})}_setHourOrMinute(t){return t<10?`0${t}`:t}_appendTimes(){const{format24:t}=this._options;if(t){this._getAppendClock(this.hoursArray,`[${wi}]`,_t),this._getAppendClock(this.innerHours,`[${Qs}]`,Bt);return}this._getAppendClock(this.hoursArray,`[${wi}]`,_t)}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...aE,...e,...t},L(Zs,t,lE),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...cE,...e,...t},L(Zs,t,hE),t}_getContainer(){return m.findOne(this._options.container)}_getValidate(t){const{format24:e,format12:i,appendValidationInfo:n}=this._options;ct.on(this.input,t,({target:o})=>{if(this._options===null||this.input.value===\"\")return;const r=/^(0?[1-9]|1[012])(:[0-5]\\d) [APap][mM]$/,a=/^([01]\\d|2[0-3])(:[0-5]\\d)$/,l=r.test(o.value);if(a.test(o.value)!==!0&&e||l!==!0&&i){n&&this.input.setAttribute(Sl,\"\"),g.addStyle(o,{marginBottom:0}),this._isInvalidTimeFormat=!0;return}this.input.removeAttribute(Sl),this._isInvalidTimeFormat=!1;const h=m.findOne(`[${oE}]`);h!==null&&h.remove()})}static getInstance(t){return O.getData(t,Go)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const dE={threshold:10,direction:\"all\"};let uE=class{constructor(t,e){this._element=t,this._startPosition=null,this._options={...dE,...e}}handleTouchStart(t){this._startPosition=this._getCoordinates(t)}handleTouchMove(t){if(!this._startPosition)return;const e=this._getCoordinates(t),i={x:e.x-this._startPosition.x,y:e.y-this._startPosition.y},n=this._getDirection(i);if(this._options.direction===\"all\"){if(n.y.valuen.x.value?n.y.direction:n.x.direction;_.trigger(this._element,`swipe${r}`),_.trigger(this._element,\"swipe\",{direction:r}),this._startPosition=null;return}const o=this._options.direction===\"left\"||this._options===\"right\"?\"x\":\"y\";n[o].direction===this._options.direction&&n[o].value>this._options.threshold&&(_.trigger(this._element,`swipe${n[o].direction}`),this._startPosition=null)}handleTouchEnd(){this._startPosition=null}_getCoordinates(t){const[e]=t.touches;return{x:e.clientX,y:e.clientY}}_getDirection(t){return{x:{direction:t.x<0?\"left\":\"right\",value:Math.abs(t.x)},y:{direction:t.y<0?\"up\":\"down\",value:Math.abs(t.y)}}}},pE=class{constructor(t,e=\"swipe\",i={}){this._element=t,this._event=e,this.swipe=new uE(t,i),this._touchStartHandler=this._handleTouchStart.bind(this),this._touchMoveHandler=this._handleTouchMove.bind(this),this._touchEndHandler=this._handleTouchEnd.bind(this)}dispose(){this._element.removeEventListener(\"touchstart\",this._touchStartHandler),this._element.removeEventListener(\"touchmove\",this._touchMoveHandler),window.removeEventListener(\"touchend\",this._touchEndHandler)}init(){this._element.addEventListener(\"touchstart\",t=>this._handleTouchStart(t)),this._element.addEventListener(\"touchmove\",t=>this._handleTouchMove(t)),window.addEventListener(\"touchend\",t=>this._handleTouchEnd(t))}_handleTouchStart(t){this[this._event].handleTouchStart(t)}_handleTouchMove(t){this[this._event].handleTouchMove(t)}_handleTouchEnd(t){this[this._event].handleTouchEnd(t)}};const $l=\"stepper\",ir=\"te.stepper\",ds=`.${ir}`,Js=`data-te-${$l}`,tn=\"horizontal\",ge=\"vertical\",fE=`onChangeStep${ds}`,_E=`onChangedStep${ds}`,gE={stepperType:\"string\",stepperLinear:\"boolean\",stepperNoEditable:\"boolean\",stepperActive:\"string\",stepperCompleted:\"string\",stepperInvalid:\"string\",stepperDisabled:\"string\",stepperVerticalBreakpoint:\"number\",stepperMobileBreakpoint:\"number\",stepperMobileBarBreakpoint:\"number\",stepperAnimationDuration:\"number\",slideInLeftAnimation:\"string\",slideOutLeftAnimation:\"string\",slideInRightAnimation:\"string\",slideOutRightAnimation:\"string\"},mE={stepperType:tn,stepperLinear:!1,stepperNoEditable:!1,stepperActive:\"\",stepperCompleted:\"\",stepperInvalid:\"\",stepperDisabled:\"\",stepperVerticalBreakpoint:0,stepperMobileBreakpoint:0,stepperMobileBarBreakpoint:4,stepperAnimationDuration:800,slideInLeftAnimation:\"animate-[slide-in-left_0.8s_both]\",slideOutLeftAnimation:\"animate-[slide-out-left_0.8s_both]\",slideInRightAnimation:\"animate-[slide-in-right_0.8s_both]\",slideOutRightAnimation:\"animate-[slide-out-right_0.8s_both]\"},Bu=`mousedown${ds}`,Hu=`keydown${ds}`,bE=`keyup${ds}`,Vu=`resize${ds}`,Ge=`[${Js}-step-ref]`,Ct=`[${Js}-head-ref]`,Fu=`[${Js}-head-text-ref]`,sr=`[${Js}-head-icon-ref]`,At=`[${Js}-content-ref]`;class Wu{constructor(t,e){this._element=t,this._options=this._getConfig(e),this._elementHeight=0,this._steps=m.find(`${Ge}`,this._element),this._currentView=\"\",this._activeStepIndex=0,this._verticalStepperStyles=[],this._timeout=0,this._element&&(O.setData(t,ir,this),this._init())}static get NAME(){return $l}get activeStep(){return this._steps[this._activeStepIndex]}get activeStepIndex(){return this._activeStepIndex}dispose(){this._steps.forEach(t=>{_.off(t,Bu),_.off(t,Hu)}),_.off(window,Vu),O.removeData(this._element,ir),this._element=null}changeStep(t){this._toggleStep(t)}nextStep(){this._toggleStep(this._activeStepIndex+1)}previousStep(){this._toggleStep(this._activeStepIndex-1)}_init(){const t=m.find(`${Ge}`,this._element)[this._activeStepIndex].setAttribute(\"data-te\",\"active-step\"),e=m.find(`${Fu}`,this._element),i=m.find(`${sr}`,this._element);switch(t?(this._activeStepIndex=this._steps.indexOf(t),this._toggleStepClass(this._activeStepIndex,\"add\",this._options.stepperActive),e[this._activeStepIndex].classList.add(\"font-medium\"),i[this._activeStepIndex].classList.add(\"!bg-primary-100\"),i[this._activeStepIndex].classList.add(\"!text-primary-700\")):(e[this._activeStepIndex].classList.add(\"font-medium\"),i[this._activeStepIndex].classList.add(\"!bg-primary-100\"),i[this._activeStepIndex].classList.add(\"!text-primary-700\"),this._toggleStepClass(this._activeStepIndex,\"add\",this._options.stepperActive)),this._bindMouseDown(),this._bindKeysNavigation(),this._options.stepperType){case ge:this._toggleVertical();break;default:this._toggleHorizontal();break}(this._options.stepperVerticalBreakpoint||this._options.stepperMobileBreakpoint)&&this._toggleStepperView(),this._bindResize()}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...mE,...e,...t},L($l,t,gE),t}_bindMouseDown(){this._steps.forEach(t=>{const e=m.findOne(`${Ct}`,t);_.on(e,Bu,i=>{const n=m.parents(i.target,`${Ge}`)[0],o=this._steps.indexOf(n);i.preventDefault(),this._toggleStep(o)})})}_bindResize(){_.on(window,Vu,()=>{this._currentView===ge&&this._setSingleStepHeight(this.activeStep),this._currentView===tn&&this._setHeight(this.activeStep),(this._options.stepperVerticalBreakpoint||this._options.stepperMobileBreakpoint)&&this._toggleStepperView()})}_toggleStepperView(){const t=this._options.stepperVerticalBreakpointwindow.innerWidth,i=this._options.stepperMobileBreakpoint>window.innerWidth;t&&this._currentView!==tn&&this._toggleHorizontal(),e&&!i&&this._currentView!==ge&&(this._steps.forEach(n=>{const o=m.findOne(`${At}`,n);this._resetStepperHeight(),this._showElement(o)}),this._toggleVertical())}_toggleStep(t){if(this._activeStepIndex===t)return;this._options.stepperNoEditable&&this._toggleDisabled();const e=this._activeStepIndex,i=_.trigger(this.activeStep,fE,{currentStep:this._activeStepIndex,nextStep:t});t>this._activeStepIndex&&i.defaultPrevented||(this._showElement(m.findOne(`${At}`,this._steps[t])),this._toggleActive(t),t>this._activeStepIndex&&this._toggleCompleted(this._activeStepIndex),this._currentView===tn?this._animateHorizontalStep(t):(this._animateVerticalStep(t),this._setSingleStepHeight(this._steps[t])),this._toggleStepTabIndex(m.findOne(`${Ct}`,this.activeStep),m.findOne(`${Ct}`,this._steps[t])),this._activeStepIndex=t,this._steps[this._activeStepIndex].setAttribute(\"data-te\",\"active-step\"),this._steps.forEach((n,o)=>{n[this._activeStepIndex]!==o&&n.removeAttribute(\"data-te\")}),_.trigger(this.activeStep,_E,{currentStep:this._activeStepIndex,prevStep:e}))}_resetStepperHeight(){this._element.style.height=\"\"}_setStepsHeight(){this._steps.forEach(t=>{const e=m.findOne(`${At}`,t),i=window.getComputedStyle(e);this._verticalStepperStyles.push({paddingTop:parseFloat(i.paddingTop),paddingBottom:parseFloat(i.paddingBottom)});const n=e.scrollHeight;e.style.height=`${n}px`})}_setSingleStepHeight(t){const e=m.findOne(`${At}`,t),i=this.activeStep===t,n=this._steps.indexOf(t);let o;i?(e.style.height=\"\",o=e.scrollHeight):o=e.scrollHeight+this._verticalStepperStyles[n].paddingTop+this._verticalStepperStyles[n].paddingBottom,e.style.height=`${o}px`}_toggleVertical(){this._currentView=ge,this._setStepsHeight(),this._hideInactiveSteps()}_toggleHorizontal(){this._currentView=tn,this._setHeight(this.activeStep),this._hideInactiveSteps()}_toggleStepperClass(){m.findOne(\"[data-te-stepper-type]\",this._element)!==null&&this._steps.forEach(e=>{m.findOne(`${At}`,e).classList.remove(\"!my-0\"),m.findOne(`${At}`,e).classList.remove(\"!py-0\"),m.findOne(`${At}`,e).classList.remove(\"!h-0\")})}_toggleStepClass(t,e,i){i&&this._steps[t].classList[e](i)}_bindKeysNavigation(){this._toggleStepTabIndex(!1,m.findOne(`${Ct}`,this.activeStep)),this._steps.forEach(t=>{const e=m.findOne(`${Ct}`,t);_.on(e,Hu,i=>{const n=m.parents(i.currentTarget,`${Ge}`)[0],o=m.next(n,`${Ge}`)[0],r=m.prev(n,`${Ge}`)[0],a=m.findOne(`${Ct}`,n),l=m.findOne(`${Ct}`,this.activeStep);let c=null,h=null;if(o&&(c=m.findOne(`${Ct}`,o)),r&&(h=m.findOne(`${Ct}`,r)),i.keyCode===cs&&this._currentView!==ge&&(h?(this._toggleStepTabIndex(a,h),this._toggleOutlineStyles(a,h),h.focus()):c&&(this._toggleStepTabIndex(a,c),this._toggleOutlineStyles(a,c),c.focus())),i.keyCode===hs&&this._currentView!==ge&&(c?(this._toggleStepTabIndex(a,c),this._toggleOutlineStyles(a,c),c.focus()):h&&(this._toggleStepTabIndex(a,h),this._toggleOutlineStyles(a,h),h.focus())),i.keyCode===ht&&this._currentView===ge&&(i.preventDefault(),c&&(this._toggleStepTabIndex(a,c),this._toggleOutlineStyles(a,c),c.focus())),i.keyCode===ut&&this._currentView===ge&&(i.preventDefault(),h&&(this._toggleStepTabIndex(a,h),this._toggleOutlineStyles(a,h),h.focus())),i.keyCode===Ti){const d=m.findOne(`${Ct}`,this._steps[0]);this._toggleStepTabIndex(a,d),this._toggleOutlineStyles(a,d),d.focus()}if(i.keyCode===Ei){const d=this._steps[this._steps.length-1],u=m.findOne(`${Ct}`,d);this._toggleStepTabIndex(a,u),this._toggleOutlineStyles(a,u),u.focus()}(i.keyCode===Et||i.keyCode===Ho)&&(i.preventDefault(),this.changeStep(this._steps.indexOf(n))),i.keyCode===Ci&&(this._toggleStepTabIndex(a,l),this._toggleOutlineStyles(a,!1),l.focus())}),_.on(e,bE,i=>{const n=m.parents(i.currentTarget,`${Ge}`)[0],o=m.findOne(`${Ct}`,n),r=m.findOne(`${Ct}`,this.activeStep);i.keyCode===Ci&&(this._toggleStepTabIndex(o,r),this._toggleOutlineStyles(!1,r),r.focus())})})}_toggleStepTabIndex(t,e){t&&t.setAttribute(\"tabIndex\",-1),e&&e.setAttribute(\"tabIndex\",0)}_toggleOutlineStyles(t,e){t&&(t.style.outline=\"\"),e&&(e.style.outline=\"revert\")}_toggleDisabled(){const t=m.find(`${Ct}`,this._element),e=m.find(`${sr}`,this._element);t[this._activeStepIndex].classList.add(\"color-[#858585]\"),t[this._activeStepIndex].classList.add(\"cursor-default\"),e[this._activeStepIndex].classList.add(\"!bg-[#858585]\"),this._toggleStepClass(this._activeStepIndex,\"add\",this._options.stepperDisabled)}_toggleActive(t){const e=m.find(`${Fu}`,this._element),i=m.find(`${sr}`,this._element);e[t].classList.add(\"font-medium\"),i[t].classList.add(\"!bg-primary-100\"),i[t].classList.add(\"!text-primary-700\"),i[t].classList.remove(\"!bg-success-100\"),i[t].classList.remove(\"!text-success-700\"),e[this._activeStepIndex].classList.remove(\"font-medium\"),i[this._activeStepIndex].classList.remove(\"!bg-primary-100\"),i[this._activeStepIndex].classList.remove(\"!text-primary-700\"),this._toggleStepClass(t,\"add\",this._options.stepperActive),this._toggleStepClass(this._activeStepIndex,\"remove\",this._options.stepperActive)}_toggleCompleted(t){const e=m.find(`${sr}`,this._element);this._options.stepperNoEditable?this._steps[t].classList.add(\"pointer-events-none\"):(e[t].classList.add(\"!bg-success-100\"),e[t].classList.add(\"!text-success-700\")),e[t].classList.remove(\"!bg-danger-100\"),e[t].classList.remove(\"!text-danger-700\"),this._toggleStepClass(t,\"add\",this._options.stepperCompleted),this._toggleStepClass(t,\"remove\",this._options.stepperInvalid)}_hideInactiveSteps(){this._steps.forEach(t=>{if(!t.getAttribute(\"data-te\")){const e=m.findOne(`${At}`,t);e.classList.remove(\"translate-x-[150%]\"),this._hideElement(e)}})}_setHeight(t){const e=m.findOne(`${At}`,t),i=getComputedStyle(e),n=m.findOne(`${Ct}`,t),o=getComputedStyle(n),r=e.offsetHeight+parseFloat(i.marginTop)+parseFloat(i.marginBottom),a=n.offsetHeight+parseFloat(o.marginTop)+parseFloat(o.marginBottom);this._element.style.height=`${a+r}px`}_hideElement(t){!m.parents(t,`${Ge}`)[0].getAttribute(\"data-te\")&&this._currentView!==ge?t.style.display=\"none\":(t.classList.add(\"!my-0\"),t.classList.add(\"!py-0\"),t.classList.add(\"!h-0\"))}_showElement(t){this._currentView===ge?(t.classList.remove(\"!my-0\"),t.classList.remove(\"!py-0\"),t.classList.remove(\"!h-0\")):t.style.display=\"block\"}_animateHorizontalStep(t){clearTimeout(this._timeout),this._clearStepsAnimation();const e=t>this._activeStepIndex,i=m.findOne(`${At}`,this._steps[t]),n=m.findOne(`${At}`,this.activeStep);let o,r;this._steps.forEach((a,l)=>{const c=m.findOne(`${At}`,a);l!==t&&l!==this._activeStepIndex&&this._hideElement(c)}),e?(r=this._options.slideOutLeftAnimation,o=this._options.slideInRightAnimation):(r=this._options.slideOutRightAnimation,o=this._options.slideInLeftAnimation),n.classList.add(r),i.classList.add(o),this._setHeight(this._steps[t]),this._timeout=setTimeout(()=>{this._hideElement(n),this._clearStepsAnimation()},this._options.stepperAnimationDuration)}_clearStepsAnimation(){this._steps.forEach(t=>{m.findOne(`${At}`,t).classList.remove(this._options.slideInLeftAnimation,this._options.slideOutLeftAnimation,this._options.slideInRightAnimation,this._options.slideOutRightAnimation)})}_animateVerticalStep(t){const e=m.findOne(`${At}`,this._steps[t]),i=m.findOne(`${At}`,this.activeStep);this._hideElement(i),this._showElement(e)}static getInstance(t){return O.getData(t,ir)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const zu=\"data-te-input-state-active\",nr=\"data-te-input-selected\",ju=\"data-te-input-multiple-active\",Yu=\"[data-te-form-check-input]\";class Ku{constructor(t,e,i,n,o,r,a,l,c,h,d){this.id=t,this.nativeOption=e,this.multiple=i,this.value=n,this.label=o,this.selected=r,this.disabled=a,this.hidden=l,this.secondaryText=c,this.groupId=h,this.icon=d,this.node=null,this.active=!1}select(){this.multiple?this._selectMultiple():this._selectSingle()}_selectSingle(){this.selected||(this.node.setAttribute(nr,\"\"),this.node.setAttribute(\"aria-selected\",!0),this.selected=!0,this.nativeOption&&(this.nativeOption.selected=!0))}_selectMultiple(){if(!this.selected){const t=m.findOne(Yu,this.node);t.checked=!0,this.node.setAttribute(nr,\"\"),this.node.setAttribute(\"aria-selected\",!0),this.selected=!0,this.nativeOption&&(this.nativeOption.selected=!0)}}deselect(){this.multiple?this._deselectMultiple():this._deselectSingle()}_deselectSingle(){this.selected&&(this.node.removeAttribute(nr),this.node.setAttribute(\"aria-selected\",!1),this.selected=!1,this.nativeOption&&(this.nativeOption.selected=!1))}_deselectMultiple(){if(this.selected){const t=m.findOne(Yu,this.node);t.checked=!1,this.node.removeAttribute(nr),this.node.setAttribute(\"aria-selected\",!1),this.selected=!1,this.nativeOption&&(this.nativeOption.selected=!1)}}setNode(t){this.node=t}setActiveStyles(){if(!this.active){if(this.multiple){this.node.setAttribute(ju,\"\");return}this.active=!0,this.node.setAttribute(zu,\"\")}}removeActiveStyles(){this.active&&(this.active=!1,this.node.removeAttribute(zu)),this.multiple&&this.node.removeAttribute(ju)}}class vE{constructor(t=!1){this._multiple=t,this._selections=[]}select(t){this._multiple?this._selections.push(t):this._selections=[t]}deselect(t){if(this._multiple){const e=this._selections.findIndex(i=>t===i);this._selections.splice(e,1)}else this._selections=[]}clear(){this._selections=[]}get selection(){return this._selections[0]}get selections(){return this._selections}get label(){return this._selections[0]&&this.selection.label}get labels(){return this._selections.map(t=>t.label).join(\", \")}get value(){return this.selections[0]&&this.selection.value}get values(){return this._selections.map(t=>t.value)}}function Rl(s){return s.filter(t=>!t.disabled).every(t=>t.selected)}const yE=\"data-te-select-form-outline-ref\",TE=\"data-te-select-wrapper-ref\",EE=\"data-te-select-input-ref\",xE=\"data-te-select-clear-btn-ref\",CE=\"data-te-select-dropdown-container-ref\",AE=\"data-te-select-dropdown-ref\",wE=\"data-te-select-options-wrapper-ref\",kE=\"data-te-select-options-list-ref\",SE=\"data-te-select-input-filter-ref\",Uu=\"data-te-select-option-ref\",OE=\"data-te-select-option-all-ref\",IE=\"data-te-select-option-text-ref\",DE=\"data-te-form-check-input\",ME=\"data-te-select-option-group-ref\",LE=\"data-te-select-option-group-label-ref\",Xu=\"data-te-select-selected\",$E=`\n\n \n\n`,RE=s=>{s.code===\"Tab\"||s.code===\"Esc\"||s.preventDefault()};function or(s,t,e,i,n){t.selectSize===\"default\"&&g.addClass(s,e),t.selectSize===\"sm\"&&g.addClass(s,i),t.selectSize===\"lg\"&&g.addClass(s,n)}function PE(s,t,e,i,n){const o=document.createElement(\"div\");o.setAttribute(\"id\",s),o.setAttribute(TE,\"\");const r=$(\"div\");r.setAttribute(yE,\"\"),g.addClass(r,i.formOutline);const a=$(\"input\"),l=t.selectFilter?\"combobox\":\"listbox\",c=t.multiple?\"true\":\"false\",h=t.disabled?\"true\":\"false\";a.setAttribute(EE,\"\"),g.addClass(a,i.selectInput),or(a,t,i.selectInputSizeDefault,i.selectInputSizeSm,i.selectInputSizeLg),t.selectFormWhite&&g.addClass(a,i.selectInputWhite),a.setAttribute(\"type\",\"text\"),a.setAttribute(\"role\",l),a.setAttribute(\"aria-multiselectable\",c),a.setAttribute(\"aria-disabled\",h),a.setAttribute(\"aria-haspopup\",\"true\"),a.setAttribute(\"aria-expanded\",!1),t.tabIndex&&a.setAttribute(\"tabIndex\",t.tabIndex),t.disabled&&a.setAttribute(\"disabled\",\"\"),t.selectPlaceholder!==\"\"&&a.setAttribute(\"placeholder\",t.selectPlaceholder),t.selectValidation?(g.addStyle(a,{\"pointer-events\":\"none\",\"caret-color\":\"transparent\"}),g.addStyle(r,{cursor:\"pointer\"})):a.setAttribute(\"readonly\",\"true\"),t.selectValidation&&(a.setAttribute(\"required\",\"true\"),a.setAttribute(\"aria-required\",\"true\"),a.addEventListener(\"keydown\",RE));const d=$(\"div\");g.addClass(d,i.selectValidationValid);const u=document.createTextNode(`${t.selectValidFeedback}`);d.appendChild(u);const p=$(\"div\");g.addClass(p,i.selectValidationInvalid);const f=document.createTextNode(`${t.selectInvalidFeedback}`);p.appendChild(f);const b=$(\"span\");b.setAttribute(xE,\"\"),g.addClass(b,i.selectClearBtn),or(b,t,i.selectClearBtnDefault,i.selectClearBtnSm,i.selectClearBtnLg),t.selectFormWhite&&g.addClass(b,i.selectClearBtnWhite);const v=document.createTextNode(\"✕\");b.appendChild(v),b.setAttribute(\"tabindex\",\"0\");const y=$(\"span\");return g.addClass(y,i.selectArrow),or(y,t,i.selectArrowDefault,i.selectArrowSm,i.selectArrowLg),t.selectFormWhite&&g.addClass(y,i.selectArrowWhite),y.innerHTML=n||$E,r.appendChild(a),e&&(g.addClass(e,i.selectLabel),or(e,t,i.selectLabelSizeDefault,i.selectLabelSizeSm,i.selectLabelSizeLg),t.selectFormWhite&&g.addClass(e,i.selectLabelWhite),r.appendChild(e)),t.selectValidation&&(r.appendChild(d),r.appendChild(p)),t.selectClearButton&&r.appendChild(b),r.appendChild(y),o.appendChild(r),o}function Gu(s,t,e,i,n,o,r,a){const l=document.createElement(\"div\");l.setAttribute(CE,\"\"),g.addClass(l,a.selectDropdownContainer),l.setAttribute(\"id\",`${s}`),l.style.width=`${e}px`;const c=document.createElement(\"div\");c.setAttribute(\"tabindex\",0),c.setAttribute(AE,\"\"),g.addClass(c,a.dropdown);const h=$(\"div\");h.setAttribute(wE,\"\"),g.addClass(h,a.optionsWrapper),g.addClass(h,a.optionsWrapperScrollbar),h.style.maxHeight=`${i}px`;const d=qu(o,n,t,a);return h.appendChild(d),t.selectFilter&&c.appendChild(NE(t.selectSearchPlaceholder,a)),c.appendChild(h),r&&c.appendChild(r),l.appendChild(c),l}function qu(s,t,e,i){const n=$(\"div\");n.setAttribute(kE,\"\"),g.addClass(n,i.optionsList);let o;return e.multiple?o=HE(s,t,e,i):o=BE(s,e,i),o.forEach(r=>{n.appendChild(r)}),n}function NE(s,t){const e=$(\"div\");g.addClass(e,t.inputGroup);const i=$(\"input\");return i.setAttribute(SE,\"\"),g.addClass(i,t.selectFilterInput),i.placeholder=s,i.setAttribute(\"role\",\"searchbox\"),i.setAttribute(\"type\",\"text\"),e.appendChild(i),e}function BE(s,t,e){return Zu(s,t,e)}function HE(s,t,e,i){let n=null;e.selectAll&&(n=VE(t,s,e,i));const o=Zu(s,e,i);return n?[n,...o]:o}function Zu(s,t,e){const i=[];return s.forEach(n=>{if(Object.prototype.hasOwnProperty.call(n,\"options\")){const r=jE(n,t,e);i.push(r)}else i.push(Qu(n,t,e))}),i}function VE(s,t,e,i){const n=Rl(t),o=$(\"div\");o.setAttribute(Uu,\"\");const r=i.selectAllOption||i.selectOption;return g.addClass(o,r),o.setAttribute(OE,\"\"),g.addStyle(o,{height:`${e.selectOptionHeight}px`}),o.setAttribute(\"role\",\"option\"),o.setAttribute(\"aria-selected\",n),n&&o.setAttribute(Xu,\"\"),o.appendChild(Ju(s,e,i)),s.setNode(o),o}function Qu(s,t,e){if(s.node)return s.node;const i=$(\"div\");return i.setAttribute(Uu,\"\"),g.addClass(i,e.selectOption),g.addStyle(i,{height:`${t.selectOptionHeight}px`}),g.setDataAttribute(i,\"id\",s.id),i.setAttribute(\"role\",\"option\"),i.setAttribute(\"aria-selected\",s.selected),i.setAttribute(\"aria-disabled\",s.disabled),s.selected&&i.setAttribute(Xu,\"\"),s.disabled&&i.setAttribute(\"data-te-select-option-disabled\",!0),s.hidden&&g.addClass(i,\"hidden\"),i.appendChild(Ju(s,t,e)),s.icon&&i.appendChild(zE(s,e)),s.setNode(i),i}function Ju(s,t,e){const i=$(\"span\");i.setAttribute(IE,\"\"),g.addClass(i,e.selectOptionText);const n=document.createTextNode(s.label);return t.multiple&&i.appendChild(WE(s,e)),i.appendChild(n),(s.secondaryText||typeof s.secondaryText==\"number\")&&i.appendChild(FE(s.secondaryText,e)),i}function FE(s,t){const e=$(\"span\");g.addClass(e,t.selectOptionSecondaryText);const i=document.createTextNode(s);return e.appendChild(i),e}function WE(s,t){const e=$(\"input\");e.setAttribute(\"type\",\"checkbox\"),g.addClass(e,t.formCheckInput),e.setAttribute(DE,\"\");const i=$(\"label\");return s.selected&&e.setAttribute(\"checked\",!0),s.disabled&&e.setAttribute(\"disabled\",!0),e.appendChild(i),e}function zE(s,t){const e=$(\"span\"),i=$(\"img\");return g.addClass(i,t.selectOptionIcon),i.src=s.icon,e.appendChild(i),e}function jE(s,t,e){const i=$(\"div\");i.setAttribute(ME,\"\"),g.addClass(i,e.selectOptionGroup),i.setAttribute(\"role\",\"group\"),i.setAttribute(\"id\",s.id),s.hidden&&g.addClass(i,\"hidden\");const n=$(\"label\");return n.setAttribute(LE,\"\"),g.addClass(n,e.selectOptionGroupLabel),g.addStyle(n,{height:`${t.selectOptionHeight}px`}),n.setAttribute(\"for\",s.id),n.textContent=s.label,i.appendChild(n),s.options.forEach(o=>{i.appendChild(Qu(o,t,e))}),i}function YE(s,t){const e=$(\"div\");return e.textContent=s,g.addClass(e,t.selectLabel),g.addClass(e,t.selectFakeValue),e}const Pl=\"select\",en=\"te.select\",sn=`.${en}`,KE=`close${sn}`,UE=`open${sn}`,tp=`optionSelect${sn}`,ep=`optionDeselect${sn}`,XE=`valueChange${sn}`,GE=\"change\",ip=\"data-te-select-init\",sp=\"data-te-select-no-results-ref\",np=\"data-te-select-open\",wt=\"data-te-input-state-active\",qe=\"data-te-input-focused\",Nl=\"data-te-input-disabled\",qE=\"data-te-select-option-group-label-ref\",ZE=\"data-te-select-option-all-ref\",nn=\"data-te-select-selected\",QE=\"[data-te-select-label-ref]\",op=\"[data-te-select-input-ref]\",JE=\"[data-te-select-input-filter-ref]\",tx=\"[data-te-select-dropdown-ref]\",ex=\"[data-te-select-options-wrapper-ref]\",rp=\"[data-te-select-options-list-ref]\",ix=\"[data-te-select-option-ref]\",sx=\"[data-te-select-clear-btn-ref]\",nx=\"[data-te-select-custom-content-ref]\",ox=`[${sp}]`,ap=\"[data-te-select-form-outline-ref]\",rx=\"[data-te-select-toggle]\",Bl=\"[data-te-input-notch-ref]\",ax={selectAutoSelect:!1,selectContainer:\"body\",selectClearButton:!1,disabled:!1,selectDisplayedLabels:5,selectFormWhite:!1,multiple:!1,selectOptionsSelectedLabel:\"options selected\",selectOptionHeight:38,selectAll:!0,selectAllLabel:\"Select all\",selectSearchPlaceholder:\"Search...\",selectSize:\"default\",selectVisibleOptions:5,selectFilter:!1,selectFilterDebounce:300,selectNoResultText:\"No results\",selectValidation:!1,selectValidFeedback:\"Valid\",selectInvalidFeedback:\"Invalid\",selectPlaceholder:\"\"},lx={selectAutoSelect:\"boolean\",selectContainer:\"string\",selectClearButton:\"boolean\",disabled:\"boolean\",selectDisplayedLabels:\"number\",selectFormWhite:\"boolean\",multiple:\"boolean\",selectOptionsSelectedLabel:\"string\",selectOptionHeight:\"number\",selectAll:\"boolean\",selectAllLabel:\"string\",selectSearchPlaceholder:\"string\",selectSize:\"string\",selectVisibleOptions:\"number\",selectFilter:\"boolean\",selectFilterDebounce:\"number\",selectNoResultText:\"string\",selectValidation:\"boolean\",selectValidFeedback:\"string\",selectInvalidFeedback:\"string\",selectPlaceholder:\"string\"},cx={dropdown:\"relative outline-none min-w-[100px] m-0 scale-y-[0.8] opacity-0 bg-white shadow-[0_2px_5px_0_rgba(0,0,0,0.16),_0_2px_10px_0_rgba(0,0,0,0.12)] transition duration-200 motion-reduce:transition-none data-[te-select-open]:scale-100 data-[te-select-open]:opacity-100 dark:bg-zinc-700\",formCheckInput:\"relative float-left mt-[0.15rem] mr-[8px] h-[1.125rem] w-[1.125rem] appearance-none rounded-[0.25rem] border-[0.125rem] border-solid border-neutral-300 dark:border-neutral-600 outline-none before:pointer-events-none before:absolute before:h-[0.875rem] before:w-[0.875rem] before:scale-0 before:rounded-full before:bg-transparent before:opacity-0 before:shadow-[0px_0px_0px_13px_transparent] before:content-[''] checked:border-primary dark:checked:border-primary checked:bg-primary dark:checked:bg-primary checked:before:opacity-[0.16] checked:after:absolute checked:after:ml-[0.25rem] checked:after:-mt-px checked:after:block checked:after:h-[0.8125rem] checked:after:w-[0.375rem] checked:after:rotate-45 checked:after:border-[0.125rem] checked:after:border-t-0 checked:after:border-l-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer hover:before:opacity-[0.04] hover:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:shadow-none focus:transition-[border-color_0.2s] focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] dark:focus:before:shadow-[0px_0px_0px_13px_rgba(255,255,255,0.4)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-[0.875rem] focus:after:w-[0.875rem] focus:after:rounded-[0.125rem] focus:after:content-[''] checked:focus:before:scale-100 checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] dark:checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] checked:focus:after:ml-[0.25rem] checked:focus:after:-mt-px checked:focus:after:h-[0.8125rem] checked:focus:after:w-[0.375rem] checked:focus:after:rotate-45 checked:focus:after:rounded-none checked:focus:after:border-[0.125rem] checked:focus:after:border-t-0 checked:focus:after:border-l-0 checked:focus:after:border-solid checked:focus:after:border-white checked:focus:after:bg-transparent\",formOutline:\"relative\",initialized:\"hidden\",inputGroup:\"flex items-center whitespace-nowrap p-2.5 text-center text-base font-normal leading-[1.6] text-gray-700 dark:bg-zinc-800 dark:text-gray-200 dark:placeholder:text-gray-200\",noResult:\"flex items-center px-4\",optionsList:\"list-none m-0 p-0\",optionsWrapper:\"overflow-y-auto\",optionsWrapperScrollbar:\"[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-button]:block [&::-webkit-scrollbar-button]:h-0 [&::-webkit-scrollbar-button]:bg-transparent [&::-webkit-scrollbar-track-piece]:bg-transparent [&::-webkit-scrollbar-track-piece]:rounded-none [&::-webkit-scrollbar-track-piece]: [&::-webkit-scrollbar-track-piece]:rounded-l [&::-webkit-scrollbar-thumb]:h-[50px] [&::-webkit-scrollbar-thumb]:bg-[#999] [&::-webkit-scrollbar-thumb]:rounded\",selectArrow:\"absolute right-3 text-[0.8rem] cursor-pointer peer-focus:text-primary peer-data-[te-input-focused]:text-primary group-data-[te-was-validated]/validation:peer-valid:text-green-600 group-data-[te-was-validated]/validation:peer-invalid:text-[rgb(220,76,100)] w-5 h-5\",selectArrowWhite:\"text-gray-50 peer-focus:!text-white peer-data-[te-input-focused]:!text-white\",selectArrowDefault:\"top-2\",selectArrowLg:\"top-[13px]\",selectArrowSm:\"top-1\",selectClearBtn:\"absolute top-2 right-9 text-black cursor-pointer focus:text-primary outline-none dark:text-gray-200\",selectClearBtnWhite:\"!text-gray-50\",selectClearBtnDefault:\"top-2 text-base\",selectClearBtnLg:\"top-[11px] text-base\",selectClearBtnSm:\"top-1 text-[0.8rem]\",selectDropdownContainer:\"z-[1070]\",selectFakeValue:\"transform-none hidden data-[te-input-state-active]:block\",selectFilterInput:\"relative m-0 block w-full min-w-0 flex-auto rounded border border-solid border-gray-300 bg-transparent bg-clip-padding px-3 py-1.5 text-base font-normal text-gray-700 transition duration-300 ease-in-out motion-reduce:transition-none focus:border-primary focus:text-gray-700 focus:shadow-te-primary focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-200\",selectInput:\"peer block min-h-[auto] w-full rounded border-0 bg-transparent outline-none transition-all duration-200 ease-linear focus:placeholder:opacity-100 data-[te-input-state-active]:placeholder:opacity-100 motion-reduce:transition-none dark:text-gray-200 dark:placeholder:text-gray-200 [&:not([data-te-input-placeholder-active])]:placeholder:opacity-0 cursor-pointer data-[te-input-disabled]:bg-[#e9ecef] data-[te-input-disabled]:cursor-default group-data-[te-was-validated]/validation:mb-4 dark:data-[te-input-disabled]:bg-zinc-600\",selectInputWhite:\"!text-gray-50\",selectInputSizeDefault:\"py-[0.32rem] px-3 leading-[1.6]\",selectInputSizeLg:\"py-[0.32rem] px-3 leading-[2.15]\",selectInputSizeSm:\"py-[0.33rem] px-3 text-xs leading-[1.5]\",selectLabel:\"pointer-events-none absolute top-0 left-3 mb-0 max-w-[90%] origin-[0_0] truncate text-gray-500 transition-all duration-200 ease-out peer-focus:scale-[0.8] peer-focus:text-primary peer-data-[te-input-state-active]:scale-[0.8] motion-reduce:transition-none dark:text-gray-200 dark:peer-focus:text-gray-200 data-[te-input-state-active]:scale-[0.8] dark:peer-focus:text-primary\",selectLabelWhite:\"!text-gray-50\",selectLabelSizeDefault:\"pt-[0.37rem] leading-[1.6] peer-focus:-translate-y-[0.9rem] peer-data-[te-input-state-active]:-translate-y-[0.9rem] data-[te-input-state-active]:-translate-y-[0.9rem]\",selectLabelSizeLg:\"pt-[0.37rem] leading-[2.15] peer-focus:-translate-y-[1.15rem] peer-data-[te-input-state-active]:-translate-y-[1.15rem] data-[te-input-state-active]:-translate-y-[1.15rem]\",selectLabelSizeSm:\"pt-[0.37rem] text-xs leading-[1.5] peer-focus:-translate-y-[0.75rem] peer-data-[te-input-state-active]:-translate-y-[0.75rem] data-[te-input-state-active]:-translate-y-[0.75rem]\",selectOption:\"flex flex-row items-center justify-between w-full px-4 truncate text-gray-700 bg-transparent select-none cursor-pointer data-[te-input-multiple-active]:bg-black/5 hover:[&:not([data-te-select-option-disabled])]:bg-black/5 data-[te-input-state-active]:bg-black/5 data-[te-select-option-selected]:data-[te-input-state-active]:bg-black/5 data-[te-select-selected]:data-[te-select-option-disabled]:cursor-default data-[te-select-selected]:data-[te-select-option-disabled]:text-gray-400 data-[te-select-selected]:data-[te-select-option-disabled]:bg-transparent data-[te-select-option-selected]:bg-black/[0.02] data-[te-select-option-disabled]:text-gray-400 data-[te-select-option-disabled]:cursor-default group-data-[te-select-option-group-ref]/opt:pl-7 dark:text-gray-200 dark:hover:[&:not([data-te-select-option-disabled])]:bg-white/30 dark:data-[te-input-state-active]:bg-white/30 dark:data-[te-select-option-selected]:data-[te-input-state-active]:bg-white/30 dark:data-[te-select-option-disabled]:text-gray-400 dark:data-[te-input-multiple-active]:bg-white/30\",selectAllOption:\"\",selectOptionGroup:\"group/opt\",selectOptionGroupLabel:\"flex flex-row items-center w-full px-4 truncate bg-transparent text-black/50 select-none dark:text-gray-300\",selectOptionIcon:\"w-7 h-7 rounded-full\",selectOptionSecondaryText:\"block text-[0.8rem] text-gray-500 dark:text-gray-300\",selectOptionText:\"group\",selectValidationValid:\"hidden absolute -mt-3 w-auto text-sm text-green-600 cursor-pointer group-data-[te-was-validated]/validation:peer-valid:block\",selectValidationInvalid:\"hidden absolute -mt-3 w-auto text-sm text-[rgb(220,76,100)] cursor-pointer group-data-[te-was-validated]/validation:peer-invalid:block\"},hx={dropdown:\"string\",formCheckInput:\"string\",formOutline:\"string\",initialized:\"string\",inputGroup:\"string\",noResult:\"string\",optionsList:\"string\",optionsWrapper:\"string\",optionsWrapperScrollbar:\"string\",selectArrow:\"string\",selectArrowDefault:\"string\",selectArrowLg:\"string\",selectArrowSm:\"string\",selectClearBtn:\"string\",selectClearBtnDefault:\"string\",selectClearBtnLg:\"string\",selectClearBtnSm:\"string\",selectDropdownContainer:\"string\",selectFakeValue:\"string\",selectFilterInput:\"string\",selectInput:\"string\",selectInputSizeDefault:\"string\",selectInputSizeLg:\"string\",selectInputSizeSm:\"string\",selectLabel:\"string\",selectLabelSizeDefault:\"string\",selectLabelSizeLg:\"string\",selectLabelSizeSm:\"string\",selectOption:\"string\",selectAllOption:\"string\",selectOptionGroup:\"string\",selectOptionGroupLabel:\"string\",selectOptionIcon:\"string\",selectOptionSecondaryText:\"string\",selectOptionText:\"string\"};class on{constructor(t,e,i){this._element=t,this._config=this._getConfig(e),this._classes=this._getClasses(i),this._config.selectPlaceholder&&!this._config.multiple&&this._addPlaceholderOption(),this._optionsToRender=this._getOptionsToRender(t),this._plainOptions=this._getPlainOptions(this._optionsToRender),this._filteredOptionsList=null,this._selectionModel=new vE(this.multiple),this._activeOptionIndex=-1,this._activeOption=null,this._wrapperId=bt(\"select-wrapper-\"),this._dropdownContainerId=bt(\"select-dropdown-container-\"),this._selectAllId=bt(\"select-all-\"),this._debounceTimeoutId=null,this._dropdownHeight=this._config.selectOptionHeight*this._config.selectVisibleOptions,this._popper=null,this._input=null,this._label=m.next(this._element,QE)[0],this._notch=null,this._fakeValue=null,this._isFakeValueActive=!1,this._customContent=m.next(t,nx)[0],this._toggleButton=null,this._elementToggle=null,this._wrapper=null,this._inputEl=null,this._dropdownContainer=null,this._container=null,this._selectAllOption=null,this._init(),this._mutationObserver=null,this._isOpen=!1,this._addMutationObserver(),this._element&&O.setData(t,en,this)}static get NAME(){return Pl}get filterInput(){return m.findOne(JE,this._dropdownContainer)}get dropdown(){return m.findOne(tx,this._dropdownContainer)}get optionsList(){return m.findOne(rp,this._dropdownContainer)}get optionsWrapper(){return m.findOne(ex,this._dropdownContainer)}get clearButton(){return m.findOne(sx,this._wrapper)}get options(){return this._filteredOptionsList?this._filteredOptionsList:this._plainOptions}get value(){return this.multiple?this._selectionModel.values:this._selectionModel.value}get multiple(){return this._config.multiple}get hasSelectAll(){return this.multiple&&this._config.selectAll}get hasSelection(){return this._selectionModel.selection||this._selectionModel.selections.length>0}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...ax,...e,...t},this._element.hasAttribute(\"multiple\")&&(t.multiple=!0),this._element.hasAttribute(\"disabled\")&&(t.disabled=!0),this._element.tabIndex&&(t.tabIndex=this._element.getAttribute(\"tabIndex\")),L(Pl,t,lx),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...cx,...e,...t},L(Pl,t,hx),t}_addPlaceholderOption(){const t=new Option(\"\",\"\",!0,!0);t.hidden=!0,t.selected=!0,this._element.prepend(t)}_getOptionsToRender(t){const e=[];return t.childNodes.forEach(n=>{if(n.nodeName===\"OPTGROUP\"){const o={id:bt(\"group-\"),label:n.label,disabled:n.hasAttribute(\"disabled\"),hidden:n.hasAttribute(\"hidden\"),options:[]};n.childNodes.forEach(a=>{a.nodeName===\"OPTION\"&&o.options.push(this._createOptionObject(a,o))}),e.push(o)}else n.nodeName===\"OPTION\"&&e.push(this._createOptionObject(n))}),e}_getPlainOptions(t){if(!m.findOne(\"optgroup\",this._element))return t;const i=[];return t.forEach(n=>{Object.prototype.hasOwnProperty.call(n,\"options\")?n.options.forEach(r=>{i.push(r)}):i.push(n)}),i}_createOptionObject(t,e={}){const i=bt(\"option-\"),n=e.id?e.id:null,o=e.disabled?e.disabled:!1,r=t.selected||t.hasAttribute(nn),a=t.hasAttribute(\"disabled\")||o,l=t.hasAttribute(\"hidden\")||e&&e.hidden,c=this.multiple,h=t.value,d=t.label,u=g.getDataAttribute(t,\"selectSecondaryText\"),p=g.getDataAttribute(t,\"select-icon\");return new Ku(i,t,c,h,d,r,a,l,u,n,p)}_getNavigationOptions(){const t=this.options.filter(e=>!e.hidden);return this.hasSelectAll?[this._selectAllOption,...t]:t}_init(){this._renderMaterialWrapper(),this._wrapper=m.findOne(`#${this._wrapperId}`),this._input=m.findOne(op,this._wrapper),this._config.disabled&&this._input.setAttribute(Nl,\"\");const t=this._config.selectContainer;t===\"body\"?this._container=document.body:this._container=m.findOne(t),this._initOutlineInput(),this._setDefaultSelections(),this._updateInputValue(),this._appendFakeValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this._bindComponentEvents(),this.hasSelectAll&&(this._selectAllOption=this._createSelectAllOption()),this._dropdownContainer=Gu(this._dropdownContainerId,this._config,this._input.offsetWidth,this._dropdownHeight,this._selectAllOption,this._optionsToRender,this._customContent,this._classes),this._setFirstActiveOption(),this._listenToFocusChange()}_renderMaterialWrapper(){const t=PE(this._wrapperId,this._config,this._label,this._classes,this._config.customArrow);this._element.parentNode.insertBefore(t,this._element),g.addClass(this._element,this._classes.initialized),t.appendChild(this._element)}_initOutlineInput(){const t=m.findOne(ap,this._wrapper);new Z(t,{inputFormWhite:this._config.selectFormWhite},this._classes).init(),this._notch=m.findOne(Bl,this._wrapper)}_bindComponentEvents(){this._listenToComponentKeydown(),this._listenToWrapperClick(),this._listenToClearBtnClick(),this._listenToClearBtnKeydown()}_setDefaultSelections(){this.options.forEach(t=>{t.selected&&this._selectionModel.select(t)})}_listenToComponentKeydown(){_.on(this._wrapper,\"keydown\",this._handleKeydown.bind(this))}_handleKeydown(t){this._isOpen&&!this._config.selectFilter?this._handleOpenKeydown(t):this._handleClosedKeydown(t)}_handleOpenKeydown(t){const e=t.keyCode,i=e===xi||e===ut&&t.altKey||e===Ci;if(e===Ci&&this._config.selectAutoSelect&&!this.multiple&&this._handleAutoSelection(this._activeOption),i){this.close(),this._input.focus();return}switch(e){case ht:this._setNextOptionActive(),this._scrollToOption(this._activeOption);break;case ut:this._setPreviousOptionActive(),this._scrollToOption(this._activeOption);break;case Ti:this._setFirstOptionActive(),this._scrollToOption(this._activeOption);break;case Ei:this._setLastOptionActive(),this._scrollToOption(this._activeOption);break;case Et:t.preventDefault(),this._activeOption&&(this.hasSelectAll&&this._activeOptionIndex===0?this._handleSelectAll():this._handleSelection(this._activeOption));return;default:return}t.preventDefault()}_handleClosedKeydown(t){const e=t.keyCode;if(e===Et&&t.preventDefault(),(e===Et||e===ht&&t.altKey||e===ht&&this.multiple)&&this.open(),this.multiple)switch(e){case ht:this.open();break;case ut:this.open();break;default:return}else switch(e){case ht:this._setNextOptionActive(),this._handleSelection(this._activeOption);break;case ut:this._setPreviousOptionActive(),this._handleSelection(this._activeOption);break;case Ti:this._setFirstOptionActive(),this._handleSelection(this._activeOption);break;case Ei:this._setLastOptionActive(),this._handleSelection(this._activeOption);break;default:return}t.preventDefault()}_scrollToOption(t){if(!t)return;let e;const i=this.options.filter(h=>!h.hidden);this.hasSelectAll?e=i.indexOf(t)+1:e=i.indexOf(t);const n=this._getNumberOfGroupsBeforeOption(e),o=e+n,r=this.optionsWrapper,a=r.offsetHeight,l=this._config.selectOptionHeight,c=r.scrollTop;if(e>-1){const h=o*l,d=h+l>c+a;h!r.hidden),i=this._optionsToRender.filter(r=>!r.hidden),n=this.hasSelectAll?t-1:t;let o=0;for(let r=0;r<=n;r++)e[r].groupId&&i[o]&&i[o].id&&e[r].groupId===i[o].id&&o++;return o}_setNextOptionActive(){let t=this._activeOptionIndex+1;const e=this._getNavigationOptions();if(e[t]){for(;e[t].disabled;)if(t+=1,!e[t])return;this._updateActiveOption(e[t],t)}}_setPreviousOptionActive(){let t=this._activeOptionIndex-1;const e=this._getNavigationOptions();if(e[t]){for(;e[t].disabled;)if(t-=1,!e[t])return;this._updateActiveOption(e[t],t)}}_setFirstOptionActive(){const e=this._getNavigationOptions();this._updateActiveOption(e[0],0)}_setLastOptionActive(){const t=this._getNavigationOptions(),e=t.length-1;this._updateActiveOption(t[e],e)}_updateActiveOption(t,e){const i=this._activeOption;i&&i.removeActiveStyles(),t.setActiveStyles(),this._activeOptionIndex=e,this._activeOption=t}_listenToWrapperClick(){_.on(this._wrapper,\"click\",()=>{this.toggle()})}_listenToClearBtnClick(){_.on(this.clearButton,\"click\",t=>{t.preventDefault(),t.stopPropagation(),this._handleClear()})}_listenToClearBtnKeydown(){_.on(this.clearButton,\"keydown\",t=>{t.keyCode===Et&&(this._handleClear(),t.preventDefault(),t.stopPropagation())})}_handleClear(){if(this.multiple)this._selectionModel.clear(),this._deselectAllOptions(this.options),this.hasSelectAll&&this._updateSelectAllState();else{const t=this._selectionModel.selection;this._selectionModel.clear(),t.deselect()}this._fakeValue.textContent=\"\",this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this._emitValueChangeEvent(null),this._emitNativeChangeEvent()}_listenToOptionsClick(){_.on(this.optionsWrapper,\"click\",t=>{if(t.target.hasAttribute(qE))return;const i=t.target.nodeName===\"DIV\"?t.target:m.closest(t.target,ix);if(i.hasAttribute(ZE)){this._handleSelectAll();return}const o=i.dataset.teId,r=this.options.find(a=>a.id===o);r&&!r.disabled&&this._handleSelection(r)})}_handleSelectAll(){this._selectAllOption.selected?(this._deselectAllOptions(this.options),this._selectAllOption.deselect()):(this._selectAllOptions(this.options),this._selectAllOption.select()),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this._emitValueChangeEvent(this.value),this._emitNativeChangeEvent()}_selectAllOptions(t){t.forEach(e=>{!e.selected&&!e.disabled&&(this._selectionModel.select(e),e.select())})}_deselectAllOptions(t){t.forEach(e=>{e.selected&&!e.disabled&&(this._selectionModel.deselect(e),e.deselect())})}_handleSelection(t){this.multiple?(this._handleMultiSelection(t),this.hasSelectAll&&this._updateSelectAllState()):this._handleSingleSelection(t),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility()}_handleAutoSelection(t){this._singleOptionSelect(t),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility()}_handleSingleSelection(t){this._singleOptionSelect(t),this.close(),this._input.focus()}_singleOptionSelect(t){const e=this._selectionModel.selections[0];e&&e!==t&&(this._selectionModel.deselect(e),e.deselect(),e.node.setAttribute(nn,!1),_.trigger(this._element,ep,{value:e.value})),(!e||e&&t!==e)&&(this._selectionModel.select(t),t.select(),t.node.setAttribute(nn,!0),_.trigger(this._element,tp,{value:t.value}),this._emitValueChangeEvent(this.value),this._emitNativeChangeEvent())}_handleMultiSelection(t){t.selected?(this._selectionModel.deselect(t),t.deselect(),t.node.setAttribute(nn,!1),_.trigger(this._element,ep,{value:t.value})):(this._selectionModel.select(t),t.select(),t.node.setAttribute(nn,!0),_.trigger(this._element,tp,{value:t.value})),this._emitValueChangeEvent(this.value),this._emitNativeChangeEvent()}_emitValueChangeEvent(t){_.trigger(this._element,XE,{value:t})}_emitNativeChangeEvent(){_.trigger(this._element,GE)}_updateInputValue(){const t=this.multiple?this._selectionModel.labels:this._selectionModel.label;let e;this.multiple&&this._config.selectDisplayedLabels!==-1&&this._selectionModel.selections.length>this._config.selectDisplayedLabels?e=`${this._selectionModel.selections.length} ${this._config.selectOptionsSelectedLabel}`:e=t,!this.multiple&&!this._isSelectionValid(this._selectionModel.selection)?this._input.value=\"\":this._isLabelEmpty(this._selectionModel.selection)?this._input.value=\" \":e?this._input.value=e:this.multiple||!this._optionsToRender[0]?this._input.value=\"\":this._input.value=this._optionsToRender[0].label}_isSelectionValid(t){return!(t&&(t.disabled||t.value===\"\"))}_isLabelEmpty(t){return!!(t&&t.label===\"\")}_appendFakeValue(){if(!this._selectionModel.selection||this._selectionModel._multiple)return;const t=this._selectionModel.selection.label;this._fakeValue=YE(t,this._classes),m.findOne(ap,this._wrapper).appendChild(this._fakeValue)}_updateLabelPosition(){const t=this._element.hasAttribute(ip),e=this._input.value!==\"\";this._label&&(t&&(e||this._isOpen||this._isFakeValueActive)?(this._label.setAttribute(wt,\"\"),this._notch.setAttribute(wt,\"\")):(this._label.removeAttribute(wt),this._notch.removeAttribute(wt,\"\")))}_updateLabelPositionWhileClosing(){this._label&&(this._input.value!==\"\"||this._isFakeValueActive?(this._label.setAttribute(wt,\"\"),this._notch.setAttribute(wt,\"\")):(this._label.removeAttribute(wt),this._notch.removeAttribute(wt)))}_updateFakeLabelPosition(){this._fakeValue&&(this._input.value===\"\"&&this._fakeValue.innerHTML!==\"\"&&!this._config.selectPlaceholder?(this._isFakeValueActive=!0,this._fakeValue.setAttribute(wt,\"\")):(this._isFakeValueActive=!1,this._fakeValue.removeAttribute(wt)))}_updateClearButtonVisibility(){if(!this.clearButton)return;this._selectionModel.selection||this._selectionModel.selections.length>0?g.addStyle(this.clearButton,{display:\"block\"}):g.addStyle(this.clearButton,{display:\"none\"})}_updateSelectAllState(){const t=this._selectAllOption.selected,e=Rl(this.options);!e&&t?this._selectAllOption.deselect():e&&!t&&this._selectAllOption.select()}toggle(){this._isOpen?this.close():this.open()}open(){const t=this._config.disabled,e=_.trigger(this._element,UE);this._isOpen||t||e.defaultPrevented||(this._openDropdown(),this._updateDropdownWidth(),this._setFirstActiveOption(),this._scrollToOption(this._activeOption),this._config.selectFilter&&(setTimeout(()=>{this.filterInput.focus()},0),this._listenToSelectSearch(),this._listenToDropdownKeydown()),this._listenToOptionsClick(),this._listenToOutsideClick(),this._listenToWindowResize(),this._isOpen=!0,this._updateLabelPosition(),this._setInputActiveStyles())}_openDropdown(){this._popper=Fe(this._input,this._dropdownContainer,{placement:\"bottom-start\",modifiers:[{name:\"offset\",options:{offset:[0,1]}}]}),this._container.appendChild(this._dropdownContainer),setTimeout(()=>{this.dropdown.setAttribute(np,\"\")},0)}_updateDropdownWidth(){const t=this._input.offsetWidth;g.addStyle(this._dropdownContainer,{width:`${t}px`})}_setFirstActiveOption(){const t=this._getNavigationOptions(),e=this._activeOption;e&&e.removeActiveStyles();const i=this.multiple?this._selectionModel.selections[0]:this._selectionModel.selection;i?(this._activeOption=i,i.setActiveStyles(),this._activeOptionIndex=t.findIndex(n=>n===i)):(this._activeOption=null,this._activeOptionIndex=-1)}_setInputActiveStyles(){this._input.setAttribute(qe,\"\"),m.findOne(Bl,this._wrapper).setAttribute(qe,\"\")}_listenToWindowResize(){_.on(window,\"resize\",this._handleWindowResize.bind(this))}_handleWindowResize(){this._dropdownContainer&&this._updateDropdownWidth()}_listenToSelectSearch(){this.filterInput.addEventListener(\"input\",t=>{const e=t.target.value,i=this._config.selectFilterDebounce;this._debounceFilter(e,i)})}_debounceFilter(t,e){this._debounceTimeoutId&&clearTimeout(this._debounceTimeoutId),this._debounceTimeoutId=setTimeout(()=>{this._filterOptions(t)},e)}_filterOptions(t){const e=[];this._optionsToRender.forEach(o=>{const r=Object.prototype.hasOwnProperty.call(o,\"options\"),a=!r&&o.label.toLowerCase().includes(t.toLowerCase()),l={};r&&(l.label=o.label,l.options=this._filter(t,o.options),l.options.length>0&&e.push(l)),a&&e.push(o)});const i=this._config.selectNoResultText!==\"\",n=e.length!==0;if(n)this._updateOptionsListTemplate(e),this._popper.forceUpdate(),this._filteredOptionsList=this._getPlainOptions(e),this.hasSelectAll&&this._updateSelectAllState(),this._setFirstActiveOption();else if(!n&&i){const o=this._getNoResultTemplate();this.optionsWrapper.innerHTML=o}}_updateOptionsListTemplate(t){const e=m.findOne(rp,this._dropdownContainer)||m.findOne(ox,this._dropdownContainer),i=qu(t,this._selectAllOption,this._config,this._classes);this.optionsWrapper.removeChild(e),this.optionsWrapper.appendChild(i)}_getNoResultTemplate(){return`
${this._config.selectNoResultText}
`}_filter(t,e){const i=t.toLowerCase();return e.filter(n=>n.label.toLowerCase().includes(i))}_listenToDropdownKeydown(){_.on(this.dropdown,\"keydown\",this._handleOpenKeydown.bind(this))}_listenToOutsideClick(){this._outsideClick=this._handleOutSideClick.bind(this),_.on(document,\"click\",this._outsideClick)}_listenToFocusChange(t=!0){if(t===!1){_.off(this._input,\"focus\",()=>this._notch.setAttribute(qe,\"\")),_.off(this._input,\"blur\",()=>this._notch.removeAttribute(qe));return}_.on(this._input,\"focus\",()=>this._notch.setAttribute(qe,\"\")),_.on(this._input,\"blur\",()=>this._notch.removeAttribute(qe))}_handleOutSideClick(t){const e=this._wrapper&&this._wrapper.contains(t.target),i=t.target===this._dropdownContainer,n=this._dropdownContainer&&this._dropdownContainer.contains(t.target);let o;this._toggleButton||(this._elementToggle=m.find(rx)),this._elementToggle&&this._elementToggle.forEach(r=>{const a=g.getDataAttribute(r,\"select-toggle\");(a===this._element.id||this._element.classList.contains(a))&&(this._toggleButton=r,o=this._toggleButton.contains(t.target))}),!e&&!i&&!n&&!o&&this.close()}close(){const t=_.trigger(this._element,KE),e=oo(this._dropdownContainer.children[0]);!this._isOpen||t.defaultPrevented||(this._config.selectFilter&&this.hasSelectAll&&(this._resetFilterState(),this._updateOptionsListTemplate(this._optionsToRender),this._config.multiple&&this._updateSelectAllState()),this._removeDropdownEvents(),this.dropdown.removeAttribute(np),setTimeout(()=>{this._input.removeAttribute(qe),this._input.blur(),m.findOne(Bl,this._wrapper).removeAttribute(qe),this._label&&!this.hasSelection&&(this._label.removeAttribute(wt),this._notch.setAttribute(wt,\"\"),this._input.removeAttribute(wt),this._notch.removeAttribute(wt)),this._updateLabelPositionWhileClosing()},0),setTimeout(()=>{this._container&&this._dropdownContainer.parentNode===this._container&&this._container.removeChild(this._dropdownContainer),this._popper.destroy(),this._isOpen=!1,_.off(this.dropdown,\"transitionend\")},e))}_resetFilterState(){this.filterInput.value=\"\",this._filteredOptionsList=null}_removeDropdownEvents(){_.off(document,\"click\",this._outsideClick),this._config.selectFilter&&_.off(this.dropdown,\"keydown\"),_.off(this.optionsWrapper,\"click\")}_addMutationObserver(){this._mutationObserver=new MutationObserver(()=>{this._wrapper&&(this._updateSelections(),this._updateDisabledState())}),this._observeMutationObserver()}_updateSelections(){this._optionsToRender=this._getOptionsToRender(this._element),this._plainOptions=this._getPlainOptions(this._optionsToRender),this._selectionModel.clear(),this._setDefaultSelections(),this._updateInputValue(),this._updateFakeLabelPosition(),this._updateLabelPosition(),this._updateClearButtonVisibility(),this.hasSelectAll&&this._updateSelectAllState();const t=this._config.filter&&this.filterInput&&this.filterInput.value;this._isOpen&&!t?(this._updateOptionsListTemplate(this._optionsToRender),this._setFirstActiveOption()):this._isOpen&&t?(this._filterOptions(this.filterInput.value),this._setFirstActiveOption()):this._dropdownContainer=Gu(this._dropdownContainerId,this._config,this._input.offsetWidth,this._dropdownHeight,this._selectAllOption,this._optionsToRender,this._customContent,this._classes)}_updateDisabledState(){const t=m.findOne(op,this._wrapper);this._element.hasAttribute(\"disabled\")?(this._config.disabled=!0,t.setAttribute(\"disabled\",\"\"),t.setAttribute(Nl,\"\")):(this._config.disabled=!1,t.removeAttribute(\"disabled\"),t.removeAttribute(Nl))}_observeMutationObserver(){this._mutationObserver&&this._mutationObserver.observe(this._element,{attributes:!0,childList:!0,characterData:!0,subtree:!0})}_disconnectMutationObserver(){this.mutationObserver&&(this._mutationObserver.disconnect(),this._mutationObserver=null)}_createSelectAllOption(){const t=this._selectAllId,e=null,i=!0,n=\"select-all\",o=this._config.selectAllLabel,r=Rl(this.options),a=!1,l=!1,c=null,h=null,d=null;return new Ku(t,e,i,n,o,r,a,l,c,h,d)}dispose(){this._removeComponentEvents(),this._destroyMaterialSelect(),this._listenToFocusChange(!1),O.removeData(this._element,en)}_removeComponentEvents(){_.off(this.input,\"click\"),_.off(this.wrapper,this._handleKeydown.bind(this)),_.off(this.clearButton,\"click\"),_.off(this.clearButton,\"keydown\"),_.off(window,\"resize\",this._handleWindowResize.bind(this))}_destroyMaterialSelect(){this._isOpen&&this.close(),this._destroyMaterialTemplate()}_destroyMaterialTemplate(){const t=this._wrapper.parentNode,e=m.find(\"label\",this._wrapper);t.appendChild(this._element),e.forEach(i=>{t.appendChild(i)}),e.forEach(i=>{i.removeAttribute(wt)}),g.removeClass(this._element,this._classes.initialized),this._element.removeAttribute(ip),t.removeChild(this._wrapper)}setValue(t){this.options.filter(i=>i.selected).forEach(i=>i.nativeOption.selected=!1),Array.isArray(t)?t.forEach(i=>{this._selectByValue(i)}):this._selectByValue(t),this._updateSelections(),this._emitValueChangeEvent(this.value)}_selectByValue(t){const e=this.options.find(i=>i.value===t);return e?(e.nativeOption.selected=!0,!0):!1}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,en);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new on(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,en)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const dx=({inputID:s,labelText:t},e)=>`
\n \n ${t}\n \n
\n `,ux=({text:s,iconSVG:t},e)=>`
\n ${s} \n \n ${t}\n \n
`,rr=\"chip\",px=`te.${rr}`,lp=\"data-te-chip-close\",Hl=`[${lp}]`,fx=\"delete.te.chips\",_x=\"select.te.chip\",gx=' ',mx={text:\"string\",closeIcon:\"boolean\",img:\"object\",iconSVG:\"string\"},bx={text:\"\",closeIcon:!1,img:{path:\"\",alt:\"\"},iconSVG:gx},vx={icon:\"float-right pl-[8px] text-[16px] opacity-[.53] cursor-pointer fill-[#afafaf] hover:text-[#8b8b8b] transition-all duration-200 ease-in-out\",chipElement:\"flex justify-between items-center h-[32px] leading-loose py-[5px] px-[12px] mr-4 my-[5px] text-[13px] font-normal text-[#4f4f4f] cursor-pointer bg-[#eceff1] dark:text-white dark:bg-neutral-600 rounded-[16px] transition-[opacity] duration-300 ease-linear [word-wrap: break-word] shadow-none normal-case hover:!shadow-none active:bg-[#cacfd1] inline-block font-medium leading-normal text-[#4f4f4f] text-center no-underline align-middle cursor-pointer select-none border-[.125rem] border-solid border-transparent py-1.5 px-3 text-xs rounded\",chipCloseIcon:\"w-4 float-right pl-[8px] text-[16px] opacity-[.53] cursor-pointer fill-[#afafaf] hover:fill-[#8b8b8b] dark:fill-gray-400 dark:hover:fill-gray-100 transition-all duration-200 ease-in-out\"},yx={icon:\"string\",chipElement:\"string\",chipCloseIcon:\"string\"};class ki{constructor(t,e={},i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i)}static get NAME(){return rr}init(){this._appendCloseIcon(),this._handleDelete(),this._handleTextChip(),this._handleClickOnChip()}dispose(){this._element=null,this._options=null,_.off(this._element,\"click\")}appendChip(){const{text:t,closeIcon:e,iconSVG:i}=this._options;return ux({text:t,closeIcon:e,iconSVG:i},this._classes)}_appendCloseIcon(t=this._element){if(!(m.find(Hl,this._element).length>0)&&this._options.closeIcon){const e=$(\"span\");e.classList=this._classes.icon,e.setAttribute(lp),e.innerHTML=this._options.iconSVG,t.insertAdjacentElement(\"beforeend\",e)}}_handleClickOnChip(){_.on(this._element,\"click\",t=>{const{textContent:e}=t.target,i={};i.tag=e.trim(),_.trigger(_x,{event:t,obj:i})})}_handleDelete(){m.find(Hl,this._element).length!==0&&_.on(this._element,\"click\",Hl,()=>{_.trigger(this._element,fx),this._element.remove()})}_handleTextChip(){this._element.innerText===\"\"&&(this._element.innerText=this._options.text)}_getConfig(t){const e={...bx,...g.getDataAttributes(this._element),...t};return L(rr,e,mx),e}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...vx,...e,...t},L(rr,t,yx),t}static getInstance(t){return O.getData(t,px)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const rn=\"chips\",an=`data-te-${rn}`,cp=`te.${rn}`,Tx=`${an}-input-init`,Wt=`${an}-active`,hp=`${an}-initial`,dp=`${an}-placeholder`,Ex=`${an}-input-wrapper`,Vl=\"data-te-chip-init\",up=\"data-te-chip-close\",pp=\"data-te-chip-text\",xx=`[${Wt}]`,Fl=`[${Vl}]`,Cx=`${Fl}${xx}`,Wl=`[${up}]`,Ax=`[${Ex}]`,wx=`[${pp}]`,kx=`[${dp}]`,Sx=\"data-te-input-notch-leading-ref\",Ox=\"data-te-input-notch-middle-ref\",Ix=`[${Sx}]`,Dx=`[${Ox}]`,us=\"data-te-input-state-active\",zl=\"[data-te-input-notch-ref]\",Mx=\"add.te.chips\",Lx=\"arrowDown.te.chips\",$x=\"arrowLeft.te.chips\",Rx=\"arrowRight.te.chips\",Px=\"arrowUp.te.chips\",fp=\"delete.te.chips\",_p=\"select.te.chips\",Nx={inputID:\"string\",parentSelector:\"string\",initialValues:\"array\",editable:\"boolean\",labelText:\"string\",inputClasses:\"object\",inputOptions:\"object\"},Bx={inputID:bt(\"chips-input-\"),parentSelector:\"\",initialValues:[{tag:\"init1\"},{tag:\"init2\"}],editable:!1,labelText:\"Example label\",inputClasses:{},inputOptions:{}},Hx={opacity:\"opacity-0\",inputWrapperPadding:\"p-[5px]\",transition:\"transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]\",contentEditable:\"outline-none !border-[3px] !border-solid !border-[#b2b3b4]\",chipsInputWrapper:\"relative flex items-center flex-wrap transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]\",chipsInput:\"peer block min-h-[auto] w-[150px] rounded border-0 bg-transparent py-[0.32rem] px-3 leading-[1.6] outline-none transition-all duration-200 ease-linear focus:placeholder:opacity-100 data-[te-input-state-active]:placeholder:opacity-100 motion-reduce:transition-none dark:text-gray-200 dark:placeholder:text-gray-200 [&:not([data-te-input-placeholder-active])]:placeholder:opacity-0\",chipsLabel:\"pointer-events-none absolute top-0 left-3 mb-0 max-w-[90%] origin-[0_0] truncate pt-[0.37rem] leading-[1.6] text-gray-500 transition-all duration-200 ease-out peer-focus:-translate-y-[0.9rem] peer-focus:scale-[0.8] peer-focus:text-primary peer-data-[te-input-state-active]:-translate-y-[0.9rem] peer-data-[te-input-state-active]:scale-[0.8] motion-reduce:transition-none dark:text-gray-200 dark:peer-focus:text-gray-200\"},Vx={opacity:\"string\",inputWrapperPadding:\"string\",transition:\"string\",contentEditable:\"string\",chipsInputWrapper:\"string\",chipsInput:\"string\",chipsLabel:\"string\"};class gp extends ki{constructor(e,i={},n){super(e,i);ke(this,\"_handleBlurInput\",({target:e})=>{e.value.length>0&&this._handleCreateChip(e,e.value),this.allChips.length>0?(e.setAttribute(Wt,\"\"),this.input.setAttribute(us,\"\"),m.findOne(zl,this.input.parentNode).setAttribute(us,\"\"),this.chipsInputWrapper.classList.add(...this._classes.inputWrapperPadding.split(\" \"))):(e.removeAttribute(Wt),this.input.removeAttribute(us),m.findOne(zl,this.input.parentNode).removeAttribute(us),this.chipsInputWrapper.classList.remove(...this._classes.inputWrapperPadding.split(\" \"))),this.allChips.forEach(i=>i.removeAttribute(Wt))});this._element=e,this._inputInstance=null,this._element&&O.setData(e,cp,this),this._options=this._getConfig(i),this._classes=this._getClasses(n),this.numberClicks=0,this.init()}static get NAME(){return rn}get activeChip(){return m.findOne(Cx,this._element)}get input(){return m.findOne(\"input\",this._element)}get allChips(){return m.find(Fl,this._element)}get chipsInputWrapper(){return m.findOne(Ax,this._element)}init(){this._setChipsClass(),this._appendInputToElement(dp),this._handleInitialValue(),this._handleInputText(),this._handleKeyboard(),this._handleChipsOnSelect(),this._handleEditable(),this._handleChipsFocus(),this._handleClicksOnChips(),this._inputInstance._getLabelWidth(),this._inputInstance._applyNotch()}dispose(){this._element=null,this._options=null}_getNotchData(){this._notchMiddle=m.findOne(Dx,this._element),this._notchLeading=m.findOne(Ix,this._element)}_setChipsClass(){this._element.setAttribute(Tx,\"\")}_handleDeleteEvents(e){const[i]=this.allChips.slice(-1);if(this.activeChip===null)i.remove(),this._handleEvents(e,fp);else{const n=this.allChips.findIndex(a=>a===this.activeChip),o=this._handleActiveChipAfterRemove(n),r=[];if(this.activeChip===null)return;this.activeChip.remove(),this._handleEvents(e,fp),this.numberClicks=n,o.setAttribute(Wt,\"\"),this.allChips.forEach(a=>{a.hasAttribute(Wt)&&(r.push(a),r.length>1&&this.allChips.forEach(l=>l.remove()))})}}_handleUpEvents(e){this.numberClicks+=1,this.numberClicks===this.allChips.length+1&&(this.numberClicks=0),this._handleRightKeyboardArrow(this.numberClicks),this._handleEvents(e,Rx),this._handleEvents(e,Px)}_handleDownEvents(e){this.numberClicks-=1,this.numberClicks<=0&&(this.numberClicks=this.allChips.length),this._handleLeftKeyboardArrow(this.numberClicks),this._handleEvents(e,$x),this._handleEvents(e,Lx)}_keyboardEvents(e){const{target:i,keyCode:n,ctrlKey:o}=e;i.value.length>0||this.allChips.length===0||(n===zy||n===jy?this._handleDeleteEvents(e):n===hs||n===ut?this._handleUpEvents(e):n===cs||n===ht?this._handleDownEvents(e):n===65&&o&&this._handleAddActiveClass())}_handleKeyboard(){_.on(this.input,\"keydown\",e=>this._keyboardEvents(e))}_handleEditable(){const{editable:e}=this._options;e&&this.allChips.forEach(i=>{_.on(i,\"dblclick\",n=>{const o=m.findOne(Wl,i);i.classList.add(...this._classes.contentEditable.split(\" \")),i.contentEditable=!0,i.focus(),setTimeout(()=>{g.addStyle(o,{display:\"none\"})},200),o.classList.add(...this._classes.opacity.split(\" \")),n.target.textContent,_.trigger(i,_p,{event:n,allChips:this.allChips})}),_.on(document,\"click\",({target:n})=>{const o=m.findOne(Wl,i),r=m.findOne(wx,i),a=n===i,l=i&&i.contains(n);!a&&!l&&(i.contentEditable=!1,i.classList.remove(...this._classes.contentEditable.split(\" \")),r.textContent!==\"\"&&setTimeout(()=>{g.addStyle(o,{display:\"block\"}),o.classList.remove(...this._classes.opacity.split(\" \"))},160)),r.textContent===\"\"&&(setTimeout(()=>{i.classList.add(...this._classes.opacity.split(\" \"))},200),setTimeout(()=>{i.remove()},300))})})}_handleRemoveActiveClass(){this.allChips.forEach(e=>e.removeAttribute(Wt))}_handleAddActiveClass(){this.allChips.forEach(e=>e.setAttribute(Wt,\"\"))}_handleRightKeyboardArrow(e){this._handleRemoveActiveClass(),e===0&&(e=1),this._handleAddActiveClassWithKebyboard(e)}_handleLeftKeyboardArrow(e){this._handleRemoveActiveClass(),this._handleAddActiveClassWithKebyboard(e)}_handleActiveChipAfterRemove(e){const i=e===0?1:e-1;return this.allChips[i]}_handleClicksOnChips(){_.on(this._element,\"click\",()=>{this.allChips.length===0&&(this.chipsInputWrapper.classList.remove(...this._classes.inputWrapperPadding.split(\" \")),this.input.removeAttribute(Wt))})}_handleTextContent(){const e=[];return this.allChips.forEach(i=>e.push({tag:i.textContent.trim()})),e}_handleEvents(e,i){const n=this._handleTextContent(),o=this.allChips.filter(r=>r.hasAttribute(Wt)&&r);_.trigger(this._element,i,{event:e,allChips:this.allChips,arrOfObjects:n,active:o,activeObj:{tag:o.length<=0?\"\":o[0].textContent.trim()}})}_handleChipsFocus(){_.on(this._element,\"click\",({target:{attributes:e}})=>{const i=[...e].map(n=>n.name);i.includes(Vl)||i.includes(up)||i.includes(pp)||this.input.focus()})}_handleInitialValue(){if(this._appendInputToElement(hp),this._element.hasAttribute(hp)){const{initialValues:e}=this._options;e.forEach(({tag:i})=>this._handleCreateChip(this.input,i)),m.findOne(zl,this.input.parentNode).setAttribute(us,\"\"),this.input.setAttribute(Wt,\"\"),this.input.setAttribute(us,\"\")}this.allChips.length>0&&(this.chipsInputWrapper.classList.add(...this._classes.inputWrapperPadding.split(\" \")),this.chipsInputWrapper.classList.add(...this._classes.transition.split(\" \")))}_handleKeysInputToElement(e){const{keyCode:i,target:n}=e;if(n.hasAttribute(Vl)){const o=m.findOne(Wl,n);i===Et&&(n.contentEditable=!1,n.classList.remove(...this._classes.contentEditable.split(\" \")),n.textContent!==\"\"?setTimeout(()=>{g.addStyle(o,{display:\"block\"}),o.classList.remove(...this._classes.opacity.split(\" \"))},160):n.textContent===\"\"&&(setTimeout(()=>{n.classList.add(...this._classes.opacity.split(\" \"))},200),setTimeout(()=>{n.remove()},300)));return}if(i===Et){if(n.value===\"\")return;this._handleCreateChip(n,n.value),this._handleRemoveActiveClass(),this.numberClicks=this.allChips.length+1,this._handleEvents(e,Mx)}this.allChips.length>0?(this.chipsInputWrapper.classList.add(...this._classes.inputWrapperPadding.split(\" \")),this.chipsInputWrapper.classList.add(...this._classes.transition.split(\" \"))):this.chipsInputWrapper.classList.remove(...this._classes.inputWrapperPadding.split(\" \"))}_handleInputText(){const e=m.findOne(kx,this._element);_.on(this._element,\"keyup\",e,i=>this._handleKeysInputToElement(i)),_.on(this.input,\"blur\",i=>this._handleBlurInput(i))}_appendInputToElement(e){if(!this._element.hasAttribute(e))return;const i=dx(this._options,this._classes);this._element.insertAdjacentHTML(\"beforeend\",i);const n=m.findOne(\"[data-te-chips-input-wrapper]\",this._element);this._inputInstance=new Z(n,this._options.inputOptions,this._options.inputClasses)}_handleCreateChip(e,i){const n=$(\"div\"),o=ki.getInstance(n),r=new ki(o,{text:i},this._classes);this._options.parentSelector!==\"\"?document.querySelector(this._options.parentSelector).insertAdjacentHTML(\"beforeend\",r.appendChip()):e.insertAdjacentHTML(\"beforebegin\",r.appendChip()),e.value=\"\",m.find(Fl).forEach(a=>{let l=ki.getInstance(a);return l||(l=new ki(a,{},this._classes)),l.init()}),this._handleEditable()}_handleChipsOnSelect(){this.allChips.forEach(e=>{_.on(this._element,\"click\",i=>{_.trigger(e,_p,{event:i,allChips:this.allChips})})})}_handleAddActiveClassWithKebyboard(e){let i;this.allChips[e-1]===void 0?i=this.allChips[e-2]:i=this.allChips[e-1],i.setAttribute(Wt)}_getConfig(e){const i={...Bx,...g.getDataAttributes(this._element),...e};return L(rn,i,Nx),i}_getClasses(e){const i=g.getDataClassAttributes(this._element);return e={...Hx,...i,...e},L(rn,e,Vx),e}static getInstance(e){return O.getData(e,cp)}static getOrCreateInstance(e,i={}){return this.getInstance(e)||new this(e,typeof i==\"object\"?i:null)}}const Ze={plugins:{legend:{labels:{color:\"rgb(102,102,102)\"}}}},ln={line:{options:{...Ze,elements:{line:{backgroundColor:\"rgba(59, 112, 202, 0.0)\",borderColor:\"rgb(59, 112, 202)\",borderWidth:2,tension:0},point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0},tooltips:{intersect:!1,mode:\"index\"},datasets:{borderColor:\"red\"},scales:{x:{stacked:!0,grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{stacked:!1,grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}},bar:{options:{...Ze,backgroundColor:\"rgb(59, 112, 202)\",borderWidth:0,responsive:!0,legend:{display:!0},tooltips:{intersect:!1,mode:\"index\"},scales:{x:{stacked:!0,grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{stacked:!0,grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}},pie:{options:{...Ze,elements:{arc:{backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0}}},doughnut:{options:{...Ze,elements:{arc:{backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0}}},polarArea:{options:{...Ze,elements:{arc:{backgroundColor:\"rgba(59, 112, 202, 0.5)\"}},responsive:!0,legend:{display:!0}}},radar:{options:{...Ze,elements:{line:{backgroundColor:\"rgba(59, 112, 202, 0.5)\",borderColor:\"rgb(59, 112, 202)\",borderWidth:2},point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgb(59, 112, 202)\"}},responsive:!0,legend:{display:!0}}},scatter:{options:{...Ze,elements:{line:{backgroundColor:\"rgba(59, 112, 202, 0.5)\",borderColor:\"rgb(59, 112, 202)\",borderWidth:2,tension:0},point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgba(59, 112, 202, 0.5)\"}},responsive:!0,legend:{display:!0},tooltips:{intersect:!1,mode:\"index\"},datasets:{borderColor:\"red\"},scales:{x:{stacked:!0,grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{stacked:!1,grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}},bubble:{options:{...Ze,elements:{point:{borderColor:\"rgb(59, 112, 202)\",backgroundColor:\"rgba(59, 112, 202, 0.5)\"}},responsive:!0,legend:{display:!0},scales:{x:{grid:{display:!1},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}},y:{grid:{borderDash:[2],drawBorder:!1,zeroLineColor:\"rgba(0,0,0,0)\",zeroLineBorderDash:[2],zeroLineBorderDashOffset:[2]},ticks:{fontColor:\"rgba(0,0,0, 0.5)\"}}}}}};var Fx=function(t){return Wx(t)&&!zx(t)};function Wx(s){return!!s&&typeof s==\"object\"}function zx(s){var t=Object.prototype.toString.call(s);return t===\"[object RegExp]\"||t===\"[object Date]\"||Kx(s)}var jx=typeof Symbol==\"function\"&&Symbol.for,Yx=jx?Symbol.for(\"react.element\"):60103;function Kx(s){return s.$$typeof===Yx}function Ux(s){return Array.isArray(s)?[]:{}}function cn(s,t){return t.clone!==!1&&t.isMergeableObject(s)?ps(Ux(s),s,t):s}function Xx(s,t,e){return s.concat(t).map(function(i){return cn(i,e)})}function Gx(s,t){if(!t.customMerge)return ps;var e=t.customMerge(s);return typeof e==\"function\"?e:ps}function qx(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter(function(t){return Object.propertyIsEnumerable.call(s,t)}):[]}function mp(s){return Object.keys(s).concat(qx(s))}function bp(s,t){try{return t in s}catch{return!1}}function Zx(s,t){return bp(s,t)&&!(Object.hasOwnProperty.call(s,t)&&Object.propertyIsEnumerable.call(s,t))}function Qx(s,t,e){var i={};return e.isMergeableObject(s)&&mp(s).forEach(function(n){i[n]=cn(s[n],e)}),mp(t).forEach(function(n){Zx(s,n)||(bp(s,n)&&e.isMergeableObject(t[n])?i[n]=Gx(n,e)(s[n],t[n],e):i[n]=cn(t[n],e))}),i}function ps(s,t,e){e=e||{},e.arrayMerge=e.arrayMerge||Xx,e.isMergeableObject=e.isMergeableObject||Fx,e.cloneUnlessOtherwiseSpecified=cn;var i=Array.isArray(t),n=Array.isArray(s),o=i===n;return o?i?e.arrayMerge(s,t,e):Qx(s,t,e):cn(t,e)}ps.all=function(t,e){if(!Array.isArray(t))throw new Error(\"first argument should be an array\");return t.reduce(function(i,n){return ps(i,n,e)},{})};var Jx=ps,jl=Jx;const vp=\"chart\",ar=\"te.chart\",tC=\"chart\",Yl=(s,t,e)=>{const i=(n,o,r)=>{const a=n.slice();return o.forEach((l,c)=>{typeof a[c]>\"u\"?a[c]=r.cloneUnlessOtherwiseSpecified(l,r):r.isMergeableObject(l)?a[c]=jl(n[c],l,r):n.indexOf(l)===-1&&a.push(l)}),a};return jl(e[t],s,{arrayMerge:i})},eC={darkTicksColor:\"#fff\",darkLabelColor:\"#fff\",darkGridLinesColor:\"#555\",darkmodeOff:\"undefined\",darkMode:null,darkBgColor:\"#262626\",darkBgColorLight:\"#fff\",options:null},iC={darkTicksColor:\"string\",darkLabelColor:\"string\",darkGridLinesColor:\"string\",darkmodeOff:\"(string|null)\",darkMode:\"(string|null)\",darkBgColor:\"string\",darkBgColorLight:\"string\",options:\"(object|null)\"};let yp=class am{constructor(t,e,i={},n={}){this._waitForCharts(t,e,i,n)}async _getChartjs(){const{Chart:t,ArcElement:e,LineElement:i,BarElement:n,PointElement:o,BarController:r,BubbleController:a,DoughnutController:l,LineController:c,PieController:h,PolarAreaController:d,RadarController:u,ScatterController:p,CategoryScale:f,LinearScale:b,LogarithmicScale:v,RadialLinearScale:y,TimeScale:T,TimeSeriesScale:x,Decimation:E,Filler:C,Legend:A,Title:w,Tooltip:S,SubTitle:k}=await Promise.resolve().then(()=>UM);return t.register(e,i,n,o,r,a,l,c,h,d,u,p,f,b,v,y,T,x,E,C,A,w,S,k),t}async _getChartDataLabels(){return await Promise.resolve().then(()=>gL)}async _waitForCharts(t,e,i={},n={}){if(this._Chartjs=await this._getChartjs(),this._ChartDataLabels=await this._getChartDataLabels(),this._element=t,this._data=e,this._options=i,this._type=e.type,this._canvas=null,this._chart=null,this._darkOptions=this._getDarkConfig(n),this._darkModeClassContainer=document.querySelector(\"html\"),this._prevConfig=null,this._observer=null,this._element&&(O.setData(t,ar,this),g.addClass(this._element,tC),this._chartConstructor()),this._darkOptions.darkmodeOff!==null){const o=this._darkOptions.darkMode===\"dark\"?\"dark\":this._darkOptions.darkMode===\"light\"?\"light\":this.systemColorMode;this._handleMode(o),this._observer=new MutationObserver(this._observerCallback.bind(this)),this._observer.observe(this._darkModeClassContainer,{attributes:!0})}}static get NAME(){return vp}get systemColorMode(){return localStorage.theme||(this._darkModeClassContainer.classList.contains(\"dark\")?\"dark\":\"light\")}dispose(){this._observer.disconnect(),O.removeData(this._element,ar),this._element=null}update(t,e){t&&(this._data={...this._data,...t},this._chart.data=this._data);const i=Object.prototype.hasOwnProperty.call(e,\"options\")?e:{options:{...e}};this._options=jl(this._options,i),this._chart.options=Yl(this._options,this._type,ln).options,this._chart.update()}setTheme(t){t!==\"dark\"&&t!==\"light\"||!this._data||this._handleMode(t)}_getDarkConfig(t){let e={};const i=g.getDataAttributes(this._element);Object.keys(i).forEach(c=>c.startsWith(\"dark\")&&(e[c]=i[c])),e={...eC,...e};const n={y:{ticks:{color:e.darkTicksColor},grid:{color:e.darkGridLinesColor}},x:{ticks:{color:e.darkTicksColor},grid:{color:e.darkGridLinesColor}}},o={r:{ticks:{color:e.darkTicksColor,backdropColor:e.darkBgColor},grid:{color:e.darkGridLinesColor},pointLabels:{color:e.darkTicksColor}}},l={scales:[\"pie\",\"doughnut\",\"polarArea\",\"radar\"].includes(this._type)?[\"polarArea\",\"radar\"].includes(this._type)?o:{}:n,plugins:{legend:{labels:{color:e.darkLabelColor}}}};return t={...e,options:{...l},...t},L(vp,t,iC),t}_chartConstructor(){if(this._data){this._createCanvas();const t=Yl(this._options,this._type,ln),e=[];t.dataLabelsPlugin&&e.push(this._ChartDataLabels.default),this._prevConfig=t,this._chart=new this._Chartjs(this._canvas,{...this._data,...t,plugins:e})}}_createCanvas(){this._canvas||(this._element.nodeName===\"CANVAS\"?this._canvas=this._element:(this._canvas=$(\"canvas\"),this._element.appendChild(this._canvas)))}_handleMode(t){t===\"dark\"?(this._changeDatasetBorderColor(),this.update(null,this._darkOptions.options)):(this._changeDatasetBorderColor(!1),this._prevConfig&&this.update(null,this._prevConfig))}_observerCallback(t){for(const e of t)e.type===\"attributes\"&&this._handleMode(this.systemColorMode)}_changeDatasetBorderColor(t=!0){[...this._data.data.datasets].forEach(e=>[\"pie\",\"doughnut\",\"polarArea\"].includes(this._type)&&(e.borderColor=t?this._darkOptions.darkBgColor:this._darkOptions.darkBgColorLight))}static jQueryInterface(t,e,i){return this.each(function(){let n=O.getData(this,ar);if(!(!n&&/dispose/.test(t))){if(!n){const o=e?Yl(e,i,ln):ln[i];n=new am(this,{...t,...o})}if(typeof t==\"string\"){if(typeof n[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);n[t](e,i)}}})}static getInstance(t){return O.getData(t,ar)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}};/*!\n * perfect-scrollbar v1.5.3\n * Copyright 2021 Hyunje Jun, MDBootstrap and Contributors\n * Licensed under MIT\n */function me(s){return getComputedStyle(s)}function Ot(s,t){for(var e in t){var i=t[e];typeof i==\"number\"&&(i=i+\"px\"),s.style[e]=i}return s}function lr(s){var t=document.createElement(\"div\");return t.className=s,t}var Tp=typeof Element<\"u\"&&(Element.prototype.matches||Element.prototype.webkitMatchesSelector||Element.prototype.mozMatchesSelector||Element.prototype.msMatchesSelector);function Qe(s,t){if(!Tp)throw new Error(\"No element matching method supported\");return Tp.call(s,t)}function fs(s){s.remove?s.remove():s.parentNode&&s.parentNode.removeChild(s)}function Ep(s,t){return Array.prototype.filter.call(s.children,function(e){return Qe(e,t)})}var at={main:\"ps\",rtl:\"ps__rtl\",element:{thumb:function(s){return\"ps__thumb-\"+s},rail:function(s){return\"ps__rail-\"+s},consuming:\"ps__child--consume\"},state:{focus:\"ps--focus\",clicking:\"ps--clicking\",active:function(s){return\"ps--active-\"+s},scrolling:function(s){return\"ps--scrolling-\"+s}}},xp={x:null,y:null};function Cp(s,t){var e=s.element.classList,i=at.state.scrolling(t);e.contains(i)?clearTimeout(xp[t]):e.add(i)}function Ap(s,t){xp[t]=setTimeout(function(){return s.isAlive&&s.element.classList.remove(at.state.scrolling(t))},s.settings.scrollingThreshold)}function sC(s,t){Cp(s,t),Ap(s,t)}var hn=function(t){this.element=t,this.handlers={}},wp={isEmpty:{configurable:!0}};hn.prototype.bind=function(t,e){typeof this.handlers[t]>\"u\"&&(this.handlers[t]=[]),this.handlers[t].push(e),this.element.addEventListener(t,e,!1)},hn.prototype.unbind=function(t,e){var i=this;this.handlers[t]=this.handlers[t].filter(function(n){return e&&n!==e?!0:(i.element.removeEventListener(t,n,!1),!1)})},hn.prototype.unbindAll=function(){for(var t in this.handlers)this.unbind(t)},wp.isEmpty.get=function(){var s=this;return Object.keys(this.handlers).every(function(t){return s.handlers[t].length===0})},Object.defineProperties(hn.prototype,wp);var _s=function(){this.eventElements=[]};_s.prototype.eventElement=function(t){var e=this.eventElements.filter(function(i){return i.element===t})[0];return e||(e=new hn(t),this.eventElements.push(e)),e},_s.prototype.bind=function(t,e,i){this.eventElement(t).bind(e,i)},_s.prototype.unbind=function(t,e,i){var n=this.eventElement(t);n.unbind(e,i),n.isEmpty&&this.eventElements.splice(this.eventElements.indexOf(n),1)},_s.prototype.unbindAll=function(){this.eventElements.forEach(function(t){return t.unbindAll()}),this.eventElements=[]},_s.prototype.once=function(t,e,i){var n=this.eventElement(t),o=function(r){n.unbind(e,o),i(r)};n.bind(e,o)};function cr(s){if(typeof window.CustomEvent==\"function\")return new CustomEvent(s);var t=document.createEvent(\"CustomEvent\");return t.initCustomEvent(s,!1,!1,void 0),t}function hr(s,t,e,i,n){i===void 0&&(i=!0),n===void 0&&(n=!1);var o;if(t===\"top\")o=[\"contentHeight\",\"containerHeight\",\"scrollTop\",\"y\",\"up\",\"down\"];else if(t===\"left\")o=[\"contentWidth\",\"containerWidth\",\"scrollLeft\",\"x\",\"left\",\"right\"];else throw new Error(\"A proper axis should be provided\");nC(s,e,o,i,n)}function nC(s,t,e,i,n){var o=e[0],r=e[1],a=e[2],l=e[3],c=e[4],h=e[5];i===void 0&&(i=!0),n===void 0&&(n=!1);var d=s.element;s.reach[l]=null,d[a]<1&&(s.reach[l]=\"start\"),d[a]>s[o]-s[r]-1&&(s.reach[l]=\"end\"),t&&(d.dispatchEvent(cr(\"ps-scroll-\"+l)),t<0?d.dispatchEvent(cr(\"ps-scroll-\"+c)):t>0&&d.dispatchEvent(cr(\"ps-scroll-\"+h)),i&&sC(s,l)),s.reach[l]&&(t||n)&&d.dispatchEvent(cr(\"ps-\"+l+\"-reach-\"+s.reach[l]))}function st(s){return parseInt(s,10)||0}function oC(s){return Qe(s,\"input,[contenteditable]\")||Qe(s,\"select,[contenteditable]\")||Qe(s,\"textarea,[contenteditable]\")||Qe(s,\"button,[contenteditable]\")}function rC(s){var t=me(s);return st(t.width)+st(t.paddingLeft)+st(t.paddingRight)+st(t.borderLeftWidth)+st(t.borderRightWidth)}var gs={isWebKit:typeof document<\"u\"&&\"WebkitAppearance\"in document.documentElement.style,supportsTouch:typeof window<\"u\"&&(\"ontouchstart\"in window||\"maxTouchPoints\"in window.navigator&&window.navigator.maxTouchPoints>0||window.DocumentTouch&&document instanceof window.DocumentTouch),supportsIePointer:typeof navigator<\"u\"&&navigator.msMaxTouchPoints,isChrome:typeof navigator<\"u\"&&/Chrome/i.test(navigator&&navigator.userAgent)};function Se(s){var t=s.element,e=Math.floor(t.scrollTop),i=t.getBoundingClientRect();s.containerWidth=Math.round(i.width),s.containerHeight=Math.round(i.height),s.contentWidth=t.scrollWidth,s.contentHeight=t.scrollHeight,t.contains(s.scrollbarXRail)||(Ep(t,at.element.rail(\"x\")).forEach(function(n){return fs(n)}),t.appendChild(s.scrollbarXRail)),t.contains(s.scrollbarYRail)||(Ep(t,at.element.rail(\"y\")).forEach(function(n){return fs(n)}),t.appendChild(s.scrollbarYRail)),!s.settings.suppressScrollX&&s.containerWidth+s.settings.scrollXMarginOffset=s.railXWidth-s.scrollbarXWidth&&(s.scrollbarXLeft=s.railXWidth-s.scrollbarXWidth),s.scrollbarYTop>=s.railYHeight-s.scrollbarYHeight&&(s.scrollbarYTop=s.railYHeight-s.scrollbarYHeight),aC(t,s),s.scrollbarXActive?t.classList.add(at.state.active(\"x\")):(t.classList.remove(at.state.active(\"x\")),s.scrollbarXWidth=0,s.scrollbarXLeft=0,t.scrollLeft=s.isRtl===!0?s.contentWidth:0),s.scrollbarYActive?t.classList.add(at.state.active(\"y\")):(t.classList.remove(at.state.active(\"y\")),s.scrollbarYHeight=0,s.scrollbarYTop=0,t.scrollTop=0)}function kp(s,t){return s.settings.minScrollbarLength&&(t=Math.max(t,s.settings.minScrollbarLength)),s.settings.maxScrollbarLength&&(t=Math.min(t,s.settings.maxScrollbarLength)),t}function aC(s,t){var e={width:t.railXWidth},i=Math.floor(s.scrollTop);t.isRtl?e.left=t.negativeScrollAdjustment+s.scrollLeft+t.containerWidth-t.contentWidth:e.left=s.scrollLeft,t.isScrollbarXUsingBottom?e.bottom=t.scrollbarXBottom-i:e.top=t.scrollbarXTop+i,Ot(t.scrollbarXRail,e);var n={top:i,height:t.railYHeight};t.isScrollbarYUsingRight?t.isRtl?n.right=t.contentWidth-(t.negativeScrollAdjustment+s.scrollLeft)-t.scrollbarYRight-t.scrollbarYOuterWidth-9:n.right=t.scrollbarYRight-s.scrollLeft:t.isRtl?n.left=t.negativeScrollAdjustment+s.scrollLeft+t.containerWidth*2-t.contentWidth-t.scrollbarYLeft-t.scrollbarYOuterWidth:n.left=t.scrollbarYLeft+s.scrollLeft,Ot(t.scrollbarYRail,n),Ot(t.scrollbarX,{left:t.scrollbarXLeft,width:t.scrollbarXWidth-t.railBorderXWidth}),Ot(t.scrollbarY,{top:t.scrollbarYTop,height:t.scrollbarYHeight-t.railBorderYWidth})}function lC(s){s.element,s.event.bind(s.scrollbarY,\"mousedown\",function(t){return t.stopPropagation()}),s.event.bind(s.scrollbarYRail,\"mousedown\",function(t){var e=t.pageY-window.pageYOffset-s.scrollbarYRail.getBoundingClientRect().top,i=e>s.scrollbarYTop?1:-1;s.element.scrollTop+=i*s.containerHeight,Se(s),t.stopPropagation()}),s.event.bind(s.scrollbarX,\"mousedown\",function(t){return t.stopPropagation()}),s.event.bind(s.scrollbarXRail,\"mousedown\",function(t){var e=t.pageX-window.pageXOffset-s.scrollbarXRail.getBoundingClientRect().left,i=e>s.scrollbarXLeft?1:-1;s.element.scrollLeft+=i*s.containerWidth,Se(s),t.stopPropagation()})}function cC(s){Sp(s,[\"containerWidth\",\"contentWidth\",\"pageX\",\"railXWidth\",\"scrollbarX\",\"scrollbarXWidth\",\"scrollLeft\",\"x\",\"scrollbarXRail\"]),Sp(s,[\"containerHeight\",\"contentHeight\",\"pageY\",\"railYHeight\",\"scrollbarY\",\"scrollbarYHeight\",\"scrollTop\",\"y\",\"scrollbarYRail\"])}function Sp(s,t){var e=t[0],i=t[1],n=t[2],o=t[3],r=t[4],a=t[5],l=t[6],c=t[7],h=t[8],d=s.element,u=null,p=null,f=null;function b(T){T.touches&&T.touches[0]&&(T[n]=T.touches[0].pageY),d[l]=u+f*(T[n]-p),Cp(s,c),Se(s),T.stopPropagation(),T.type.startsWith(\"touch\")&&T.changedTouches.length>1&&T.preventDefault()}function v(){Ap(s,c),s[h].classList.remove(at.state.clicking),s.event.unbind(s.ownerDocument,\"mousemove\",b)}function y(T,x){u=d[l],x&&T.touches&&(T[n]=T.touches[0].pageY),p=T[n],f=(s[i]-s[e])/(s[o]-s[a]),x?s.event.bind(s.ownerDocument,\"touchmove\",b):(s.event.bind(s.ownerDocument,\"mousemove\",b),s.event.once(s.ownerDocument,\"mouseup\",v),T.preventDefault()),s[h].classList.add(at.state.clicking),T.stopPropagation()}s.event.bind(s[r],\"mousedown\",function(T){y(T)}),s.event.bind(s[r],\"touchstart\",function(T){y(T,!0)})}function hC(s){var t=s.element,e=function(){return Qe(t,\":hover\")},i=function(){return Qe(s.scrollbarX,\":focus\")||Qe(s.scrollbarY,\":focus\")};function n(o,r){var a=Math.floor(t.scrollTop);if(o===0){if(!s.scrollbarYActive)return!1;if(a===0&&r>0||a>=s.contentHeight-s.containerHeight&&r<0)return!s.settings.wheelPropagation}var l=t.scrollLeft;if(r===0){if(!s.scrollbarXActive)return!1;if(l===0&&o<0||l>=s.contentWidth-s.containerWidth&&o>0)return!s.settings.wheelPropagation}return!0}s.event.bind(s.ownerDocument,\"keydown\",function(o){if(!(o.isDefaultPrevented&&o.isDefaultPrevented()||o.defaultPrevented)&&!(!e()&&!i())){var r=document.activeElement?document.activeElement:s.ownerDocument.activeElement;if(r){if(r.tagName===\"IFRAME\")r=r.contentDocument.activeElement;else for(;r.shadowRoot;)r=r.shadowRoot.activeElement;if(oC(r))return}var a=0,l=0;switch(o.which){case 37:o.metaKey?a=-s.contentWidth:o.altKey?a=-s.containerWidth:a=-30;break;case 38:o.metaKey?l=s.contentHeight:o.altKey?l=s.containerHeight:l=30;break;case 39:o.metaKey?a=s.contentWidth:o.altKey?a=s.containerWidth:a=30;break;case 40:o.metaKey?l=-s.contentHeight:o.altKey?l=-s.containerHeight:l=-30;break;case 32:o.shiftKey?l=s.containerHeight:l=-s.containerHeight;break;case 33:l=s.containerHeight;break;case 34:l=-s.containerHeight;break;case 36:l=s.contentHeight;break;case 35:l=-s.contentHeight;break;default:return}s.settings.suppressScrollX&&a!==0||s.settings.suppressScrollY&&l!==0||(t.scrollTop-=l,t.scrollLeft+=a,Se(s),n(a,l)&&o.preventDefault())}})}function dC(s){var t=s.element;function e(r,a){var l=Math.floor(t.scrollTop),c=t.scrollTop===0,h=l+t.offsetHeight===t.scrollHeight,d=t.scrollLeft===0,u=t.scrollLeft+t.offsetWidth===t.scrollWidth,p;return Math.abs(a)>Math.abs(r)?p=c||h:p=d||u,p?!s.settings.wheelPropagation:!0}function i(r){var a=r.deltaX,l=-1*r.deltaY;return(typeof a>\"u\"||typeof l>\"u\")&&(a=-1*r.wheelDeltaX/6,l=r.wheelDeltaY/6),r.deltaMode&&r.deltaMode===1&&(a*=10,l*=10),a!==a&&l!==l&&(a=0,l=r.wheelDelta),r.shiftKey?[-l,-a]:[a,l]}function n(r,a,l){if(!gs.isWebKit&&t.querySelector(\"select:focus\"))return!0;if(!t.contains(r))return!1;for(var c=r;c&&c!==t;){if(c.classList.contains(at.element.consuming))return!0;var h=me(c);if(l&&h.overflowY.match(/(scroll|auto)/)){var d=c.scrollHeight-c.clientHeight;if(d>0&&(c.scrollTop>0&&l<0||c.scrollTop0))return!0}if(a&&h.overflowX.match(/(scroll|auto)/)){var u=c.scrollWidth-c.clientWidth;if(u>0&&(c.scrollLeft>0&&a<0||c.scrollLeft0))return!0}c=c.parentNode}return!1}function o(r){var a=i(r),l=a[0],c=a[1];if(!n(r.target,l,c)){var h=!1;s.settings.useBothWheelAxes?s.scrollbarYActive&&!s.scrollbarXActive?(c?t.scrollTop-=c*s.settings.wheelSpeed:t.scrollTop+=l*s.settings.wheelSpeed,h=!0):s.scrollbarXActive&&!s.scrollbarYActive&&(l?t.scrollLeft+=l*s.settings.wheelSpeed:t.scrollLeft-=c*s.settings.wheelSpeed,h=!0):(t.scrollTop-=c*s.settings.wheelSpeed,t.scrollLeft+=l*s.settings.wheelSpeed),Se(s),h=h||e(l,c),h&&!r.ctrlKey&&(r.stopPropagation(),r.preventDefault())}}typeof window.onwheel<\"u\"?s.event.bind(t,\"wheel\",o):typeof window.onmousewheel<\"u\"&&s.event.bind(t,\"mousewheel\",o)}function uC(s){if(!gs.supportsTouch&&!gs.supportsIePointer)return;var t=s.element;function e(f,b){var v=Math.floor(t.scrollTop),y=t.scrollLeft,T=Math.abs(f),x=Math.abs(b);if(x>T){if(b<0&&v===s.contentHeight-s.containerHeight||b>0&&v===0)return window.scrollY===0&&b>0&&gs.isChrome}else if(T>x&&(f<0&&y===s.contentWidth-s.containerWidth||f>0&&y===0))return!0;return!0}function i(f,b){t.scrollTop-=b,t.scrollLeft-=f,Se(s)}var n={},o=0,r={},a=null;function l(f){return f.targetTouches?f.targetTouches[0]:f}function c(f){return f.pointerType&&f.pointerType===\"pen\"&&f.buttons===0?!1:!!(f.targetTouches&&f.targetTouches.length===1||f.pointerType&&f.pointerType!==\"mouse\"&&f.pointerType!==f.MSPOINTER_TYPE_MOUSE)}function h(f){if(c(f)){var b=l(f);n.pageX=b.pageX,n.pageY=b.pageY,o=new Date().getTime(),a!==null&&clearInterval(a)}}function d(f,b,v){if(!t.contains(f))return!1;for(var y=f;y&&y!==t;){if(y.classList.contains(at.element.consuming))return!0;var T=me(y);if(v&&T.overflowY.match(/(scroll|auto)/)){var x=y.scrollHeight-y.clientHeight;if(x>0&&(y.scrollTop>0&&v<0||y.scrollTop0))return!0}if(b&&T.overflowX.match(/(scroll|auto)/)){var E=y.scrollWidth-y.clientWidth;if(E>0&&(y.scrollLeft>0&&b<0||y.scrollLeft0))return!0}y=y.parentNode}return!1}function u(f){if(c(f)){var b=l(f),v={pageX:b.pageX,pageY:b.pageY},y=v.pageX-n.pageX,T=v.pageY-n.pageY;if(d(f.target,y,T))return;i(y,T),n=v;var x=new Date().getTime(),E=x-o;E>0&&(r.x=y/E,r.y=T/E,o=x),e(y,T)&&f.preventDefault()}}function p(){s.settings.swipeEasing&&(clearInterval(a),a=setInterval(function(){if(s.isInitialized){clearInterval(a);return}if(!r.x&&!r.y){clearInterval(a);return}if(Math.abs(r.x)<.01&&Math.abs(r.y)<.01){clearInterval(a);return}if(!s.element){clearInterval(a);return}i(r.x*30,r.y*30),r.x*=.8,r.y*=.8},10))}gs.supportsTouch?(s.event.bind(t,\"touchstart\",h),s.event.bind(t,\"touchmove\",u),s.event.bind(t,\"touchend\",p)):gs.supportsIePointer&&(window.PointerEvent?(s.event.bind(t,\"pointerdown\",h),s.event.bind(t,\"pointermove\",u),s.event.bind(t,\"pointerup\",p)):window.MSPointerEvent&&(s.event.bind(t,\"MSPointerDown\",h),s.event.bind(t,\"MSPointerMove\",u),s.event.bind(t,\"MSPointerUp\",p)))}var pC=function(){return{handlers:[\"click-rail\",\"drag-thumb\",\"keyboard\",\"wheel\",\"touch\"],maxScrollbarLength:null,minScrollbarLength:null,scrollingThreshold:1e3,scrollXMarginOffset:0,scrollYMarginOffset:0,suppressScrollX:!1,suppressScrollY:!1,swipeEasing:!0,useBothWheelAxes:!1,wheelPropagation:!0,wheelSpeed:1}},fC={\"click-rail\":lC,\"drag-thumb\":cC,keyboard:hC,wheel:dC,touch:uC},dn=function(t,e){var i=this;if(e===void 0&&(e={}),typeof t==\"string\"&&(t=document.querySelector(t)),!t||!t.nodeName)throw new Error(\"no element is specified to initialize PerfectScrollbar\");this.element=t,t.classList.add(at.main),this.settings=pC();for(var n in e)this.settings[n]=e[n];this.containerWidth=null,this.containerHeight=null,this.contentWidth=null,this.contentHeight=null;var o=function(){return t.classList.add(at.state.focus)},r=function(){return t.classList.remove(at.state.focus)};this.isRtl=me(t).direction===\"rtl\",this.isRtl===!0&&t.classList.add(at.rtl),this.isNegativeScroll=function(){var c=t.scrollLeft,h=null;return t.scrollLeft=-1,h=t.scrollLeft<0,t.scrollLeft=c,h}(),this.negativeScrollAdjustment=this.isNegativeScroll?t.scrollWidth-t.clientWidth:0,this.event=new _s,this.ownerDocument=t.ownerDocument||document,this.scrollbarXRail=lr(at.element.rail(\"x\")),t.appendChild(this.scrollbarXRail),this.scrollbarX=lr(at.element.thumb(\"x\")),this.scrollbarXRail.appendChild(this.scrollbarX),this.scrollbarX.setAttribute(\"tabindex\",0),this.event.bind(this.scrollbarX,\"focus\",o),this.event.bind(this.scrollbarX,\"blur\",r),this.scrollbarXActive=null,this.scrollbarXWidth=null,this.scrollbarXLeft=null;var a=me(this.scrollbarXRail);this.scrollbarXBottom=parseInt(a.bottom,10),isNaN(this.scrollbarXBottom)?(this.isScrollbarXUsingBottom=!1,this.scrollbarXTop=st(a.top)):this.isScrollbarXUsingBottom=!0,this.railBorderXWidth=st(a.borderLeftWidth)+st(a.borderRightWidth),Ot(this.scrollbarXRail,{display:\"block\"}),this.railXMarginWidth=st(a.marginLeft)+st(a.marginRight),Ot(this.scrollbarXRail,{display:\"\"}),this.railXWidth=null,this.railXRatio=null,this.scrollbarYRail=lr(at.element.rail(\"y\")),t.appendChild(this.scrollbarYRail),this.scrollbarY=lr(at.element.thumb(\"y\")),this.scrollbarYRail.appendChild(this.scrollbarY),this.scrollbarY.setAttribute(\"tabindex\",0),this.event.bind(this.scrollbarY,\"focus\",o),this.event.bind(this.scrollbarY,\"blur\",r),this.scrollbarYActive=null,this.scrollbarYHeight=null,this.scrollbarYTop=null;var l=me(this.scrollbarYRail);this.scrollbarYRight=parseInt(l.right,10),isNaN(this.scrollbarYRight)?(this.isScrollbarYUsingRight=!1,this.scrollbarYLeft=st(l.left)):this.isScrollbarYUsingRight=!0,this.scrollbarYOuterWidth=this.isRtl?rC(this.scrollbarY):null,this.railBorderYWidth=st(l.borderTopWidth)+st(l.borderBottomWidth),Ot(this.scrollbarYRail,{display:\"block\"}),this.railYMarginHeight=st(l.marginTop)+st(l.marginBottom),Ot(this.scrollbarYRail,{display:\"\"}),this.railYHeight=null,this.railYRatio=null,this.reach={x:t.scrollLeft<=0?\"start\":t.scrollLeft>=this.contentWidth-this.containerWidth?\"end\":null,y:t.scrollTop<=0?\"start\":t.scrollTop>=this.contentHeight-this.containerHeight?\"end\":null},this.isAlive=!0,this.settings.handlers.forEach(function(c){return fC[c](i)}),this.lastScrollTop=Math.floor(t.scrollTop),this.lastScrollLeft=t.scrollLeft,this.event.bind(this.element,\"scroll\",function(c){return i.onScroll(c)}),Se(this)};dn.prototype.update=function(){this.isAlive&&(this.negativeScrollAdjustment=this.isNegativeScroll?this.element.scrollWidth-this.element.clientWidth:0,Ot(this.scrollbarXRail,{display:\"block\"}),Ot(this.scrollbarYRail,{display:\"block\"}),this.railXMarginWidth=st(me(this.scrollbarXRail).marginLeft)+st(me(this.scrollbarXRail).marginRight),this.railYMarginHeight=st(me(this.scrollbarYRail).marginTop)+st(me(this.scrollbarYRail).marginBottom),Ot(this.scrollbarXRail,{display:\"none\"}),Ot(this.scrollbarYRail,{display:\"none\"}),Se(this),hr(this,\"top\",0,!1,!0),hr(this,\"left\",0,!1,!0),Ot(this.scrollbarXRail,{display:\"\"}),Ot(this.scrollbarYRail,{display:\"\"}))},dn.prototype.onScroll=function(t){this.isAlive&&(Se(this),hr(this,\"top\",this.element.scrollTop-this.lastScrollTop),hr(this,\"left\",this.element.scrollLeft-this.lastScrollLeft),this.lastScrollTop=Math.floor(this.element.scrollTop),this.lastScrollLeft=this.element.scrollLeft)},dn.prototype.destroy=function(){this.isAlive&&(this.event.unbindAll(),fs(this.scrollbarX),fs(this.scrollbarY),fs(this.scrollbarXRail),fs(this.scrollbarYRail),this.removePsClasses(),this.element=null,this.scrollbarX=null,this.scrollbarY=null,this.scrollbarXRail=null,this.scrollbarYRail=null,this.isAlive=!1)},dn.prototype.removePsClasses=function(){this.element.className=this.element.className.split(\" \").filter(function(t){return!t.match(/^ps([-_].+|)$/)}).join(\" \")};const Kl=\"perfectScrollbar\",_C=\"perfect-scrollbar\",dr=\"te.perfectScrollbar\",be=\"te\",ve=\"ps\",Ul=[{te:`scrollX.${be}.${ve}`,ps:\"ps-scroll-x\"},{te:`scrollY.${be}.${ve}`,ps:\"ps-scroll-y\"},{te:`scrollUp.${be}.${ve}`,ps:\"ps-scroll-up\"},{te:`scrollDown.${be}.${ve}`,ps:\"ps-scroll-down\"},{te:`scrollLeft.${be}.${ve}`,ps:\"ps-scroll-left\"},{te:`scrollRight.${be}.${ve}`,ps:\"ps-scroll-right\"},{te:`scrollXEnd.${be}.${ve}`,ps:\"ps-x-reach-end\"},{te:`scrollYEnd.${be}.${ve}`,ps:\"ps-y-reach-end\"},{te:`scrollXStart.${be}.${ve}`,ps:\"ps-x-reach-start\"},{te:`scrollYStart.${be}.${ve}`,ps:\"ps-y-reach-start\"}],gC={handlers:[\"click-rail\",\"drag-thumb\",\"keyboard\",\"wheel\",\"touch\"],wheelSpeed:1,wheelPropagation:!0,swipeEasing:!0,minScrollbarLength:null,maxScrollbarLength:null,scrollingThreshold:1e3,useBothWheelAxes:!1,suppressScrollX:!1,suppressScrollY:!1,scrollXMarginOffset:0,scrollYMarginOffset:0,positionRight:!0},mC={handlers:\"(string|array)\",wheelSpeed:\"number\",wheelPropagation:\"boolean\",swipeEasing:\"boolean\",minScrollbarLength:\"(number|null)\",maxScrollbarLength:\"(number|null)\",scrollingThreshold:\"number\",useBothWheelAxes:\"boolean\",suppressScrollX:\"boolean\",suppressScrollY:\"boolean\",scrollXMarginOffset:\"number\",scrollYMarginOffset:\"number\",positionRight:\"boolean\"},bC={ps:\"group/ps overflow-hidden [overflow-anchor:none] touch-none\",railX:\"group/x absolute bottom-0 h-[0.9375rem] hidden opacity-0 transition-[background-color,_opacity] duration-200 ease-linear motion-reduce:transition-none z-[1035] group-[&.ps--active-x]/ps:block group-hover/ps:opacity-60 group-focus/ps:opacity-60 group-[&.ps--scrolling-x]/ps:opacity-60 hover:!opacity-90 focus:!opacity-90 [&.ps--clicking]:!opacity-90 outline-none\",railXColors:\"group-[&.ps--active-x]/ps:bg-transparent hover:!bg-[#eee] focus:!bg-[#eee] [&.ps--clicking]:!bg-[#eee] dark:hover:!bg-[#555] dark:focus:!bg-[#555] dark:[&.ps--clicking]:!bg-[#555]\",railXThumb:\"absolute bottom-0.5 rounded-md h-1.5 group-focus/ps:opacity-100 group-active/ps:opacity-100 [transition:background-color_.2s_linear,_height_.2s_ease-in-out] group-hover/x:h-[11px] group-focus/x:h-[0.6875rem] group-[&.ps--clicking]/x:bg-[#999] group-[&.ps--clicking]/x:h-[11px] outline-none\",railXThumbColors:\"bg-[#aaa] group-hover/x:bg-[#999] group-focus/x:bg-[#999]\",railY:\"group/y absolute right-0 w-[0.9375rem] hidden opacity-0 transition-[background-color,_opacity] duration-200 ease-linear motion-reduce:transition-none z-[1035] group-[&.ps--active-y]/ps:block group-hover/ps:opacity-60 group-focus/ps:opacity-60 group-[&.ps--scrolling-y]/ps:opacity-60 hover:!opacity-90 focus:!opacity-90 [&.ps--clicking]:!opacity-90 outline-none\",railYColors:\"group-[&.ps--active-y]/ps:bg-transparent hover:!bg-[#eee] focus:!bg-[#eee] [&.ps--clicking]:!bg-[#eee] dark:hover:!bg-[#555] dark:focus:!bg-[#555] dark:[&.ps--clicking]:!bg-[#555]\",railYThumb:\"absolute right-0.5 rounded-md w-1.5 group-focus/ps:opacity-100 group-active/ps:opacity-100 [transition:background-color_.2s_linear,_width_.2s_ease-in-out,_opacity] group-hover/y:w-[11px] group-focus/y:w-[0.6875rem] group-[&.ps--clicking]/y:w-[11px] outline-none\",railYThumbColors:\"bg-[#aaa] group-hover/y:bg-[#999] group-focus/y:bg-[#999] group-[&.ps--clicking]/y:bg-[#999]\"},vC={ps:\"string\",railX:\"string\",railXColors:\"string\",railXThumb:\"string\",railXThumbColors:\"string\",railY:\"string\",railYColors:\"string\",railYThumb:\"string\",railYThumbColors:\"string\"};class ms{constructor(t,e={},i={}){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this.perfectScrollbar=null,this._observer=null,this._psClasses=[{ps:\"ps__rail-x\",te:this._classes.railX,teColor:this._classes.railXColors},{ps:\"ps__rail-y\",te:this._classes.railY,teColor:this._classes.railYColors},{ps:\"ps__thumb-x\",te:this._classes.railXThumb,teColor:this._classes.railXThumbColors},{ps:\"ps__thumb-y\",te:this._classes.railYThumb,teColor:this._classes.railYThumbColors}],this._element&&(O.setData(t,dr,this),g.addClass(this._element,_C)),this.init()}static get NAME(){return Kl}get railX(){return m.findOne(\".ps__rail-x\",this._element)}get railY(){return m.findOne(\".ps__rail-y\",this._element)}_getConfig(t){const e=g.getDataAttributes(this._element);return e.handlers!==void 0&&(e.handlers=e.handlers.split(\" \")),t={...gC,...e,...t},L(Kl,t,mC),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...bC,...e,...t},L(Kl,t,vC),t}dispose(){this._options.positionRight&&this._observer.disconnect(),O.removeData(this._element,dr),this._element=null,this._dataAttrOptions=null,this._options=null,this.perfectScrollbar.destroy(),this.removeEvent(Ul),this.perfectScrollbar=null}init(){if(this.perfectScrollbar=new dn(this._element,this._options),this._addPerfectScrollbarStyles(),this._updateScrollPosition(),this.perfectScrollbar.update(),this._initEvents(Ul),this._options.positionRight){this._observer=new ResizeObserver(()=>{setTimeout(()=>{this._updateScrollPosition()},100)});const t={attributes:!0,attributeFilter:[\"class\",\"className\"]};this._observer.observe(this._element,t)}}_updateScrollPosition(){const t=getComputedStyle(this._element).getPropertyValue(\"height\"),e=getComputedStyle(this._element).getPropertyValue(\"width\");this.railX&&(this.railX.style.transform=`translateY(calc(-100% + ${this._canTransform(t)?t:\"0px\"}))`),this.railY&&(this.railY.style.transform=`translateX(calc(-100% + ${this._canTransform(e)?e:\"0px\"}))`)}_canTransform(t){return t&&t.includes(\"px\")}update(){return this.perfectScrollbar.update()}_initEvents(t=[]){t.forEach(({ps:e,te:i})=>_.on(this._element,e,n=>_.trigger(this._element,i,{e:n})))}_addPerfectScrollbarStyles(){this._psClasses.forEach(t=>{const e=m.findOne(`.${t.ps}`,this._element);g.addClass(e,t.te),g.addClass(e,t.teColor)}),g.addClass(this._element,this._classes.ps),g.removeClass(this._element,\"ps\")}removeEvent(t){let e=[];typeof t==\"string\"&&(e=Ul.filter(({te:i})=>i===t)),e.forEach(({ps:i,te:n})=>{_.off(this._element,i),_.off(this._element,n)})}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,dr);const i=typeof t==\"object\"&&t;if(!(!e&&/dispose|hide/.test(t))&&(e||(e=new ms(this,i)),typeof t==\"string\")){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t]()}})}static getInstance(t){return O.getData(t,dr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const yC=\"data-te-datatable-select-ref\",TC=\"data-te-datatable-pagination-nav-ref\",EC=\"data-te-datatable-pagination-right-ref\",xC=\"data-te-datatable-pagination-left-ref\",CC=\"data-te-datatable-pagination-start-ref\",AC=\"data-te-datatable-pagination-end-ref\",wC=({text:s,entries:t,entriesOptions:e,fullPagination:i,rowsText:n,allText:o,paginationStartIconTemplate:r,paginationLeftIconTemplate:a,paginationRightIconTemplate:l,paginationEndIconTemplate:c,classes:h},d,u)=>{const p=e.map(f=>f===\"All\"?``:``).join(`\n`);return`\n
\n
\n

${n}

\n
\n \n
\n
\n
\n ${s}\n
\n
\n ${i?``:\"\"}\n \n \n ${i?``:\"\"}\n
\n
\n`},kC=\"data-te-datatable-sort-icon-ref\",SC=\"data-te-datatable-header-checkbox-ref\",OC=(s,t,e,i,n,o,r,a)=>{const l=e?`\n \n
\n \n
\n \n `:'',c=s.map((h,d)=>{const u=h.fixed?s.filter((p,f)=>p.fixed===h.fixed&&fp+f.width,0):null;return`${h.sort?`
${r}`:\"\"} ${h.label}
`});return[t?l:\"\",...c].join(`\n`)},IC=\"data-te-datatable-row-ref\",DC=\"data-te-datatable-row-checkbox-ref\",MC=\"data-te-datatable-cell-ref\",LC=({rows:s,columns:t,noFoundMessage:e,edit:i,selectable:n,loading:o,bordered:r,borderless:a,striped:l,hover:c,sm:h,classes:d})=>{const u=s.map(p=>{const f=`\n \n
\n \n
\n `,b=t.map((v,y)=>{const T={};if(v.width&&(T[\"min-width\"]=`${v.width-1}px`,T[\"max-width\"]=`${v.width}px`,T.width=`${v.width}px`),v.fixed){const E=t.filter((C,A)=>C.fixed===v.fixed&&AC+A.width,0);T[v.fixed===\"right\"?\"right\":\"left\"]=`${E}px`}return``${E}: ${T[E]}`).join(\"; \")}\" class=\"${d.rowItem} ${d.borderColor} ${i?`${d.edit}`:\"\"} ${r?`${d.tableBordered}`:\"\"} ${h?`${d.sm}`:\"\"} ${v.fixed?`${d.fixedHeader} ${d.color}`:\"\"}\" ${MC} data-te-field=\"${v.field}\" ${i&&'contenteditable=\"true\"'}>${p[v.field]}`}).join(\"\");return`${n?f:\"\"}${b}`});return s.length>0||o?u.join(`\n`):`${e}`},$C=\"data-te-datatable-inner-ref\",RC=\"data-te-datatable-header-ref\",Op=({columns:s,rows:t,noFoundMessage:e,edit:i,multi:n,selectable:o,loading:r,loadingMessage:a,pagination:l,bordered:c,borderless:h,striped:d,hover:u,fixedHeader:p,sm:f,sortIconTemplate:b,classes:v})=>{const y=LC({rows:t,columns:s,noFoundMessage:e,edit:i,loading:r,selectable:o,bordered:c,borderless:h,striped:d,hover:u,sm:f,classes:v}),T=OC(s,o,n,c,f,r,b,v);return{table:`\n
\n \n \n \n ${T}\n \n \n \n ${r?\"\":y}\n \n
\n
\n${r?`\n
\n
\n
\n
\n
\n

${a}

\n`:\"\"}\n${l.enable?wC(l,r,c):\"\"}\n `,rows:y,column:T}},PC=({rows:s,field:t,order:e})=>s.sort((n,o)=>{let r=n[t],a=o[t];return typeof r==\"string\"&&(r=r.toLowerCase()),typeof a==\"string\"&&(a=a.toLowerCase()),ra?e===\"desc\"?-1:1:0}),NC=(s,t,e)=>{if(!t)return s;const i=n=>{const o=document.createElement(\"div\");return o.innerHTML=n,n=o.textContent||o.innerText||\"\",n.toString().toLowerCase().match(t.toLowerCase())};return s.filter(n=>{if(e&&typeof e==\"string\")return i(n[e]);let o=Object.values(n);return e&&Array.isArray(e)&&(o=Object.keys(n).filter(r=>e.includes(r)).map(r=>n[r])),o.filter(r=>i(r)).length>0})},Ip=({rows:s,entries:t,activePage:e})=>{const i=e*t;return s.slice(i,i+Number(t))},un=\"datatable\",Ht=`data-te-${un}`,pn=`te.${un}`,ur=`.${pn}`,BC=`[${Ht}-inner-ref]`,Xl=`[${Ht}-cell-ref]`,HC=`[${Ht}-header-ref]`,VC=`[${Ht}-header-checkbox-ref]`,FC=`[${Ht}-pagination-right-ref]`,WC=`[${Ht}-pagination-left-ref]`,zC=`[${Ht}-pagination-start-ref]`,jC=`[${Ht}-pagination-end-ref]`,YC=`[${Ht}-pagination-nav-ref]`,KC=`[${Ht}-select-ref]`,Gl=`[${Ht}-sort-icon-ref]`,fn=`[${Ht}-row-ref]`,ql=`[${Ht}-row-checkbox-ref]`,UC=`selectRows${ur}`,Dp=`render${ur}`,XC=`rowClick${ur}`,GC=`update${ur}`,qC=`\n \n`,ZC=`\n \n`,QC=`\n \n`,JC=`\n \n`,tA=`\n \n`,eA=\"border-neutral-200 dark:border-neutral-500\",iA=\"border-none\",sA=\"relative float-left -ml-[1.5rem] mr-[6px] mt-[0.15rem] h-[1.125rem] w-[1.125rem] appearance-none rounded-[0.25rem] border-[0.125rem] border-solid border-neutral-300 outline-none before:pointer-events-none before:absolute before:h-[0.875rem] before:w-[0.875rem] before:scale-0 before:rounded-full before:bg-transparent before:opacity-0 before:shadow-[0px_0px_0px_13px_transparent] before:content-[''] checked:border-primary checked:bg-primary checked:before:opacity-[0.16] checked:after:absolute checked:after:-mt-px checked:after:ml-[0.25rem] checked:after:block checked:after:h-[0.8125rem] checked:after:w-[0.375rem] checked:after:rotate-45 checked:after:border-[0.125rem] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer hover:before:opacity-[0.04] hover:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:shadow-none focus:transition-[border-color_0.2s] focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-[0.875rem] focus:after:w-[0.875rem] focus:after:rounded-[0.125rem] focus:after:content-[''] checked:focus:before:scale-100 checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] checked:focus:after:-mt-px checked:focus:after:ml-[0.25rem] checked:focus:after:h-[0.8125rem] checked:focus:after:w-[0.375rem] checked:focus:after:rotate-45 checked:focus:after:rounded-none checked:focus:after:border-[0.125rem] checked:focus:after:border-l-0 checked:focus:after:border-t-0 checked:focus:after:border-solid checked:focus:after:border-white checked:focus:after:bg-transparent dark:border-neutral-600 dark:checked:border-primary dark:checked:bg-primary dark:focus:before:shadow-[0px_0px_0px_13px_rgba(255,255,255,0.4)] dark:checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] dark:border-neutral-400\",nA=\"mb-[0.125rem] min-h-[1.5rem] pl-[1.5rem] ml-3 flex items-center\",oA=\"relative float-left -ml-[1.5rem] mr-[6px] mt-[0.15rem] h-[1.125rem] w-[1.125rem] appearance-none rounded-[0.25rem] border-[0.125rem] border-solid border-neutral-300 outline-none before:pointer-events-none before:absolute before:h-[0.875rem] before:w-[0.875rem] before:scale-0 before:rounded-full before:bg-transparent before:opacity-0 before:shadow-[0px_0px_0px_13px_transparent] before:content-[''] checked:border-primary checked:bg-primary checked:before:opacity-[0.16] checked:after:absolute checked:after:-mt-px checked:after:ml-[0.25rem] checked:after:block checked:after:h-[0.8125rem] checked:after:w-[0.375rem] checked:after:rotate-45 checked:after:border-[0.125rem] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer hover:before:opacity-[0.04] hover:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:shadow-none focus:transition-[border-color_0.2s] focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[0px_0px_0px_13px_rgba(0,0,0,0.6)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-[0.875rem] focus:after:w-[0.875rem] focus:after:rounded-[0.125rem] focus:after:content-[''] checked:focus:before:scale-100 checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] checked:focus:after:-mt-px checked:focus:after:ml-[0.25rem] checked:focus:after:h-[0.8125rem] checked:focus:after:w-[0.375rem] checked:focus:after:rotate-45 checked:focus:after:rounded-none checked:focus:after:border-[0.125rem] checked:focus:after:border-l-0 checked:focus:after:border-t-0 checked:focus:after:border-solid checked:focus:after:border-white checked:focus:after:bg-transparent dark:border-neutral-600 dark:checked:border-primary dark:checked:bg-primary dark:focus:before:shadow-[0px_0px_0px_13px_rgba(255,255,255,0.4)] dark:checked:focus:before:shadow-[0px_0px_0px_13px_#3b71ca] dark:border-neutral-400\",rA=\"mb-[0.125rem] min-h-[1.5rem] pl-[1.5rem] ml-3 flex items-center\",aA=\"bg-white dark:bg-neutral-800\",lA=\"py-4 pl-1 text-clip overflow-hidden text-[#212529] dark:text-white\",cA=\"focus:outline-none\",hA=\"sticky top-0 z-30\",dA=\"sticky z-10 bg-inherit\",uA=\"hover:bg-neutral-100 dark:hover:bg-neutral-700\",pA=\"pointer-events-none cursor-none text-neutral-400 dark:text-neutral-300\",fA=\"h-[2px] relative w-full overflow-hidden\",_A=\"text-center text-neutral-500 font-ligh text-sm my-4 dark:text-neutral-400\",gA=\"text-neutral-500 dark:text-neutral-300\",mA=\"text-neutral-500 dark:text-neutral-300\",bA=\"pointer-events-none cursor-none\",vA=\"h-full w-[45%] bg-primary-400 dark:bg-primary-600\",yA=\"h-full animate-[progress_3s_ease-in-out_infinite]\",TA=\"pl-2 py-3 font-light text-sm dark:text-neutral-300\",EA=\"border-b\",xA=\"flex md:flex-row justify-end items-center py-2 space-x-4 text-sm flex-col leading-[1.6]\",CA=\"border border-t-0\",AA=\"order-1 my-3 md:order-none md:my-0 md:pr-1\",wA=\"inline-block rounded p-2.5 text-xs font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",kA=\"inline-block rounded p-2.5 font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",SA=\"font-normal order-2 mb-3 md:order-none md:mb-0\",OA=\"inline-block rounded p-2.5 font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",IA=\"font-light\",DA=\"inline-block rounded p-2.5 font-medium uppercase leading-normal transition duration-150 ease-in-out hover:bg-neutral-100 hover:text-primary-600 focus:text-primary-600 focus:outline-none focus:ring-0 active:text-primary-700 disabled:text-slate-300 disabled:hover:bg-transparent dark:hover:bg-neutral-500 dark:disabled:hover:bg-transparent dark:disabled:text-neutral-600\",MA=\"border-b\",LA=\"transition ease-in-out duration-300 motion-reduce:transition-none\",$A=\"whitespace-nowrap text-clip overflow-auto px-[1.4rem] py-4\",RA=\"relative\",PA=\"!bg-neutral-100 dark:!bg-neutral-600\",NA=\"flex items-center space-x-4 order-3 md:order-none\",BA=\"w-[70px]\",HA=\"!py-2\",VA=\"w-[15px] h-[10px] origin-bottom font-black mr-1 opacity-0 text-neutral-500 group-hover:opacity-100 transition hover:ease-in-out transform ease-linear duration-300 motion-reduce:transition-none dark:text-neutral-400\",FA=\"flex flex-row group\",WA=\"[&:nth-child(odd)]:bg-neutral-50 [&:nth-child(odd)]:dark:bg-neutral-700\",zA=\"border\",jA=\"border-b font-normal px-[1.4rem]\",YA=\"text-left text-sm font-light w-full leading-[1.6]\",KA={bordered:\"boolean\",borderless:\"boolean\",clickableRows:\"boolean\",defaultValue:\"string\",edit:\"boolean\",entries:\"(number|string)\",entriesOptions:\"array\",fullPagination:\"boolean\",hover:\"boolean\",loading:\"boolean\",loadingMessage:\"string\",maxWidth:\"(null|number|string)\",maxHeight:\"(null|number|string)\",multi:\"boolean\",noFoundMessage:\"string\",pagination:\"boolean\",selectable:\"boolean\",sm:\"boolean\",sortField:\"(null|string)\",sortOrder:\"string\",fixedHeader:\"boolean\",striped:\"boolean\",rowsText:\"string\",ofText:\"string\",allText:\"string\",forceSort:\"boolean\",sortIconTemplate:\"string\",paginationStartIconTemplate:\"string\",paginationEndIconTemplate:\"string\",paginationLeftIconTemplate:\"string\",paginationRightIconTemplate:\"string\"},UA={bordered:!1,borderless:!1,clickableRows:!1,defaultValue:\"-\",edit:!1,entries:10,entriesOptions:[10,25,50,200],fixedHeader:!1,fullPagination:!1,hover:!1,loading:!1,loadingMessage:\"Loading results...\",maxWidth:null,maxHeight:null,multi:!1,noFoundMessage:\"No matching results found\",pagination:!0,selectable:!1,sm:!1,sortField:null,sortOrder:\"asc\",striped:!1,rowsText:\"Rows per page:\",ofText:\"of\",allText:\"All\",forceSort:!1,sortIconTemplate:qC,paginationStartIconTemplate:ZC,paginationEndIconTemplate:tA,paginationLeftIconTemplate:QC,paginationRightIconTemplate:JC},XA={label:\"string\",field:\"string\",fixed:\"(boolean|string)\",format:\"(function|null)\",width:\"(number|null)\",sort:\"boolean\",columnIndex:\"number\"},GA={label:\"\",field:\"\",fixed:!1,format:null,width:null,sort:!0,columnIndex:0},qA={table:YA,tableHeader:jA,column:lA,pagination:xA,selectWrapper:BA,scroll:RA,tableBordered:zA,paginationBordered:CA,borderless:iA,checkboxRowWrapper:rA,checkboxRow:oA,checkboxHeaderWrapper:nA,checkboxHeader:sA,row:MA,rowItem:$A,striped:WA,sortIconWrapper:FA,sortIcon:VA,paginationRowsText:IA,paginationNav:SA,paginationButtonsWrapper:AA,hoverRow:uA,borderColor:eA,color:aA,fixedHeader:hA,fixedHeaderBody:dA,selectableRow:PA,rowAnimation:LA,sm:HA,edit:cA,selectItemsWrapper:NA,paginationStartButton:DA,paginationLeftButton:kA,paginationRightButton:OA,paginationEndButton:wA,loadingItemsWrapper:fA,loadingProgressBarWrapper:yA,loadingProgressBar:vA,loadingMessage:_A,loadingPaginationRowsText:mA,loadingPaginationSelectWrapper:bA,loadingPaginationNav:gA,loadingColumn:pA,noFoundMessageWrapper:EA,noFoundMessage:TA},ZA={table:\"string\",tableHeader:\"string\",column:\"string\",pagination:\"string\",selectWrapper:\"string\",scroll:\"string\",tableBordered:\"string\",paginationBordered:\"string\",borderless:\"string\",checkboxRowWrapper:\"string\",checkboxRow:\"string\",checkboxHeaderWrapper:\"string\",checkboxHeader:\"string\",row:\"string\",rowItem:\"string\",striped:\"string\",sortIconWrapper:\"string\",sortIcon:\"string\",paginationRowsText:\"string\",paginationNav:\"string\",paginationButtonsWrapper:\"string\",hoverRow:\"string\",borderColor:\"string\",color:\"string\",fixedHeader:\"string\",fixedHeaderBody:\"string\",selectableRow:\"string\",rowAnimation:\"string\",sm:\"string\",edit:\"string\",selectItemsWrapper:\"string\",paginationStartButton:\"string\",paginationLeftButton:\"string\",paginationRightButton:\"string\",paginationEndButton:\"string\",loadingItemsWrapper:\"string\",loadingProgressBarWrapper:\"string\",loadingProgressBar:\"string\",loadingMessage:\"string\",loadingPaginationRowsText:\"string\",loadingPaginationSelectWrapper:\"string\",loadingPaginationNav:\"string\",loadingColumn:\"string\",noFoundMessageWrapper:\"string\",noFoundMessage:\"string\"};class pr{constructor(t,e={},i={},n={}){this._element=t,this._options=this._getOptions(i),this._classes=this._getClasses(n),this._sortReverse=!1,this._activePage=0,this._search=\"\",this._searchColumn=null,this._paginationLeft=null,this._paginationRight=null,this._paginationStart=null,this._paginationEnd=null,this._select=null,this._selectInstance=null,this._selected=[],this._checkboxes=null,this._headerCheckbox=null,this._rows=this._getRows(e.rows),this._columns=this._getColumns(e.columns),this._element&&(O.setData(t,pn,this),this._perfectScrollbar=null,this._setup())}static get NAME(){return un}get columns(){return this._columns.map((t,e)=>{let i={...GA,field:`field_${e}`,columnIndex:e};return typeof t==\"string\"?i.label=t:typeof t==\"object\"&&(i={...i,...t}),L(\"column\",i,XA),i})}get rows(){return this._rows.map((t,e)=>{const i={rowIndex:e};return Array.isArray(t)?this.columns.forEach((n,o)=>{t[o]===0?i[n.field]=t[o]:i[n.field]=t[o]||this._options.defaultValue}):typeof t==\"object\"&&this.columns.forEach(n=>{t[n.field]===0?i[n.field]=t[n.field]:i[n.field]=t[n.field]||this._options.defaultValue}),i})}get searchResult(){return NC(this.rows,this._search,this._searchColumn)}get computedRows(){let t=[...this.searchResult];return this._options.sortOrder&&(t=PC({rows:t,field:this._options.sortField,order:this._options.sortOrder})),this._options.pagination&&(this._options.entries===\"All\"?t=Ip({rows:t,entries:t.length,activePage:this._activePage}):t=Ip({rows:t,entries:this._options.entries,activePage:this._activePage})),t}get pages(){return this._options.entries===\"All\"?1:Math.ceil(this.searchResult.length/this._options.entries)}get navigationText(){const t=this._activePage*this._options.entries;return this.searchResult.length===0?`0 ${this._options.ofText} 0`:this._options.entries===\"All\"?`1 - ${this.searchResult.length} ${this._options.ofText} ${this.searchResult.length}`:`${t+1} - ${this.computedRows.length+t} ${this._options.ofText} ${this.searchResult.length}`}get tableOptions(){return{classes:this._classes,columns:this.columns,rows:this.computedRows,noFoundMessage:this._options.noFoundMessage,edit:this._options.edit,loading:this._options.loading,loaderClass:this._options.loaderClass,loadingMessage:this._options.loadingMessage,selectable:this._options.selectable,multi:this._options.multi,bordered:this._options.bordered,borderless:this._options.borderless,striped:this._options.striped,hover:this._options.hover,fixedHeader:this._options.fixedHeader,sm:this._options.sm,sortIconTemplate:this._options.sortIconTemplate,pagination:{enable:this._options.pagination,text:this.navigationText,entries:this._options.entries,entriesOptions:this._options.entriesOptions,fullPagination:this._options.fullPagination,rowsText:this._options.rowsText,ofText:this._options.ofText,allText:this._options.allText,paginationStartIconTemplate:this._options.paginationStartIconTemplate,paginationLeftIconTemplate:this._options.paginationLeftIconTemplate,paginationRightIconTemplate:this._options.paginationRightIconTemplate,paginationEndIconTemplate:this._options.paginationEndIconTemplate,classes:this._classes},forceSort:this._options.forceSort}}update(t,e={}){t&&t.rows&&(this._rows=t.rows),t&&t.columns&&(this._columns=t.columns),this._clearClassList(e),this._options=this._getOptions({...this._options,...e}),this._setup(),this._performSort()}dispose(){this._selectInstance&&this._selectInstance.dispose(),O.removeData(this._element,pn),this._removeEventListeners(),this._perfectScrollbar.destroy(),this._element=null}search(t,e){this._search=t,this._searchColumn=e,this._activePage=0,this._options.pagination&&this._toggleDisableState(),this._renderRows(),this._options.maxHeight&&(this._perfectScrollbar.element.scrollTop=0,this._perfectScrollbar.update())}sort(t,e=\"asc\"){this._options.sortOrder=e,typeof t==\"string\"?this._options.sortField=this.columns.find(n=>n.label===t).field:this._options.sortField=t.field;const i=m.findOne(`[data-te-sort=\"${this._options.sortField}\"]`,this._element);this._activePage=0,this._toggleDisableState(),this._renderRows(),this._setActiveSortIcon(i)}setActivePage(t){t{this._options[e]&&!t[e]&&g.removeDataAttribute(`data-te-${e}`)})}_emitSelectEvent(){_.trigger(this._element,UC,{selectedRows:this.rows.filter(t=>this._selected.indexOf(t.rowIndex)!==-1),selectedIndexes:this._selected,allSelected:this._selected.length===this.rows.length})}_getRows(t=[]){const e=m.findOne(\"tbody\",this._element);return e?[...m.find(\"tr\",e).map(n=>m.find(\"td\",n).map(o=>o.innerHTML)),...t]:t}_getColumns(t=[]){const e=m.findOne(\"thead\",this._element);if(!e)return t;const i=m.findOne(\"tr\",e);return[...m.find(\"th\",i).map(o=>({label:o.innerHTML,...g.getDataAttributes(o)})),...t]}_getCSSValue(t){return typeof t==\"string\"?t:`${t}px`}_getOptions(t){const e={...UA,...g.getDataAttributes(this._element),...t};return L(un,e,KA),e}_setActiveRows(){m.find(fn,this._element).forEach(t=>{this._selected.includes(g.getDataAttribute(t,\"index\"))?g.addClass(t,`active ${this._classes.selectableRow}`):g.removeClass(t,`active ${this._classes.selectableRow}`)})}_setEntries(t){this._options=this._getOptions({...this._options,entries:t.target.value}),this._activePage>this.pages-1&&(this._activePage=this.pages-1),this._toggleDisableState(),this._renderRows()}_setSelected(){m.find(ql,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"rowIndex\");t.checked=this._selected.includes(e)}),this._setActiveRows()}_setActiveSortIcon(t){m.find(Gl,this._element).forEach(e=>{const i=this._options.sortOrder===\"desc\"&&e===t?180:0;g.style(e,{transform:`rotate(${i}deg)`}),e===t&&this._options.sortOrder?g.addClass(e,\"opacity-100\"):g.removeClass(e,\"opacity-100\")})}_setup(){this._renderTable(),this._options.pagination&&this._setupPagination(),this._options.edit&&this._setupEditable(),this._options.clickableRows&&this._setupClickableRows(),this._options.selectable&&this._setupSelectable(),this._setupScroll(),this._setupSort()}_setupClickableRows(){m.find(fn,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"index\");g.addClass(t,\"cursor-pointer\"),_.on(t,\"click\",i=>{m.matches(i.target,ql)||_.trigger(this._element,XC,{index:e,row:this.rows[e]})})})}_setupEditable(){m.find(fn,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"index\");m.find(Xl,t).forEach(i=>{_.on(i,\"input\",n=>this._updateRow(n,e))})})}_setupScroll(){const t=m.findOne(BC,this._element),e={};if(this._options.maxHeight&&(e.maxHeight=this._getCSSValue(this._options.maxHeight)),this._options.maxWidth){const i=this._getCSSValue(this._options.maxWidth);e.maxWidth=i,g.style(this._element,{maxWidth:i})}if(g.style(t,e),g.addClass(t,`${this._classes.scroll}`),this._options.fixedHeader){let i=m.find(HC,this._element);this._options.selectable&&(i=i.filter((n,o)=>(g.addClass(n,`${this._classes.fixedHeader} ${this._classes.color}`),o!==0))),i.forEach((n,o)=>{g.addClass(n,`${this._classes.fixedHeader} ${this._classes.color}`),this.columns[o].fixed&&g.addClass(n,\"!z-40\")})}this._perfectScrollbar=new ms(t)}_setupSort(){m.find(Gl,this._element).forEach(t=>{const e=g.getDataAttribute(t,\"sort\"),[i]=m.parents(t,\"th\");if(this.columns.sort)g.addClass(i,\"cursor-pointer\");else return;e===this._options.sortField&&this._setActiveSortIcon(t),_.on(i,\"click\",()=>{this._options.sortField===e&&this._options.sortOrder===\"asc\"?this._options.sortOrder=\"desc\":this._options.sortField===e&&this._options.sortOrder===\"desc\"?this._options.sortOrder=this._options.forceSort?\"asc\":null:this._options.sortOrder=\"asc\",this._options.sortField=e,this._activePage=0,this._performSort(),this._setActiveSortIcon(t)})})}_performSort(){this._toggleDisableState(),this._renderRows()}_setupSelectable(){this._checkboxes=m.find(ql,this._element),this._headerCheckbox=m.findOne(VC,this._element),_.on(this._headerCheckbox,\"input\",t=>this._toggleSelectAll(t)),this._checkboxes.forEach(t=>{const e=g.getDataAttribute(t,\"rowIndex\");_.on(t,\"input\",i=>this._toggleSelectRow(i,e))})}_setupPagination(){this._paginationRight=m.findOne(FC,this._element),this._paginationLeft=m.findOne(WC,this._element),_.on(this._paginationRight,\"click\",()=>this._changeActivePage(this._activePage+1)),_.on(this._paginationLeft,\"click\",()=>this._changeActivePage(this._activePage-1)),this._options.fullPagination&&(this._paginationStart=m.findOne(zC,this._element),this._paginationEnd=m.findOne(jC,this._element),_.on(this._paginationStart,\"click\",()=>this._changeActivePage(0)),_.on(this._paginationEnd,\"click\",()=>this._changeActivePage(this.pages-1))),this._toggleDisableState(),this._setupPaginationSelect()}_setupPaginationSelect(){this._select=m.findOne(KC,this._element),this._selectInstance=new on(this._select),_.on(this._select,\"valueChange.te.select\",t=>this._setEntries(t))}_removeEventListeners(){this._options.pagination&&(_.off(this._paginationRight,\"click\"),_.off(this._paginationLeft,\"click\"),_.off(this._select,\"valueChange.te.select\"),this._options.fullPagination&&(_.off(this._paginationStart,\"click\"),_.off(this._paginationEnd,\"click\"))),this._options.edit&&m.find(Xl,this._element).forEach(t=>{_.off(t,\"input\")}),this._options.clickableRows&&m.find(fn,this._element).forEach(t=>{_.off(t,\"click\")}),m.find(Gl,this._element).forEach(t=>{const[e]=m.parents(t,\"th\");_.off(e,\"click\")}),this._options.selectable&&(_.off(this._headerCheckbox,\"input\"),this._checkboxes.forEach(t=>{_.off(t,\"input\")}))}_renderTable(){this._element.innerHTML=Op(this.tableOptions).table,this._formatCells(),_.trigger(this._element,Dp)}_renderRows(){const t=m.findOne(\"tbody\",this._element);if(this._options.pagination){const e=m.findOne(YC,this._element);e.innerText=this.navigationText}t.innerHTML=Op(this.tableOptions).rows,this._formatCells(),this._options.edit&&this._setupEditable(),this._options.selectable&&(this._setupSelectable(),this._setSelected()),this._options.clickableRows&&this._setupClickableRows(),_.trigger(this._element,Dp)}_formatCells(){m.find(fn,this._element).forEach(e=>{const i=g.getDataAttribute(e,\"index\");m.find(Xl,e).forEach(o=>{const r=g.getDataAttribute(o,\"field\"),a=this.columns.find(l=>l.field===r);a&&a.format!==null&&a.format(o,this.rows[i][r])})})}_toggleDisableState(){this._options.pagination!==!1&&(this._activePage===0||this._options.loading?(this._paginationLeft.setAttribute(\"disabled\",\"\"),this._options.fullPagination&&this._paginationStart.setAttribute(\"disabled\",\"\")):(this._paginationLeft.removeAttribute(\"disabled\"),this._options.fullPagination&&this._paginationStart.removeAttribute(\"disabled\")),this._activePage===this.pages-1||this._options.loading||this.pages===0?(this._paginationRight.setAttribute(\"disabled\",\"\"),this._options.fullPagination&&this._paginationEnd.setAttribute(\"disabled\",\"\")):(this._paginationRight.removeAttribute(\"disabled\"),this._options.fullPagination&&this._paginationEnd.removeAttribute(\"disabled\")))}_toggleSelectAll(t){t.target.checked?this._selected=this.rows.map(e=>e.rowIndex):this._selected=[],this._setSelected(),this._emitSelectEvent()}_toggleSelectRow(t,e){t.target.checked?this._options.multi&&!this._selected.includes(e)?this._selected=[...this._selected,e]:(this._selected=[e],this._checkboxes.forEach(i=>{i!==t.target&&(i.checked=!1)})):this._selected=this._selected.filter(i=>i!==e),this._options.multi&&!t.target.checked&&(this._headerCheckbox.checked=!1),this._setActiveRows(),this._emitSelectEvent()}_updateRow(t,e){const i=g.getDataAttribute(t.target,\"field\"),n=t.target.textContent,o=this._rows[e];if(Array.isArray(o)){const a=this.columns.find(l=>l.field===i).columnIndex;o[a]=n}else o[i]=n;_.trigger(this._element,GC,{rows:this._rows,columns:this._columns})}static jQueryInterface(t,e,i){return this.each(function(){let n=O.getData(this,pn);const o=typeof t==\"object\"&&t;if(!(!n&&/dispose/.test(t))&&(n||(n=new pr(this,o,e)),typeof t==\"string\")){if(typeof n[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);n[t](e,i)}})}static getInstance(t){return O.getData(t,pn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Mp=\"rating\",fr=\"te.rating\",QA=\"data-te-rating-init\",JA=\"[data-te-rating-icon-ref]\",bs=`.${fr}`,tw=\"ArrowLeft\",ew=\"ArrowRight\",iw={tooltip:\"string\",value:\"(string|number)\",readonly:\"boolean\",after:\"string\",before:\"string\",dynamic:\"boolean\",active:\"string\"},sw={tooltip:\"top\",value:\"\",readonly:!1,after:\"\",before:\"\",dynamic:!1,active:\"fill-current\"},Lp=`onSelect${bs}`,nw=`onHover${bs}`,$p=`keyup${bs}`,Rp=`focusout${bs}`,Pp=`keydown${bs}`,Np=`mousedown${bs}`;class Bp{constructor(t,e){this._element=t,this._icons=m.find(JA,this._element),this._options=this._getConfig(e),this._index=-1,this._savedIndex=null,this._originalClassList=[],this._originalIcons=[],this._fn={},this._tooltips=[],this._element&&(O.setData(t,fr,this),this._init())}static get NAME(){return Mp}dispose(){O.removeData(this._element,fr),this._options.readonly||(_.off(this._element,$p),_.off(this._element,Rp),_.off(this._element,Pp),this._element.removeEventListener(\"mouseleave\",this._fn.mouseleave),this._icons.forEach((t,e)=>{_.off(t,Np),t.removeEventListener(\"mouseenter\",this._fn.mouseenter[e]),g.removeClass(t,\"cursor-pointer\")}),this._tooltips.forEach(t=>{t._element.removeAttribute(QA),t.dispose()}),this._icons.forEach(t=>t.removeAttribute(\"tabIndex\"))),this._element=null}_init(){this._options.readonly||(this._bindMouseEnter(),this._bindMouseLeave(),this._bindMouseDown(),this._bindKeyDown(),this._bindKeyUp(),this._bindFocusLost(),this._icons.forEach(t=>{g.addClass(t,\"cursor-pointer\")})),this._options.dynamic&&(this._saveOriginalClassList(),this._saveOriginalIcons()),this._setCustomText(),this._setToolTips(),this._options.value&&(this._index=this._options.value-1,this._updateRating(this._index))}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...sw,...e,...t},L(Mp,t,iw),t}_bindMouseEnter(){this._fn.mouseenter=[],this._icons.forEach((t,e)=>{t.addEventListener(\"mouseenter\",this._fn.mouseenter[e]=i=>{this._index=this._icons.indexOf(i.target),this._updateRating(this._index),this._triggerEvents(t,nw)})})}_bindMouseLeave(){this._element.addEventListener(\"mouseleave\",this._fn.mouseleave=()=>{this._savedIndex!==null?(this._updateRating(this._savedIndex),this._index=this._savedIndex):this._options.value?(this._updateRating(this._options.value-1),this._index=this._options.value-1):(this._index=-1,this._clearRating())})}_bindMouseDown(){this._icons.forEach(t=>{_.on(t,Np,()=>{this._setElementOutline(\"none\"),this._savedIndex=this._index,this._triggerEvents(t,Lp)})})}_bindKeyDown(){this._element.tabIndex=0,_.on(this._element,Pp,t=>this._updateAfterKeyDown(t))}_bindKeyUp(){_.on(this._element,$p,()=>this._setElementOutline(\"auto\"))}_bindFocusLost(){_.on(this._element,Rp,()=>this._setElementOutline(\"none\"))}_setElementOutline(t){this._element.style.outline=t}_triggerEvents(t,e){_.trigger(t,e,{value:this._index+1})}_updateAfterKeyDown(t){const e=this._icons.length-1,i=this._index;t.key===ew&&this._index-1&&(this._index-=1),i!==this._index&&(this._savedIndex=this._index,this._updateRating(this._savedIndex),this._triggerEvents(this._icons[this._savedIndex],Lp))}_updateRating(t){this._clearRating(),this._options.dynamic&&this._restoreOriginalIcon(t),this._icons.forEach((e,i)=>{i<=t&&g.addClass(e.querySelector(\"svg\"),this._options.active)})}_clearRating(){this._icons.forEach((t,e)=>{const i=t.querySelector(\"svg\");this._options.dynamic&&(t.classList=this._originalClassList[e],i.innerHTML=this._originalIcons[e]),g.removeClass(i,this._options.active)})}_setToolTips(){this._icons.forEach((t,e)=>{const i=g.getDataAttribute(t,\"toggle\");t.title&&!i&&(g.setDataAttribute(t,\"toggle\",\"tooltip\"),this._tooltips[e]=new is(t,{placement:this._options.tooltip}))})}_setCustomText(){this._icons.forEach(t=>{const e=g.getDataAttribute(t,\"after\"),i=g.getDataAttribute(t,\"before\");e&&t.insertAdjacentHTML(\"afterEnd\",e),i&&t.insertAdjacentHTML(\"beforeBegin\",i)})}_saveOriginalClassList(){this._icons.forEach(t=>{const e=t.classList.value;this._originalClassList.push(e)})}_saveOriginalIcons(){this._icons.forEach(t=>{const e=t.querySelector(\"svg\").innerHTML;this._originalIcons.push(e)})}_restoreOriginalIcon(t){const e=this._originalClassList[t],i=this._originalIcons[t];this._icons.forEach((n,o)=>{if(o<=t){const r=n.querySelector(\"svg\");r.innerHTML=i,n.classList=e}})}static getInstance(t){return O.getData(t,fr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Zl=\"popconfirm\",_n=\"te.popconfirm\",Hp=`.${_n}`,ow=`cancel${Hp}`,rw=`confirm${Hp}`,aw=\"[data-te-popconfirm-body]\",Ql=\"data-te-popconfirm-popover\",lw=\"data-te-popconfirm-modal\",Vp=\"data-te-popconfirm-backdrop\",cw={popconfirmMode:\"string\",message:\"string\",cancelText:\"(null|string)\",okText:\"(null|string)\",popconfirmIconTemplate:\"string\",cancelLabel:\"(null|string)\",confirmLabel:\"(null|string)\",position:\"(null|string)\"},hw={popconfirmMode:\"inline\",message:\"Are you sure?\",cancelText:\"Cancel\",okText:\"OK\",popconfirmIconTemplate:\"\",cancelLabel:\"Cancel\",confirmLabel:\"Confirm\",position:\"bottom\"},dw={backdrop:\"string\",body:\"string\",btnCancel:\"string\",btnConfirm:\"string\",btnsContainer:\"string\",fade:\"string\",icon:\"string\",message:\"string\",messageText:\"string\",modal:\"string\",popover:\"string\"},uw={backdrop:\"h-full w-full z-[1070] fixed top-0 left-0 bg-[#00000066] flex justify-center items-center\",body:\"p-[1rem] bg-white rounded-[0.5rem] opacity-0 dark:bg-neutral-700\",btnCancel:\"inline-block rounded bg-primary-100 px-4 pb-[5px] pt-[6px] text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200\",btnConfirm:\"inline-block rounded bg-primary px-4 pb-[5px] pt-[6px] text-xs font-medium uppercase leading-normal text-white shadow-[0_4px_9px_-4px_#3b71ca] transition duration-150 ease-in-out hover:bg-primary-600 hover:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:bg-primary-600 focus:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:outline-none focus:ring-0 active:bg-primary-700 active:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] dark:shadow-[0_4px_9px_-4px_rgba(59,113,202,0.5)] dark:hover:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.2),0_4px_18px_0_rgba(59,113,202,0.1)] dark:focus:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.2),0_4px_18px_0_rgba(59,113,202,0.1)] dark:active:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.2),0_4px_18px_0_rgba(59,113,202,0.1)]\",btnsContainer:\"flex justify-end space-x-2\",fade:\"transition-opacity duration-[150ms] ease-linear\",icon:\"pr-2\",message:\"flex mb-3\",messageText:\"text-neutral-600 dark:text-white\",modal:\"absolute w-[300px] z-[1080] shadow-sm rounded-[0.5rem]\",popover:\"w-[300px] border-0 rounded-[0.5rem] z-[1080] shadow-sm\"};class _r{constructor(t,e,i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._popper=null,this._cancelButton=\"\",this._confirmButton=\"\",this._isOpen=!1,this._uid=this._element.id?`popconfirm-${this._element.id}`:bt(\"popconfirm-\"),t&&O.setData(t,_n,this),this._clickHandler=this.open.bind(this),_.on(this._element,\"click\",this._clickHandler)}static get NAME(){return Zl}get container(){return m.findOne(`#${this._uid}`)}get popconfirmBody(){return m.findOne(aw,this.container)}dispose(){(this._isOpen||this.container!==null)&&this.close(),O.removeData(this._element,_n),_.off(this._element,\"click\",this._clickHandler),this._element=null}open(){this._isOpen||(this._options.popconfirmMode===\"inline\"?this._openPopover(this._getPopoverTemplate()):this._openModal(this._getModalTemplate()),this._handleCancelButtonClick(),this._handleConfirmButtonClick(),this._listenToEscapeKey(),this._listenToOutsideClick())}close(){if(this._isOpen){if(this._popper!==null||m.findOne(`[${Ql}]`)!==null)_.on(this.popconfirmBody,\"transitionend\",this._handlePopconfirmTransitionEnd.bind(this)),g.removeClass(this.popconfirmBody,\"opacity-100\");else{const t=m.findOne(`[${Vp}]`);g.removeClass(this.popconfirmBody,\"opacity-100\"),document.body.removeChild(t),this._isOpen=!1}_.off(document,\"click\",this._handleOutsideClick.bind(this)),_.off(document,\"keydown\",this._handleEscapeKey.bind(this))}}_handlePopconfirmTransitionEnd(t){if(t.target!==this.popconfirmBody)return;const e=m.findOne(`[${Ql}]`);_.off(this.popconfirmBody,\"transitionend\"),this._isOpen&&t&&t.propertyName===\"opacity\"&&(this._popper.destroy(),e&&document.body.removeChild(e),this._isOpen=!1)}_getPopoverTemplate(){const t=$(\"div\"),e=this._getPopconfirmTemplate();return t.setAttribute(Ql,\"\"),g.addClass(t,this._classes.popover),t.id=this._uid,t.innerHTML=e,t}_getModalTemplate(){const t=$(\"div\"),e=this._getPopconfirmTemplate();return t.setAttribute(lw,\"\"),g.addClass(t,`${this._classes.modal}`),t.id=this._uid,t.innerHTML=e,t}_getPopconfirmTemplate(){return`
\n

\n ${this._options.popconfirmIconTemplate?`${this._options.popconfirmIconTemplate}`:\"\"}\n ${this._options.message}\n

\n
\n ${this._options.cancelText?``:\"\"}\n \n
\n
`}_getConfig(t){return t={...hw,...g.getDataAttributes(this._element),...t},L(Zl,t,cw),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...uw,...e,...t},L(Zl,t,dw),t}_openPopover(t){this._popper=Fe(this._element,t,{placement:this._translatePositionValue(),modifiers:[{name:\"offset\",options:{offset:[0,5]}}]}),document.body.appendChild(t),setTimeout(()=>{g.addClass(this.popconfirmBody,`${this._classes.fade} opacity-100`),this._isOpen=!0},0)}_openModal(t){const e=$(\"div\");e.setAttribute(Vp,\"\"),g.addClass(e,this._classes.backdrop),document.body.appendChild(e),e.appendChild(t),g.addClass(this.popconfirmBody,\"opacity-100\"),this._isOpen=!0}_handleCancelButtonClick(){const t=this.container;this._cancelButton=m.findOne(\"#popconfirm-button-cancel\",t),Ye.getOrCreateInstance(this._cancelButton,{rippleColor:\"light\"}),this._cancelButton!==null&&_.on(this._cancelButton,\"click\",()=>{this.close(),_.trigger(this._element,ow)})}_handleConfirmButtonClick(){const t=this.container;this._confirmButton=m.findOne(\"#popconfirm-button-confirm\",t),Ye.getOrCreateInstance(this._confirmButton,{rippleColor:\"light\"}),_.on(this._confirmButton,\"click\",()=>{this.close(),_.trigger(this._element,rw)})}_listenToEscapeKey(){_.on(document,\"keydown\",this._handleEscapeKey.bind(this))}_handleEscapeKey(t){t.keyCode===xi&&this.close()}_listenToOutsideClick(){_.on(document,\"click\",this._handleOutsideClick.bind(this))}_handleOutsideClick(t){const e=this.container,i=t.target===e,n=e&&e.contains(t.target),o=t.target===this._element,r=this._element&&this._element.contains(t.target);!i&&!n&&!o&&!r&&this.close()}_translatePositionValue(){switch(this._options.position){case\"top left\":return\"top-end\";case\"top\":return\"top\";case\"top right\":return\"top-start\";case\"bottom left\":return\"bottom-end\";case\"bottom\":return\"bottom\";case\"bottom right\":return\"bottom-start\";case\"left\":return\"left\";case\"left top\":return\"left-end\";case\"left bottom\":return\"left-start\";case\"right\":return\"right\";case\"right top\":return\"right-end\";case\"right bottom\":return\"right-start\";case void 0:return\"bottom\";default:return\"bottom\"}}static jQueryInterface(t,e){return this.each(function(){const i=O.getData(this,_n),n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))){if(!i)return new _r(this,n);if(typeof t==\"string\"){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}}})}static getInstance(t){return O.getData(t,_n)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Jl=\"lightbox\",gn=\"te.lightbox\",vs=`click${`.${gn}`}.data-api`,Fp=\"[data-te-lightbox-init]\",pw=`${Fp} img:not([data-te-lightbox-disabled])`,Wp=\"data-te-lightbox-caption\",fw=\"data-te-lightbox-disabled\",ye=\"data-te-lightbox-active\",_w=`\n \n\n`,gw=`\n \n\n`,mw=`\n \n\n`,bw=`\n \n\n`,vw=`\n \n\n`,yw=`\n\n\n`,Tw=`\n \n\n`,Ew={container:\"string\",zoomLevel:\"(number|string)\",prevIconTemplate:\"string\",nextIconTemplate:\"string\",showFullscreenIconTemplate:\"string\",hideFullscreenIconTemplate:\"string\",zoomInIconTemplate:\"string\",closeIconTemplate:\"string\",zoomOutIconTemplate:\"string\",spinnerContent:\"string\"},xw={container:\"body\",zoomLevel:1,prevIconTemplate:_w,nextIconTemplate:gw,showFullscreenIconTemplate:mw,hideFullscreenIconTemplate:bw,zoomInIconTemplate:vw,zoomOutIconTemplate:yw,closeIconTemplate:Tw,spinnerContent:\"Loading...\"},Cw={caption:\"text-white text-ellipsis overflow-hidden whitespace-nowrap mx-[10px] text-center\",captionWrapper:\"fixed left-0 bottom-0 w-full h-[50px] flex justify-center items-center\",closeBtn:\"border-none bg-transparent w-[50px] h-[50px] px-4 text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",fullscreenBtn:\"border-none bg-transparent w-[50px] h-[50px] px-4 text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",gallery:\"invisible fixed left-0 top-0 w-full h-full z-[1100] pointer-events-none opacity-0 bg-[#000000e6] transition-all duration-[400ms] motion-reduce:transition-none\",galleryContent:\"fixed top-[50px] left-[50px] w-[calc(100%-100px)] h-[calc(100%-100px)]\",galleryCounter:\"flex justify-center items-center px-[10px] mb-0 h-full text-[#b3b3b3]\",img:\"absolute left-0 top-0 w-full max-h-full h-auto cursor-pointer pointer-events-auto\",imgWrapper:\"absolute top-0 left-0 w-full h-full opacity-0 transform scale-[0.25] transition-all duration-[400ms] ease-out pointer-events-none motion-reduce:transition-none motion-reduce:transform-none\",leftTools:\"float-left h-full\",loader:\"fixed left-0 top-0 z-[2] w-full h-full text-neutral-50 opacity-1 flex justify-center items-center pointer-events-none transition-opacity duration-[1000ms] motion-reduce:transition-none\",nextBtn:\"border-none bg-transparent w-full h-[50px] flex justify-center items-center text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",nextBtnWrapper:\"fixed right-0 top-0 w-[50px] h-full flex justify-center items-center transition-opacity duration-[400ms] motion-reduce:transition-none\",prevBtn:\"border-none bg-transparent w-full h-[50px] flex justify-center items-center text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\",prevBtnWrapper:\"fixed left-0 top-0 w-[50px] h-full flex justify-center items-center transition-opacity duration-[400ms] motion-reduce:transition-none\",rightTools:\"float-right\",spinner:\"inline-block h-8 w-8 animate-[spinner-grow_0.75s_linear_infinite] rounded-full bg-current align-[-0.125em] motion-reduce:animate-[spinner-grow_1.5s_linear_infinite]\",spinnerContent:\"!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]\",toolbar:\"absolute top-0 left-0 w-full h-[50px] z-20 transition-opacity duration-[400ms] motion-reduce:transition-none\",vertical:\"h-full max-h-full w-auto\",zoomBtn:\"border-none bg-transparent w-[50px] h-[50px] px-4 text-[#b3b3b3] transition-colors duration-200 ease-in-out hover:text-white focus:text-white motion-reduce:transition-none outline-none\"},Aw={caption:\"string\",captionWrapper:\"string\",closeBtn:\"string\",fullscreenBtn:\"string\",gallery:\"string\",galleryContent:\"string\",galleryCounter:\"string\",img:\"string\",imgWrapper:\"string\",leftTools:\"string\",loader:\"string\",nextBtn:\"string\",nextBtnWrapper:\"string\",prevBtn:\"string\",prevBtnWrapper:\"string\",rightTools:\"string\",spinner:\"string\",spinnerContent:\"string\",toolbar:\"string\",vertical:\"string\",zoomBtn:\"string\"};class ys{constructor(t,e={},i){this._element=t,this._options=e,this._classes=this._getClasses(i),this._getContainer(),this._id=`lightbox-${Math.random().toString(36).substr(2,9)}`,this._activeImg=0,this._images=[],this._zoom=1,this._gallery=null,this._galleryToolbar=null,this._galleryContent=null,this._loader=null,this._imgCounter=null,this._animating=!1,this._fullscreen=!1,this._zoomBtn=null,this._fullscreenBtn=null,this._toolsToggleTimer=0,this._mousedown=!1,this._mousedownPositionX=0,this._mousedownPositionY=0,this._originalPositionX=0,this._originalPositionY=0,this._positionX=0,this._positionY=0,this._zoomTimer=0,this._tapCounter=0,this._tapTime=0,this._rightArrow=null,this._leftArrowWrapper=null,this._rightArrowWrapper=null,this._initiated=!1,this._multitouch=!1,this._touchZoomPosition=[],this._element&&(O.setData(t,gn,this),this.init())}static get NAME(){return Jl}get activeImg(){return this._activeImg}get currentImg(){return m.findOne(`[${ye}]`,this._galleryContent)}get options(){const t={...xw,...g.getDataAttributes(this._element),...this._options};return L(Jl,t,Ew),t}init(){this._initiated||(this._appendTemplate(),this._initiated=!0)}open(t=0){this._getImages(),this._setActiveImg(t),this._sortImages(),this._triggerEvents(\"open\",\"opened\"),this._loadImages().then(e=>{this._resizeImages(e),this._toggleTemplate(),this._addEvents(),this._focusFullscreenBtn()})}close(){this.reset(),this._removeEvents(),this._toggleTemplate(),this._triggerEvents(\"close\",\"closed\")}slide(t=\"right\"){this._animating===!0||this._images.length<=1||(this._triggerEvents(\"slide\",\"slided\"),this._beforeSlideEvents(),t===\"right\"&&this._slideHorizontally(t),t===\"left\"&&this._slideHorizontally(t),t===\"first\"&&this._slideToTarget(t),t===\"last\"&&this._slideToTarget(t),this._afterSlideEvents())}zoomIn(){this._zoom>=3||(this._triggerEvents(\"zoomIn\",\"zoomedIn\"),this._zoom+=parseFloat(this.options.zoomLevel),g.style(this.currentImg.parentNode,{transform:`scale(${this._zoom})`}),this._updateZoomBtn())}zoomOut(){this._zoom<=1||(this._triggerEvents(\"zoomOut\",\"zoomedOut\"),this._zoom-=parseFloat(this.options.zoomLevel),g.style(this.currentImg.parentNode,{transform:`scale(${this._zoom})`}),this._updateZoomBtn(),this._updateImgPosition())}toggleFullscreen(){this._fullscreen===!1?(this._fullscreenBtn.setAttribute(ye,\"\"),this._fullscreenBtn.innerHTML=this.options.hideFullscreenIconTemplate,this._gallery.requestFullscreen&&this._gallery.requestFullscreen(),this._fullscreen=!0):(this._fullscreenBtn.removeAttribute(ye),document.exitFullscreen&&document.exitFullscreen(),this._fullscreen=!1)}reset(){this._restoreDefaultFullscreen(),this._restoreDefaultPosition(),this._restoreDefaultZoom(),clearTimeout(this._toolsToggleTimer),clearTimeout(this._doubleTapTimer)}dispose(){_.off(document,vs,pw,this.toggle),this._galleryContent&&this._removeEvents(),this._gallery&&this._gallery.remove(),O.removeData(this._element,gn),this._element=null}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Cw,...e,...t},L(Jl,t,Aw),t}_getImages(){const e=m.find(\"img\",this._element).filter(i=>!i.hasAttribute(fw));this._images=e}_getContainer(){this._container=m.findOne(this.options.container)}_setActiveImg(t){this._activeImg=typeof t==\"number\"?t:this._images.indexOf(t.target)}_appendTemplate(){this._gallery=$(\"div\"),g.addClass(this._gallery,`${this._classes.gallery}`),this._element.dataset.id=this._id,this._gallery.id=this._id,this._appendLoader(),this._appendToolbar(),this._appendContent(),this._appendArrows(),this._appendCaption(),this._container.append(this._gallery)}_appendToolbar(){this._galleryToolbar=$(\"div\"),this._imgCounter=$(\"p\"),this._fullscreenBtn=$(\"button\"),this._zoomBtn=$(\"button\");const t=$(\"button\"),e=$(\"div\"),i=$(\"div\");g.addClass(this._galleryToolbar,`${this._classes.toolbar}`),g.addClass(this._imgCounter,`${this._classes.galleryCounter}`),g.addClass(this._fullscreenBtn,`${this._classes.fullscreenBtn}`),g.addClass(this._zoomBtn,`${this._classes.zoomInBtn}`),g.addClass(this._zoomBtn,this._classes.zoomBtn),g.addClass(e,`${this._classes.leftTools}`),g.addClass(i,`${this._classes.rightTools}`),g.addClass(t,`${this._classes.closeBtn}`),this._fullscreenBtn.innerHTML=this.options.showFullscreenIconTemplate,t.innerHTML=this.options.closeIconTemplate,this._zoomBtn.innerHTML=this.options.zoomInIconTemplate,this._fullscreenBtn.setAttribute(\"aria-label\",\"Toggle fullscreen\"),this._zoomBtn.setAttribute(\"aria-label\",\"Zoom in\"),t.setAttribute(\"aria-label\",\"Close\"),_.on(this._fullscreenBtn,vs,()=>this.toggleFullscreen()),_.on(this._zoomBtn,vs,()=>this._toggleZoom()),_.on(t,vs,()=>this.close()),e.append(this._imgCounter),i.append(this._fullscreenBtn),i.append(this._zoomBtn),i.append(t),this._galleryToolbar.append(e),this._galleryToolbar.append(i),this._gallery.append(this._galleryToolbar)}_appendContent(){this._galleryContent=$(\"div\"),g.addClass(this._galleryContent,`${this._classes.galleryContent}`),this._gallery.append(this._galleryContent)}_appendLoader(){this._loader=$(\"div\");const t=$(\"div\"),e=$(\"span\");g.addClass(this._loader,`${this._classes.loader}`),g.addClass(t,`${this._classes.spinner}`),g.addClass(e,`${this._classes.spinnerContent}`),t.setAttribute(\"role\",\"status\"),e.innerHTML=this.options.spinnerContent,t.append(e),this._loader.append(t),this._gallery.append(this._loader)}_appendArrows(){this._leftArrowWrapper=$(\"div\"),g.addClass(this._leftArrowWrapper,`${this._classes.prevBtnWrapper}`);const t=$(\"button\");t.setAttribute(\"aria-label\",\"Previous\"),g.addClass(t,`${this._classes.prevBtn}`),_.on(t,vs,()=>this.slide(\"left\")),this._leftArrowWrapper.append(t),this._rightArrowWrapper=$(\"div\"),g.addClass(this._rightArrowWrapper,`${this._classes.nextBtnWrapper}`),this._rightArrow=$(\"button\"),this._rightArrow.setAttribute(\"aria-label\",\"Next\"),g.addClass(this._rightArrow,`${this._classes.nextBtn}`),_.on(this._rightArrow,vs,()=>this.slide()),this._rightArrowWrapper.append(this._rightArrow),this._rightArrow.innerHTML=this.options.nextIconTemplate,t.innerHTML=this.options.prevIconTemplate,this._getImages(),!(this._images.length<=1)&&(this._gallery.append(this._leftArrowWrapper),this._gallery.append(this._rightArrowWrapper))}_appendCaption(){const t=$(\"div\"),e=$(\"p\");e.setAttribute(Wp,\"\"),g.addClass(t,`${this._classes.captionWrapper}`),g.addClass(e,`${this._classes.caption}`),t.append(e),this._gallery.append(t)}_sortImages(){for(let t=0;t{t.push(new Promise(r=>{const a=new Image,l=$(\"div\");g.addClass(l,`${this._classes.imgWrapper}`),g.addClass(a,`${this._classes.img}`),this._addImgStyles(a,l,i,o,n),l.append(a),this._galleryContent.append(l),a.onload=r,a.src=n.dataset.teImg||n.src,e.push(a),i+=100}))}),await Promise.all(t),e}_addImgStyles(t,e,i,n,o){t.alt=o.alt,t.draggable=!1,g.style(e,{position:\"absolute\",left:`${i}%`,top:0}),(o.dataset.teCaption||o.dataset.teCaption===\"\")&&(t.dataset.caption=o.dataset.teCaption),i===0?(o.width1&&g.style(e,{left:\"-100%\"})}_resizeImages(t){t.forEach(e=>{this._calculateImgSize(e)})}_calculateImgSize(t){t.width>=t.height?(t.style.width=\"100%\",t.style.maxWidth=\"100%\",t.style.height=\"auto\",t.style.top=`${(t.parentNode.offsetHeight-t.height)/2}px`,t.style.left=0):(t.style.height=\"100%\",t.style.maxHeight=\"100%\",t.style.width=\"auto\",t.style.left=`${(t.parentNode.offsetWidth-t.width)/2}px`,t.style.top=0),t.width>=t.parentNode.offsetWidth&&(t.style.width=`${t.parentNode.offsetWidth}px`,t.style.height=\"auto\",t.style.left=0,t.style.top=`${(t.parentNode.offsetHeight-t.height)/2}px`),t.height>=t.parentNode.offsetHeight&&(t.style.height=`${t.parentNode.offsetHeight}px`,t.style.width=\"auto\",t.style.top=0,t.style.left=`${(t.parentNode.offsetWidth-t.width)/2}px`),this._positionX=parseFloat(t.style.left)||0,this._positionY=parseFloat(t.style.top)||0}_onResize(){this._images=m.find(\"img\",this._galleryContent),this._images.forEach(t=>{this._calculateImgSize(t)})}_onFullscreenChange(){(document.webkitIsFullScreen||document.mozFullScreen||document.msFullscreenElement)===void 0&&(this._fullscreen=!1,this._fullscreenBtn.innerHTML=this.options.showFullscreenIconTemplate,this._fullscreenBtn.removeAttribute(ye))}_beforeSlideEvents(){this._animationStart(),this._restoreDefaultZoom(),this._restoreDefaultPosition(),this._resetDoubleTap()}_slideHorizontally(t){this._images=m.find(\"img\",this._galleryContent),this._images.forEach(e=>{let i;t===\"right\"?(i=parseInt(e.parentNode.style.left,10)-100,i<-100&&(i=(this._images.length-2)*100)):(i=parseInt(e.parentNode.style.left,10)+100,i===(this._images.length-1)*100&&(i=-100)),this._slideImg(e,i)}),this._updateActiveImg(t)}_slideImg(t,e){e===0?(t.setAttribute(ye,\"\"),g.style(t.parentNode,{opacity:1,transform:\"scale(1)\"})):(t.removeAttribute(ye),g.style(t.parentNode,{opacity:0,transform:\"scale(0.25)\"})),t.parentNode.style.left=`${e}%`}_slideToTarget(t){t===\"first\"&&this._activeImg===0||t===\"last\"&&this._activeImg===this._images.length-1||(this.reset(),this._removeEvents(),this._showLoader(),this._getImages(),this._activeImg=t===\"first\"?0:this._images.length-1,this._sortImages(),g.style(this.currentImg.parentNode,{transform:\"scale(0.25)\",opacity:0}),setTimeout(()=>{this._loadImages().then(e=>{this._resizeImages(e),this._addEvents(),this._updateCaption(),this._hideLoader(),setTimeout(()=>{g.style(this.currentImg.parentNode,{transform:\"scale(1)\",opacity:1})},10)})},400))}_updateActiveImg(t){t===\"right\"&&(this._activeImg===this._images.length-1?this._activeImg=0:this._activeImg++),t===\"left\"&&(this._activeImg===0?this._activeImg=this._images.length-1:this._activeImg--)}_afterSlideEvents(){this._updateCounter(),this._updateCaption()}_updateCounter(){this._images.length<=1||setTimeout(()=>{this._imgCounter.innerHTML=`${this._activeImg+1} / ${this._images.length}`},200)}_updateCaption(){setTimeout(()=>{let t=this.currentImg.alt;(this.currentImg.dataset.caption||this.currentImg.dataset.caption===\"\")&&(t=this.currentImg.dataset.caption),m.findOne(`[${Wp}]`,this._gallery).innerHTML=t},200)}_toggleTemplate(){this._gallery.style.visibility===\"visible\"?(g.style(this.currentImg.parentNode,{transform:\"scale(0.25)\"}),setTimeout(()=>{this._hideGallery(),this._enableScroll(),this._showLoader()},100)):(this._showGallery(),this._disableScroll(),this._updateCounter(),this._updateCaption(),this._setToolsToggleTimout(),this._hideLoader())}_showLoader(){g.style(this._loader,{opacity:1})}_hideLoader(){g.style(this._loader,{opacity:0})}_hideGallery(){g.style(this._gallery,{opacity:0,pointerEvents:\"none\",visibility:\"hidden\"})}_showGallery(){g.style(this._gallery,{opacity:1,pointerEvents:\"initial\",visibility:\"visible\"}),setTimeout(()=>{g.style(this.currentImg.parentNode,{transform:\"scale(1)\"})},50)}_toggleZoom(){this._zoom!==1?this.zoomOut():this.zoomIn()}_updateZoomBtn(){this._zoom>1?(this._zoomBtn.setAttribute(ye,\"\"),this._zoomBtn.setAttribute(\"aria-label\",\"Zoom out\"),this._zoomBtn.innerHTML=this.options.zoomOutIconTemplate):(this._zoomBtn.removeAttribute(ye),this._zoomBtn.setAttribute(\"aria-label\",\"Zoom in\"),this._zoomBtn.innerHTML=this.options.zoomInIconTemplate)}_updateImgPosition(){this._zoom===1&&this._restoreDefaultPosition()}_addEvents(){const t=m.find(\"img\",this._galleryContent);this._onWindowTouchmove=this._onWindowTouchmove.bind(this),this._onWindowTouchstart=this._onWindowTouchstart.bind(this),this._onImgMousedown=this._onMousedown.bind(this),this._onImgMousemove=this._onMousemove.bind(this),this._onImgWheel=this._onZoom.bind(this),this._onImgMouseup=this._onMouseup.bind(this),this._onImgTouchend=this._onTouchend.bind(this),this._onImgDoubleClick=this._onDoubleClick.bind(this),this._onWindowResize=this._onResize.bind(this),this._onWindowFullscreenChange=this._onFullscreenChange.bind(this),this._onAnyImgAction=this._resetToolsToggler.bind(this),this._onGalleryClick=this._onBackdropClick.bind(this),this._onKeyupEvent=this._onKeyup.bind(this),this._onRightArrowKeydownEvent=this._onRightArrowKeydown.bind(this),this._onFullscreenBtnKeydownEvent=this._onFullscreenBtnKeydown.bind(this),t.forEach(e=>{_.on(e,\"mousedown\",this._onImgMousedown,{passive:!0}),_.on(e,\"touchstart\",this._onImgMousedown,{passive:!0}),_.on(e,\"mousemove\",this._onImgMousemove,{passive:!0}),_.on(e,\"touchmove\",this._onImgMousemove,{passive:!0}),_.on(e,\"wheel\",this._onImgWheel,{passive:!0}),_.on(e,\"dblclick\",this._onImgDoubleClick,{passive:!0})}),document.addEventListener(\"touchmove\",this._onWindowTouchmove,{passive:!1}),_.on(window,\"touchstart\",this._onWindowTouchstart),_.on(window,\"mouseup\",this._onImgMouseup),_.on(window,\"touchend\",this._onImgTouchend),_.on(window,\"resize\",this._onWindowResize),_.on(window,\"orientationchange\",this._onWindowResize),_.on(window,\"keyup\",this._onKeyupEvent),_.on(window,\"fullscreenchange\",this._onWindowFullscreenChange),_.on(this._gallery,\"mousemove\",this._onAnyImgAction),_.on(this._gallery,\"click\",this._onGalleryClick),_.on(this._rightArrow,\"keydown\",this._onRightArrowKeydownEvent),_.on(this._fullscreenBtn,\"keydown\",this._onFullscreenBtnKeydownEvent)}_removeEvents(){m.find(\"img\",this._galleryContent).forEach(e=>{_.off(e,\"mousedown\",this._onImgMousedown),_.off(e,\"touchstart\",this._onImgMousedown),_.off(e,\"mousemove\",this._onImgMousemove),_.off(e,\"touchmove\",this._onImgMousemove),_.off(e,\"wheel\",this._onImgWheel),_.off(e,\"dblclick\",this._onImgDoubleClick)}),document.removeEventListener(\"touchmove\",this._onWindowTouchmove,{passive:!1}),_.off(window,\"touchstart\",this._onWindowTouchstart),_.off(window,\"mouseup\",this._onImgMouseup),_.off(window,\"touchend\",this._onImgTouchend),_.off(window,\"resize\",this._onWindowResize),_.off(window,\"orientationchange\",this._onWindowResize),_.off(window,\"keyup\",this._onKeyupEvent),_.off(window,\"fullscreenchange\",this._onWindowFullscreenChange),_.off(this._gallery,\"mousemove\",this._onAnyImgAction),_.off(this._gallery,\"click\",this._onGalleryClick),_.off(this._rightArrow,\"keydown\",this._onRightArrowKeydownEvent),_.off(this._fullscreenBtn,\"keydown\",this._onFullscreenBtnKeydownEvent)}_onMousedown(t){const e=t.touches,i=e?e[0].clientX:t.clientX,n=e?e[0].clientY:t.clientY;this._originalPositionX=parseFloat(this.currentImg.style.left)||0,this._originalPositionY=parseFloat(this.currentImg.style.top)||0,this._positionX=this._originalPositionX,this._positionY=this._originalPositionY,this._mousedownPositionX=i*(1/this._zoom)-this._positionX,this._mousedownPositionY=n*(1/this._zoom)-this._positionY,this._mousedown=!0,t.type===\"touchstart\"&&t.touches.length>1&&(this._multitouch=!0,this._touchZoomPosition=t.touches)}_onMousemove(t){if(!this._mousedown)return;const e=t.touches,i=e?e[0].clientX:t.clientX,n=e?e[0].clientY:t.clientY;if(e&&this._resetToolsToggler(),!this._multitouch)if(this._zoom!==1)this._positionX=i*(1/this._zoom)-this._mousedownPositionX,this._positionY=n*(1/this._zoom)-this._mousedownPositionY,g.style(this.currentImg,{left:`${this._positionX}px`,top:`${this._positionY}px`});else{if(this._images.length<=1)return;this._positionX=i*(1/this._zoom)-this._mousedownPositionX,g.style(this.currentImg,{left:`${this._positionX}px`})}}_onMouseup(t){this._mousedown=!1,this._moveImg(t.target)}_onTouchend(t){this._mousedown=!1,this._multitouch?t.targetTouches.length===0&&(this._multitouch=!1,this._touchZoomPosition=[]):this._multitouch||(this._checkDoubleTap(t),this._moveImg(t.target))}_calculateTouchZoom(t){const e=Math.hypot(this._touchZoomPosition[1].pageX-this._touchZoomPosition[0].pageX,this._touchZoomPosition[1].pageY-this._touchZoomPosition[0].pageY),i=Math.hypot(t.touches[1].pageX-t.touches[0].pageX,t.touches[1].pageY-t.touches[0].pageY),n=Math.abs(e-i),o=t.view.screen.width;n>o*.03&&(e<=i?this.zoomIn():this.zoomOut(),this._touchZoomPosition=t.touches)}_onWindowTouchstart(t){t.touches.length>1&&(this._multitouch=!0,this._touchZoomPosition=t.touches)}_onWindowTouchmove(t){t.preventDefault(),t.type===\"touchmove\"&&t.targetTouches.length>1&&this._calculateTouchZoom(t)}_onRightArrowKeydown(t){switch(t.keyCode){case 9:if(t.shiftKey)break;t.preventDefault(),this._focusFullscreenBtn();break}}_onFullscreenBtnKeydown(t){switch(t.keyCode){case 9:if(!t.shiftKey)break;t.preventDefault(),this._focusRightArrow();break}}_onKeyup(t){switch(this._resetToolsToggler(),t.keyCode){case 39:this.slide();break;case 37:this.slide(\"left\");break;case 27:this.close();break;case 36:this.slide(\"first\");break;case 35:this.slide(\"last\");break;case 38:this.zoomIn();break;case 40:this.zoomOut();break}}_focusFullscreenBtn(){setTimeout(()=>{this._fullscreenBtn.focus()},100)}_focusRightArrow(){this._rightArrow.focus()}_moveImg(t){if(this._multitouch||this._zoom!==1||t!==this.currentImg||this._images.length<=1)return;const e=this._positionX-this._originalPositionX;e>0?this.slide(\"left\"):e<0&&this.slide()}_checkDoubleTap(t){clearTimeout(this._doubleTapTimer);const i=new Date().getTime()-this._tapTime;this._tapCounter>0&&i<500?(this._onDoubleClick(t),this._doubleTapTimer=setTimeout(()=>{this._tapTime=new Date().getTime(),this._tapCounter=0},300)):(this._tapCounter++,this._tapTime=new Date().getTime())}_resetDoubleTap(){this._tapTime=0,this._tapCounter=0,clearTimeout(this._doubleTapTimer)}_onDoubleClick(t){this._multitouch||(t.touches||this._setNewPositionOnZoomIn(t),this._zoom!==1?this._restoreDefaultZoom():this.zoomIn())}_onZoom(t){if(t.deltaY>0)this.zoomOut();else{if(this._zoom>=3)return;this._setNewPositionOnZoomIn(t),this.zoomIn()}}_onBackdropClick(t){this._resetToolsToggler(),t.target.tagName===\"DIV\"&&this.close()}_setNewPositionOnZoomIn(t){clearTimeout(this._zoomTimer),this._positionX=window.innerWidth/2-t.offsetX-50,this._positionY=window.innerHeight/2-t.offsetY-50,this.currentImg.style.transition=\"all 0.5s ease-out\",this.currentImg.style.left=`${this._positionX}px`,this.currentImg.style.top=`${this._positionY}px`,this._zoomTimer=setTimeout(()=>{this.currentImg.style.transition=\"none\"},500)}_resetToolsToggler(){this._showTools(),clearTimeout(this._toolsToggleTimer),this._setToolsToggleTimout()}_setToolsToggleTimout(){this._toolsToggleTimer=setTimeout(()=>{this._hideTools(),clearTimeout(this._toolsToggleTimer)},4e3)}_hideTools(){g.style(this._galleryToolbar,{opacity:0}),g.style(this._leftArrowWrapper,{opacity:0}),g.style(this._rightArrowWrapper,{opacity:0})}_showTools(){g.style(this._galleryToolbar,{opacity:1}),g.style(this._leftArrowWrapper,{opacity:1}),g.style(this._rightArrowWrapper,{opacity:1})}_disableScroll(){g.addClass(document.body,\"overflow-y-hidden relative\"),document.documentElement.scrollHeight>document.documentElement.clientHeight&&g.addClass(document.body,\"md:pr-[17px]\")}_enableScroll(){setTimeout(()=>{g.removeClass(document.body,\"overflow-y-hidden relative\"),g.removeClass(document.body,\"md:pr-[17px]\")},300)}_animationStart(){this._animating=!0,setTimeout(()=>{this._animating=!1},400)}_restoreDefaultZoom(){this._zoom!==1&&(this._zoom=1,g.style(this.currentImg.parentNode,{transform:`scale(${this._zoom})`}),this._updateZoomBtn(),this._updateImgPosition())}_restoreDefaultFullscreen(){this._fullscreen&&this.toggleFullscreen()}_restoreDefaultPosition(){clearTimeout(this._zoomTimer);const t=this.currentImg;g.style(this.currentImg.parentNode,{left:0,top:0}),g.style(this.currentImg,{transition:\"all 0.5s ease-out\",left:0,top:0}),this._calculateImgSize(t),setTimeout(()=>{g.style(this.currentImg,{transition:\"none\"})},500)}async _triggerEvents(t,e){_.trigger(this._element,`${t}.te.lightbox`),e&&await setTimeout(()=>{_.trigger(this._element,`${e}.te.lightbox`)},505)}static getInstance(t){return O.getData(t,gn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static toggle(){return function(t){const e=m.closest(t.target,`${Fp}`);(ys.getInstance(e)||new ys(e)).open(t)}}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,gn);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new ys(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}}const ww={isRequired:\"This is required\",isEmail:\"Please enter a valid email address\",isLongerThan:\"This field must be longer than {length} characters\",isShorterThan:\"This field must be shorter than {length} characters\",isChecked:\"This is required\",isPhone:\"Please enter a valid phone number\",isNumber:\"Expected value with type Number\",isString:\"Expected value with type String\",isBoolean:\"Expected value with type Boolean\",isDate:\"Please enter a valid date\",is12hFormat:\"Please enter a valid time in 12h format\",is24hFormat:\"Please enter a valid time in 24h format\"},kw={isRequired:(s,t)=>(s==null?void 0:s.trim())?!0:t,isEmail:(s,t)=>/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/.test(s)?!0:t,isLongerThan:(s,t,e)=>s.length>e?!0:t.replace(\"{length}\",e),isShorterThan:(s,t,e)=>s.lengths?!0:\"This is required\",isPhone:(s,t)=>s.length===9?!0:t,isNumber:(s,t)=>s&&!isNaN(Number(s))?!0:t,isString:(s,t)=>typeof s==\"string\"?!0:t,isBoolean:(s,t)=>typeof s==\"boolean\"?!0:t,isDate:(s,t)=>{const e=/^([0-9]{1,2})\\/([0-9]{1,2})\\/([0-9]{4})$/;return s.match(e)?!0:t},is12hFormat:(s,t)=>{const e=/^(0?[1-9]|1[0-2]):[0-5][0-9] [APap][mM]$/;return s.match(e)?!0:t},is24hFormat:(s,t)=>{const e=/^(?:[01]\\d|2[0-3]):[0-5][0-9]$/;return s.match(e)?!0:t}},tc=\"validation\",ec=\"te.validation\",gr=`.${ec}`,zp=\"data-te-validate\",mr=\"data-te-validated\",br=\"data-te-validation-state\",vr=\"data-te-validation-feedback\",ic=\"data-te-valid-feedback\",yr=\"data-te-invalid-feedback\",jp=\"data-te-validation-ruleset\",Sw=\"data-te-submit-btn-ref\",Ow=`[${zp}]`,Iw=\"[data-te-input-notch-ref] div\",Dw=`[${Sw}]`,Mw=`validated${gr}`,Lw=`valid${gr}`,$w=`invalid${gr}`,Rw=`changed${gr}`,Pw={validFeedback:\"string\",invalidFeedback:\"string\",disableFeedback:\"boolean\",customRules:\"object\",customErrorMessages:\"object\",activeValidation:\"boolean\",submitCallback:\"(function|null)\"},Yp={validFeedback:\"Looks good!\",invalidFeedback:\"Something is wrong!\",disableFeedback:!1,customRules:{},customErrorMessages:{},activeValidation:!1,submitCallback:null},Nw={notchLeadingValid:\"border-[#14a44d] dark:border-[#14a44d] group-data-[te-input-focused]:shadow-[-1px_0_0_#14a44d,_0_1px_0_0_#14a44d,_0_-1px_0_0_#14a44d] group-data-[te-input-focused]:border-[#14a44d]\",notchMiddleValid:\"border-[#14a44d] dark:border-[#14a44d] group-data-[te-input-focused]:shadow-[0_1px_0_0_#14a44d] group-data-[te-input-focused]:border-[#14a44d]\",notchTrailingValid:\"border-[#14a44d] dark:border-[#14a44d] group-data-[te-input-focused]:shadow-[1px_0_0_#14a44d,_0_-1px_0_0_#14a44d,_0_1px_0_0_#14a44d] group-data-[te-input-focused]:border-[#14a44d]\",notchLeadingInvalid:\"border-[#dc4c64] dark:border-[#dc4c64] group-data-[te-input-focused]:shadow-[-1px_0_0_#dc4c64,_0_1px_0_0_#dc4c64,_0_-1px_0_0_#dc4c64] group-data-[te-input-focused]:border-[#dc4c64]\",notchMiddleInvalid:\"border-[#dc4c64] dark:border-[#dc4c64] group-data-[te-input-focused]:shadow-[0_1px_0_0_#dc4c64] group-data-[te-input-focused]:border-[#dc4c64]\",notchTrailingInvalid:\"border-[#dc4c64] dark:border-[#dc4c64] group-data-[te-input-focused]:shadow-[1px_0_0_#dc4c64,_0_-1px_0_0_#dc4c64,_0_1px_0_0_#dc4c64] group-data-[te-input-focused]:border-[#dc4c64]\",basicInputValid:\"!border-[#14a44d] focus:!border-[#14a44d] focus:!shadow-[inset_0_0_0_1px_#14a44d]\",basicInputInvalid:\"!border-[#dc4c64] focus:!border-[#dc4c64] focus:!shadow-[inset_0_0_0_1px_#dc4c64]\",checkboxValid:\"checked:!border-[#14a44d] checked:!bg-[#14a44d] checked:after:!bg-[#14a44d]\",checkboxInvalid:\"checked:!border-[#dc4c64] checked:!bg-[#dc4c64] checked:after:!bg-[#dc4c64]\",radioValid:\"checked:!border-[#14a44d] checked:after:!bg-[#14a44d]\",radioInvalid:\"checked:!border-[#dc4c64] checked:after:!bg-[#dc4c64]\",labelValid:\"!text-[#14a44d]\",labelInvalid:\"!text-[#dc4c64]\",validFeedback:\"absolute top-full left-0 m-1 w-auto text-sm text-[#14a44d] animate-[fade-in_0.3s_both]\",invalidFeedback:\"absolute top-full left-0 m-1 w-auto text-sm text-[#dc4c64] animate-[fade-in_0.3s_both]\",elementValidated:\"mb-8\"},Bw={notchLeadingValid:\"string\",notchMiddleValid:\"string\",notchTrailingValid:\"string\",notchLeadingInvalid:\"string\",notchMiddleInvalid:\"string\",notchTrailingInvalid:\"string\",basicInputValid:\"string\",basicInputInvalid:\"string\",checkboxValid:\"string\",checkboxInvalid:\"string\",radioValid:\"string\",radioInvalid:\"string\",labelValid:\"string\",labelInvalid:\"string\",validFeedback:\"string\",invalidFeedback:\"string\",elementValidated:\"string\"};class Tr extends Mt{constructor(t,e,i){super(t),this._element=t,this._element&&O.setData(t,ec,this),this._config=this._getConfig(e),this._classes=this._getClasses(i),this._isValid=!0,this._shouldApplyInputEvents=!0,this._submitCallback=null,this._errorMessages={...ww,...this._config.customErrorMessages},this._validationElements=this._getValidationElements(),this._validationElements.forEach(({element:n,input:o})=>{this._createFeedbackWrapper(n,o)}),this._validationObserver=this._watchForValidationChanges(),this._validationObserver.observe(this._element,{attributes:!0}),this._submitButton=null,this._handleSubmitButton(),this._validationResult=[]}static get DefaultType(){return Pw}static get Default(){return Yp}static get NAME(){return tc}dispose(){var t;(t=this._validationObserver)==null||t.disconnect(),this._validationObserver=null,this._submitCallback=null,this._element.removeAttribute(mr),this._removeInputEvents(),this._removeValidationTraces(),this._validationResult=[],this._submitButton&&_.off(this._submitButton,\"click\"),this._config.activeValidation&&(this._validationElements.forEach(e=>{const{input:i}=e;_.off(i,\"input\")}),this._shouldApplyInputEvents=!0)}_removeValidationTraces(){this._removeFeedbackWrapper(),this._validationElements.forEach(({element:t,classes:e,initialHTML:i})=>{t.className=e,t.innerHTML=i,t.removeAttribute(br),t.removeAttribute(yr),t.removeAttribute(ic)}),this._validationElements=[]}_getValidationElements(){return m.find(Ow,this._element).map(e=>{const i=m.findOne(\"input\",e)||m.findOne(\"textarea\",e),n=m.findOne(\"select\",e);return{id:i.name||i.id||(n==null?void 0:n.name)||bt(\"validation-\"),element:e,type:e.getAttribute(zp),input:i,validFeedback:e.getAttribute(ic),invalidFeedback:e.getAttribute(yr),classes:e.className,initialHTML:e.innerHTML,ruleset:e.getAttribute(jp)}})}_createFeedbackWrapper(t,e){if(t.querySelectorAll(`[${vr}]`).length>0)return;const i=document.createElement(\"span\");i.setAttribute(vr,\"\"),e.parentNode.appendChild(i)}_removeFeedbackWrapper(){m.find(`[${vr}]`,this._element).forEach(e=>{e.remove()})}_watchForValidationChanges(){return new MutationObserver(e=>{e.forEach(i=>{const{attributeName:n}=i;n===mr&&(this._handleValidation(),this._config.activeValidation&&this._shouldApplyInputEvents&&this._applyInputEvents())})})}_handleValidation(){this._element.getAttribute(mr)&&(this._validationResult=[],this._isValid=!0,this._validationElements.forEach(t=>this._validateSingleElement(t)),this._emitEvents(this._isValid),this._submitCallback&&this._submitCallback(this._isValid))}_validateSingleElement(t){var c;const{element:e,type:i,input:n,ruleset:o,id:r}=t;o&&this._validateByRuleset(t);const a=e.getAttribute(br);if(a!==\"valid\"&&a!==\"invalid\")return;const l=a.replace(a.charAt(0),a.charAt(0).toUpperCase());i===\"input\"&&this._restyleNotches(e,l),i===\"basic\"&&this._restyleBasicInputs(n,l),(i===\"checkbox\"||i===\"radio\")&&this._restyleCheckboxes(n,l,i),this._restyleLabels(e,l),a===\"invalid\"&&(this._isValid=!1),this._config.disableFeedback||this._applyFeedback(e,a),_.trigger(this._element,Rw,{value:{name:r,result:a,validation:(c=this._validationResult[r])==null?void 0:c.validation}})}_validateByRuleset({element:t,type:e,invalidFeedback:i,input:n,id:o}){const r=this._getRuleset(t);if(!r.length)return;const a=e===\"checkbox\"||e===\"radio\"?n.checked:n.value;let l=\"\",c=[];for(const h of r){const d=h.callback(a,this._errorMessages[h.name]||this._config.invalidFeedback,h.parameter);c.push({result:d===!0,name:h.name,fullName:h.fullName}),typeof d==\"string\"&&!l&&(l=d)}if(this._validationResult[o]={element:t,validation:c},!l){t.setAttribute(br,\"valid\");return}t.setAttribute(br,\"invalid\"),i||t.setAttribute(yr,l)}_handleInputChange(t){this._validateSingleElement(t)}_getRuleset(t){const i=t.getAttribute(jp).split(\"|\");let n=[];const o={...kw,...this._config.customRules};return i.forEach(r=>{const a=this._getRuleData(r,o);a.callback?n.push(a):console.warn(`Rule ${r} does not exist`)}),n}_getRuleData(t,e){const i=t.split(\"(\");return{callback:e[i[0]],parameter:i[1]?i[1].split(\")\")[0]:null,name:i[0],fullName:t}}_applyFeedback(t,e){const i=m.findOne(`[${vr}]`,t),n=t.getAttribute(ic)||this._config.validFeedback,o=t.getAttribute(yr)||this._config.invalidFeedback;g.addClass(t,this._classes.elementValidated),i.textContent=e===\"valid\"?n:o,i.className=this._classes[e===\"valid\"?\"validFeedback\":\"invalidFeedback\"]}_restyleCheckboxes(t,e,i){g.removeClass(t,this._classes.checkboxValid),g.removeClass(t,this._classes.checkboxInvalid),g.addClass(t,this._classes[`${i}${e}`])}_restyleBasicInputs(t,e){g.removeClass(t,this._classes.basicInputValid),g.removeClass(t,this._classes.basicInputInvalid),g.addClass(t,this._classes[`basicInput${e}`])}_restyleNotches(t,e){m.find(Iw,t).forEach((n,o)=>{let r=o===0?\"notchLeading\":o===1?\"notchMiddle\":\"notchTrailing\";n.className=\"\",g.addClass(n,nu[r]),r+=e,g.addClass(n,this._classes[r])})}_restyleLabels(t,e){const i=m.find(\"label\",t);i.length&&i.forEach(n=>{g.removeClass(n,this._classes.labelValid),g.removeClass(n,this._classes.labelInvalid),g.addClass(n,this._classes[`label${e}`])})}_emitEvents(t){if(_.trigger(this._element,Mw),t){_.trigger(this._element,Lw,{value:this._validationResult});return}_.trigger(this._element,$w,{value:this._validationResult})}_applyInputEvents(){this._validationElements.forEach(t=>{const{input:e,element:i}=t;_.on(e,\"input\",()=>this._handleInputChange(t)),_.on(i,\"valueChange.te.select\",()=>this._delayedInputChange(t)),_.on(i,\"itemSelect.te.autocomplete\",()=>this._delayedInputChange(t))}),this._shouldApplyInputEvents=!1}_removeInputEvents(){this._validationElements.forEach(t=>{const{input:e,element:i}=t;_.off(e,\"input\",()=>this._handleInputChange(t)),_.off(i,\"valueChange.te.select\",()=>this._delayedInputChange(t)),_.off(i,\"itemSelect.te.autocomplete\",()=>this._delayedInputChange(t))})}_delayedInputChange(t){setTimeout(()=>{this._handleInputChange(t)},10)}_handleSubmitButton(){this._submitButton=m.findOne(Dw,this._element),this._submitButton&&_.on(this._submitButton,\"click\",t=>this._handleSubmitButtonClick(t))}_handleSubmitButtonClick(t){if(this._element.setAttribute(mr,!0),this._config.submitCallback){this._submitCallback=e=>this._config.submitCallback(t,e);return}}_getConfig(t){return t={...Yp,...g.getDataAttributes(this._element),...typeof t==\"object\"&&t?t:{}},L(tc,t,this.constructor.DefaultType),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Nw,...e,...t},L(tc,t,Bw),t}static getInstance(t){return O.getData(t,ec)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){const e=Tr.getOrCreateInstance(this);if(typeof t==\"string\"){if(e[t]===void 0||t.startsWith(\"_\")||t===\"constructor\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}class mn{_getCoordinates(t){const[e]=t.touches;return{x:e.clientX,y:e.clientY}}_getDirection({x:t,y:e}){return{x:{direction:t<0?\"left\":\"right\",value:Math.abs(t)},y:{direction:e<0?\"up\":\"down\",value:Math.abs(e)}}}_getOrigin({x:t,y:e},{x:i,y:n}){return{x:t-i,y:e-n}}_getDistanceBetweenTwoPoints(t,e,i,n){return Math.hypot(e-t,n-i)}_getMidPoint({x1:t,x2:e,y1:i,y2:n}){return{x:(t+e)/2,y:(i+n)/2}}_getVectorLength({x1:t,x2:e,y1:i,y2:n}){return Math.sqrt((e-t)**2+(n-i)**2)}_getRightMostTouch(t){let e=null;const i=Number.MIN_VALUE;return t.forEach(n=>{n.clientX>i&&(e=n)}),e}_getAngle(t,e,i,n){return Math.atan2(n-e,i-t)}_getAngularDistance(t,e){return e-t}_getCenterXY({x1:t,x2:e,y1:i,y2:n}){return{x:t+(e-t)/2,y:i+(n-i)/2}}_getPinchTouchOrigin(t){const[e,i]=t,n={x1:e.clientX,x2:i.clientX,y1:e.clientY,y2:i.clientY};return[this._getVectorLength(n),this._getCenterXY(n)]}_getPosition({x1:t,x2:e,y1:i,y2:n}){return{x1:t,x2:e,y1:i,y2:n}}}const sc=\"press\",Hw=\"pressup\",Vw={time:\"number\",pointers:\"number\"},Fw={time:250,pointers:1};class Ww extends mn{constructor(t,e={}){super(),this._element=t,this._options=this._getConfig(e),this._timer=null}static get NAME(){return sc}handleTouchStart(t){const{time:e,pointers:i}=this._options;t.touches.length===i&&(this._timer=setTimeout(()=>{_.trigger(this._element,sc,{touch:t,time:e}),_.trigger(this._element,Hw,{touch:t})},e))}handleTouchEnd(){clearTimeout(this._timer)}_getConfig(t){const e={...Fw,...g.getDataAttributes(this._element),...t};return L(sc,e,Vw),e}}const zw=\"swipe\",jw={threshold:\"number\",direction:\"string\"},Yw={threshold:10,direction:\"all\"};class Kw{constructor(t,e){this._element=t,this._startPosition=null,this._options=this._getConfig(e)}handleTouchStart(t){this._startPosition=this._getCoordinates(t)}handleTouchMove(t){if(!this._startPosition)return;const e=this._getCoordinates(t),i={x:e.x-this._startPosition.x,y:e.y-this._startPosition.y},n=this._getDirection(i);if(this._options.direction===\"all\"){if(n.y.valuen.x.value?n.y.direction:n.x.direction;_.trigger(this._element,`swipe${r}`,{touch:t}),_.trigger(this._element,\"swipe\",{touch:t,direction:r}),this._startPosition=null;return}const o=this._options.direction===\"left\"||this._options===\"right\"?\"x\":\"y\";n[o].direction===this._options.direction&&n[o].value>this._options.threshold&&(_.trigger(this._element,`swipe${n[o].direction}`,{touch:t}),this._startPosition=null)}handleTouchEnd(){this._startPosition=null}_getCoordinates(t){const[e]=t.touches;return{x:e.clientX,y:e.clientY}}_getDirection(t){return{x:{direction:t.x<0?\"left\":\"right\",value:Math.abs(t.x)},y:{direction:t.y<0?\"up\":\"down\",value:Math.abs(t.y)}}}_getConfig(t){const e={...Yw,...g.getDataAttributes(this._element),...t};return L(zw,e,jw),e}}const Je=\"pan\",Uw=`${Je}start`,Xw=`${Je}end`,Gw=`${Je}move`,qw=\"left\",Zw=\"right\",Qw={threshold:\"number\",direction:\"string\",pointers:\"number\"},Jw={threshold:20,direction:\"all\",pointers:1};class tk extends mn{constructor(t,e={}){super(),this._element=t,this._options=this._getConfig(e),this._startTouch=null}static get NAME(){return Je}handleTouchStart(t){this._startTouch=this._getCoordinates(t),this._movedTouch=t,_.trigger(this._element,Uw,{touch:t})}handleTouchMove(t){t.type===\"touchmove\"&&t.preventDefault();const{threshold:e,direction:i}=this._options,n=this._getCoordinates(t),o=this._getCoordinates(this._movedTouch),r=this._getOrigin(n,this._startTouch),a=this._getOrigin(n,o),l=this._getDirection(r),c=this._getDirection(a),{x:h,y:d}=l;if(i===\"all\"&&(d.value>e||h.value>e)){const p=d.value>h.value?d.direction:h.direction;_.trigger(this._element,`${Je}${p}`,{touch:t}),_.trigger(this._element,Je,{...a,touch:t})}const u=i===qw||i===Zw?\"x\":\"y\";c[u].direction===i&&l[u].value>e&&_.trigger(this._element,`${Je}${i}`,{touch:t,[u]:n[u]-o[u]}),this._movedTouch=t,_.trigger(this._element,Gw,{touch:t})}handleTouchEnd(t){t.type===\"touchend\"&&t.preventDefault(),this._movedTouch=null,this._startTouch=null,_.trigger(this._element,Xw,{touch:t})}_getConfig(t){const e={...Jw,...g.getDataAttributes(this._element),...t};return L(Je,e,Qw),e}}const Ts=\"pinch\",ek=`${Ts}end`,ik=`${Ts}start`,sk=`${Ts}move`,nk={threshold:\"number\",pointers:\"number\"},ok={threshold:10,pointers:2};class rk extends mn{constructor(t,e={}){super(),this._element=t,this._options=this._getConfig(e),this._startTouch=null,this._origin=null,this._touch=null,this._math=null,this._ratio=null}static get NAME(){return Ts}get isNumber(){return typeof this._startTouch==\"number\"&&typeof this._touch==\"number\"&&!isNaN(this._startTouch)&&!isNaN(this._touch)}handleTouchStart(t){if(t.touches.length!==this._options.pointers)return;t.type===\"touchstart\"&&t.preventDefault();const[e,i]=this._getPinchTouchOrigin(t.touches);this._touch=e,this._origin=i,this._startTouch=this._touch,_.trigger(this._element,ik,{touch:t,ratio:this._ratio,origin:this._origin})}handleTouchMove(t){const{threshold:e,pointers:i}=this._options;t.touches.length===i&&(t.type===\"touchmove\"&&t.preventDefault(),this._touch=this._getPinchTouchOrigin(t.touches)[0],this._ratio=this._touch/this._startTouch,this.isNumber&&(this._origin.x>e||this._origin.y>e)&&(this._startTouch=this._touch,_.trigger(this._element,Ts,{touch:t,ratio:this._ratio,origin:this._origin}),_.trigger(this._element,sk,{touch:t,ratio:this._ratio,origin:this._origin})))}handleTouchEnd(t){this.isNumber&&(this._startTouch=null,_.trigger(this._element,ek,{touch:t,ratio:this._ratio,origin:this._origin}))}_getConfig(t){const e={...ok,...g.getDataAttributes(this._element),...t};return L(Ts,e,nk),e}}const nc=\"tap\",ak={interval:\"number\",time:\"number\",taps:\"number\",pointers:\"number\"},lk={interval:500,time:250,taps:1,pointers:1};class ck extends mn{constructor(t,e){super(),this._element=t,this._options=this._getConfig(e),this._timer=null,this._tapCount=0}static get NAME(){return nc}handleTouchStart(t){const{x:e,y:i}=this._getCoordinates(t),{interval:n,taps:o,pointers:r}=this._options;return t.touches.length===r&&(this._tapCount+=1,this._tapCount===1&&(this._timer=setTimeout(()=>{this._tapCount=0},n)),this._tapCount===o&&(clearTimeout(this._timer),this._tapCount=0,_.trigger(this._element,nc,{touch:t,origin:{x:e,y:i}}))),t}handleTouchEnd(){}handleTouchMove(){}_getConfig(t){const e={...lk,...g.getDataAttributes(this._element),...t};return L(nc,e,ak),e}}const bn=\"rotate\",hk=`${bn}end`,dk=`${bn}start`,uk={angle:\"number\",pointers:\"number\"},pk={angle:0,pointers:2};class fk extends mn{constructor(t,e){super(),this._element=t,this._options=this._getConfig(e),this._origin={}}static get NAME(){return bn}handleTouchStart(t){t.type===\"touchstart\"&&t.preventDefault(),!(t.touches.length<2)&&(this._startTouch=t,this._origin={},_.trigger(this._element,dk,{touch:t}))}handleTouchMove(t){t.type===\"touchmove\"&&t.preventDefault();let e,i;const n=t.touches;if(n.length===1&&this._options.pointers===1){const{left:o,top:r,width:a,height:l}=this._element.getBoundingClientRect();e={x:o+a/2,y:r+l/2},i=n[0]}else if(t.touches.length===2&&this._options.pointers===2){const[o,r]=t.touches,a={x1:r.clientX,x2:o.clientX,y1:r.clientY,y2:o.clientY};e=this._getMidPoint(a),i=this._getRightMostTouch(t.touches)}else return;this.currentAngle=this._getAngle(e.x,e.y,i.clientX,i.clientY),this._origin.initialAngle?(this._origin.change=this._getAngularDistance(this._origin.previousAngle,this.currentAngle),this._origin.distance+=this._origin.change):(this._origin.initialAngle=this._origin.previousAngle=this.currentAngle,this._origin.distance=this._origin.change=0),this._origin.previousAngle=this.currentAngle,this.rotate={currentAngle:this.currentAngle,distance:this._origin.distance,change:this._origin.change},_.trigger(this._element,bn,{...this.rotate,touch:t})}handleTouchEnd(t){t.type===\"touchend\"&&t.preventDefault(),this._origin={},_.trigger(this._element,hk,{touch:t})}_getConfig(t){const e={...pk,...g.getDataAttributes(this._element),...t};return L(bn,e,uk),e}}const oc=\"touch\",rc=`te.${oc}`,_k={event:\"string\"},gk={event:\"swipe\"};class Er{constructor(t,e={}){this._element=t,this._options=this._getConfig(e),this._event=this._options.event,this.swipe=this._event===\"swipe\"?new Kw(t,e):null,this.press=this._event===\"press\"?new Ww(t,e):null,this.pan=this._event===\"pan\"?new tk(t,e):null,this.pinch=this._event===\"pinch\"?new rk(t,e):null,this.tap=this._event===\"tap\"?new ck(t,e):null,this.rotate=this._event===\"rotate\"?new fk(t,e):null,this._touchStartHandler=i=>this._handleTouchStart(i),this._touchMoveHandler=i=>this._handleTouchMove(i),this._touchEndHandler=i=>this._handleTouchEnd(i),_.on(this._element,\"touchstart\",this._touchStartHandler),_.on(this._element,\"touchmove\",this._touchMoveHandler),_.on(this._element,\"touchend\",this._touchEndHandler),this._element&&O.setData(t,rc,this)}static get NAME(){return oc}dispose(){_.off(this._element,\"touchstart\",this._touchStartHandler),_.off(this._element,\"touchmove\",this._touchMoveHandler),_.off(this._element,\"touchend\",this._touchEndHandler),this.swipe=null,this.press=null,this.pan=null,this.pinch=null,this.tap=null,this.rotate=null}_getConfig(t){const e={...gk,...g.getDataAttributes(this._element),...t};return L(oc,e,_k),e}_handleTouchStart(t){this[this._event].handleTouchStart(t)}_handleTouchMove(t){this[this._event].handleTouchMove&&this[this._event].handleTouchMove(t)}_handleTouchEnd(t){this[this._event].handleTouchEnd(t)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,rc);const i=typeof t==\"object\"&&t;if(!(!e&&/dispose/.test(t))&&(e||(e=new Er(this,i)),typeof t==\"string\")){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);return e[t]}})}static getInstance(t){return O.getData(t,rc)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const ac=\"smoothScroll\",vn=`te.${ac}`,lc=`.${vn}`,mk={container:\"string\",offset:\"number\",easing:\"string\",duration:\"number\"},bk={container:\"body\",offset:0,easing:\"linear\",duration:500},vk=`scrollStart${lc}`,yk=`scrollEnd${lc}`,Tk=`scrollCancel${lc}`;class xr{constructor(t,e={}){this._element=t,this._options=this._getConfig(e),this._href=this._element.getAttribute(\"href\"),this.isCancel=!1,this._element&&(O.setData(t,vn,this),this._setup())}static get NAME(){return ac}get isWindow(){return this._options.container===\"body\"}get containerToScroll(){return this.isWindow?document.documentElement:m.findOne(this._options.container,document.documentElement)}get elFromHrefExist(){return!!m.findOne(this._href,this.containerToScroll)}get offsetFromEl(){const t=this.containerToScroll.scrollTop,e=m.findOne(this._href,this.containerToScroll);if(this.isWindow)return g.offset(e).top-this._options.offset+t;const i=e.getBoundingClientRect().y,n=this.containerToScroll.getBoundingClientRect().y;return i-n-this._options.offset+t}get easingFunction(){const t=this._options.easing,e=`_motion${t[0].toUpperCase()}${t.slice(1)}`;return this[e]?this[e]:this._motionLinear}dispose(){_.off(this._element,\"click\",this._handleClick),O.removeData(this._element,vn),this._element=null}cancelScroll(){this.isCancel=!0}_getConfig(t){const e={...bk,...g.getDataAttributes(this._element),...t};return L(ac,e,mk),e}_inViewport(){if(this.isWindow)return!0;const t=this.containerToScroll.getBoundingClientRect();return t.top>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)}_setup(){const t=typeof this._href<\"u\",e=this._href.includes(\"#\");t&&e&&this.elFromHrefExist&&(this._scrollOnClickEvent(),this._preventNativeScroll())}_scrollOnClickEvent(){_.on(this._element,\"click\",t=>{this._handleClick(t)})}_handleClick(t){t.preventDefault(),this.isCancel=!1,_.trigger(this._element,vk);const e=this.containerToScroll,i=this.containerToScroll.scrollTop,n=this.offsetFromEl,o=0,r=1/this._options.duration,a=4.25,l=this.easingFunction;this._inViewport()?this._scrollOnNextTick(e,i,n,o,r,a,l):(this._scrollOnNextTick(document.documentElement,document.documentElement.scrollTop,this.containerToScroll.offsetTop,o,r,a,l),setTimeout(()=>{this._scrollOnNextTick(e,i,n,o,r,a,l),this.isCancel=!1},this._options.duration))}_scrollOnNextTick(t,e,i,n,o,r,a){const l=n<0,c=n>1,h=o<=0;if(l||c||h||this.isCancel){if(this.isCancel){this.isInViewport&&(this.isCancel=!1),_.trigger(this._element,Tk);return}_.trigger(this._element,yk),t.scrollTop=i;return}t.scrollTo({top:e-(e-i)*a(n)}),n+=o*r,setTimeout(()=>{this._scrollOnNextTick(t,e,i,n,o,r,a)})}_preventDefault(t){t.preventDefault()}_preventNativeScroll(){let t=!1;try{window.addEventListener(\"test\",null,Object.defineProperty({},\"passive\",{get:()=>t=!0}))}catch(n){this._scrollError=n}const e=t?{passive:!1}:!1,i=\"onwheel\"in $(\"div\")?\"wheel\":\"mousewheel\";this.isWindow&&(this._deleteScrollOnStart(e,i),this._addScrollOnEnd(e,i),this._addScrollOnCancel(e,i))}_deleteScrollOnStart(t,e){_.on(this._element,\"scrollStart.te.smoothScroll\",()=>{window.addEventListener(e,this._preventDefault,t),window.addEventListener(\"touchmove\",this._preventDefault,t)})}_addScrollOnEnd(t,e){_.on(this._element,\"scrollEnd.te.smoothScroll\",()=>{window.removeEventListener(e,this._preventDefault,t),window.removeEventListener(\"touchmove\",this._preventDefault,t)})}_addScrollOnCancel(t,e){_.on(this._element,\"scrollCancel.te.smoothScroll\",()=>{window.removeEventListener(e,this._preventDefault,t),window.removeEventListener(\"touchmove\",this._preventDefault,t)})}_motionLinear(t){return t}_motionEaseInQuad(t){return t*t}_motionEaseInCubic(t){return t*t*t}_motionEaseInQuart(t){return t*t*t*t}_motionEaseInQuint(t){return t*t*t*t*t}_motionEaseInOutQuad(t){return t<.5?2*t*t:-1+(4-2*t)*t}_motionEaseInOutCubic(t){return t/=.5,t<1?t*t*t/2:(t-=2,(t*t*t+2)/2)}_motionEaseInOutQuart(t){return t/=.5,t<1?.5*t*t*t*t:(t-=2,-(t*t*t*t-2)/2)}_motionEaseInOutQuint(t){return t/=.5,t<1?t*t*t*t*t/2:(t-=2,(t*t*t*t*t+2)/2)}_motionEaseOutQuad(t){return-t*(t-2)}_motionEaseOutCubic(t){return t--,t*t*t+1}_motionEaseOutQuart(t){return t--,-(t*t*t*t-1)}_motionEaseOutQuint(t){return t--,t*t*t*t*t+1}static getInstance(t){return O.getData(t,vn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,vn);const i=typeof t==\"object\"&&t;if(e||(e=new xr(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Kp=\"lazyLoad\",Cr=\"te.lazyLoad\",Ek=\"[data-te-lazy-load-init]\",Up=\"data-te-lazy-load\",xk=\"onLoad.te.lazy\",Ck=\"onError.te.lazy\",Xp=[\"img\",\"video\"],Ak={lazySrc:\"(string|null)\",lazyDelay:\"number\",lazyAnimation:\"string\",lazyOffset:\"number\",lazyPlaceholder:\"(string|undefined)\",lazyError:\"(string|undefined)\"},wk={lazySrc:null,lazyDelay:500,lazyAnimation:\"[fade-in_1s_ease-in-out]\",lazyOffset:0};class yn{constructor(t,e){this._element=t,this._element&&O.setData(t,Cr,this),this._options=this._getConfig(e),this.scrollHandler=this._scrollHandler.bind(this),this.errorHandler=this._setElementError.bind(this),this._childrenInstances=null,this._init()}static get NAME(){return Kp}get offsetValues(){return this._element.getBoundingClientRect()}get inViewport(){if(this.parent){const t=this.parent.getBoundingClientRect();return t.y>0&&t.y=t.y&&this.offsetValues.y<=t.y+t.height&&this.offsetValues.y<=window.innerHeight}return this.offsetValues.top+this._options.lazyOffset<=window.innerHeight&&this.offsetValues.bottom>=0}get parent(){const[t]=m.parents(this._element,Ek);return t}get node(){return this._element.nodeName}get isContainer(){return!m.matches(this._element,Xp)}dispose(){O.removeData(this._element,Cr),this._animation&&(this._animation.dispose(),this._animation=null),this._element=null,this._childrenInstances&&this._childrenInstances.forEach(t=>t.dispose())}_init(){if(this._element.setAttribute(Up,\"\"),this.isContainer){this._setupContainer();return}this._setupElement()}_setupElement(){_.one(this._element,\"error\",this.errorHandler),this._options.lazyPlaceholder&&this._setPlaceholder(),this._animation=new Gs(this._element,{animation:`${this._options.lazyAnimation}`,animationStart:\"onLoad\"}),_.one(this._element,\"load\",()=>this._scrollHandler()),this.parent&&_.on(this.parent,\"scroll\",this.scrollHandler),_.on(window,\"scroll\",this.scrollHandler)}_scrollHandler(){this.inViewport&&(this._timeout=setTimeout(()=>{this._setSrc(),this._element.removeAttribute(Up),this._removeAttrs(),this._animation.init()},this._options.lazyDelay),this.parent&&_.off(this.parent,\"scroll\",this.scrollHandler),_.off(window,\"scroll\",this.scrollHandler))}_setElementError(){!this._options.lazyError||this._element.src===this._options.lazyError?this._element.alt=\"404 not found\":this._element.setAttribute(\"src\",this._options.lazyError),_.trigger(this._element,Ck)}_setSrc(){this._element.setAttribute(\"src\",this._options.lazySrc),_.trigger(this._element,xk)}_setPlaceholder(){this.node===\"IMG\"?this._element.setAttribute(\"src\",this._options.lazyPlaceholder):this.node===\"VIDEO\"&&this._element.setAttribute(\"poster\",this._options.lazyPlaceholder)}_removeAttrs(){[\"src\",\"delay\",\"animation\",\"placeholder\",\"offset\",\"error\"].forEach(t=>{g.removeDataAttribute(this._element,`lazy-${t}`)})}_setupContainer(){this._childrenInstances=m.children(this._element,Xp).map(t=>new yn(t,this._options))}_getConfig(t){const e={...wk,...t,...g.getDataAttributes(this._element)};return L(Kp,e,Ak),e}static getInstance(t){return O.getData(t,Cr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,Cr);const i=typeof t==\"object\"&&t;if(e||(e=new yn(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Gp=\"clipboard\",Tn=\"te.clipboard\",kk=`.${Tn}`,Sk={clipboardTarget:null},Ok={clipboardTarget:\"null|string\"},Ik=`copy${kk}`;class Ar{constructor(t,e={}){this._element=t,this._options=e,this._element&&(O.setData(t,Tn,this),this._initCopy=this._initCopy.bind(this),this._setup())}static get NAME(){return Gp}get options(){const t={...Sk,...g.getDataAttributes(this._element),...this._options};return L(Gp,t,Ok),t}get clipboardTarget(){return m.findOne(this.options.clipboardTarget)}get copyText(){const t=this.clipboardTarget.hasAttribute(\"data-te-clipboard-text\"),e=this.clipboardTarget.value,i=this.clipboardTarget.textContent;return t?this.clipboardTarget.getAttribute(\"data-te-clipboard-text\"):e||i}dispose(){_.off(this._element,\"click\",this._initCopy),O.removeData(this._element,Tn),this._element=null}_setup(){_.on(this._element,\"click\",this._initCopy)}_initCopy(){const t=this._createNewInput();document.body.appendChild(t),this._selectInput(t),_.trigger(this._element,Ik,{copyText:this.copyText}),t.remove()}_createNewInput(){const t=this.clipboardTarget.tagName===\"TEXTAREA\"?\"textarea\":\"input\",e=$(t);return e.value=this.copyText,g.addClass(e,\"-left-[9999px] absolute\"),e}_selectInput(t){t.select(),t.focus(),t.setSelectionRange(0,99999),document.execCommand(\"copy\")}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,Tn);const i=typeof t==\"object\"&&t;if(e||(e=new Ar(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}static getInstance(t){return O.getData(t,Tn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const cc=\"infiniteScroll\",wr=`te.${cc}`,Dk={infiniteDirection:\"y\"},Mk={infiniteDirection:\"string\"};class kr{constructor(t,e){this._element=t,this._element&&O.setData(t,wr,this),this._options=this._getConfig(e),this.scrollHandler=this._scrollHandler.bind(this),this._init()}static get NAME(){return cc}get rect(){return this._element.getBoundingClientRect()}get condition(){return this._element===window?Math.abs(window.scrollY+window.innerHeight-document.documentElement.scrollHeight)<1:this._options.infiniteDirection===\"x\"?this.rect.width+this._element.scrollLeft+10>=this._element.scrollWidth:Math.ceil(this.rect.height+this._element.scrollTop)>=this._element.scrollHeight}dispose(){_.off(this._element,\"scroll\",this.scrollHandler),O.removeData(this._element,wr),this._element=null}_init(){_.on(this._element,\"scroll\",()=>this._scrollHandler())}_scrollHandler(){this.condition&&_.trigger(this._element,\"complete.te.infiniteScroll\"),_.off(this._element,\"scroll\",this.scrollHandler)}_getConfig(t){const e={...Dk,...this._element!==window?g.getDataAttributes(this._element):{},...t};return L(cc,e,Mk),e}static getInstance(t){return O.getData(t,wr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,wr);const i=typeof t==\"object\"&&t;if(e||(e=new kr(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}function Lk({backdropID:s},t){const e=$(\"div\");return g.addClass(e,`${t.backdrop} ${t.backdropColor}`),e.id=s,e}const En=\"loadingManagement\",Sr=`te.${En}`,$k=\"[data-te-loading-icon-ref]\",Rk=\"[data-te-loading-text-ref]\",Pk=`show.te.${En}`,Nk={backdrop:\"(null|boolean)\",backdropID:\"(null|string|number)\",delay:\"(null|number)\",loader:\"(null|string|number)\",parentSelector:\"(null|string)\",loadingIcon:\"boolean\",loadingText:\"boolean\",scroll:\"boolean\"},Bk={backdrop:!0,backdropID:null,delay:0,loader:\"\",parentSelector:null,scroll:!0,loadingText:!0,loadingIcon:!0},Hk={loadingSpinner:\"absolute top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] flex flex-col justify-center items-center z-40\",spinnerColor:\"text-primary dark:text-primary-400\",backdrop:\"w-full h-full fixed top-0 left-0 bottom-0 right-0 z-30\",backdropColor:\"bg-[rgba(0,0,0,0.4)]\"},Vk={loadingSpinner:\"string\",spinnerColor:\"string\",backdrop:\"string\",backdropColor:\"string\"};class Or{constructor(t,e={},i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._element&&O.setData(t,Sr,this),this._backdropElement=null,this._parentElement=m.findOne(this._options.parentSelector),this._loadingIcon=m.findOne($k,this._element),this._loadingText=m.findOne(Rk,this._element),this.init()}static get NAME(){return En}init(){const t=this._loadingIcon.cloneNode(!0),e=this._loadingText.cloneNode(!0);this._removeElementsOnStart(),setTimeout(()=>{g.addClass(this._element,`${this._classes.loadingSpinner} ${this._classes.spinnerColor}`),this._setBackdrop(),this._setLoadingIcon(t),this._setLoadingText(e),this._setScrollOption(),_.trigger(this._element,Pk)},this._options.delay)}dispose(){O.removeData(this._element,Sr),g.removeClass(this._element,`${this._classes.loadingSpinner} ${this._classes.spinnerColor}`);const t=this._options.delay;setTimeout(()=>{this._removeBackdrop(),this._backdropElement=null,this._element=null,this._options=null},t)}_setBackdrop(){const{backdrop:t}=this._options;t&&(this._backdropElement=Lk(this._options,this._classes),this._parentElement!==null?(g.addClass(this._element,\"absolute\"),g.addClass(this._parentElement,\"relative\"),g.addClass(this._backdropElement,\"absolute\"),this._parentElement.appendChild(this._backdropElement)):(g.addClass(this._element,\"!fixed\"),document.body.appendChild(this._backdropElement),document.body.appendChild(this._element)))}_removeBackdrop(){const{backdrop:t}=this._options;t&&(this._parentElement!==null?(g.removeClass(this._element,\"absolute\"),g.removeClass(this._parentElement,\"relative\"),this._backdropElement.remove()):(this._backdropElement.remove(),this._element.remove()))}_setLoadingIcon(t){if(!this._options.loadingIcon){t.remove();return}this._element.appendChild(t),t.id=this._options.loader}_setLoadingText(t){if(!this._options.loadingText){t.remove();return}this._element.appendChild(t)}_removeElementsOnStart(){this._element!==null&&(this._loadingIcon.remove(),this._loadingText.remove())}_setScrollOption(){if(this._options.scroll){if(this._parentElement===null){g.addClass(document.body,\"overflow-auto\");return}g.addClass(this._parentElement,\"overflow-auto\")}else{if(this._parentElement===null){g.addClass(document.body,\"overflow-hidden\");return}g.addClass(this._parentElement,\"overflow-hidden\")}}_getConfig(t){const e={...Bk,...g.getDataAttributes(this._element),...t};return L(En,e,Nk),e}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...Hk,...e,...t},L(En,t,Vk),t}static getInstance(t){return O.getData(t,Sr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}static jQueryInterface(t){return this.each(function(){let e=O.getData(this,Sr);const i=typeof t==\"object\"&&t;if(e||(e=new Or(this,i)),typeof t==\"string\"){if(typeof e[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);e[t](this)}})}}const Fk=s=>{const t=/^(0?[1-9]|1[012])(:[0-5]\\d) [APap][mM]$/,e=/^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;return s.match(t)||s.match(e)},Wk=s=>s&&Object.prototype.toString.call(s)===\"[object Date]\"&&!isNaN(s),zk=s=>s.getMonth(),jk=s=>s.getFullYear(),Yk=s=>s.match(/[^(dmy)]{1,}/g),Kk=(s,t,e,i)=>{let n;e[0]!==e[1]?n=e[0]+e[1]:n=e[0];const o=new RegExp(`[${n}]`),r=s.split(o),a=t.split(o),l=t.indexOf(\"mmm\")!==-1,c=[];for(let b=0;bt.findIndex(e=>e===s),Xk=(s,t,e)=>`\n \n \n`,Gk=(s,t)=>`\n \n`,Ir=\"datetimepicker\",xn=`te.${Ir}`,hc=`.${xn}`,qp=\"data-te-datepicker-init\",Zp=\"data-te-timepicker-init\",qk=\"data-te-datepicker-header\",Zk=\"data-te-datepicker-cancel-button-ref\",Qk=\"data-te-datepicker-ok-button-ref\",dc=\"data-te-timepicker-wrapper\",Qp=\"data-te-timepicker-cancel\",Jk=\"data-te-timepicker-submit\",tS=\"data-te-timepicker-clear\",Jp=\"data-te-buttons-timepicker\",eS=\"data-te-date-timepicker-toggle-ref\",iS=\"data-te-datepicker-toggle-button-ref\",sS=\"data-te-timepicker-toggle-button-ref\",nS=`[${Zp}]`,oS=`[${qp}]`,rS=`[${eS}]`,aS=`[${sS}]`,lS=\"[data-te-input-notch-ref]\",cS=\"[data-te-date-timepicker-toggle-ref]\",hS=\"[data-te-timepicker-elements-wrapper]\",dS=\"[data-te-timepicker-clock-wrapper]\",uS=`open${hc}`,pS=`close${hc}`,fS=`datetimeChange${hc}`,tf=\"close.te.datepicker\",ef=\"input.te.timepicker\",Es=$(\"div\"),sf={inline:!1,toggleButton:!0,container:\"body\",disabled:!1,disablePast:!1,disableFuture:!1,defaultTime:\"\",defaultDate:\"\",timepicker:{},datepicker:{},showFormat:!1,dateTimepickerToggleIconTemplate:`\n \n `,datepickerToggleIconTemplate:`\n \n `,timepickerToggleIconTemplate:`\n \n `},_S={inline:\"boolean\",toggleButton:\"boolean\",container:\"string\",disabled:\"boolean\",disablePast:\"boolean\",disableFuture:\"boolean\",defaultTime:\"(string|date|number)\",defaultDate:\"(string|date|number)\",timepicker:\"object\",datepicker:\"object\",showFormat:\"boolean\",dateTimepickerToggleIconTemplate:\"string\",datepickerToggleIconTemplate:\"string\",timepickerToggleIconTemplate:\"string\"},gS={toggleButton:\"flex items-center justify-content-center [&>svg]:w-5 [&>svg]:h-5 absolute outline-none border-none bg-transparent right-0.5 top-1/2 -translate-x-1/2 -translate-y-1/2 hover:text-primary focus:text-primary dark:hover:text-primary-400 dark:focus:text-primary-400 dark:text-neutral-200\",pickerIcon:\"[&>svg]:w-6 [&>svg]:h-6 [&>svg]:mx-auto [&>svg]:pointer-events-none w-1/2 px-1.5 py-[1px] rounded-[10px] min-h-[40px] cursor-pointer outline-none border-none text-white hover:bg-primary-600 dark:hover:bg-neutral-600\",buttonsContainer:\"flex justify-evenly items-end bg-primary dark:bg-zinc-800 dark:data-[te-buttons-timepicker]:bg-zinc-700\",timepicker:{},datepicker:{}},mS={toggleButton:\"string\",pickerIcon:\"string\",buttonsContainer:\"string\",timepicker:\"object\",datepicker:\"object\"};class Dr{constructor(t,e,i){this._element=t,this._input=m.findOne(\"input\",this._element),this._options=this._getConfig(e),this._classes=this._getClasses(i),this._timepicker=null,this._datepicker=null,this._dateValue=this._options.defaultDate?this._options.defaultDate:\"\",this._timeValue=this._options.defaultTime?this._options.defaultTime:\"\",this._isInvalidTimeFormat=!1,this._format=this._options.datepicker.format?this._options.datepicker.format:\"dd/mm/yyyy\",this._cancel=!1,this._scrollBar=new Qi,this._element&&O.setData(t,xn,this),this._init()}static get NAME(){return Ir}get toggleButton(){return m.findOne(rS,this._element)}get notch(){return m.findOne(lS,this._element)}dispose(){_.off(this._element,\"click\",this._openDatePicker),_.off(this._input,\"input\",this._handleInput),_.off(this._element,\"click\"),O.removeData(this._element,xn),this._removeTimePicker(),this._removeDatepicker(),this.toggleButton.remove(),this._options=sf,this._timepicker=null,this._datepicker=null,this._dateValue=null,this._timeValue=null,this._isInvalidTimeFormat=null}update(t={}){const e=this._getConfig({...this._options,...t});this.dispose(),this._options=e,this._init()}_init(){this._addDatepicker(),this._addTimePicker(),this._appendToggleButton(),this._listenToToggleClick(),this._listenToUserInput(),this._disableInput(),this._setInitialDefaultInput(),this._applyFormatPlaceholder(),this._options.disablePast&&this._handleTimepickerDisablePast(),this._options.disableFuture&&this._handleTimepickerDisableFuture()}_removeDatepicker(){const t=this._element.querySelector(oS);t&&t.remove()}_addDatepicker(){const t=$(\"div\");t.id=this._element.id?`datepicker-${this._element.id}`:bt(\"datepicker-\");const e='';t.innerHTML=e,t.setAttribute(qp,\"\"),this._element.appendChild(t),g.addClass(t,\"hidden\");let i={...this._options.datepicker,container:this._options.container,disablePast:this._options.disablePast,disableFuture:this._options.disableFuture};(this._options.inline||this._options.datepicker.inline)&&(i={...i,inline:!0}),this._datepicker=new xl(t,i,{...this._classes.datepicker}),this._datepicker._input.value=this._dateValue}_removeTimePicker(){const t=this._element.querySelector(nS);t&&(t.remove(),this._scrollBar.reset())}_addTimePicker(){const t=$(\"div\");t.id=this._element.id?`timepicker-${this._element.id}`:bt(\"timepicker-\");const e='';t.innerHTML=e,t.setAttribute(Zp,\"\"),this._element.appendChild(t),g.addClass(t,\"hidden\");let i={...this._options.timepicker,container:this._options.container};(this._options.inline||this._options.timepicker.inline)&&(i={...i,inline:!0}),this._timepicker=new Ll(t,i,{...this._classes.timepicker}),this._timepicker.input.value=this._timeValue}_addIconButtons(){if(g.addClass(Es,this._classes.buttonsContainer),Es.innerHTML=Xk(this._options.datepickerToggleIconTemplate,this._options.timepickerToggleIconTemplate,this._classes),Es.removeAttribute(Jp),!(this._options.inline||this._options.datepicker.inline)){if(this._scrollBar.hide(),this._datepicker._isOpen)m.findOne(`[${qk}]`,document.body).appendChild(Es);else if(this._timepicker._modal&&!this._options.timepicker.inline){const t=m.findOne(hS,document.body),e=m.findOne(dS,document.body);Es.setAttribute(Jp,\"\"),t.insertBefore(Es,e)}}}_enableOrDisableToggleButton(){this._options.disabled?(this.toggleButton.disabled=!0,g.addClass(this.toggleButton,\"pointer-events-none\")):(this.toggleButton.disabled=!1,g.removeClass(this.toggleButton,\"pointer-events-none\"))}_appendToggleButton(){this._options.toggleButton&&(this._element.insertAdjacentHTML(\"beforeend\",Gk(this._options.dateTimepickerToggleIconTemplate,this._classes)),this._enableOrDisableToggleButton())}_applyFormatPlaceholder(){this._options.showFormat&&(this._input.placeholder=this._format)}_listenToCancelClick(){const t=m.findOne(`[${Zk}]`,document.body);_.one(t,\"mousedown\",()=>{this._cancel=!0,this._scrollBar.reset(),_.off(t,\"mousedown\")})}_listenToToggleClick(){_.on(this._element,\"click\",cS,t=>{t.preventDefault(),this._openDatePicker()})}_listenToUserInput(){_.on(this._input,\"input\",t=>{this._handleInput(t.target.value)})}_disableInput(){this._options.disabled&&(this._input.disabled=\"true\")}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...sf,...e,...t},L(Ir,t,_S),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...gS,...e,...t},L(Ir,t,mS),t}_handleInput(t){const e=t.split(\", \"),i=Yk(this._format),n=e[0],o=e[1]||\"\",r=Kk(n,this._format,i,this._datepicker._options);e.length===2&&(Wk(r)&&Fk(o)?(this._dateValue=n,this._timeValue=o,this._datepicker._input.value=this._dateValue,this._datepicker._activeDate=this._dateValue,this._datepicker._selectedYear=jk(r),this._datepicker._selectedMonth=zk(r),this._datepicker._headerDate=r,this._timepicker.input.value=this._timeValue,this._timepicker._isInvalidTimeFormat=!1):(this._datepicker._activeDate=new Date,this._datepicker._selectedDate=null,this._datepicker._selectedMonth=null,this._datepicker._selectedYear=null,this._datepicker._headerDate=null,this._datepicker._headerMonth=null,this._datepicker._headerYear=null,this._timepicker._isInvalidTimeFormat=!0))}_openDatePicker(){if(_.trigger(this._element,uS).defaultPrevented)return;this._datepicker.open(),this._options.inline||this._scrollBar.hide(),(this._options.inline||this._options.datepicker.inline)&&this._openDropdownDate(),this._addIconButtons(),this._listenToCancelClick(),this._options.inline&&this._datepicker._isOpen&&g.addClass(this.toggleButton,\"pointer-events-none\"),_.one(this._datepicker._element,tf,()=>{if(this._dateValue=this._datepicker._input.value,this._updateInputValue(),this._cancel){this._cancel=!1;return}let i=!1;_.on(this._datepicker.container,\"click\",n=>{!this._datepicker._selectedDate&&n.target.hasAttribute(Qk)||i||(this._openTimePicker(),i=!0,setTimeout(()=>{i=!1},500))}),setTimeout(()=>{m.findOne(`[${dc}]`,document.body)||this._scrollBar.reset()},10),this._options.inline&&g.removeClass(this.toggleButton,\"pointer-events-none\")});const e=m.findOne(aS,document.body);_.on(e,\"click\",()=>{this._datepicker.close(),this._scrollBar.hide(),_.trigger(this._datepicker._element,tf)})}_handleTimepickerDisablePast(){const t=new Date;t.setHours(0,0,0,0),_.on(this._datepicker._element,\"dateChange.te.datepicker\",()=>{this._datepicker._selectedDate.getTime()===t.getTime()?this._timepicker.update({disablePast:!0}):this._timepicker.update({disablePast:!1})})}_handleTimepickerDisableFuture(){const t=new Date;t.setHours(0,0,0,0),_.on(this._datepicker._element,\"dateChange.te.datepicker\",()=>{this._datepicker._selectedDate.getTime()===t.getTime()?this._timepicker.update({disableFuture:!0}):this._timepicker.update({disableFuture:!1})})}_handleEscapeKey(){_.one(document.body,\"keyup\",()=>{setTimeout(()=>{m.findOne(`[${dc}]`,document.body)||this._scrollBar.reset()},250)})}_handleCancelButton(){const t=m.findOne(`[${Qp}]`,document.body);_.one(t,\"mousedown\",()=>{this._scrollBar.reset()})}_openDropdownDate(){const t=this._datepicker._popper;t.state.elements.reference=this._input,this._scrollBar.reset()}_openTimePicker(){_.trigger(this._timepicker.elementToggle,\"click\"),setTimeout(()=>{if(this._addIconButtons(),(this._options.inline||this._options.timepicker.inline)&&this._openDropdownTime(),this._timepicker._modal){const t=m.findOne(`[${Qp}]`,document.body);this._handleEscapeKey(),this._handleCancelButton(),_.on(this._timepicker._modal,\"click\",e=>{(e.target.hasAttribute(dc)||e.target.hasAttribute(Jk))&&setTimeout(()=>{this._scrollBar.reset()},200),e.target.hasAttribute(tS)&&_.trigger(this._timepicker._element,ef),e.target.hasAttribute(iS)&&(_.trigger(t,\"click\"),setTimeout(()=>{this._openDatePicker(),this._scrollBar.hide()},200))})}}),_.one(this._timepicker._element,ef,()=>{this._timeValue=this._timepicker.input.value,this._updateInputValue(),_.trigger(this._element,pS)})}_openDropdownTime(){const t=this._timepicker._popper;t.state.elements.reference=this._input,t.update(),this._scrollBar.reset()}_setInitialDefaultInput(){(this._options.defaultDate||this._options.defaultTime)&&this._updateInputValue()}_updateInputValue(){this._timeValue&&this._dateValue&&(this._input.value=`${this._dateValue}, ${this._timeValue}`,_.trigger(this._element,fS,{value:this._input.value}).defaultPrevented)||(_.trigger(this._input,\"focus\"),this.notch&&this.notch.removeAttribute(\"data-te-input-focused\"))}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,xn);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Dr(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,xn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const Mr=\"sticky\",Cn=`te.${Mr}`,nf=`.${Cn}`,bS=`active${nf}`,vS=`inactive${nf}`,yS={stickyAnimationSticky:\"\",stickyAnimationUnsticky:\"\",stickyBoundary:!1,stickyDelay:0,stickyDirection:\"down\",stickyMedia:0,stickyOffset:0,stickyPosition:\"top\",stickyZIndex:100},TS={stickyAnimationSticky:\"string\",stickyAnimationUnsticky:\"string\",stickyBoundary:\"(boolean|string)\",stickyDelay:\"number\",stickyDirection:\"string\",stickyMedia:\"number\",stickyOffset:\"number\",stickyPosition:\"string\",stickyZIndex:\"(string|number)\"},ES={stickyActive:\"\"},xS={stickyActive:\"string\"};class Lr{constructor(t,e,i){this._element=t,this._hiddenElement=null,this._elementPositionStyles={},this._scrollDirection=\"\",this._isSticked=!1,this._elementOffsetTop=null,this._scrollTop=0,this._pushPoint=\"\",this._manuallyDeactivated=!1,this._element&&(this._options=this._getConfig(e),this._classes=this._getClasses(i),O.setData(t,Cn,this),this._init())}static get NAME(){return Mr}dispose(){const{stickyAnimationUnsticky:t}=this._options;let{animationDuration:e}=getComputedStyle(this._element);e=t!==\"\"?parseFloat(e)*1e3:0,this._disableSticky(),setTimeout(()=>{O.removeData(this._element,Cn),this._element=null,this._options=null,this._hiddenElement=null,this._elementPositionStyles=null,this._scrollDirection=null,this._isSticked=null,this._elementOffsetTop=null,this._scrollTop=null,this._pushPoint=null,this._manuallyDeactivated=null},e)}active(){this._isSticked||(this._createHiddenElement(),this._enableSticky(),this._changeBoundaryPosition(),this._isSticked=!0,this._manuallyDeactivated=!1)}inactive(){this._isSticked&&(this._disableSticky(),this._isSticked=!1,this._manuallyDeactivated=!0)}_init(){this._userActivityListener()}_userActivityListener(){_.on(window,\"resize\",()=>{this._updateElementPosition(),this._updateElementOffset()}),_.on(window,\"scroll\",()=>{if(!this._element||window.innerWidth<=this._options.stickyMedia||this._manuallyDeactivated)return;const t=document.documentElement,{stickyDirection:e}=this._options,i=window.pageYOffset||t.scrollTop;this._updateElementOffset(),this._updatePushPoint(),this._updateScrollDirection(i),this._clearInProgressAnimations();const n=[this._scrollDirection,\"both\"].includes(e),o=this._pushPoint<=i,r=o&&!this._isSticked&&n,a=(!o||!n)&&this._isSticked;r&&(this._createHiddenElement(),this._enableSticky(),this._changeBoundaryPosition(),this._isSticked=!0),a&&(this._disableSticky(),this._isSticked=!1),this._isSticked&&(this._updatePosition({styles:this._elementPositionStyles}),this._changeBoundaryPosition()),this._scrollTop=i<=0?0:i})}_updatePushPoint(){this._options.stickyPosition===\"top\"?this._pushPoint=this._elementOffsetTop-this._options.stickyDelay:this._pushPoint=this._elementOffsetTop+this._element.height-document.body.scrollHeight+this._options.stickyDelay}_updateElementOffset(){this._hiddenElement?this._elementOffsetTop=this._hiddenElement.offsetTop:this._elementOffsetTop=this._element.offsetTop,this._options.stickyAnimationUnsticky&&(this._elementOffsetTop+=this._element.height||0)}_updateElementPosition(){if(this._hiddenElement){const{left:t}=this._hiddenElement.getBoundingClientRect();this._elementPositionStyles={left:`${t}px`}}else this._elementPositionStyles={};this._setStyle(this._element,this._elementPositionStyles)}_updateScrollDirection(t){t>this._scrollTop?this._scrollDirection=\"down\":this._scrollDirection=\"up\"}_clearInProgressAnimations(){const t=this._scrollDirection===\"up\",e=this._element.classList.contains(this._options.stickyAnimationUnsticky),i=window.scrollY<=this._elementOffsetTop-this._element.height;t&&e&&i&&(this._removeUnstickyAnimation(),this._resetStyles(),this._removeHiddenElement())}_enableSticky(){const{stickyAnimationSticky:t,stickyAnimationUnsticky:e,stickyOffset:i,stickyPosition:n,stickyZIndex:o}=this._options,{height:r,left:a,width:l}=this._element.getBoundingClientRect();t!==\"\"&&this._toggleClass(t,e,this._element),this._toggleClass(this._classes.stickyActive,\"\",this._element),this._setStyle(this._element,{top:n===\"top\"&&`${0+i}px`,bottom:n===\"bottom\"&&`${0+i}px`,height:`${r}px`,width:`${l}px`,left:`${a}px`,zIndex:`${o}`,position:\"fixed\"}),this._hiddenElement.hidden=!1,_.trigger(this._element,bS)}_changeBoundaryPosition(){const{stickyPosition:t,stickyBoundary:e,stickyOffset:i}=this._options,{height:n}=this._element.getBoundingClientRect(),o={height:this._element.parentElement.getBoundingClientRect().height,...this._getOffset(this._element.parentElement)};let r;const a=m.findOne(e);a?r=this._getOffset(a).top-n-i:r=o.height+o[t]-n-i;const l=t===\"top\",c=t===\"bottom\",h=e,d=r<0,u=r>o.height-n;let p;l&&(d&&h?p={top:`${i+r}px`}:p={top:`${i+0}px`}),c&&(d&&h?p={bottom:`${i+r}px`}:u&&h?p={bottom:`${i+o.bottom}px`}:p={bottom:`${i+0}px`}),this._setStyle(this._element,p)}_disableSticky(){const{stickyAnimationUnsticky:t,stickyAnimationSticky:e}=this._options;let{animationDuration:i}=getComputedStyle(this._element);i=t!==\"\"?parseFloat(i)*1e3:0,this._options.stickyAnimationUnsticky!==\"\"&&this._toggleClass(t,e,this._element),setTimeout(()=>{this._element.classList.contains(e)||(this._removeUnstickyAnimation(),this._resetStyles(),this._removeHiddenElement(),this._toggleClass(\"\",this._classes.stickyActive,this._element),_.trigger(this._element,vS))},i)}_createHiddenElement(){this._hiddenElement||(this._hiddenElement=this._copyElement(this._element))}_removeHiddenElement(){this._hiddenElement&&(this._hiddenElement.remove(),this._hiddenElement=null)}_removeUnstickyAnimation(){this._toggleClass(\"\",this._options.stickyAnimationUnsticky,this._element)}_resetStyles(){this._setStyle(this._element,{top:null,bottom:null,position:null,left:null,zIndex:null,width:null,height:null})}_updatePosition({styles:t}){this._setStyle(this._element,t)}_toggleClass(t,e,i){t&&g.addClass(i,t),e&&g.removeClass(i,e)}_getOffset(t){const e=g.offset(t),i=t.getBoundingClientRect(),n=e.left===0&&e.top===0?0:window.innerHeight-i.bottom;return{...e,bottom:n}}_copyElement(t){const{height:e,width:i}=t.getBoundingClientRect(),n=t.cloneNode(!1);return n.hidden=!0,this._setStyle(n,{height:`${e}px`,width:`${i}px`,opacity:\"0\"}),t.parentElement.insertBefore(n,t),n}_getConfig(t={}){const e=g.getDataAttributes(this._element);return t={...yS,...e,...t},L(Mr,t,TS),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...ES,...e,...t},L(Mr,t,xS),t}_setStyle(t,e){Object.keys(e).forEach(i=>{t.style[i]=e[i]})}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,Cn);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose|hide/.test(t))&&(i||(i=new Lr(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,Cn)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const CS=\"data-te-autocomplete-dropdown-ref\",AS=\"data-te-autocomplete-items-list-ref\",wS=\"data-te-autocomplete-item-ref\",kS=\"data-te-autocomplete-loader-ref\";function SS(s,t){const{id:e,items:i,width:n,options:o}=s,r=$(\"div\");g.addClass(r,t.dropdownContainer),g.addStyle(r,{width:`${n}px`}),r.setAttribute(\"id\",e);const a=$(\"div\");a.setAttribute(CS,\"\"),g.addClass(a,t.dropdown);const l=$(\"ul\"),c=o.listHeight;l.setAttribute(AS,\"\"),g.addClass(l,t.autocompleteList),g.addClass(l,t.scrollbar),g.addStyle(l,{maxHeight:`${c}px`}),l.setAttribute(\"role\",\"listbox\");const h=of(i,o);return l.innerHTML=h,a.appendChild(l),r.appendChild(a),r}function of(s=[],t,e){const i=t.displayValue,n=t.itemContent;return`\n ${s.map((o,r)=>{const a=typeof n==\"function\"?To(n(o),Pd,null):i(o);return`
  • ${a}
  • `}).join(\"\")}\n `}function OS(s){const t=$(\"div\");t.setAttribute(kS,\"\"),g.addClass(t,s.autocompleteLoader),g.addClass(t,s.spinnerIcon),t.setAttribute(\"role\",\"status\");const e='Loading...';return t.innerHTML=e,t}function IS(s,t){return`
  • ${s}
  • `}const uc=\"autocomplete\",An=\"te.autocomplete\",xs=\"data-te-input-state-active\",pc=\"data-te-autocomplete-item-active\",rf=\"data-te-input-focused\",af=\"data-te-autocomplete-state-open\",DS=\"data-te-autocomplete-custom-content-ref\",MS=\"[data-te-autocomplete-dropdown-ref]\",$r=\"[data-te-autocomplete-items-list-ref]\",lf=\"[data-te-autocomplete-item-ref]\",LS=\"[data-te-autocomplete-loader-ref]\",$S=`[${DS}]`,RS=\"[data-te-input-notch-ref]\",Rr=`.${An}`,PS=`close${Rr}`,NS=`open${Rr}`,cf=`itemSelect${Rr}`,BS=`update${Rr}`,HS={autoSelect:!1,container:\"body\",customContent:\"\",debounce:300,displayValue:s=>s,filter:null,itemContent:null,listHeight:190,loaderCloseDelay:300,noResults:\"No results found\",threshold:0},VS={autoSelect:\"boolean\",container:\"string\",customContent:\"string\",debounce:\"number\",displayValue:\"function\",filter:\"(null|function)\",itemContent:\"(null|function)\",listHeight:\"number\",loaderCloseDelay:\"number\",noResults:\"string\",threshold:\"number\"},FS={autocompleteItem:\"flex flex-row items-center justify-between w-full px-4 py-[0.4375rem] truncate text-gray-700 bg-transparent select-none cursor-pointer hover:[&:not([data-te-autocomplete-option-disabled])]:bg-black/5 data-[te-autocomplete-item-active]:bg-black/5 data-[data-te-autocomplete-option-disabled]:text-gray-400 data-[data-te-autocomplete-option-disabled]:cursor-default dark:text-gray-200 dark:hover:[&:not([data-te-autocomplete-option-disabled])]:bg-white/30 dark:data-[te-autocomplete-item-active]:bg-white/30\",autocompleteList:\"list-none m-0 p-0 overflow-y-auto\",autocompleteLoader:\"absolute right-1 top-2 w-[1.4rem] h-[1.4rem] border-[0.15em]\",dropdown:\"relative outline-none min-w-[100px] m-0 scale-y-[0.8] opacity-0 bg-white shadow-[0_2px_5px_0_rgba(0,0,0,0.16),_0_2px_10px_0_rgba(0,0,0,0.12)] transition duration-200 motion-reduce:transition-none data-[te-autocomplete-state-open]:scale-y-100 data-[te-autocomplete-state-open]:opacity-100 dark:bg-zinc-700\",dropdownContainer:\"z-[1070]\",scrollbar:\"[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-button]:block [&::-webkit-scrollbar-button]:h-0 [&::-webkit-scrollbar-button]:bg-transparent [&::-webkit-scrollbar-track-piece]:bg-transparent [&::-webkit-scrollbar-track-piece]:rounded-none [&::-webkit-scrollbar-track-piece]: [&::-webkit-scrollbar-track-piece]:rounded-l [&::-webkit-scrollbar-thumb]:h-[50px] [&::-webkit-scrollbar-thumb]:bg-[#999] [&::-webkit-scrollbar-thumb]:rounded\",spinnerIcon:\"inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]\"},WS={autocompleteItem:\"string\",autocompleteList:\"string\",autocompleteLoader:\"string\",dropdown:\"string\",dropdownContainer:\"string\",scrollbar:\"string\",spinnerIcon:\"string\"};class Pr{constructor(t,e,i){this._element=t,this._options=this._getConfig(e),this._classes=this._getClasses(i),this._getContainer(),this._input=m.findOne(\"input\",t),this._notch=m.findOne(RS,t),this._customContent=m.findOne($S,t),this._loader=OS(this._classes),this._popper=null,this._debounceTimeoutId=null,this._loaderTimeout=null,this._activeItemIndex=-1,this._activeItem=null,this._filteredResults=null,this._lastQueryValue=null,this._canOpenOnFocus=!0,this._isOpen=!1,this._outsideClickHandler=this._handleOutsideClick.bind(this),this._inputFocusHandler=this._handleInputFocus.bind(this),this._userInputHandler=this._handleUserInput.bind(this),this._keydownHandler=this._handleKeydown.bind(this),t&&O.setData(t,An,this),this._init()}static get NAME(){return uc}get filter(){return this._options.filter}get dropdown(){return m.findOne(MS,this._dropdownContainer)}get items(){return m.find(lf,this._dropdownContainer)}get itemsList(){return m.findOne($r,this._dropdownContainer)}initSearch(t){this._filterResults(t)}_getContainer(){this._container=m.findOne(this._options.container)}_getConfig(t){const e=g.getDataAttributes(this._element);return t={...HS,...e,...t},L(uc,t,VS),t}_getClasses(t){const e=g.getDataClassAttributes(this._element);return t={...FS,...e,...t},L(uc,t,WS),t}_init(){this._initDropdown(),this._updateInputState(),this._setInputAriaAttributes(),this._listenToInputFocus(),this._listenToUserInput(),this._listenToKeydown()}_initDropdown(){this._dropdownContainerId=this._element.id?`autocomplete-dropdown-${this._element.id}`:bt(\"autocomplete-dropdown-\");const t={id:this._dropdownContainerId,items:[],width:this._input.offsetWidth,options:this._options};if(this._dropdownContainer=SS(t,this._classes),this._options.customContent!==\"\"){const e=this._options.customContent,i=To(e,Pd,null);this.dropdown.insertAdjacentHTML(\"beforeend\",i)}}_setInputAriaAttributes(){this._input.setAttribute(\"role\",\"combobox\"),this._input.setAttribute(\"aria-expanded\",!1),this._input.setAttribute(\"aria-owns\",this._dropdownContainerId),this._input.setAttribute(\"aria-haspopup\",!0),this._input.setAttribute(\"autocomplete\",\"off\")}_updateInputState(){var t,e;this._input.value!==\"\"||this._isOpen?(this._input.setAttribute(xs,\"\"),(t=this._notch)==null||t.setAttribute(xs,\"\")):(this._input.removeAttribute(xs),(e=this._notch)==null||e.removeAttribute(xs))}_listenToInputFocus(){_.on(this._input,\"focus\",this._inputFocusHandler)}_handleInputFocus(t){const{value:e}=t.target,i=this._options.threshold;if(!this._canOpenOnFocus){this._canOpenOnFocus=!0;return}e.length{this._filterResults(t)},e)}_filterResults(t){this._lastQueryValue=t;const e=this.filter(t);this._isPromise(e)?this._asyncUpdateResults(e):this._updateResults(e)}_isPromise(t){return!!t&&typeof t.then==\"function\"}_asyncUpdateResults(t){this._resetActiveItem(),this._showLoader(),t.then(e=>{this._updateResults(e),this._loaderTimeout=setTimeout(()=>{this._hideLoader(),this._loaderTimeout=null},this._options.loaderCloseDelay)})}_resetActiveItem(){const t=this._activeItem;t&&(t.removeAttribute(pc),this._activeItem=null,this._activeItemIndex=-1)}_showLoader(){this._element.appendChild(this._loader)}_hideLoader(){m.findOne(LS,this._element)&&this._element.removeChild(this._loader)}_updateResults(t){this._resetActiveItem(),this._filteredResults=t,_.trigger(this._element,BS,{results:t});const e=m.findOne($r,this._dropdownContainer),i=of(t,this._options,this._classes.autocompleteItem),n=IS(this._options.noResults,this._classes);t.length===0&&this._options.noResults!==\"\"?e.innerHTML=n:e.innerHTML=i,this._isOpen||this.open(),this._popper&&this._popper.forceUpdate()}_listenToKeydown(){_.on(this._element,\"keydown\",this._keydownHandler)}_handleKeydown(t){this._isOpen?this._handleOpenKeydown(t):this._handleClosedKeydown(t)}_handleOpenKeydown(t){const e=t.keyCode;if(e===Ci&&this._options.autoSelect&&this._selectActiveItem(),e===xi||e===ut&&t.altKey){this.close(),this._input.focus();return}if(e===xi||e===ut&&t.altKey||e===Ci){this.close(),this._input.focus();return}switch(e){case ht:this._setActiveItem(this._activeItemIndex+1),this._scrollToItem(this._activeItem);break;case ut:this._setActiveItem(this._activeItemIndex-1),this._scrollToItem(this._activeItem);break;case Ti:this._activeItemIndex>-1?(this._setActiveItem(0),this._scrollToItem(this._activeItem)):this._input.setSelectionRange(0,0);break;case Ei:if(this._activeItemIndex>-1)this._setActiveItem(this.items.length-1),this._scrollToItem(this._activeItem);else{const n=this._input.value.length;this._input.setSelectionRange(n,n)}break;case Et:if(t.preventDefault(),this._activeItemIndex>-1){const n=this._filteredResults[this._activeItemIndex];this._handleSelection(n)}return;default:return}t.preventDefault()}_setActiveItem(t){const e=this.items;e[t]&&this._updateActiveItem(e[t],t)}_updateActiveItem(t,e){const i=this._activeItem;i&&i.removeAttribute(pc),t.setAttribute(pc,\"\"),this._activeItemIndex=e,this._activeItem=t}_scrollToItem(t){if(!t)return;const e=this.itemsList,i=e.offsetHeight,n=this.items.indexOf(t),o=t.offsetHeight,r=e.scrollTop;if(n>-1){const a=n*o,l=a+o>r+i;a{this.dropdown.setAttribute(af,\"\"),this._isOpen=!0,this._setInputActiveStyles(),this._updateInputState()},0))}_listenToOutsideClick(){_.on(document,\"click\",this._outsideClickHandler)}_handleOutsideClick(t){const e=this._input===t.target,i=t.target===this._dropdownContainer,n=this._dropdownContainer&&this._dropdownContainer.contains(t.target);!e&&!i&&!n&&this.close()}_listenToItemsClick(){const t=m.findOne($r,this._dropdownContainer);_.on(t,\"click\",this._handleItemsClick.bind(this))}_handleItemsClick(t){const e=m.closest(t.target,lf),i=g.getDataAttribute(e,\"index\"),n=this._filteredResults[i];this._handleSelection(n)}_selectActiveItem(){const t=this._filteredResults[this._activeItemIndex];if(!t)return;const e=this._options.displayValue(t);_.trigger(this._element,cf,{value:t}).defaultPrevented||setTimeout(()=>{this._canOpenOnFocus=!1,this._updateInputValue(e),this._updateInputState()},0)}_handleSelection(t){const e=this._options.displayValue(t),i=_.trigger(this._element,cf,{value:t});t!==void 0&&(i.defaultPrevented||setTimeout(()=>{this._canOpenOnFocus=!1,this._updateInputValue(e),this._updateInputState(),this._input.focus(),this.close()},0))}_updateInputValue(t){this._input.value=t}_setInputActiveStyles(){this._input.setAttribute(rf,\"\")}close(){var e;const t=_.trigger(this._element,PS);!this._isOpen||t.defaultPrevented||(this._resetActiveItem(),this._removeDropdownEvents(),this.dropdown.removeAttribute(af),_.on(this.dropdown,\"transitionend\",this._handleDropdownTransitionEnd.bind(this)),this._input.removeAttribute(rf),this._input.value||(this._input.removeAttribute(xs),(e=this._notch)==null||e.removeAttribute(xs)))}_removeDropdownEvents(){const t=m.findOne($r,this._dropdownContainer);_.off(t,\"click\"),_.off(document,\"click\",this._outsideClickHandler),_.off(window,\"resize\",this._handleWindowResize.bind(this))}_handleDropdownTransitionEnd(t){this._isOpen&&t&&t.propertyName===\"opacity\"&&(this._popper.destroy(),this._dropdownContainer&&this._container.removeChild(this._dropdownContainer),this._isOpen=!1,_.off(this.dropdown,\"transitionend\"),this._canOpenOnFocus=!0)}dispose(){this._isOpen&&this.close(),this._removeInputAndElementEvents(),this._dropdownContainer.remove(),O.removeData(this._element,An)}_removeInputAndElementEvents(){_.off(this._input,\"focus\",this._inputFocusHandler),_.off(this._input,\"input\",this._userInputHandler),_.off(this._element,\"keydown\",this._keydownHandler)}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,An);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Pr(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,An)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const zS=(s,t)=>`
    \n
    \n
    `,jS=(s,t)=>`
    \n \n
    `,YS=(s,t)=>`\n \n \n \n `,Si=s=>s.type===\"touchmove\"?s.touches[0].clientX:s.clientX,Nr=\"multiRangeSlider\",Br=`te.${Nr}`,hf=`valueChanged${`.${Br}`}`,Oi=\"data-te-active\",df=\"data-te-multi-range-slider-hand-ref\",uf=\"data-te-multi-range-slider-connect-ref\",pf=\"data-te-multi-range-slider-tooltip-ref\",KS={max:\"number\",min:\"number\",numberOfRanges:\"number\",startValues:\"(array|string)\",step:\"(string|null|number)\",tooltip:\"boolean\"},US={max:100,min:0,numberOfRanges:2,startValues:[0,100],step:null,tooltip:!1},XS={connect:\"z-10 h-full w-full bg-[#eee] will-change-transform dark:bg-[#4f4f4f]\",connectContainer:\"relative border-[1px] border-[#eee] z-0 h-full w-full overflow-hidden dark:border-[#4f4f4f]\",container:\"apperance-none relative m-auto w-full cursor-pointer h-1 border-0 bg-transparent p-0 focus:outline-none dark:border-[#4f4f4f]\",hand:\"apperance-none absolute top-[50%] border-0 -mt-1 h-4 w-4 cursor-pointer rounded-[50%] border-0 bg-primary transition-colors ease-in-out will-change-transform active:bg-[#c4d4ef] active:z-60\",tooltip:\"absolute -top-[18px] origin-[50%_50%] -translate-x-[6px] -rotate-45 scale-0 rounded-bl-none rounded-br-2xl rounded-tl-2xl rounded-tr-2xl bg-primary text-white transition-all duration-[200ms] data-[te-active]:-top-[38px] data-[te-active]:scale-100\",tooltipValue:\"block h-[30px] w-[30px] -translate-x-[6px] translate-y-[6px] rotate-45 text-center text-[10px]\"},GS={container:\"string\",connectContainer:\"string\",connect:\"string\",hand:\"string\",tooltip:\"string\",tooltipValue:\"string\"};class Hr extends Mt{constructor(t,e,i){super(t),this._options=this._getConfig(e),this._mousemove=!1,this._classes=this._getClasses(i),this._maxTranslation=null,this._minTranslation=null,this._currentStepValue=null,this._canChangeStep=!1,this.init()}static get NAME(){return Nr}get hands(){return m.find(`[${df}]`,this._element)}get connect(){return m.findOne(`[${uf}]`,this._element)}get leftConnectRect(){return this.connect.getBoundingClientRect().left}get handActive(){return m.findOne(`[${Oi}]`)}get activeTooltipValue(){return m.find(`[${pf}]`).filter(n=>n.hasAttribute(Oi))[0].children[0]}init(){this._setContainerClasses(),this._setRangeConnectsElement(),this._setRangeHandleElements(),this._setMaxAndMinTranslation(),this._setTransofrmationOnStart(),this._handleClickEventOnHand(),this._handleEndMoveEventDocument(),this._handleClickOnRange(),this._setTooltipToHand()}dispose(){O.removeData(this._element,Br),this._options=null,this._mousemove=null,this._maxTranslation=null,this._minTranslation=null,this._currentStepValue=null,this._canChangeStep=null,this.hands.forEach(t=>{ct.off(t,\"mousedown touchstart\"),ct.off(t,\"mouseup touchend\")}),ct.off(document,\"mousemove touchmove\"),ct.off(document,\"mouseup touchend\"),ct.off(this.connect,\"mousedown touchstart\")}_setMaxAndMinTranslation(){this._maxTranslation=this.connect.offsetWidth-this.hands[0].offsetWidth/2,this._minTranslation=this.connect.offsetLeft-this.hands[0].offsetWidth/2}_setTransofrmationOnStart(){const{max:t,min:e}=this._options;let{startValues:i}=this._options;typeof i==\"string\"&&(i=JSON.parse(i.replace(/'/g,'\"'))),i.length===0?this.hands.forEach(n=>{g.setDataAttribute(n,\"translation\",Math.round(this._minTranslation)),g.addStyle(n,{transform:`translate(${this._minTranslation}px,-25%)`})}):this.hands.forEach((n,o)=>{if(i[o]>t||i[o]{ct.on(n,\"mousedown touchstart\",o=>{if(this._mousemove=!0,n.setAttribute(Oi,\"\"),this._options.tooltip&&n.children[1].setAttribute(Oi,\"\"),this._handleMoveEvent(n),this._handleEndMoveEvent(n,o),!this._canChangeStep&&i!==null)return;const r=Si(o)-this.leftConnectRect-n.offsetWidth/2,a=(Si(o)-this.leftConnectRect)/(this.connect.offsetWidth/(t-e))%(t-e);r>=this._maxTranslation?this._handleOutOfMaxRangeValue(n,t):r<=this._minTranslation?this._handleOutOfMinRangeValue(n,e):this._handleNormalMove(n,r,a)})})}_setContainerClasses(){g.addClass(this._element,this._classes.container)}_setRangeConnectsElement(){this._element.insertAdjacentHTML(\"afterbegin\",zS({connectContainer:this._classes.connectContainer,connect:this._classes.connect},uf))}_setRangeHandleElements(){for(let t=0;t{t.setAttribute(\"aria-orientation\",\"horizontal\"),t.setAttribute(\"role\",\"slider\"),g.setDataAttribute(t,\"handle\",e)})}_setTooltipToHand(){this._options.tooltip&&this.hands.forEach(t=>t.insertAdjacentHTML(\"beforeend\",YS({tooltip:this._classes.tooltip,tooltipValue:this._classes.tooltipValue},pf)))}_handleMoveEvent(t){const{tooltip:e,step:i}=this._options;ct.on(document,\"mousemove touchmove\",n=>{n.type===\"mousemove\"&&n.preventDefault();const{max:o,min:r,numberOfRanges:a}=this._options;if(t.hasAttribute(Oi)){const l=(Si(n)-this.leftConnectRect)/this.connect.offsetWidth*o;let c=(Si(n)-this.leftConnectRect)/(this.connect.offsetWidth/(o-r))%(o-r)+r;if((this._currentStepValue===Math.round(c)||Math.round(c)%i!==0)&&i!==null){this._canChangeStep=!1;return}this._canChangeStep=!0;let h=Si(n)-this.leftConnectRect-t.offsetWidth/2;const d=g.getDataAttribute(this.handActive,\"handle\"),u=g.getDataAttribute(this.handActive,\"translation\");if(c=o)return;const p=this.hands.map(f=>g.getDataAttribute(f,\"translation\"));if(a<2)Math.round(c)%i===0&&i!==null?(this._currentStepValue=Math.round(c),g.addStyle(t,{transform:`translate(${h}px,-25%)`}),e&&(this.activeTooltipValue.innerText=Math.round(c))):i===null&&(g.addStyle(t,{transform:`translate(${h}px,-25%)`}),e&&(this.activeTooltipValue.innerText=Math.round(c))),g.setDataAttribute(t,\"translation\",h);else{const f=d>0&&d=y?(b=y,v=h<=b):d===a-1&&u<=T?(b=T,v=h>=b):f&&(u>=y||u<=T)&&(b=u>=y?y:T,v=b===y?h<=b:h>=b),Math.round(c)%i===0&&i!==null?(this._currentStepValue=Math.round(c),g.addStyle(t,{transform:`translate(${b}px,-25%)`}),e&&b===h&&this.activeTooltipValue!==null&&(this.activeTooltipValue.innerText=Math.round(c))):i===null&&(g.addStyle(t,{transform:`translate(${b}px,-25%)`}),e&&b===h&&this.activeTooltipValue!==null&&(this.activeTooltipValue.innerText=Math.round(c))),g.setDataAttribute(t,\"translation\",v?h:b)}this._canChangeStep&&this._handleEventChangeValuesOnRange()}})}_handleEventChangeValuesOnRange(){const{max:t,min:e,numberOfRanges:i}=this._options,n=r=>{const a=r.getBoundingClientRect().left-this.leftConnectRect+r.offsetWidth/2;let l=a/(this.connect.offsetWidth/(t-e))%(t-e);return a===this.connect.offsetWidth?l=t:l+=e,g.setDataAttribute(r,\"value\",Math.round(l*10)/10),{value:l}};if(i<2){const{value:r}=n(this.hands[0]);_.trigger(this._element,hf,{values:{value:r+e,rounded:Math.round(r+e)}});return}const o=this.hands.map(r=>n(r));_.trigger(this._element,hf,{values:{value:o.map(({value:r})=>r+e),rounded:o.map(({value:r})=>Math.round(r+e))}})}_resetHandState(t,e){_.off(t,e),t.removeAttribute(Oi),this._options.tooltip&&t.children[1].removeAttribute(Oi)}_handleEndMoveEventDocument(){ct.on(document,\"mouseup touchend\",()=>{this._mousemove&&(this.hands.forEach(t=>{this._resetHandState(t,\"mousemove\")}),ct.off(document,\"mousemove touchmove\"),this._mousemove=!1)})}_handleEndMoveEvent(t){ct.on(t,\"mouseup touchend\",()=>{this._resetHandState(t,\"mousemove\"),ct.off(document,\"mousemove touchmove\"),this._mousemove=!1})}_handleClickOnRange(){this._options.step===null&&ct.on(this.connect,\"mousedown touchstart\",t=>{const e=[];let i=0;if(this.hands.forEach(n=>{this._mousemove=!0;const o=Si(t),r=n.offsetWidth,a=g.getDataAttribute(n,\"translation\"),l=o-this.leftConnectRect-r/2;this._options.numberOfRanges<2?this._updateHand(n,l):(e.push(Math.abs(l-a)),e.forEach((c,h)=>{c=2){const n=Si(t)-this.leftConnectRect-this.hands[i].offsetWidth/2;this._updateAdjacentHands(i,n)}this._handleEventChangeValuesOnRange()})}_updateHand(t,e){g.addStyle(t,{transform:`translate(${e}px,-25%)`}),g.setDataAttribute(t,\"translation\",e)}_updateAdjacentHands(t,e){const i=this.hands[t+1],n=this.hands[t-1],o=i?g.getDataAttribute(i,\"translation\"):void 0,r=n?g.getDataAttribute(n,\"translation\"):void 0;i&&e>o?this._updateHand(i,e):n&&e\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,Br)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}const qS=s=>{ph(()=>{const t=uh();if(t){const e=s.NAME,i=t.fn[e];t.fn[e]=s.jQueryInterface,t.fn[e].Constructor=s,t.fn[e].noConflict=()=>(t.fn[e]=i,s.jQueryInterface)}})},ZS=(s,t)=>{_.on(document,`click.te.${s.NAME}`,t,function(e){e.preventDefault(),s.getOrCreateInstance(this).toggle()})},QS=(s,t)=>{_.on(document,`click.te.${s.NAME}.data-api`,t,function(e){if([\"A\",\"AREA\"].includes(this.tagName)&&e.preventDefault(),ci(this))return;s.getOrCreateInstance(this).show()})},JS=(s,t)=>{_.on(document,`click.te.${s.NAME}.data-api`,t,function(e){const i=Ne(this);if([\"A\",\"AREA\"].includes(this.tagName)&&e.preventDefault(),ci(this))return;_.one(i,s.EVENT_HIDDEN,()=>{ae(this)&&this.focus()});const n=m.findOne(s.OPEN_SELECTOR);n&&n!==i&&s.getInstance(n).hide(),s.getOrCreateInstance(i).toggle(this)})},tO=(s,t)=>{_.on(document,`click.te.${s.NAME}`,t,e=>{e.preventDefault();const i=e.target.closest(t);s.getOrCreateInstance(i).toggle()})},eO=(s,t)=>{_.on(document,`click.te.${s.NAME}`,t,function(e){const i=Ne(this);[\"A\",\"AREA\"].includes(this.tagName)&&e.preventDefault(),_.one(i,s.EVENT_SHOW,r=>{r.defaultPrevented||_.one(i,s.EVENT_HIDDEN,()=>{ae(this)&&this.focus()})});const n=m.findOne(`[${s.OPEN_SELECTOR}=\"true\"]`);n&&s.getInstance(n).hide(),s.getOrCreateInstance(i).toggle(this)})},iO=(s,t)=>{_.one(document,\"mousedown\",t,s.autoInitial(new s))},sO=(s,t)=>{_.on(document,`click.te.${s.NAME}.data-api`,t,function(e){(e.target.tagName===\"A\"||e.delegateTarget&&e.delegateTarget.tagName===\"A\")&&e.preventDefault();const i=Ca(this);m.find(i).forEach(o=>{s.getOrCreateInstance(o,{toggle:!1}).toggle()})})},nO=(s,t)=>{[].slice.call(document.querySelectorAll(t)).map(function(i){return new s(i)})},oO=(s,t)=>{[].slice.call(document.querySelectorAll(t)).map(function(i){return new s(i)})},rO=(s,t)=>{m.find(t).forEach(e=>{new s(e)}),_.on(document,`click.te.${s.NAME}.data-api`,`${t} img:not([data-te-lightbox-disabled])`,s.toggle())},aO=(s,t)=>{const e=o=>o[0]===\"{\"&&o[o.length-1]===\"}\"||o[0]===\"[\"&&o[o.length-1]===\"]\",i=o=>typeof o!=\"string\"?o:e(o)?JSON.parse(o.replace(/'/g,'\"')):o,n=o=>{const r={};return Object.keys(o).forEach(a=>{if(a.match(/dataset.*/)){const l=a.slice(7,8).toLowerCase().concat(a.slice(8));r[l]=i(o[a])}}),r};m.find(t).forEach(o=>{if(g.getDataAttribute(o,\"chart\")!==\"bubble\"&&g.getDataAttribute(o,\"chart\")!==\"scatter\"){const r=g.getDataAttributes(o),a={data:{datasets:[n(r)]}};return r.chart&&(a.type=r.chart),r.labels&&(a.data.labels=JSON.parse(r.labels.replace(/'/g,'\"'))),new s(o,{...a,...ln[a.type]})}return null})};class lO{constructor(){this.inits=[]}get initialized(){return this.inits}isInited(t){return this.inits.includes(t)}add(t){this.isInited(t)||this.inits.push(t)}}const fc=new lO,wn={alert:{name:\"Alert\",selector:\"[data-te-alert-init]\",isToggler:!1},animation:{name:\"Animate\",selector:\"[data-te-animation-init]\",isToggler:!1},carousel:{name:\"Carousel\",selector:\"[data-te-carousel-init]\",isToggler:!1},chips:{name:\"ChipsInput\",selector:\"[data-te-chips-input-init]\",isToggler:!1},chip:{name:\"Chip\",selector:\"[data-te-chip-init]\",isToggler:!1,onInit:\"init\"},datepicker:{name:\"Datepicker\",selector:\"[data-te-datepicker-init]\",isToggler:!1},datetimepicker:{name:\"Datetimepicker\",selector:\"[data-te-date-timepicker-init]\",isToggler:!1},input:{name:\"Input\",selector:\"[data-te-input-wrapper-init]\",isToggler:!1},perfectScrollbar:{name:\"PerfectScrollbar\",selector:\"[data-te-perfect-scrollbar-init]\",isToggler:!1},rating:{name:\"Rating\",selector:\"[data-te-rating-init]\",isToggler:!1},scrollspy:{name:\"ScrollSpy\",selector:\"[data-te-spy='scroll']\",isToggler:!1},select:{name:\"Select\",selector:\"[data-te-select-init]\",isToggler:!1},sidenav:{name:\"Sidenav\",selector:\"[data-te-sidenav-init]\",isToggler:!1},stepper:{name:\"Stepper\",selector:\"[data-te-stepper-init]\",isToggler:!1},timepicker:{name:\"Timepicker\",selector:\"[data-te-timepicker-init]\",isToggler:!1},toast:{name:\"Toast\",selector:\"[data-te-toast-init]\",isToggler:!1},datatable:{name:\"Datatable\",selector:\"[data-te-datatable-init]\"},popconfirm:{name:\"Popconfirm\",selector:\"[data-te-toggle='popconfirm']\"},validation:{name:\"Validation\",selector:\"[data-te-validation-init]\"},smoothScroll:{name:\"SmoothScroll\",selector:\"a[data-te-smooth-scroll-init]\"},lazyLoad:{name:\"LazyLoad\",selector:\"[data-te-lazy-load-init]\"},clipboard:{name:\"Clipboard\",selector:\"[data-te-clipboard-init]\"},infiniteScroll:{name:\"InfiniteScroll\",selector:\"[data-te-infinite-scroll-init]\"},loadingManagement:{name:\"LoadingManagement\",selector:\"[data-te-loading-management-init]\"},sticky:{name:\"Sticky\",selector:\"[data-te-sticky-init]\"},multiRangeSlider:{name:\"MultiRangeSlider\",selector:\"[data-te-multi-range-slider-init]\"},chart:{name:\"Chart\",selector:\"[data-te-chart]\",isToggler:!1,advanced:aO},button:{name:\"Button\",selector:\"[data-te-toggle='button']\",isToggler:!0,callback:tO},collapse:{name:\"Collapse\",selector:\"[data-te-collapse-init]\",isToggler:!0,callback:sO},dropdown:{name:\"Dropdown\",selector:\"[data-te-dropdown-toggle-ref]\",isToggler:!0,callback:ZS},modal:{name:\"Modal\",selector:\"[data-te-toggle='modal']\",isToggler:!0,callback:eO},ripple:{name:\"Ripple\",selector:\"[data-te-ripple-init]\",isToggler:!0,callback:iO},offcanvas:{name:\"Offcanvas\",selector:\"[data-te-offcanvas-toggle]\",isToggler:!0,callback:JS},tab:{name:\"Tab\",selector:\"[data-te-toggle='tab'], [data-te-toggle='pill'], [data-te-toggle='list']\",isToggler:!0,callback:QS},tooltip:{name:\"Tooltip\",selector:\"[data-te-toggle='tooltip']\",isToggler:!1,callback:nO},popover:{name:\"Popover\",selector:\"[data-te-toggle='popover']\",isToggler:!0,callback:oO},lightbox:{name:\"Lightbox\",selector:\"[data-te-lightbox-init]\",isToggler:!0,callback:rO},touch:{name:\"Touch\",selector:\"[data-te-touch-init]\"}},cO=s=>wn[s.NAME]||null,hO=(s,t)=>{if(!s||!t.allowReinits&&fc.isInited(s.NAME))return;fc.add(s.NAME);const e=cO(s),i=(e==null?void 0:e.isToggler)||!1;if(qS(s),e!=null&&e.advanced){e==null||e.advanced(s,e==null?void 0:e.selector);return}if(i){e==null||e.callback(s,e==null?void 0:e.selector);return}m.find(e==null?void 0:e.selector).forEach(n=>{let o=s.getInstance(n);o||(o=new s(n),e!=null&&e.onInit&&o[e.onInit]())})},dO=(s,t)=>{s.forEach(e=>hO(e,t))},uO={allowReinits:!1,checkOtherImports:!1},ff=(s,t={})=>{t={...uO,...t};const e=Object.keys(wn).map(i=>{if(!!document.querySelector(wn[i].selector)){const o=s[wn[i].name];return!o&&!fc.isInited(i)&&t.checkOtherImports&&console.warn(`Please import ${wn[i].name} from \"tw-elements\" package and add it to a object parameter inside \"initTE\" function`),o}});dO(e,t)},_f=\"sidenav\",Vr=\"te.sidenav\",pO=\"data-te-sidenav-rotate-icon-ref\",_c=\"[data-te-sidenav-toggle-ref]\",fO=\"[data-te-collapse-init]\",_O='[data-te-sidenav-slim=\"true\"]',gO='[data-te-sidenav-slim=\"false\"]',mO=\"[data-te-sidenav-menu-ref]\",Cs=\"[data-te-sidenav-collapse-ref]\",kn=\"[data-te-sidenav-link-ref]\",bO=et()?100:-100,vO=et()?-100:100,yO={sidenavAccordion:\"(boolean)\",sidenavBackdrop:\"(boolean)\",sidenavBackdropClass:\"(null|string)\",sidenavCloseOnEsc:\"(boolean)\",sidenavColor:\"(string)\",sidenavContent:\"(null|string)\",sidenavExpandable:\"(boolean)\",sidenavExpandOnHover:\"(boolean)\",sidenavFocusTrap:\"(boolean)\",sidenavHidden:\"(boolean)\",sidenavMode:\"(string)\",sidenavModeBreakpointOver:\"(null|string|number)\",sidenavModeBreakpointSide:\"(null|string|number)\",sidenavModeBreakpointPush:\"(null|string|number)\",sidenavBreakpointSm:\"(number)\",sidenavBreakpointMd:\"(number)\",sidenavBreakpointLg:\"(number)\",sidenavBreakpointXl:\"(number)\",sidenavBreakpoint2xl:\"(number)\",sidenavScrollContainer:\"(null|string)\",sidenavSlim:\"(boolean)\",sidenavSlimCollapsed:\"(boolean)\",sidenavSlimWidth:\"(number)\",sidenavPosition:\"(string)\",sidenavRight:\"(boolean)\",sidenavTransitionDuration:\"(number)\",sidenavWidth:\"(number)\"},TO={sidenavAccordion:!1,sidenavBackdrop:!0,sidenavBackdropClass:null,sidenavCloseOnEsc:!0,sidenavColor:\"primary\",sidenavContent:null,sidenavExpandable:!0,sidenavExpandOnHover:!1,sidenavFocusTrap:!0,sidenavHidden:!0,sidenavMode:\"over\",sidenavModeBreakpointOver:null,sidenavModeBreakpointSide:null,sidenavModeBreakpointPush:null,sidenavBreakpointSm:640,sidenavBreakpointMd:768,sidenavBreakpointLg:1024,sidenavBreakpointXl:1280,sidenavBreakpoint2xl:1536,sidenavScrollContainer:null,sidenavSlim:!1,sidenavSlimCollapsed:!1,sidenavSlimWidth:77,sidenavPosition:\"fixed\",sidenavRight:!1,sidenavTransitionDuration:300,sidenavWidth:240};class Ii{constructor(t,e={}){ke(this,\"_addBackdropOnInit\",()=>{this._options.sidenavHidden||(this._backdrop.show(),_.off(this._element,\"transitionend\",this._addBackdropOnInit))});this._element=t,this._options=e,this._ID=bt(\"\"),this._content=null,this._initialContentStyle=null,this._slimCollapsed=!1,this._activeNode=null,this._tempSlim=!1,this._backdrop=this._initializeBackDrop(),this._focusTrap=null,this._perfectScrollbar=null,this._touch=null,this._setModeFromBreakpoints(),this.escHandler=i=>{i.keyCode===xi&&this.toggler&&ae(this.toggler)&&(this._update(!1),_.off(window,\"keydown\",this.escHandler))},this.hashHandler=()=>{this._setActiveElements()},t&&(O.setData(t,Vr,this),this._setup()),this.options.sidenavBackdrop&&!this.options.sidenavHidden&&this.options.sidenavMode===\"over\"&&_.on(this._element,\"transitionend\",this._addBackdropOnInit),this._didInit=!1,this._init()}static get NAME(){return _f}get container(){if(this.options.sidenavPosition===\"fixed\")return m.findOne(\"body\");const t=e=>!e.parentNode||e.parentNode===document?e:e.parentNode.style.position===\"relative\"||e.parentNode.classList.contains(\"relative\")?e.parentNode:t(e.parentNode);return t(this._element)}get isVisible(){let t=0,e=window.innerWidth;if(this.options.sidenavPosition!==\"fixed\"){const n=this.container.getBoundingClientRect();t=n.x,e=n.x+n.width}const{x:i}=this._element.getBoundingClientRect();if(this.options.sidenavRight&&!et()||!this.options.sidenavRight&&et()){let n=0;if(this.container.scrollHeight>this.container.clientHeight&&(n=this.container.offsetWidth-this.container.clientWidth),this.container.tagName===\"BODY\"){const o=document.documentElement.clientWidth;n=Math.abs(window.innerWidth-o)}return Math.abs(i+n-e)>10}return Math.abs(i-t)<10}get links(){return m.find(kn,this._element)}get navigation(){return m.find(mO,this._element)}get options(){const t={...TO,...g.getDataAttributes(this._element),...this._options};return L(_f,t,yO),t}get sidenavStyle(){return{width:`${this.width}px`,height:this.options.sidenavPosition===\"fixed\"?\"100vh\":\"100%\",position:this.options.sidenavPosition,transition:`all ${this.transitionDuration} linear`}}get toggler(){return m.find(_c).find(e=>{const i=g.getDataAttribute(e,\"target\");return m.findOne(i)===this._element})}get transitionDuration(){return`${this.options.sidenavTransitionDuration/1e3}s`}get translation(){return this.options.sidenavRight?vO:bO}get width(){return this._slimCollapsed?this.options.sidenavSlimWidth:this.options.sidenavWidth}get isBackdropVisible(){return!!this._backdrop._element}changeMode(t){this._setMode(t)}dispose(){_.off(window,\"keydown\",this.escHandler),this.options.sidenavBackdrop&&this._backdrop.dispose(),_.off(window,\"hashchange\",this.hashHandler),this._touch.dispose(),O.removeData(this._element,Vr),this._element=null}hide(){this._emitEvents(!1),this._update(!1),this._options.sidenavBackdrop&&this.isBackdropVisible&&this._backdrop.hide()}show(){this._emitEvents(!0),this._update(!0),this._options.sidenavBackdrop&&this._options.sidenavMode===\"over\"&&this._backdrop.show()}toggle(){this._emitEvents(!this.isVisible),this._update(!this.isVisible)}toggleSlim(){this._setSlim(!this._slimCollapsed)}update(t){this._options=t,this._setup()}getBreakpoint(t){return this._transformBreakpointValuesToObject()[t]}_init(){this._didInit||(_.on(document,\"click\",_c,Ii.toggleSidenav()),this._didInit=!0)}_transformBreakpointValuesToObject(){return{sm:this.options.sidenavBreakpointSm,md:this.options.sidenavBreakpointMd,lg:this.options.sidenavBreakpointLg,xl:this.options.sidenavBreakpointXl,\"2xl\":this.options.sidenavBreakpoint2xl}}_setModeFromBreakpoints(){const t=window.innerWidth,e=this._transformBreakpointValuesToObject();if(t===void 0||!e)return;const i=typeof this.options.sidenavModeBreakpointOver==\"number\"?t-this.options.sidenavModeBreakpointOver:t-e[this.options.sidenavModeBreakpointOver],n=typeof this.options.sidenavModeBreakpointSide==\"number\"?t-this.options.sidenavModeBreakpointSide:t-e[this.options.sidenavModeBreakpointSide],o=typeof this.options.sidenavModeBreakpointPush==\"number\"?t-this.options.sidenavModeBreakpointPush:t-e[this.options.sidenavModeBreakpointPush],r=(l,c)=>l-c<0?-1:c-l<0?1:0,a=[i,n,o].filter(l=>l!=null&&l>=0).sort(r)[0];i>0&&i===a?(this._options.sidenavMode=\"over\",this._options.sidenavHidden=!0):n>0&&n===a?this._options.sidenavMode=\"side\":o>0&&o===a&&(this._options.sidenavMode=\"push\")}_collapseItems(){this.navigation.forEach(t=>{m.find(Cs,t).forEach(i=>{ce.getInstance(i).hide()})})}_getOffsetValue(t,{index:e,property:i,offsets:n}){const o=this._getPxValue(this._initialContentStyle[e][n[i].property]),r=t?n[i].value:0;return o+r}_getProperty(...t){return t.map((e,i)=>i===0?e:e[0].toUpperCase().concat(e.slice(1))).join(\"\")}_getPxValue(t){return t?parseFloat(t):0}_handleSwipe(t,e){e&&this._slimCollapsed&&this.options.sidenavSlim&&this.options.sidenavExpandable?this.toggleSlim():e||(this._slimCollapsed||!this.options.sidenavSlim||!this.options.sidenavExpandable?this.toggler&&ae(this.toggler)&&this.toggle():this.toggleSlim())}_isActive(t,e){return e?e===t:t.attributes.href?new URL(t,window.location.href).href===window.location.href:!1}_isAllToBeCollapsed(){return m.find(fO,this._element).filter(i=>i.getAttribute(\"aria-expanded\")===\"true\").length===0}_isAllCollapsed(){return m.find(Cs,this._element).filter(t=>ae(t)).length===0}_initializeBackDrop(){if(!this.options.sidenavBackdrop)return;const t=this.options.sidenavBackdropClass?this.options.sidenavBackdropClass.split(\" \"):this.options.sidenavPosition?[\"opacity-50\",\"transition-all\",\"duration-300\",\"ease-in-out\",this.options.sidenavPosition,\"top-0\",\"left-0\",\"z-50\",\"bg-black/10\",\"dark:bg-black-60\",\"w-full\",\"h-full\",this._element.id]:null;return new Qa({isVisible:this.options.sidenavBackdrop,isAnimated:!0,rootElement:this._element.parentNode,backdropClasses:t,clickCallback:()=>this.hide()})}_updateBackdrop(t){if(this.options.sidenavMode===\"over\"){t?this._backdrop.show():this.isBackdropVisible&&this._backdrop.hide();return}this.isBackdropVisible&&this._backdrop.hide()}_setup(){this._setupTouch(),this.options.sidenavFocusTrap&&this._setupFocusTrap(),this._setupCollapse(),this.options.sidenavSlim&&this._setupSlim(),this._setupInitialStyling(),this._setupScrolling(),this.options.sidenavContent&&this._setupContent(),this._setupActiveState(),this._setupRippleEffect(),this.options.sidenavHidden||this._updateOffsets(!0,!0),this.options.sidenavMode===\"over\"&&this._setTabindex(!0)}_setupActiveState(){this._setActiveElements(),this.links.forEach(t=>{_.on(t,\"click\",()=>this._setActiveElements(t)),_.on(t,\"keydown\",e=>{e.keyCode===Et&&this._setActiveElements(t)})}),_.on(window,\"hashchange\",this.hashHandler)}_setupCollapse(){this.navigation.forEach((t,e)=>{m.find(Cs,t).forEach((n,o)=>this._setupCollapseList({list:n,index:o,menu:t,menuIndex:e}))})}_generateCollpaseID(t,e){return`sidenav-collapse-${this._ID}-${e}-${t}`}_setupCollapseList({list:t,index:e,menu:i,menuIndex:n}){const o=this._generateCollpaseID(e,n);t.setAttribute(\"id\",o),t.setAttribute(\"data-te-collapse-item\",\"\");const[r]=m.prev(t,kn);g.setDataAttribute(r,\"collapse-init\",\"\"),r.setAttribute(\"href\",`#${o}`),r.setAttribute(\"role\",\"button\");const a=ce.getInstance(t)||new ce(t,{toggle:!1,parent:this.options.sidenavAccordion?i:t});(t.dataset.teSidenavStateShow===\"\"||t.dataset.teCollapseShow===\"\")&&this._rotateArrow(r,!1),_.on(r,\"click\",l=>{this._toggleCategory(l,a,t),this._tempSlim&&this._isAllToBeCollapsed()&&(this._setSlim(!0),this._tempSlim=!1),this.options.sidenavMode===\"over\"&&this._focusTrap&&this._focusTrap.update()}),_.on(t,\"show.te.collapse\",()=>this._rotateArrow(r,!1)),_.on(t,\"hide.te.collapse\",()=>this._rotateArrow(r,!0)),_.on(t,\"shown.te.collapse\",()=>{this.options.sidenavMode===\"over\"&&this._focusTrap&&this._focusTrap.update()}),_.on(t,\"hidden.te.collapse\",()=>{this._tempSlim&&this._isAllCollapsed()&&(this._setSlim(!0),this._tempSlim=!1),this.options.sidenavMode===\"over\"&&this._focusTrap&&this._focusTrap.update()})}_setupContent(){this._content=m.find(this.options.sidenavContent),this._content.forEach(t=>{const e=[\"!p\",\"!m\",\"!px\",\"!pl\",\"!pr\",\"!mx\",\"!ml\",\"!mr\",\"!-p\",\"!-m\",\"!-px\",\"!-pl\",\"!-pr\",\"!-mx\",\"!-ml\",\"!-mr\"];[...t.classList].filter(n=>e.findIndex(o=>n.includes(o))>=0).forEach(n=>t.classList.remove(n))}),this._initialContentStyle=this._content.map(t=>{const{paddingLeft:e,paddingRight:i,marginLeft:n,marginRight:o,transition:r}=window.getComputedStyle(t);return{paddingLeft:e,paddingRight:i,marginLeft:n,marginRight:o,transition:r}})}_setupFocusTrap(){this._focusTrap=new Vs(this._element,{event:\"keydown\",condition:t=>t.keyCode===Ci,onlyVisible:!0},this.toggler)}_setupInitialStyling(){this._setColor(),g.style(this._element,this.sidenavStyle)}_setupScrolling(){let t=this._element;if(this.options.sidenavScrollContainer){t=m.findOne(this.options.sidenavScrollContainer,this._element);const i=dm(t.parentNode.children).filter(n=>n!==t).reduce((n,o)=>n+o.clientHeight,0);g.style(t,{maxHeight:`calc(100% - ${i}px)`,position:\"relative\"})}this._perfectScrollbar=new ms(t,{suppressScrollX:!0,handlers:[\"click-rail\",\"drag-thumb\",\"wheel\",\"touch\"]})}_setupSlim(){this._slimCollapsed=this.options.sidenavSlimCollapsed,this._toggleSlimDisplay(this._slimCollapsed),this.options.sidenavExpandOnHover&&(this._element.addEventListener(\"mouseenter\",()=>{this._slimCollapsed&&this._setSlim(!1)}),this._element.addEventListener(\"mouseleave\",()=>{this._slimCollapsed||this._setSlim(!0)}))}_setupRippleEffect(){this.links.forEach(t=>{let e=Ye.getInstance(t),i=this.options.sidenavColor;if(e&&e._options.sidenavColor!==this.options.sidenavColor)e.dispose();else if(e)return;(localStorage.theme===\"dark\"||!(\"theme\"in localStorage)&&window.matchMedia(\"(prefers-color-scheme: dark)\").matches)&&(i=\"white\"),e=new Ye(t,{rippleColor:i})})}_setupTouch(){this._touch=new pE(this._element,\"swipe\",{threshold:20}),this._touch.init(),_.on(this._element,\"swipeleft\",t=>this._handleSwipe(t,this.options.sidenavRight)),_.on(this._element,\"swiperight\",t=>this._handleSwipe(t,!this.options.sidenavRight))}_setActive(t,e){t.setAttribute(\"data-te-sidebar-state-active\",\"\"),this._activeNode&&t.removeAttribute(\"data-te-sidebar-state-active\"),this._activeNode=t;const[i]=m.parents(this._activeNode,Cs);if(!i){this._setActiveCategory();return}const[n]=m.prev(i,kn);this._setActiveCategory(n),!e&&!this._slimCollapsed&&ce.getInstance(i).show()}_setActiveCategory(t){this.navigation.forEach(e=>{m.find(Cs,e).forEach(n=>{const[o]=m.prev(n,kn);o!==t?o.removeAttribute(\"data-te-sidenav-state-active\"):o.setAttribute(\"data-te-sidenav-state-active\",\"\")})})}_setActiveElements(t){this.navigation.forEach(e=>{m.find(kn,e).filter(n=>m.next(n,Cs).length===0).forEach(n=>{this._isActive(n,t)&&n!==this._activeNode&&this._setActive(n,t)})}),t&&this._updateFocus(this.isVisible)}_setColor(){const t=[\"primary\",\"secondary\",\"success\",\"info\",\"warning\",\"danger\",\"light\",\"dark\"],{sidenavColor:e}=this.options,i=t.includes(e)?e:\"primary\";t.forEach(n=>{this._element.classList.remove(`sidenav-${n}`)}),g.addClass(this._element,`sidenav-${i}`)}_setContentOffsets(t,e,i){this._content.forEach((n,o)=>{const r=this._getOffsetValue(t,{index:o,property:\"padding\",offsets:e}),a=this._getOffsetValue(t,{index:o,property:\"margin\",offsets:e}),l={};if(i||(l.transition=`all ${this.transitionDuration} linear`),l[e.padding.property]=`${r}px`,l[e.margin.property]=`${a}px`,g.style(n,l),!!t){if(i){g.style(n,{transition:this._initialContentStyle[o].transition});return}_.on(n,\"transitionend\",()=>{g.style(n,{transition:this._initialContentStyle[o].transition})})}})}_setMode(t){this.options.sidenavMode!==t&&(this._options.sidenavMode=t,this._update(this.isVisible))}_setSlim(t){const e=t?[\"collapse\",\"collapsed\"]:[\"expand\",\"expanded\"];this._triggerEvents(...e),t&&this._collapseItems(),this._slimCollapsed=t,this._toggleSlimDisplay(t),g.style(this._element,{width:`${this.width}px`}),this._updateOffsets(this.isVisible)}_setTabindex(t){this.links.forEach(e=>{e.tabIndex=t?0:-1})}_emitEvents(t){const e=t?[\"show\",\"shown\"]:[\"hide\",\"hidden\"];this._triggerEvents(...e)}_rotateArrow(t,e){const[i]=m.children(t,`[${pO}]`);i&&(e?g.removeClass(i,\"rotate-180\"):g.addClass(i,\"rotate-180\"))}_toggleCategory(t,e){t.preventDefault(),e.toggle(),this._slimCollapsed&&this.options.sidenavExpandable&&(this._tempSlim=!0,this._setSlim(!1))}_toggleSlimDisplay(t){const e=m.find(_O,this._element),i=m.find(gO,this._element),n=()=>{e.forEach(o=>{g.style(o,{display:this._slimCollapsed?\"unset\":\"none\"})}),i.forEach(o=>{g.style(o,{display:this._slimCollapsed?\"none\":\"unset\"})})};t?setTimeout(()=>n(),this.options.sidenavTransitionDuration):n()}async _triggerEvents(t,e){_.trigger(this._element,`${t}.te.sidenav`),e&&await setTimeout(()=>{_.trigger(this._element,`${e}.te.sidenav`)},this.options.sidenavTransitionDuration+5)}_isiPhone(){return/iPhone|iPod/i.test(navigator.userAgent)}_update(t){t&&this._isiPhone()&&g.addClass(this._element,\"ps--scrolling-y\"),this.toggler&&this._updateTogglerAria(t),this._updateDisplay(t),this.options.sidenavBackdrop&&this._updateBackdrop(t),this._updateOffsets(t),t&&this.options.sidenavCloseOnEsc&&this.options.sidenavMode!==\"side\"&&_.on(window,\"keydown\",this.escHandler),this.options.sidenavFocusTrap&&this._updateFocus(t)}_updateDisplay(t){const e=t?0:this.translation;g.style(this._element,{transform:`translateX(${e}%)`})}_updateFocus(t){if(this._setTabindex(t),this.options.sidenavMode===\"over\"&&this.options.sidenavFocusTrap){if(t){this._focusTrap.trap();return}this._focusTrap.disable()}this._focusTrap.disable()}_updateOffsets(t,e=!1){const[i,n]=this.options.sidenavRight?[\"right\",\"left\"]:[\"left\",\"right\"],o={property:this._getProperty(\"padding\",i),value:this.options.sidenavMode===\"over\"?0:this.width},r={property:this._getProperty(\"margin\",n),value:this.options.sidenavMode===\"push\"?-1*this.width:0};_.trigger(this._element,\"update.te.sidenav\",{margin:r,padding:o}),this._content&&(this._content.className=\"\",this._setContentOffsets(t,{padding:o,margin:r},e))}_updateTogglerAria(t){this.toggler.setAttribute(\"aria-expanded\",t)}static toggleSidenav(){return function(t){const e=m.closest(t.target,_c),i=g.getDataAttributes(e).target;m.find(i).forEach(n=>{(Ii.getInstance(n)||new Ii(n)).toggle()})}}static jQueryInterface(t,e){return this.each(function(){let i=O.getData(this,Vr);const n=typeof t==\"object\"&&t;if(!(!i&&/dispose/.test(t))&&(i||(i=new Ii(this,n)),typeof t==\"string\")){if(typeof i[t]>\"u\")throw new TypeError(`No method named \"${t}\"`);i[t](e)}})}static getInstance(t){return O.getData(t,Vr)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,typeof e==\"object\"?e:null)}}ff({Animate:Gs,Alert:Ws,Button:ao,ChipsInput:gp,Chip:ki,Dropdown:Ft,Carousel:he,Collapse:ce,Offcanvas:ts,Modal:Ys,Popover:Eo,ScrollSpy:xo,Select:on,Tab:wo,Toast:Xs,Tooltip:is,Ripple:Ye,Datepicker:xl,Timepicker:Ll,Sidenav:Ii,Stepper:Wu,Input:Z,PerfectScrollbar:ms,Rating:Bp,Chart:yp,Datatable:pr,Popconfirm:_r,SmoothScroll:xr,Lightbox:ys,Validation:Tr,Touch:Er,LazyLoad:yn,Datetimepicker:Dr,Clipboard:Ar,InfiniteScroll:kr,LoadingManagement:Or,Autocomplete:Pr,Sticky:Lr,MultiRangeSlider:Hr});/*!\n * Chart.js v3.9.1\n * https://www.chartjs.org\n * (c) 2022 Chart.js Contributors\n * Released under the MIT License\n */function Oe(){}const EO=function(){let s=0;return function(){return s++}}();function H(s){return s===null||typeof s>\"u\"}function Q(s){if(Array.isArray&&Array.isArray(s))return!0;const t=Object.prototype.toString.call(s);return t.slice(0,7)===\"[object\"&&t.slice(-6)===\"Array]\"}function V(s){return s!==null&&Object.prototype.toString.call(s)===\"[object Object]\"}const rt=s=>(typeof s==\"number\"||s instanceof Number)&&isFinite(+s);function zt(s,t){return rt(s)?s:t}function B(s,t){return typeof s>\"u\"?t:s}const xO=(s,t)=>typeof s==\"string\"&&s.endsWith(\"%\")?parseFloat(s)/100:s/t,gf=(s,t)=>typeof s==\"string\"&&s.endsWith(\"%\")?parseFloat(s)/100*t:+s;function G(s,t,e){if(s&&typeof s.call==\"function\")return s.apply(e,t)}function U(s,t,e,i){let n,o,r;if(Q(s))if(o=s.length,i)for(n=o-1;n>=0;n--)t.call(e,s[n],n);else for(n=0;ns,x:s=>s.x,y:s=>s.y};function ti(s,t){return(bf[t]||(bf[t]=wO(t)))(s)}function wO(s){const t=kO(s);return e=>{for(const i of t){if(i===\"\")break;e=e&&e[i]}return e}}function kO(s){const t=s.split(\".\"),e=[];let i=\"\";for(const n of t)i+=n,i.endsWith(\"\\\\\")?i=i.slice(0,-1)+\".\":(e.push(i),i=\"\");return e}function gc(s){return s.charAt(0).toUpperCase()+s.slice(1)}const jt=s=>typeof s<\"u\",ei=s=>typeof s==\"function\",vf=(s,t)=>{if(s.size!==t.size)return!1;for(const e of s)if(!t.has(e))return!1;return!0};function SO(s){return s.type===\"mouseup\"||s.type===\"click\"||s.type===\"contextmenu\"}const it=Math.PI,q=2*it,OO=q+it,zr=Number.POSITIVE_INFINITY,IO=it/180,nt=it/2,On=it/4,yf=it*2/3,Yt=Math.log10,Ee=Math.sign;function Tf(s){const t=Math.round(s);s=In(s,t,s/1e3)?t:s;const e=Math.pow(10,Math.floor(Yt(s))),i=s/e;return(i<=1?1:i<=2?2:i<=5?5:10)*e}function DO(s){const t=[],e=Math.sqrt(s);let i;for(i=1;in-o).pop(),t}function As(s){return!isNaN(parseFloat(s))&&isFinite(s)}function In(s,t,e){return Math.abs(s-t)=s}function Ef(s,t,e){let i,n,o;for(i=0,n=s.length;il&&c=Math.min(t,e)-i&&s<=Math.max(t,e)+i}function vc(s,t,e){e=e||(r=>s[r]1;)o=n+i>>1,e(o)?n=o:i=o;return{lo:n,hi:i}}const De=(s,t,e,i)=>vc(s,e,i?n=>s[n][t]<=e:n=>s[n][t]vc(s,e,i=>s[i][t]>=e);function PO(s,t,e){let i=0,n=s.length;for(;ii&&s[n-1]>e;)n--;return i>0||n{const i=\"_onData\"+gc(e),n=s[e];Object.defineProperty(s,e,{configurable:!0,enumerable:!1,value(...o){const r=n.apply(this,o);return s._chartjs.listeners.forEach(a=>{typeof a[i]==\"function\"&&a[i](...o)}),r}})})}function wf(s,t){const e=s._chartjs;if(!e)return;const i=e.listeners,n=i.indexOf(t);n!==-1&&i.splice(n,1),!(i.length>0)&&(Af.forEach(o=>{delete s[o]}),delete s._chartjs)}function kf(s){const t=new Set;let e,i;for(e=0,i=s.length;e\"u\"?function(s){return s()}:window.requestAnimationFrame}();function Of(s,t,e){const i=e||(r=>Array.prototype.slice.call(r));let n=!1,o=[];return function(...r){o=i(r),n||(n=!0,Sf.call(window,()=>{n=!1,s.apply(t,o)}))}}function BO(s,t){let e;return function(...i){return t?(clearTimeout(e),e=setTimeout(s,t,i)):s.apply(this,i),t}}const yc=s=>s===\"start\"?\"left\":s===\"end\"?\"right\":\"center\",gt=(s,t,e)=>s===\"start\"?t:s===\"end\"?e:(t+e)/2,HO=(s,t,e,i)=>s===(i?\"left\":\"right\")?e:s===\"center\"?(t+e)/2:t;function If(s,t,e){const i=t.length;let n=0,o=i;if(s._sorted){const{iScale:r,_parsed:a}=s,l=r.axis,{min:c,max:h,minDefined:d,maxDefined:u}=r.getUserBounds();d&&(n=dt(Math.min(De(a,r.axis,c).lo,e?i:De(t,l,r.getPixelForValue(c)).lo),0,i-1)),u?o=dt(Math.max(De(a,r.axis,h,!0).hi+1,e?0:De(t,l,r.getPixelForValue(h),!0).hi+1),n,i)-n:o=i-n}return{start:n,count:o}}function Df(s){const{xScale:t,yScale:e,_scaleRanges:i}=s,n={xmin:t.min,xmax:t.max,ymin:e.min,ymax:e.max};if(!i)return s._scaleRanges=n,!0;const o=i.xmin!==t.min||i.xmax!==t.max||i.ymin!==e.min||i.ymax!==e.max;return Object.assign(i,n),o}const jr=s=>s===0||s===1,Mf=(s,t,e)=>-(Math.pow(2,10*(s-=1))*Math.sin((s-t)*q/e)),Lf=(s,t,e)=>Math.pow(2,-10*s)*Math.sin((s-t)*q/e)+1,Mn={linear:s=>s,easeInQuad:s=>s*s,easeOutQuad:s=>-s*(s-2),easeInOutQuad:s=>(s/=.5)<1?.5*s*s:-.5*(--s*(s-2)-1),easeInCubic:s=>s*s*s,easeOutCubic:s=>(s-=1)*s*s+1,easeInOutCubic:s=>(s/=.5)<1?.5*s*s*s:.5*((s-=2)*s*s+2),easeInQuart:s=>s*s*s*s,easeOutQuart:s=>-((s-=1)*s*s*s-1),easeInOutQuart:s=>(s/=.5)<1?.5*s*s*s*s:-.5*((s-=2)*s*s*s-2),easeInQuint:s=>s*s*s*s*s,easeOutQuint:s=>(s-=1)*s*s*s*s+1,easeInOutQuint:s=>(s/=.5)<1?.5*s*s*s*s*s:.5*((s-=2)*s*s*s*s+2),easeInSine:s=>-Math.cos(s*nt)+1,easeOutSine:s=>Math.sin(s*nt),easeInOutSine:s=>-.5*(Math.cos(it*s)-1),easeInExpo:s=>s===0?0:Math.pow(2,10*(s-1)),easeOutExpo:s=>s===1?1:-Math.pow(2,-10*s)+1,easeInOutExpo:s=>jr(s)?s:s<.5?.5*Math.pow(2,10*(s*2-1)):.5*(-Math.pow(2,-10*(s*2-1))+2),easeInCirc:s=>s>=1?s:-(Math.sqrt(1-s*s)-1),easeOutCirc:s=>Math.sqrt(1-(s-=1)*s),easeInOutCirc:s=>(s/=.5)<1?-.5*(Math.sqrt(1-s*s)-1):.5*(Math.sqrt(1-(s-=2)*s)+1),easeInElastic:s=>jr(s)?s:Mf(s,.075,.3),easeOutElastic:s=>jr(s)?s:Lf(s,.075,.3),easeInOutElastic(s){return jr(s)?s:s<.5?.5*Mf(s*2,.1125,.45):.5+.5*Lf(s*2-1,.1125,.45)},easeInBack(s){return s*s*((1.70158+1)*s-1.70158)},easeOutBack(s){return(s-=1)*s*((1.70158+1)*s+1.70158)+1},easeInOutBack(s){let t=1.70158;return(s/=.5)<1?.5*(s*s*(((t*=1.525)+1)*s-t)):.5*((s-=2)*s*(((t*=1.525)+1)*s+t)+2)},easeInBounce:s=>1-Mn.easeOutBounce(1-s),easeOutBounce(s){return s<1/2.75?7.5625*s*s:s<2/2.75?7.5625*(s-=1.5/2.75)*s+.75:s<2.5/2.75?7.5625*(s-=2.25/2.75)*s+.9375:7.5625*(s-=2.625/2.75)*s+.984375},easeInOutBounce:s=>s<.5?Mn.easeInBounce(s*2)*.5:Mn.easeOutBounce(s*2-1)*.5+.5};/*!\n * @kurkle/color v0.2.1\n * https://github.com/kurkle/color#readme\n * (c) 2022 Jukka Kurkela\n * Released under the MIT License\n */function Ln(s){return s+.5|0}const ii=(s,t,e)=>Math.max(Math.min(s,e),t);function $n(s){return ii(Ln(s*2.55),0,255)}function si(s){return ii(Ln(s*255),0,255)}function Me(s){return ii(Ln(s/2.55)/100,0,1)}function $f(s){return ii(Ln(s*100),0,100)}const Kt={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Tc=[...\"0123456789ABCDEF\"],VO=s=>Tc[s&15],FO=s=>Tc[(s&240)>>4]+Tc[s&15],Yr=s=>(s&240)>>4===(s&15),WO=s=>Yr(s.r)&&Yr(s.g)&&Yr(s.b)&&Yr(s.a);function zO(s){var t=s.length,e;return s[0]===\"#\"&&(t===4||t===5?e={r:255&Kt[s[1]]*17,g:255&Kt[s[2]]*17,b:255&Kt[s[3]]*17,a:t===5?Kt[s[4]]*17:255}:(t===7||t===9)&&(e={r:Kt[s[1]]<<4|Kt[s[2]],g:Kt[s[3]]<<4|Kt[s[4]],b:Kt[s[5]]<<4|Kt[s[6]],a:t===9?Kt[s[7]]<<4|Kt[s[8]]:255})),e}const jO=(s,t)=>s<255?t(s):\"\";function YO(s){var t=WO(s)?VO:FO;return s?\"#\"+t(s.r)+t(s.g)+t(s.b)+jO(s.a,t):void 0}const KO=/^(hsla?|hwb|hsv)\\(\\s*([-+.e\\d]+)(?:deg)?[\\s,]+([-+.e\\d]+)%[\\s,]+([-+.e\\d]+)%(?:[\\s,]+([-+.e\\d]+)(%)?)?\\s*\\)$/;function Rf(s,t,e){const i=t*Math.min(e,1-e),n=(o,r=(o+s/30)%12)=>e-i*Math.max(Math.min(r-3,9-r,1),-1);return[n(0),n(8),n(4)]}function UO(s,t,e){const i=(n,o=(n+s/60)%6)=>e-e*t*Math.max(Math.min(o,4-o,1),0);return[i(5),i(3),i(1)]}function XO(s,t,e){const i=Rf(s,1,.5);let n;for(t+e>1&&(n=1/(t+e),t*=n,e*=n),n=0;n<3;n++)i[n]*=1-t-e,i[n]+=t;return i}function GO(s,t,e,i,n){return s===n?(t-e)/i+(t.5?h/(2-o-r):h/(o+r),l=GO(e,i,n,h,o),l=l*60+.5),[l|0,c||0,a]}function xc(s,t,e,i){return(Array.isArray(t)?s(t[0],t[1],t[2]):s(t,e,i)).map(si)}function Cc(s,t,e){return xc(Rf,s,t,e)}function qO(s,t,e){return xc(XO,s,t,e)}function ZO(s,t,e){return xc(UO,s,t,e)}function Pf(s){return(s%360+360)%360}function QO(s){const t=KO.exec(s);let e=255,i;if(!t)return;t[5]!==i&&(e=t[6]?$n(+t[5]):si(+t[5]));const n=Pf(+t[2]),o=+t[3]/100,r=+t[4]/100;return t[1]===\"hwb\"?i=qO(n,o,r):t[1]===\"hsv\"?i=ZO(n,o,r):i=Cc(n,o,r),{r:i[0],g:i[1],b:i[2],a:e}}function JO(s,t){var e=Ec(s);e[0]=Pf(e[0]+t),e=Cc(e),s.r=e[0],s.g=e[1],s.b=e[2]}function tI(s){if(!s)return;const t=Ec(s),e=t[0],i=$f(t[1]),n=$f(t[2]);return s.a<255?`hsla(${e}, ${i}%, ${n}%, ${Me(s.a)})`:`hsl(${e}, ${i}%, ${n}%)`}const Nf={x:\"dark\",Z:\"light\",Y:\"re\",X:\"blu\",W:\"gr\",V:\"medium\",U:\"slate\",A:\"ee\",T:\"ol\",S:\"or\",B:\"ra\",C:\"lateg\",D:\"ights\",R:\"in\",Q:\"turquois\",E:\"hi\",P:\"ro\",O:\"al\",N:\"le\",M:\"de\",L:\"yello\",F:\"en\",K:\"ch\",G:\"arks\",H:\"ea\",I:\"ightg\",J:\"wh\"},Bf={OiceXe:\"f0f8ff\",antiquewEte:\"faebd7\",aqua:\"ffff\",aquamarRe:\"7fffd4\",azuY:\"f0ffff\",beige:\"f5f5dc\",bisque:\"ffe4c4\",black:\"0\",blanKedOmond:\"ffebcd\",Xe:\"ff\",XeviTet:\"8a2be2\",bPwn:\"a52a2a\",burlywood:\"deb887\",caMtXe:\"5f9ea0\",KartYuse:\"7fff00\",KocTate:\"d2691e\",cSO:\"ff7f50\",cSnflowerXe:\"6495ed\",cSnsilk:\"fff8dc\",crimson:\"dc143c\",cyan:\"ffff\",xXe:\"8b\",xcyan:\"8b8b\",xgTMnPd:\"b8860b\",xWay:\"a9a9a9\",xgYF:\"6400\",xgYy:\"a9a9a9\",xkhaki:\"bdb76b\",xmagFta:\"8b008b\",xTivegYF:\"556b2f\",xSange:\"ff8c00\",xScEd:\"9932cc\",xYd:\"8b0000\",xsOmon:\"e9967a\",xsHgYF:\"8fbc8f\",xUXe:\"483d8b\",xUWay:\"2f4f4f\",xUgYy:\"2f4f4f\",xQe:\"ced1\",xviTet:\"9400d3\",dAppRk:\"ff1493\",dApskyXe:\"bfff\",dimWay:\"696969\",dimgYy:\"696969\",dodgerXe:\"1e90ff\",fiYbrick:\"b22222\",flSOwEte:\"fffaf0\",foYstWAn:\"228b22\",fuKsia:\"ff00ff\",gaRsbSo:\"dcdcdc\",ghostwEte:\"f8f8ff\",gTd:\"ffd700\",gTMnPd:\"daa520\",Way:\"808080\",gYF:\"8000\",gYFLw:\"adff2f\",gYy:\"808080\",honeyMw:\"f0fff0\",hotpRk:\"ff69b4\",RdianYd:\"cd5c5c\",Rdigo:\"4b0082\",ivSy:\"fffff0\",khaki:\"f0e68c\",lavFMr:\"e6e6fa\",lavFMrXsh:\"fff0f5\",lawngYF:\"7cfc00\",NmoncEffon:\"fffacd\",ZXe:\"add8e6\",ZcSO:\"f08080\",Zcyan:\"e0ffff\",ZgTMnPdLw:\"fafad2\",ZWay:\"d3d3d3\",ZgYF:\"90ee90\",ZgYy:\"d3d3d3\",ZpRk:\"ffb6c1\",ZsOmon:\"ffa07a\",ZsHgYF:\"20b2aa\",ZskyXe:\"87cefa\",ZUWay:\"778899\",ZUgYy:\"778899\",ZstAlXe:\"b0c4de\",ZLw:\"ffffe0\",lime:\"ff00\",limegYF:\"32cd32\",lRF:\"faf0e6\",magFta:\"ff00ff\",maPon:\"800000\",VaquamarRe:\"66cdaa\",VXe:\"cd\",VScEd:\"ba55d3\",VpurpN:\"9370db\",VsHgYF:\"3cb371\",VUXe:\"7b68ee\",VsprRggYF:\"fa9a\",VQe:\"48d1cc\",VviTetYd:\"c71585\",midnightXe:\"191970\",mRtcYam:\"f5fffa\",mistyPse:\"ffe4e1\",moccasR:\"ffe4b5\",navajowEte:\"ffdead\",navy:\"80\",Tdlace:\"fdf5e6\",Tive:\"808000\",TivedBb:\"6b8e23\",Sange:\"ffa500\",SangeYd:\"ff4500\",ScEd:\"da70d6\",pOegTMnPd:\"eee8aa\",pOegYF:\"98fb98\",pOeQe:\"afeeee\",pOeviTetYd:\"db7093\",papayawEp:\"ffefd5\",pHKpuff:\"ffdab9\",peru:\"cd853f\",pRk:\"ffc0cb\",plum:\"dda0dd\",powMrXe:\"b0e0e6\",purpN:\"800080\",YbeccapurpN:\"663399\",Yd:\"ff0000\",Psybrown:\"bc8f8f\",PyOXe:\"4169e1\",saddNbPwn:\"8b4513\",sOmon:\"fa8072\",sandybPwn:\"f4a460\",sHgYF:\"2e8b57\",sHshell:\"fff5ee\",siFna:\"a0522d\",silver:\"c0c0c0\",skyXe:\"87ceeb\",UXe:\"6a5acd\",UWay:\"708090\",UgYy:\"708090\",snow:\"fffafa\",sprRggYF:\"ff7f\",stAlXe:\"4682b4\",tan:\"d2b48c\",teO:\"8080\",tEstN:\"d8bfd8\",tomato:\"ff6347\",Qe:\"40e0d0\",viTet:\"ee82ee\",JHt:\"f5deb3\",wEte:\"ffffff\",wEtesmoke:\"f5f5f5\",Lw:\"ffff00\",LwgYF:\"9acd32\"};function eI(){const s={},t=Object.keys(Bf),e=Object.keys(Nf);let i,n,o,r,a;for(i=0;i>16&255,o>>8&255,o&255]}return s}let Kr;function iI(s){Kr||(Kr=eI(),Kr.transparent=[0,0,0,0]);const t=Kr[s.toLowerCase()];return t&&{r:t[0],g:t[1],b:t[2],a:t.length===4?t[3]:255}}const sI=/^rgba?\\(\\s*([-+.\\d]+)(%)?[\\s,]+([-+.e\\d]+)(%)?[\\s,]+([-+.e\\d]+)(%)?(?:[\\s,/]+([-+.e\\d]+)(%)?)?\\s*\\)$/;function nI(s){const t=sI.exec(s);let e=255,i,n,o;if(t){if(t[7]!==i){const r=+t[7];e=t[8]?$n(r):ii(r*255,0,255)}return i=+t[1],n=+t[3],o=+t[5],i=255&(t[2]?$n(i):ii(i,0,255)),n=255&(t[4]?$n(n):ii(n,0,255)),o=255&(t[6]?$n(o):ii(o,0,255)),{r:i,g:n,b:o,a:e}}}function oI(s){return s&&(s.a<255?`rgba(${s.r}, ${s.g}, ${s.b}, ${Me(s.a)})`:`rgb(${s.r}, ${s.g}, ${s.b})`)}const Ac=s=>s<=.0031308?s*12.92:Math.pow(s,1/2.4)*1.055-.055,ws=s=>s<=.04045?s/12.92:Math.pow((s+.055)/1.055,2.4);function rI(s,t,e){const i=ws(Me(s.r)),n=ws(Me(s.g)),o=ws(Me(s.b));return{r:si(Ac(i+e*(ws(Me(t.r))-i))),g:si(Ac(n+e*(ws(Me(t.g))-n))),b:si(Ac(o+e*(ws(Me(t.b))-o))),a:s.a+e*(t.a-s.a)}}function Ur(s,t,e){if(s){let i=Ec(s);i[t]=Math.max(0,Math.min(i[t]+i[t]*e,t===0?360:1)),i=Cc(i),s.r=i[0],s.g=i[1],s.b=i[2]}}function Hf(s,t){return s&&Object.assign(t||{},s)}function Vf(s){var t={r:0,g:0,b:0,a:255};return Array.isArray(s)?s.length>=3&&(t={r:s[0],g:s[1],b:s[2],a:255},s.length>3&&(t.a=si(s[3]))):(t=Hf(s,{r:0,g:0,b:0,a:1}),t.a=si(t.a)),t}function aI(s){return s.charAt(0)===\"r\"?nI(s):QO(s)}class Xr{constructor(t){if(t instanceof Xr)return t;const e=typeof t;let i;e===\"object\"?i=Vf(t):e===\"string\"&&(i=zO(t)||iI(t)||aI(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=Hf(this._rgb);return t&&(t.a=Me(t.a)),t}set rgb(t){this._rgb=Vf(t)}rgbString(){return this._valid?oI(this._rgb):void 0}hexString(){return this._valid?YO(this._rgb):void 0}hslString(){return this._valid?tI(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,n=t.rgb;let o;const r=e===o?.5:e,a=2*r-1,l=i.a-n.a,c=((a*l===-1?a:(a+l)/(1+a*l))+1)/2;o=1-c,i.r=255&c*i.r+o*n.r+.5,i.g=255&c*i.g+o*n.g+.5,i.b=255&c*i.b+o*n.b+.5,i.a=r*i.a+(1-r)*n.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=rI(this._rgb,t._rgb,e)),this}clone(){return new Xr(this.rgb)}alpha(t){return this._rgb.a=si(t),this}clearer(t){const e=this._rgb;return e.a*=1-t,this}greyscale(){const t=this._rgb,e=Ln(t.r*.3+t.g*.59+t.b*.11);return t.r=t.g=t.b=e,this}opaquer(t){const e=this._rgb;return e.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Ur(this._rgb,2,t),this}darken(t){return Ur(this._rgb,2,-t),this}saturate(t){return Ur(this._rgb,1,t),this}desaturate(t){return Ur(this._rgb,1,-t),this}rotate(t){return JO(this._rgb,t),this}}function Ff(s){return new Xr(s)}function Wf(s){if(s&&typeof s==\"object\"){const t=s.toString();return t===\"[object CanvasPattern]\"||t===\"[object CanvasGradient]\"}return!1}function zf(s){return Wf(s)?s:Ff(s)}function wc(s){return Wf(s)?s:Ff(s).saturate(.5).darken(.1).hexString()}const Di=Object.create(null),kc=Object.create(null);function Rn(s,t){if(!t)return s;const e=t.split(\".\");for(let i=0,n=e.length;ie.chart.platform.getDevicePixelRatio(),this.elements={},this.events=[\"mousemove\",\"mouseout\",\"click\",\"touchstart\",\"touchmove\"],this.font={family:\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",size:12,style:\"normal\",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(e,i)=>wc(i.backgroundColor),this.hoverBorderColor=(e,i)=>wc(i.borderColor),this.hoverColor=(e,i)=>wc(i.color),this.indexAxis=\"x\",this.interaction={mode:\"nearest\",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t)}set(t,e){return Sc(this,t,e)}get(t){return Rn(this,t)}describe(t,e){return Sc(kc,t,e)}override(t,e){return Sc(Di,t,e)}route(t,e,i,n){const o=Rn(this,t),r=Rn(this,i),a=\"_\"+e;Object.defineProperties(o,{[a]:{value:o[e],writable:!0},[e]:{enumerable:!0,get(){const l=this[a],c=r[n];return V(l)?Object.assign({},c,l):B(l,c)},set(l){this[a]=l}}})}}var F=new lI({_scriptable:s=>!s.startsWith(\"on\"),_indexable:s=>s!==\"events\",hover:{_fallback:\"interaction\"},interaction:{_scriptable:!1,_indexable:!1}});function cI(s){return!s||H(s.size)||H(s.family)?null:(s.style?s.style+\" \":\"\")+(s.weight?s.weight+\" \":\"\")+s.size+\"px \"+s.family}function Gr(s,t,e,i,n){let o=t[n];return o||(o=t[n]=s.measureText(n).width,e.push(n)),o>i&&(i=o),i}function hI(s,t,e,i){i=i||{};let n=i.data=i.data||{},o=i.garbageCollect=i.garbageCollect||[];i.font!==t&&(n=i.data={},o=i.garbageCollect=[],i.font=t),s.save(),s.font=t;let r=0;const a=e.length;let l,c,h,d,u;for(l=0;le.length){for(l=0;l0&&s.stroke()}}function Pn(s,t,e){return e=e||.5,!t||s&&s.x>t.left-e&&s.xt.top-e&&s.y0&&o.strokeColor!==\"\";let l,c;for(s.save(),s.font=n.string,pI(s,o),l=0;l+s||0;function Ic(s,t){const e={},i=V(t),n=i?Object.keys(t):t,o=V(s)?i?r=>B(s[r],s[t[r]]):r=>s[r]:()=>s;for(const r of n)e[r]=bI(o(r));return e}function Kf(s){return Ic(s,{top:\"y\",right:\"x\",bottom:\"y\",left:\"x\"})}function $i(s){return Ic(s,[\"topLeft\",\"topRight\",\"bottomLeft\",\"bottomRight\"])}function pt(s){const t=Kf(s);return t.width=t.left+t.right,t.height=t.top+t.bottom,t}function lt(s,t){s=s||{},t=t||F.font;let e=B(s.size,t.size);typeof e==\"string\"&&(e=parseInt(e,10));let i=B(s.style,t.style);i&&!(\"\"+i).match(gI)&&(console.warn('Invalid font style specified: \"'+i+'\"'),i=\"\");const n={family:B(s.family,t.family),lineHeight:mI(B(s.lineHeight,t.lineHeight),e),size:e,style:i,weight:B(s.weight,t.weight),string:\"\"};return n.string=cI(n),n}function tt(s,t,e,i){let n=!0,o,r,a;for(o=0,r=s.length;oe&&a===0?0:a+l;return{min:r(i,-Math.abs(o)),max:r(n,o)}}function ni(s,t){return Object.assign(Object.create(s),t)}function Dc(s,t=[\"\"],e=s,i,n=()=>s[0]){jt(i)||(i=Zf(\"_fallback\",s));const o={[Symbol.toStringTag]:\"Object\",_cacheable:!0,_scopes:s,_rootScopes:e,_fallback:i,_getTarget:n,override:r=>Dc([r,...s],t,e,i)};return new Proxy(o,{deleteProperty(r,a){return delete r[a],delete r._keys,delete s[0][a],!0},get(r,a){return Xf(r,a,()=>kI(a,t,s,r))},getOwnPropertyDescriptor(r,a){return Reflect.getOwnPropertyDescriptor(r._scopes[0],a)},getPrototypeOf(){return Reflect.getPrototypeOf(s[0])},has(r,a){return Qf(r).includes(a)},ownKeys(r){return Qf(r)},set(r,a,l){const c=r._storage||(r._storage=n());return r[a]=c[a]=l,delete r._keys,!0}})}function ks(s,t,e,i){const n={_cacheable:!1,_proxy:s,_context:t,_subProxy:e,_stack:new Set,_descriptors:Uf(s,i),setContext:o=>ks(s,o,e,i),override:o=>ks(s.override(o),t,e,i)};return new Proxy(n,{deleteProperty(o,r){return delete o[r],delete s[r],!0},get(o,r,a){return Xf(o,r,()=>TI(o,r,a))},getOwnPropertyDescriptor(o,r){return o._descriptors.allKeys?Reflect.has(s,r)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(s,r)},getPrototypeOf(){return Reflect.getPrototypeOf(s)},has(o,r){return Reflect.has(s,r)},ownKeys(){return Reflect.ownKeys(s)},set(o,r,a){return s[r]=a,delete o[r],!0}})}function Uf(s,t={scriptable:!0,indexable:!0}){const{_scriptable:e=t.scriptable,_indexable:i=t.indexable,_allKeys:n=t.allKeys}=s;return{allKeys:n,scriptable:e,indexable:i,isScriptable:ei(e)?e:()=>e,isIndexable:ei(i)?i:()=>i}}const yI=(s,t)=>s?s+gc(t):t,Mc=(s,t)=>V(t)&&s!==\"adapters\"&&(Object.getPrototypeOf(t)===null||t.constructor===Object);function Xf(s,t,e){if(Object.prototype.hasOwnProperty.call(s,t))return s[t];const i=e();return s[t]=i,i}function TI(s,t,e){const{_proxy:i,_context:n,_subProxy:o,_descriptors:r}=s;let a=i[t];return ei(a)&&r.isScriptable(t)&&(a=EI(t,a,s,e)),Q(a)&&a.length&&(a=xI(t,a,s,r.isIndexable)),Mc(t,a)&&(a=ks(a,n,o&&o[t],r)),a}function EI(s,t,e,i){const{_proxy:n,_context:o,_subProxy:r,_stack:a}=e;if(a.has(s))throw new Error(\"Recursion detected: \"+Array.from(a).join(\"->\")+\"->\"+s);return a.add(s),t=t(o,r||i),a.delete(s),Mc(s,t)&&(t=Lc(n._scopes,n,s,t)),t}function xI(s,t,e,i){const{_proxy:n,_context:o,_subProxy:r,_descriptors:a}=e;if(jt(o.index)&&i(s))t=t[o.index%t.length];else if(V(t[0])){const l=t,c=n._scopes.filter(h=>h!==l);t=[];for(const h of l){const d=Lc(c,n,s,h);t.push(ks(d,o,r&&r[s],a))}}return t}function Gf(s,t,e){return ei(s)?s(t,e):s}const CI=(s,t)=>s===!0?t:typeof s==\"string\"?ti(t,s):void 0;function AI(s,t,e,i,n){for(const o of t){const r=CI(e,o);if(r){s.add(r);const a=Gf(r._fallback,e,n);if(jt(a)&&a!==e&&a!==i)return a}else if(r===!1&&jt(i)&&e!==i)return null}return!1}function Lc(s,t,e,i){const n=t._rootScopes,o=Gf(t._fallback,e,i),r=[...s,...n],a=new Set;a.add(i);let l=qf(a,r,e,o||e,i);return l===null||jt(o)&&o!==e&&(l=qf(a,r,o,l,i),l===null)?!1:Dc(Array.from(a),[\"\"],n,o,()=>wI(t,e,i))}function qf(s,t,e,i,n){for(;e;)e=AI(s,t,e,i,n);return e}function wI(s,t,e){const i=s._getTarget();t in i||(i[t]={});const n=i[t];return Q(n)&&V(e)?e:n}function kI(s,t,e,i){let n;for(const o of t)if(n=Zf(yI(o,s),e),jt(n))return Mc(s,n)?Lc(e,i,s,n):n}function Zf(s,t){for(const e of t){if(!e)continue;const i=e[s];if(jt(i))return i}}function Qf(s){let t=s._keys;return t||(t=s._keys=SI(s._scopes)),t}function SI(s){const t=new Set;for(const e of s)for(const i of Object.keys(e).filter(n=>!n.startsWith(\"_\")))t.add(i);return Array.from(t)}function Jf(s,t,e,i){const{iScale:n}=s,{key:o=\"r\"}=this._parsing,r=new Array(i);let a,l,c,h;for(a=0,l=i;ats===\"x\"?\"y\":\"x\";function II(s,t,e,i){const n=s.skip?t:s,o=t,r=e.skip?t:e,a=bc(o,n),l=bc(r,o);let c=a/(a+l),h=l/(a+l);c=isNaN(c)?0:c,h=isNaN(h)?0:h;const d=i*c,u=i*h;return{previous:{x:o.x-d*(r.x-n.x),y:o.y-d*(r.y-n.y)},next:{x:o.x+u*(r.x-n.x),y:o.y+u*(r.y-n.y)}}}function DI(s,t,e){const i=s.length;let n,o,r,a,l,c=Ss(s,0);for(let h=0;h!c.skip)),t.cubicInterpolationMode===\"monotone\")LI(s,n);else{let c=i?s[s.length-1]:s[0];for(o=0,r=s.length;owindow.getComputedStyle(s,null);function PI(s,t){return ta(s).getPropertyValue(t)}const NI=[\"top\",\"right\",\"bottom\",\"left\"];function Ri(s,t,e){const i={};e=e?\"-\"+e:\"\";for(let n=0;n<4;n++){const o=NI[n];i[o]=parseFloat(s[t+\"-\"+o+e])||0}return i.width=i.left+i.right,i.height=i.top+i.bottom,i}const BI=(s,t,e)=>(s>0||t>0)&&(!e||!e.shadowRoot);function HI(s,t){const e=s.touches,i=e&&e.length?e[0]:s,{offsetX:n,offsetY:o}=i;let r=!1,a,l;if(BI(n,o,s.target))a=n,l=o;else{const c=t.getBoundingClientRect();a=i.clientX-c.left,l=i.clientY-c.top,r=!0}return{x:a,y:l,box:r}}function Pi(s,t){if(\"native\"in s)return s;const{canvas:e,currentDevicePixelRatio:i}=t,n=ta(e),o=n.boxSizing===\"border-box\",r=Ri(n,\"padding\"),a=Ri(n,\"border\",\"width\"),{x:l,y:c,box:h}=HI(s,e),d=r.left+(h&&a.left),u=r.top+(h&&a.top);let{width:p,height:f}=t;return o&&(p-=r.width+a.width,f-=r.height+a.height),{x:Math.round((l-d)/p*e.width/i),y:Math.round((c-u)/f*e.height/i)}}function VI(s,t,e){let i,n;if(t===void 0||e===void 0){const o=$c(s);if(!o)t=s.clientWidth,e=s.clientHeight;else{const r=o.getBoundingClientRect(),a=ta(o),l=Ri(a,\"border\",\"width\"),c=Ri(a,\"padding\");t=r.width-c.width-l.width,e=r.height-c.height-l.height,i=Jr(a.maxWidth,o,\"clientWidth\"),n=Jr(a.maxHeight,o,\"clientHeight\")}}return{width:t,height:e,maxWidth:i||zr,maxHeight:n||zr}}const Rc=s=>Math.round(s*10)/10;function FI(s,t,e,i){const n=ta(s),o=Ri(n,\"margin\"),r=Jr(n.maxWidth,s,\"clientWidth\")||zr,a=Jr(n.maxHeight,s,\"clientHeight\")||zr,l=VI(s,t,e);let{width:c,height:h}=l;if(n.boxSizing===\"content-box\"){const d=Ri(n,\"border\",\"width\"),u=Ri(n,\"padding\");c-=u.width+d.width,h-=u.height+d.height}return c=Math.max(0,c-o.width),h=Math.max(0,i?Math.floor(c/i):h-o.height),c=Rc(Math.min(c,r,l.maxWidth)),h=Rc(Math.min(h,a,l.maxHeight)),c&&!h&&(h=Rc(c/2)),{width:c,height:h}}function i_(s,t,e){const i=t||1,n=Math.floor(s.height*i),o=Math.floor(s.width*i);s.height=n/i,s.width=o/i;const r=s.canvas;return r.style&&(e||!r.style.height&&!r.style.width)&&(r.style.height=`${s.height}px`,r.style.width=`${s.width}px`),s.currentDevicePixelRatio!==i||r.height!==n||r.width!==o?(s.currentDevicePixelRatio=i,r.height=n,r.width=o,s.ctx.setTransform(i,0,0,i,0,0),!0):!1}const WI=function(){let s=!1;try{const t={get passive(){return s=!0,!1}};window.addEventListener(\"test\",null,t),window.removeEventListener(\"test\",null,t)}catch{}return s}();function s_(s,t){const e=PI(s,t),i=e&&e.match(/^(\\d+)(\\.\\d+)?px$/);return i?+i[1]:void 0}function Ni(s,t,e,i){return{x:s.x+e*(t.x-s.x),y:s.y+e*(t.y-s.y)}}function zI(s,t,e,i){return{x:s.x+e*(t.x-s.x),y:i===\"middle\"?e<.5?s.y:t.y:i===\"after\"?e<1?s.y:t.y:e>0?t.y:s.y}}function jI(s,t,e,i){const n={x:s.cp2x,y:s.cp2y},o={x:t.cp1x,y:t.cp1y},r=Ni(s,n,e),a=Ni(n,o,e),l=Ni(o,t,e),c=Ni(r,a,e),h=Ni(a,l,e);return Ni(c,h,e)}const n_=new Map;function YI(s,t){t=t||{};const e=s+JSON.stringify(t);let i=n_.get(e);return i||(i=new Intl.NumberFormat(s,t),n_.set(e,i)),i}function Bn(s,t,e){return YI(t,e).format(s)}const KI=function(s,t){return{x(e){return s+s+t-e},setWidth(e){t=e},textAlign(e){return e===\"center\"?e:e===\"right\"?\"left\":\"right\"},xPlus(e,i){return e-i},leftForLtr(e,i){return e-i}}},UI=function(){return{x(s){return s},setWidth(s){},textAlign(s){return s},xPlus(s,t){return s+t},leftForLtr(s,t){return s}}};function Os(s,t,e){return s?KI(t,e):UI()}function o_(s,t){let e,i;(t===\"ltr\"||t===\"rtl\")&&(e=s.canvas.style,i=[e.getPropertyValue(\"direction\"),e.getPropertyPriority(\"direction\")],e.setProperty(\"direction\",t,\"important\"),s.prevTextDirection=i)}function r_(s,t){t!==void 0&&(delete s.prevTextDirection,s.canvas.style.setProperty(\"direction\",t[0],t[1]))}function a_(s){return s===\"angle\"?{between:Dn,compare:LO,normalize:Vt}:{between:Ie,compare:(t,e)=>t-e,normalize:t=>t}}function l_({start:s,end:t,count:e,loop:i,style:n}){return{start:s%e,end:t%e,loop:i&&(t-s+1)%e===0,style:n}}function XI(s,t,e){const{property:i,start:n,end:o}=e,{between:r,normalize:a}=a_(i),l=t.length;let{start:c,end:h,loop:d}=s,u,p;if(d){for(c+=l,h+=l,u=0,p=l;ul(n,x,y)&&a(n,x)!==0,C=()=>a(o,y)===0||l(o,x,y),A=()=>b||E(),w=()=>!b||C();for(let S=h,k=h;S<=d;++S)T=t[S%r],!T.skip&&(y=c(T[i]),y!==x&&(b=l(y,n,o),v===null&&A()&&(v=a(y,n)===0?S:k),v!==null&&w()&&(f.push(l_({start:v,end:S,loop:u,count:r,style:p})),v=null),k=S,x=y));return v!==null&&f.push(l_({start:v,end:d,loop:u,count:r,style:p})),f}function h_(s,t){const e=[],i=s.segments;for(let n=0;nn&&s[o%t].skip;)o--;return o%=t,{start:n,end:o}}function qI(s,t,e,i){const n=s.length,o=[];let r=t,a=s[t],l;for(l=t+1;l<=e;++l){const c=s[l%n];c.skip||c.stop?a.skip||(i=!1,o.push({start:t%n,end:(l-1)%n,loop:i}),t=r=c.stop?l:null):(r=l,a.skip&&(t=l)),a=c}return r!==null&&o.push({start:t%n,end:r%n,loop:i}),o}function ZI(s,t){const e=s.points,i=s.options.spanGaps,n=e.length;if(!n)return[];const o=!!s._loop,{start:r,end:a}=GI(e,n,o,i);if(i===!0)return d_(s,[{start:r,end:a,loop:o}],e,t);const l=aa({chart:t,initial:e.initial,numSteps:r,currentStep:Math.min(i-e.start,r)}))}_refresh(){this._request||(this._running=!0,this._request=Sf.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(t=Date.now()){let e=0;this._charts.forEach((i,n)=>{if(!i.running||!i.items.length)return;const o=i.items;let r=o.length-1,a=!1,l;for(;r>=0;--r)l=o[r],l._active?(l._total>i.duration&&(i.duration=l._total),l.tick(t),a=!0):(o[r]=o[o.length-1],o.pop());a&&(n.draw(),this._notify(n,i,t,\"progress\")),o.length||(i.running=!1,this._notify(n,i,t,\"complete\"),i.initial=!1),e+=o.length}),this._lastDate=t,e===0&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){!e||!e.length||this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce((i,n)=>Math.max(i,n._duration),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!(!e||!e.running||!e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let n=i.length-1;for(;n>=0;--n)i[n].cancel();e.items=[],this._notify(t,e,Date.now(),\"complete\")}remove(t){return this._charts.delete(t)}}var xe=new tD;const p_=\"transparent\",eD={boolean(s,t,e){return e>.5?t:s},color(s,t,e){const i=zf(s||p_),n=i.valid&&zf(t||p_);return n&&n.valid?n.mix(i,e).hexString():t},number(s,t,e){return s+(t-s)*e}};class f_{constructor(t,e,i,n){const o=e[i];n=tt([t.to,n,o,t.from]);const r=tt([t.from,o,n]);this._active=!0,this._fn=t.fn||eD[t.type||typeof r],this._easing=Mn[t.easing]||Mn.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=r,this._to=n,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const n=this._target[this._prop],o=i-this._start,r=this._duration-o;this._start=i,this._duration=Math.floor(Math.max(r,t.duration)),this._total+=o,this._loop=!!t.loop,this._to=tt([t.to,e,n,t.from]),this._from=tt([t.from,n,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,n=this._prop,o=this._from,r=this._loop,a=this._to;let l;if(this._active=o!==a&&(r||e1?2-l:l,l=this._easing(Math.min(1,Math.max(0,l))),this._target[n]=this._fn(o,a,l)}wait(){const t=this._promises||(this._promises=[]);return new Promise((e,i)=>{t.push({res:e,rej:i})})}_notify(t){const e=t?\"res\":\"rej\",i=this._promises||[];for(let n=0;ns!==\"onProgress\"&&s!==\"onComplete\"&&s!==\"fn\"}),F.set(\"animations\",{colors:{type:\"color\",properties:sD},numbers:{type:\"number\",properties:iD}}),F.describe(\"animations\",{_fallback:\"animation\"}),F.set(\"transitions\",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:\"transparent\"},visible:{type:\"boolean\",duration:0}}},hide:{animations:{colors:{to:\"transparent\"},visible:{type:\"boolean\",easing:\"linear\",fn:s=>s|0}}}});class Pc{constructor(t,e){this._chart=t,this._properties=new Map,this.configure(e)}configure(t){if(!V(t))return;const e=this._properties;Object.getOwnPropertyNames(t).forEach(i=>{const n=t[i];if(!V(n))return;const o={};for(const r of nD)o[r]=n[r];(Q(n.properties)&&n.properties||[i]).forEach(r=>{(r===i||!e.has(r))&&e.set(r,o)})})}_animateOptions(t,e){const i=e.options,n=rD(t,i);if(!n)return[];const o=this._createAnimations(n,i);return i.$shared&&oD(t.options.$animations,i).then(()=>{t.options=i},()=>{}),o}_createAnimations(t,e){const i=this._properties,n=[],o=t.$animations||(t.$animations={}),r=Object.keys(e),a=Date.now();let l;for(l=r.length-1;l>=0;--l){const c=r[l];if(c.charAt(0)===\"$\")continue;if(c===\"options\"){n.push(...this._animateOptions(t,e));continue}const h=e[c];let d=o[c];const u=i.get(c);if(d)if(u&&d.active()){d.update(u,h,a);continue}else d.cancel();if(!u||!u.duration){t[c]=h;continue}o[c]=d=new f_(u,t,c,h),n.push(d)}return n}update(t,e){if(this._properties.size===0){Object.assign(t,e);return}const i=this._createAnimations(t,e);if(i.length)return xe.add(this._chart,i),!0}}function oD(s,t){const e=[],i=Object.keys(t);for(let n=0;n0||!e&&o<0)return n.index}return null}function y_(s,t){const{chart:e,_cachedMeta:i}=s,n=e._stacks||(e._stacks={}),{iScale:o,vScale:r,index:a}=i,l=o.axis,c=r.axis,h=hD(o,r,i),d=t.length;let u;for(let p=0;pe[i].axis===t).shift()}function pD(s,t){return ni(s,{active:!1,dataset:void 0,datasetIndex:t,index:t,mode:\"default\",type:\"dataset\"})}function fD(s,t,e){return ni(s,{active:!1,dataIndex:t,parsed:void 0,raw:void 0,element:e,index:t,mode:\"default\",type:\"data\"})}function Hn(s,t){const e=s.controller.index,i=s.vScale&&s.vScale.axis;if(i){t=t||s._parsed;for(const n of t){const o=n._stacks;if(!o||o[i]===void 0||o[i][e]===void 0)return;delete o[i][e]}}}const Bc=s=>s===\"reset\"||s===\"none\",T_=(s,t)=>t?s:Object.assign({},s),_D=(s,t,e)=>s&&!t.hidden&&t._stacked&&{keys:g_(e,!0),values:null};class Ut{constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=b_(t.vScale,t),this.addElements()}updateIndex(t){this.index!==t&&Hn(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),n=(d,u,p,f)=>d===\"x\"?u:d===\"r\"?f:p,o=e.xAxisID=B(i.xAxisID,Nc(t,\"x\")),r=e.yAxisID=B(i.yAxisID,Nc(t,\"y\")),a=e.rAxisID=B(i.rAxisID,Nc(t,\"r\")),l=e.indexAxis,c=e.iAxisID=n(l,o,r,a),h=e.vAxisID=n(l,r,o,a);e.xScale=this.getScaleForId(o),e.yScale=this.getScaleForId(r),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(c),e.vScale=this.getScaleForId(h)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update(\"reset\")}_destroy(){const t=this._cachedMeta;this._data&&wf(this._data,this),t._stacked&&Hn(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(V(e))this._data=cD(e);else if(i!==e){if(i){wf(i,this);const n=this._cachedMeta;Hn(n),n._parsed=[]}e&&Object.isExtensible(e)&&NO(e,this),this._syncList=[],this._data=e}}addElements(){const t=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(t.dataset=new this.datasetElementType)}buildOrUpdateElements(t){const e=this._cachedMeta,i=this.getDataset();let n=!1;this._dataCheck();const o=e._stacked;e._stacked=b_(e.vScale,e),e.stack!==i.stack&&(n=!0,Hn(e),e.stack=i.stack),this._resyncElements(t),(n||o!==e._stacked)&&y_(this,e._parsed)}configure(){const t=this.chart.config,e=t.datasetScopeKeys(this._type),i=t.getOptionScopes(this.getDataset(),e,!0);this.options=t.createResolver(i,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(t,e){const{_cachedMeta:i,_data:n}=this,{iScale:o,_stacked:r}=i,a=o.axis;let l=t===0&&e===n.length?!0:i._sorted,c=t>0&&i._parsed[t-1],h,d,u;if(this._parsing===!1)i._parsed=n,i._sorted=!0,u=n;else{Q(n[t])?u=this.parseArrayData(i,n,t,e):V(n[t])?u=this.parseObjectData(i,n,t,e):u=this.parsePrimitiveData(i,n,t,e);const p=()=>d[a]===null||c&&d[a]b||d=0;--u)if(!f()){this.updateRangeFromParsed(c,t,p,l);break}}return c}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let n,o,r;for(n=0,o=e.length;n=0&&tthis.getContext(i,n),b=c.resolveNamedOptions(u,p,f,d);return b.$shared&&(b.$shared=l,o[r]=Object.freeze(T_(b,l))),b}_resolveAnimations(t,e,i){const n=this.chart,o=this._cachedDataOpts,r=`animation-${e}`,a=o[r];if(a)return a;let l;if(n.options.animation!==!1){const h=this.chart.config,d=h.datasetAnimationScopeKeys(this._type,e),u=h.getOptionScopes(this.getDataset(),d);l=h.createResolver(u,this.getContext(t,i,e))}const c=new Pc(n,l&&l.animations);return l&&l._cacheable&&(o[r]=Object.freeze(c)),c}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bc(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),n=this._sharedOptions,o=this.getSharedOptions(i),r=this.includeOptions(e,o)||o!==n;return this.updateSharedOptions(o,e,i),{sharedOptions:o,includeOptions:r}}updateElement(t,e,i,n){Bc(n)?Object.assign(t,i):this._resolveAnimations(e,n).update(t,i)}updateSharedOptions(t,e,i){t&&!Bc(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,n){t.active=n;const o=this.getStyle(e,n);this._resolveAnimations(e,i,n).update(t,{options:!n&&this.getSharedOptions(o)||o})}removeHoverStyle(t,e,i){this._setStyle(t,i,\"active\",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,\"active\",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,\"active\",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,\"active\",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[a,l,c]of this._syncList)this[a](l,c);this._syncList=[];const n=i.length,o=e.length,r=Math.min(o,n);r&&this.parse(0,r),o>n?this._insertElements(n,o-n,t):o{for(c.length+=e,a=c.length-1;a>=r;a--)c[a]=c[a-e]};for(l(o),a=t;an-o))}return s._cache.$bar}function mD(s){const t=s.iScale,e=gD(t,s.type);let i=t._length,n,o,r,a;const l=()=>{r===32767||r===-32768||(jt(a)&&(i=Math.min(i,Math.abs(r-a)||i)),a=r)};for(n=0,o=e.length;n0?n[s-1]:null,a=sMath.abs(a)&&(l=a,c=r),t[e.axis]=c,t._custom={barStart:l,barEnd:c,start:n,end:o,min:r,max:a}}function E_(s,t,e,i){return Q(s)?yD(s,t,e,i):t[e.axis]=e.parse(s,i),t}function x_(s,t,e,i){const n=s.iScale,o=s.vScale,r=n.getLabels(),a=n===o,l=[];let c,h,d,u;for(c=e,h=e+i;c=e?1:-1)}function ED(s){let t,e,i,n,o;return s.horizontal?(t=s.base>s.x,e=\"left\",i=\"right\"):(t=s.basel.controller.options.grouped),o=i.options.stacked,r=[],a=l=>{const c=l.controller.getParsed(e),h=c&&c[l.vScale.axis];if(H(h)||isNaN(h))return!0};for(const l of n)if(!(e!==void 0&&a(l))&&((o===!1||r.indexOf(l.stack)===-1||o===void 0&&l.stack===void 0)&&r.push(l.stack),l.index===t))break;return r.length||r.push(void 0),r}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const n=this._getStacks(t,i),o=e!==void 0?n.indexOf(e):-1;return o===-1?n.length-1:o}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,n=[];let o,r;for(o=0,r=e.data.length;o=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,{xScale:i,yScale:n}=e,o=this.getParsed(t),r=i.getLabelForValue(o.x),a=n.getLabelForValue(o.y),l=o._custom;return{label:e.label,value:\"(\"+r+\", \"+a+(l?\", \"+l:\"\")+\")\"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,n){const o=n===\"reset\",{iScale:r,vScale:a}=this._cachedMeta,{sharedOptions:l,includeOptions:c}=this._getSharedOptions(e,n),h=r.axis,d=a.axis;for(let u=e;uDn(x,a,l,!0)?1:Math.max(E,E*e,C,C*e),f=(x,E,C)=>Dn(x,a,l,!0)?-1:Math.min(E,E*e,C,C*e),b=p(0,c,d),v=p(nt,h,u),y=f(it,c,d),T=f(it+nt,h,u);i=(b-y)/2,n=(v-T)/2,o=-(b+y)/2,r=-(v+T)/2}return{ratioX:i,ratioY:n,offsetX:o,offsetY:r}}class Bi extends Ut{constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,n=this._cachedMeta;if(this._parsing===!1)n._parsed=i;else{let o=l=>+i[l];if(V(i[t])){const{key:l=\"value\"}=this._parsing;o=c=>+ti(i[c],l)}let r,a;for(r=t,a=t+e;r0&&!isNaN(t)?q*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,n=i.data.labels||[],o=Bn(e._parsed[t],i.options.locale);return{label:n[t]||\"\",value:o}}getMaxBorderWidth(t){let e=0;const i=this.chart;let n,o,r,a,l;if(!t){for(n=0,o=i.data.datasets.length;ns!==\"spacing\",_indexable:s=>s!==\"spacing\"},Bi.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(s){const t=s.data;if(t.labels.length&&t.datasets.length){const{labels:{pointStyle:e}}=s.legend.options;return t.labels.map((i,n)=>{const r=s.getDatasetMeta(0).controller.getStyle(n);return{text:i,fillStyle:r.backgroundColor,strokeStyle:r.borderColor,lineWidth:r.borderWidth,pointStyle:e,hidden:!s.getDataVisibility(n),index:n}})}return[]}},onClick(s,t,e){e.chart.toggleDataVisibility(t.index),e.chart.update()}},tooltip:{callbacks:{title(){return\"\"},label(s){let t=s.label;const e=\": \"+s.formattedValue;return Q(t)?(t=t.slice(),t[0]+=e):t+=e,t}}}}};class Wn extends Ut{initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(t){const e=this._cachedMeta,{dataset:i,data:n=[],_dataset:o}=e,r=this.chart._animationsDisabled;let{start:a,count:l}=If(e,n,r);this._drawStart=a,this._drawCount=l,Df(e)&&(a=0,l=n.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!o._decimated,i.points=n;const c=this.resolveDatasetElementOptions(t);this.options.showLine||(c.borderWidth=0),c.segment=this.options.segment,this.updateElement(i,void 0,{animated:!r,options:c},t),this.updateElements(n,a,l,t)}updateElements(t,e,i,n){const o=n===\"reset\",{iScale:r,vScale:a,_stacked:l,_dataset:c}=this._cachedMeta,{sharedOptions:h,includeOptions:d}=this._getSharedOptions(e,n),u=r.axis,p=a.axis,{spanGaps:f,segment:b}=this.options,v=As(f)?f:Number.POSITIVE_INFINITY,y=this.chart._animationsDisabled||o||n===\"none\";let T=e>0&&this.getParsed(e-1);for(let x=e;x0&&Math.abs(C[u]-T[u])>v,b&&(A.parsed=C,A.raw=c.data[x]),d&&(A.options=h||this.resolveDataElementOptions(x,E.active?\"active\":n)),y||this.updateElement(E,x,A,n),T=C}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,n=t.data||[];if(!n.length)return i;const o=n[0].size(this.resolveDataElementOptions(0)),r=n[n.length-1].size(this.resolveDataElementOptions(n.length-1));return Math.max(i,o,r)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}}Wn.id=\"line\",Wn.defaults={datasetElementType:\"line\",dataElementType:\"point\",showLine:!0,spanGaps:!1},Wn.overrides={scales:{_index_:{type:\"category\"},_value_:{type:\"linear\"}}};class zn extends Ut{constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,n=i.data.labels||[],o=Bn(e._parsed[t].r,i.options.locale);return{label:n[t]||\"\",value:o}}parseObjectData(t,e,i,n){return Jf.bind(this)(t,e,i,n)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach((i,n)=>{const o=this.getParsed(n).r;!isNaN(o)&&this.chart.getDataVisibility(n)&&(oe.max&&(e.max=o))}),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,n=Math.min(e.right-e.left,e.bottom-e.top),o=Math.max(n/2,0),r=Math.max(i.cutoutPercentage?o/100*i.cutoutPercentage:1,0),a=(o-r)/t.getVisibleDatasetCount();this.outerRadius=o-a*this.index,this.innerRadius=this.outerRadius-a}updateElements(t,e,i,n){const o=n===\"reset\",r=this.chart,l=r.options.animation,c=this._cachedMeta.rScale,h=c.xCenter,d=c.yCenter,u=c.getIndexAngle(0)-.5*it;let p=u,f;const b=360/this.countVisibleElements();for(f=0;f{!isNaN(this.getParsed(n).r)&&this.chart.getDataVisibility(n)&&e++}),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?se(this.resolveDataElementOptions(t,e).angle||i):0}}zn.id=\"polarArea\",zn.defaults={dataElementType:\"arc\",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:\"number\",properties:[\"x\",\"y\",\"startAngle\",\"endAngle\",\"innerRadius\",\"outerRadius\"]}},indexAxis:\"r\",startAngle:0},zn.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(s){const t=s.data;if(t.labels.length&&t.datasets.length){const{labels:{pointStyle:e}}=s.legend.options;return t.labels.map((i,n)=>{const r=s.getDatasetMeta(0).controller.getStyle(n);return{text:i,fillStyle:r.backgroundColor,strokeStyle:r.borderColor,lineWidth:r.borderWidth,pointStyle:e,hidden:!s.getDataVisibility(n),index:n}})}return[]}},onClick(s,t,e){e.chart.toggleDataVisibility(t.index),e.chart.update()}},tooltip:{callbacks:{title(){return\"\"},label(s){return s.chart.data.labels[s.dataIndex]+\": \"+s.formattedValue}}}},scales:{r:{type:\"radialLinear\",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};class ea extends Bi{}ea.id=\"pie\",ea.defaults={cutout:0,rotation:0,circumference:360,radius:\"100%\"};class jn extends Ut{getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:\"\"+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,n){return Jf.bind(this)(t,e,i,n)}update(t){const e=this._cachedMeta,i=e.dataset,n=e.data||[],o=e.iScale.getLabels();if(i.points=n,t!==\"resize\"){const r=this.resolveDatasetElementOptions(t);this.options.showLine||(r.borderWidth=0);const a={_loop:!0,_fullLoop:o.length===n.length,options:r};this.updateElement(i,void 0,a,t)}this.updateElements(n,0,n.length,t)}updateElements(t,e,i,n){const o=this._cachedMeta.rScale,r=n===\"reset\";for(let a=e;a{n[o]=i[o]&&i[o].active()?i[o]._to:this[o]}),n}};Xt.defaults={},Xt.defaultRoutes=void 0;const w_={values(s){return Q(s)?s:\"\"+s},numeric(s,t,e){if(s===0)return\"0\";const i=this.chart.options.locale;let n,o=s;if(e.length>1){const c=Math.max(Math.abs(e[0].value),Math.abs(e[e.length-1].value));(c<1e-4||c>1e15)&&(n=\"scientific\"),o=kD(s,e)}const r=Yt(Math.abs(o)),a=Math.max(Math.min(-1*Math.floor(r),20),0),l={notation:n,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(l,this.options.ticks.format),Bn(s,i,l)},logarithmic(s,t,e){if(s===0)return\"0\";const i=s/Math.pow(10,Math.floor(Yt(s)));return i===1||i===2||i===5?w_.numeric.call(this,s,t,e):\"\"}};function kD(s,t){let e=t.length>3?t[2].value-t[1].value:t[1].value-t[0].value;return Math.abs(e)>=1&&s!==Math.floor(s)&&(e=s-Math.floor(s)),e}var Yn={formatters:w_};F.set(\"scale\",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:\"ticks\",grace:0,grid:{display:!0,lineWidth:1,drawBorder:!0,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(s,t)=>t.lineWidth,tickColor:(s,t)=>t.color,offset:!1,borderDash:[],borderDashOffset:0,borderWidth:1},title:{display:!1,text:\"\",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:\"\",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:Yn.formatters.values,minor:{},major:{},align:\"center\",crossAlign:\"near\",showLabelBackdrop:!1,backdropColor:\"rgba(255, 255, 255, 0.75)\",backdropPadding:2}}),F.route(\"scale.ticks\",\"color\",\"\",\"color\"),F.route(\"scale.grid\",\"color\",\"\",\"borderColor\"),F.route(\"scale.grid\",\"borderColor\",\"\",\"borderColor\"),F.route(\"scale.title\",\"color\",\"\",\"color\"),F.describe(\"scale\",{_fallback:!1,_scriptable:s=>!s.startsWith(\"before\")&&!s.startsWith(\"after\")&&s!==\"callback\"&&s!==\"parser\",_indexable:s=>s!==\"borderDash\"&&s!==\"tickBorderDash\"}),F.describe(\"scales\",{_fallback:\"scale\"}),F.describe(\"scale.ticks\",{_scriptable:s=>s!==\"backdropPadding\"&&s!==\"callback\",_indexable:s=>s!==\"backdropPadding\"});function SD(s,t){const e=s.options.ticks,i=e.maxTicksLimit||OD(s),n=e.major.enabled?DD(t):[],o=n.length,r=n[0],a=n[o-1],l=[];if(o>i)return MD(t,l,n,o/i),l;const c=ID(n,t,i);if(o>0){let h,d;const u=o>1?Math.round((a-r)/(o-1)):null;for(ia(t,l,c,H(u)?0:r-u,r),h=0,d=o-1;hn)return l}return Math.max(n,1)}function DD(s){const t=[];let e,i;for(e=0,i=s.length;es===\"left\"?\"right\":s===\"right\"?\"left\":s,k_=(s,t,e)=>t===\"top\"||t===\"left\"?s[t]+e:s[t]-e;function S_(s,t){const e=[],i=s.length/t,n=s.length;let o=0;for(;or+a)))return l}function PD(s,t){U(s,e=>{const i=e.gc,n=i.length/2;let o;if(n>t){for(o=0;oi?i:e,i=n&&e>i?e:i,{min:zt(e,zt(i,e)),max:zt(i,zt(e,i))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){G(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:n,grace:o,ticks:r}=this.options,a=r.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=vI(this,o,n),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const l=a=o||i<=1||!this.isHorizontal()){this.labelRotation=n;return}const h=this._getLabelSizes(),d=h.widest.width,u=h.highest.height,p=dt(this.chart.width-d,0,this.maxWidth);a=t.offset?this.maxWidth/i:p/(i-1),d+6>a&&(a=p/(i-(t.offset?.5:1)),l=this.maxHeight-Kn(t.grid)-e.padding-O_(t.title,this.chart.options.font),c=Math.sqrt(d*d+u*u),r=mc(Math.min(Math.asin(dt((h.highest.height+6)/a,-1,1)),Math.asin(dt(l/c,-1,1))-Math.asin(dt(u/c,-1,1)))),r=Math.max(n,Math.min(o,r))),this.labelRotation=r}afterCalculateLabelRotation(){G(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){G(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:n,grid:o}}=this,r=this._isVisible(),a=this.isHorizontal();if(r){const l=O_(n,e.options.font);if(a?(t.width=this.maxWidth,t.height=Kn(o)+l):(t.height=this.maxHeight,t.width=Kn(o)+l),i.display&&this.ticks.length){const{first:c,last:h,widest:d,highest:u}=this._getLabelSizes(),p=i.padding*2,f=se(this.labelRotation),b=Math.cos(f),v=Math.sin(f);if(a){const y=i.mirror?0:v*d.width+b*u.height;t.height=Math.min(this.maxHeight,t.height+y+p)}else{const y=i.mirror?0:b*d.width+v*u.height;t.width=Math.min(this.maxWidth,t.width+y+p)}this._calculatePadding(c,h,v,b)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,n){const{ticks:{align:o,padding:r},position:a}=this.options,l=this.labelRotation!==0,c=a!==\"top\"&&this.axis===\"x\";if(this.isHorizontal()){const h=this.getPixelForTick(0)-this.left,d=this.right-this.getPixelForTick(this.ticks.length-1);let u=0,p=0;l?c?(u=n*t.width,p=i*e.height):(u=i*t.height,p=n*e.width):o===\"start\"?p=e.width:o===\"end\"?u=t.width:o!==\"inner\"&&(u=t.width/2,p=e.width/2),this.paddingLeft=Math.max((u-h+r)*this.width/(this.width-h),0),this.paddingRight=Math.max((p-d+r)*this.width/(this.width-d),0)}else{let h=e.height/2,d=t.height/2;o===\"start\"?(h=0,d=t.height):o===\"end\"&&(h=e.height,d=0),this.paddingTop=h+r,this.paddingBottom=d+r}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){G(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return e===\"top\"||e===\"bottom\"||t===\"x\"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){this.beforeTickToLabelConversion(),this.generateTickLabels(t);let e,i;for(e=0,i=t.length;e({width:o[w]||0,height:r[w]||0});return{first:A(0),last:A(e-1),widest:A(E),highest:A(C),widths:o,heights:r}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return $O(this._alignToPixels?Mi(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*n?a/i:l/n:l*n0}_computeGridLineItems(t){const e=this.axis,i=this.chart,n=this.options,{grid:o,position:r}=n,a=o.offset,l=this.isHorizontal(),h=this.ticks.length+(a?1:0),d=Kn(o),u=[],p=o.setContext(this.getContext()),f=p.drawBorder?p.borderWidth:0,b=f/2,v=function(R){return Mi(i,R,f)};let y,T,x,E,C,A,w,S,k,D,I,M;if(r===\"top\")y=v(this.bottom),A=this.bottom-d,S=y-b,D=v(t.top)+b,M=t.bottom;else if(r===\"bottom\")y=v(this.top),D=t.top,M=v(t.bottom)-b,A=y+b,S=this.top+d;else if(r===\"left\")y=v(this.right),C=this.right-d,w=y-b,k=v(t.left)+b,I=t.right;else if(r===\"right\")y=v(this.left),k=t.left,I=v(t.right)-b,C=y+b,w=this.left+d;else if(e===\"x\"){if(r===\"center\")y=v((t.top+t.bottom)/2+.5);else if(V(r)){const R=Object.keys(r)[0],z=r[R];y=v(this.chart.scales[R].getPixelForValue(z))}D=t.top,M=t.bottom,A=y+b,S=A+d}else if(e===\"y\"){if(r===\"center\")y=v((t.left+t.right)/2);else if(V(r)){const R=Object.keys(r)[0],z=r[R];y=v(this.chart.scales[R].getPixelForValue(z))}C=y-b,w=C-d,k=t.left,I=t.right}const P=B(n.ticks.maxTicksLimit,h),X=Math.max(1,Math.ceil(h/P));for(T=0;To.value===t);return n>=0?e.setContext(this.getContext(n)).lineWidth:0}drawGrid(t){const e=this.options.grid,i=this.ctx,n=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let o,r;const a=(l,c,h)=>{!h.width||!h.color||(i.save(),i.lineWidth=h.width,i.strokeStyle=h.color,i.setLineDash(h.borderDash||[]),i.lineDashOffset=h.borderDashOffset,i.beginPath(),i.moveTo(l.x,l.y),i.lineTo(c.x,c.y),i.stroke(),i.restore())};if(e.display)for(o=0,r=n.length;o{this.draw(n)}}]:[{z:i,draw:n=>{this.drawBackground(),this.drawGrid(n),this.drawTitle()}},{z:i+1,draw:()=>{this.drawBorder()}},{z:e,draw:n=>{this.drawLabels(n)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+\"AxisID\",n=[];let o,r;for(o=0,r=e.length;o{const i=e.split(\".\"),n=i.pop(),o=[s].concat(i).join(\".\"),r=t[e].split(\".\"),a=r.pop(),l=r.join(\".\");F.route(o,n,l,a)})}function zD(s){return\"id\"in s&&\"defaults\"in s}class jD{constructor(){this.controllers=new sa(Ut,\"datasets\",!0),this.elements=new sa(Xt,\"elements\"),this.plugins=new sa(Object,\"plugins\"),this.scales=new sa(oi,\"scales\"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each(\"register\",t)}remove(...t){this._each(\"unregister\",t)}addControllers(...t){this._each(\"register\",t,this.controllers)}addElements(...t){this._each(\"register\",t,this.elements)}addPlugins(...t){this._each(\"register\",t,this.plugins)}addScales(...t){this._each(\"register\",t,this.scales)}getController(t){return this._get(t,this.controllers,\"controller\")}getElement(t){return this._get(t,this.elements,\"element\")}getPlugin(t){return this._get(t,this.plugins,\"plugin\")}getScale(t){return this._get(t,this.scales,\"scale\")}removeControllers(...t){this._each(\"unregister\",t,this.controllers)}removeElements(...t){this._each(\"unregister\",t,this.elements)}removePlugins(...t){this._each(\"unregister\",t,this.plugins)}removeScales(...t){this._each(\"unregister\",t,this.scales)}_each(t,e,i){[...e].forEach(n=>{const o=i||this._getRegistryForType(n);i||o.isForType(n)||o===this.plugins&&n.id?this._exec(t,o,n):U(n,r=>{const a=i||this._getRegistryForType(r);this._exec(t,a,r)})})}_exec(t,e,i){const n=gc(t);G(i[\"before\"+n],[],i),e[t](i),G(i[\"after\"+n],[],i)}_getRegistryForType(t){for(let e=0;e0&&this.getParsed(e-1);for(let E=e;E0&&Math.abs(A[p]-x[p])>y,v&&(w.parsed=A,w.raw=c.data[E]),u&&(w.options=d||this.resolveDataElementOptions(E,C.active?\"active\":n)),T||this.updateElement(C,E,w,n),x=A}this.updateSharedOptions(d,n,h)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let a=0;for(let l=e.length-1;l>=0;--l)a=Math.max(a,e[l].size(this.resolveDataElementOptions(l))/2);return a>0&&a}const i=t.dataset,n=i.options&&i.options.borderWidth||0;if(!e.length)return n;const o=e[0].size(this.resolveDataElementOptions(0)),r=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(n,o,r)/2}}Un.id=\"scatter\",Un.defaults={datasetElementType:!1,dataElementType:\"point\",showLine:!1,fill:!1},Un.overrides={interaction:{mode:\"point\"},plugins:{tooltip:{callbacks:{title(){return\"\"},label(s){return\"(\"+s.label+\", \"+s.formattedValue+\")\"}}}},scales:{x:{type:\"linear\"},y:{type:\"linear\"}}};var I_=Object.freeze({__proto__:null,BarController:Vn,BubbleController:Fn,DoughnutController:Bi,LineController:Wn,PolarAreaController:zn,PieController:ea,RadarController:jn,ScatterController:Un});function Hi(){throw new Error(\"This method is not implemented: Check that a complete date adapter is provided.\")}class Vc{constructor(t){this.options=t||{}}init(t){}formats(){return Hi()}parse(t,e){return Hi()}format(t,e){return Hi()}add(t,e,i){return Hi()}diff(t,e,i){return Hi()}startOf(t,e,i){return Hi()}endOf(t,e){return Hi()}}Vc.override=function(s){Object.assign(Vc.prototype,s)};var D_={_date:Vc};function YD(s,t,e,i){const{controller:n,data:o,_sorted:r}=s,a=n._cachedMeta.iScale;if(a&&t===a.axis&&t!==\"r\"&&r&&o.length){const l=a._reversePixels?RO:De;if(i){if(n._sharedOptions){const c=o[0],h=typeof c.getRange==\"function\"&&c.getRange(t);if(h){const d=l(o,t,e-h),u=l(o,t,e+h);return{lo:d.lo,hi:u.hi}}}}else return l(o,t,e)}return{lo:0,hi:o.length-1}}function Xn(s,t,e,i,n){const o=s.getSortedVisibleDatasetMetas(),r=e[t];for(let a=0,l=o.length;a{l[r](t[e],n)&&(o.push({element:l,datasetIndex:c,index:h}),a=a||l.inRange(t.x,t.y,n))}),i&&!a?[]:o}var L_={evaluateInteractionItems:Xn,modes:{index(s,t,e,i){const n=Pi(t,s),o=e.axis||\"x\",r=e.includeInvisible||!1,a=e.intersect?Fc(s,n,o,i,r):Wc(s,n,o,!1,i,r),l=[];return a.length?(s.getSortedVisibleDatasetMetas().forEach(c=>{const h=a[0].index,d=c.data[h];d&&!d.skip&&l.push({element:d,datasetIndex:c.index,index:h})}),l):[]},dataset(s,t,e,i){const n=Pi(t,s),o=e.axis||\"xy\",r=e.includeInvisible||!1;let a=e.intersect?Fc(s,n,o,i,r):Wc(s,n,o,!1,i,r);if(a.length>0){const l=a[0].datasetIndex,c=s.getDatasetMeta(l).data;a=[];for(let h=0;he.pos===t)}function R_(s,t){return s.filter(e=>$_.indexOf(e.pos)===-1&&e.box.axis===t)}function qn(s,t){return s.sort((e,i)=>{const n=t?i:e,o=t?e:i;return n.weight===o.weight?n.index-o.index:n.weight-o.weight})}function GD(s){const t=[];let e,i,n,o,r,a;for(e=0,i=(s||[]).length;ec.box.fullSize),!0),i=qn(Gn(t,\"left\"),!0),n=qn(Gn(t,\"right\")),o=qn(Gn(t,\"top\"),!0),r=qn(Gn(t,\"bottom\")),a=R_(t,\"x\"),l=R_(t,\"y\");return{fullSize:e,leftAndTop:i.concat(o),rightAndBottom:n.concat(l).concat(r).concat(a),chartArea:Gn(t,\"chartArea\"),vertical:i.concat(n).concat(l),horizontal:o.concat(r).concat(a)}}function P_(s,t,e,i){return Math.max(s[e],t[e])+Math.max(s[i],t[i])}function N_(s,t){s.top=Math.max(s.top,t.top),s.left=Math.max(s.left,t.left),s.bottom=Math.max(s.bottom,t.bottom),s.right=Math.max(s.right,t.right)}function JD(s,t,e,i){const{pos:n,box:o}=e,r=s.maxPadding;if(!V(n)){e.size&&(s[n]-=e.size);const d=i[e.stack]||{size:0,count:1};d.size=Math.max(d.size,e.horizontal?o.height:o.width),e.size=d.size/d.count,s[n]+=e.size}o.getPadding&&N_(r,o.getPadding());const a=Math.max(0,t.outerWidth-P_(r,s,\"left\",\"right\")),l=Math.max(0,t.outerHeight-P_(r,s,\"top\",\"bottom\")),c=a!==s.w,h=l!==s.h;return s.w=a,s.h=l,e.horizontal?{same:c,other:h}:{same:h,other:c}}function t1(s){const t=s.maxPadding;function e(i){const n=Math.max(t[i]-s[i],0);return s[i]+=n,n}s.y+=e(\"top\"),s.x+=e(\"left\"),e(\"right\"),e(\"bottom\")}function e1(s,t){const e=t.maxPadding;function i(n){const o={left:0,top:0,right:0,bottom:0};return n.forEach(r=>{o[r]=Math.max(t[r],e[r])}),o}return i(s?[\"left\",\"right\"]:[\"top\",\"bottom\"])}function Zn(s,t,e,i){const n=[];let o,r,a,l,c,h;for(o=0,r=s.length,c=0;o{typeof b.beforeLayout==\"function\"&&b.beforeLayout()});const h=l.reduce((b,v)=>v.box.options&&v.box.options.display===!1?b:b+1,0)||1,d=Object.freeze({outerWidth:t,outerHeight:e,padding:n,availableWidth:o,availableHeight:r,vBoxMaxWidth:o/2/h,hBoxMaxHeight:r/2}),u=Object.assign({},n);N_(u,pt(i));const p=Object.assign({maxPadding:u,w:o,h:r,x:n.left,y:n.top},n),f=ZD(l.concat(c),d);Zn(a.fullSize,p,d,f),Zn(l,p,d,f),Zn(c,p,d,f)&&Zn(l,p,d,f),t1(p),B_(a.leftAndTop,p,d,f),p.x+=p.w,p.y+=p.h,B_(a.rightAndBottom,p,d,f),s.chartArea={left:p.left,top:p.top,right:p.left+p.w,bottom:p.top+p.h,height:p.h,width:p.w},U(a.chartArea,b=>{const v=b.box;Object.assign(v,s.chartArea),v.update(p.w,p.h,{left:0,top:0,right:0,bottom:0})})}};class zc{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,n){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,n?Math.floor(e/n):i)}}isAttached(t){return!0}updateConfig(t){}}class H_ extends zc{acquireContext(t){return t&&t.getContext&&t.getContext(\"2d\")||null}updateConfig(t){t.options.animation=!1}}const oa=\"$chartjs\",i1={touchstart:\"mousedown\",touchmove:\"mousemove\",touchend:\"mouseup\",pointerenter:\"mouseenter\",pointerdown:\"mousedown\",pointermove:\"mousemove\",pointerup:\"mouseup\",pointerleave:\"mouseout\",pointerout:\"mouseout\"},V_=s=>s===null||s===\"\";function s1(s,t){const e=s.style,i=s.getAttribute(\"height\"),n=s.getAttribute(\"width\");if(s[oa]={initial:{height:i,width:n,style:{display:e.display,height:e.height,width:e.width}}},e.display=e.display||\"block\",e.boxSizing=e.boxSizing||\"border-box\",V_(n)){const o=s_(s,\"width\");o!==void 0&&(s.width=o)}if(V_(i))if(s.style.height===\"\")s.height=s.width/(t||2);else{const o=s_(s,\"height\");o!==void 0&&(s.height=o)}return s}const F_=WI?{passive:!0}:!1;function n1(s,t,e){s.addEventListener(t,e,F_)}function o1(s,t,e){s.canvas.removeEventListener(t,e,F_)}function r1(s,t){const e=i1[s.type]||s.type,{x:i,y:n}=Pi(s,t);return{type:e,chart:t,native:s,x:i!==void 0?i:null,y:n!==void 0?n:null}}function ra(s,t){for(const e of s)if(e===t||e.contains(t))return!0}function a1(s,t,e){const i=s.canvas,n=new MutationObserver(o=>{let r=!1;for(const a of o)r=r||ra(a.addedNodes,i),r=r&&!ra(a.removedNodes,i);r&&e()});return n.observe(document,{childList:!0,subtree:!0}),n}function l1(s,t,e){const i=s.canvas,n=new MutationObserver(o=>{let r=!1;for(const a of o)r=r||ra(a.removedNodes,i),r=r&&!ra(a.addedNodes,i);r&&e()});return n.observe(document,{childList:!0,subtree:!0}),n}const Qn=new Map;let W_=0;function z_(){const s=window.devicePixelRatio;s!==W_&&(W_=s,Qn.forEach((t,e)=>{e.currentDevicePixelRatio!==s&&t()}))}function c1(s,t){Qn.size||window.addEventListener(\"resize\",z_),Qn.set(s,t)}function h1(s){Qn.delete(s),Qn.size||window.removeEventListener(\"resize\",z_)}function d1(s,t,e){const i=s.canvas,n=i&&$c(i);if(!n)return;const o=Of((a,l)=>{const c=n.clientWidth;e(a,l),c{const l=a[0],c=l.contentRect.width,h=l.contentRect.height;c===0&&h===0||o(c,h)});return r.observe(n),c1(s,o),r}function jc(s,t,e){e&&e.disconnect(),t===\"resize\"&&h1(s)}function u1(s,t,e){const i=s.canvas,n=Of(o=>{s.ctx!==null&&e(r1(o,s))},s,o=>{const r=o[0];return[r,r.offsetX,r.offsetY]});return n1(i,t,n),n}class j_ extends zc{acquireContext(t,e){const i=t&&t.getContext&&t.getContext(\"2d\");return i&&i.canvas===t?(s1(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[oa])return!1;const i=e[oa].initial;[\"height\",\"width\"].forEach(o=>{const r=i[o];H(r)?e.removeAttribute(o):e.setAttribute(o,r)});const n=i.style||{};return Object.keys(n).forEach(o=>{e.style[o]=n[o]}),e.width=e.width,delete e[oa],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const n=t.$proxies||(t.$proxies={}),r={attach:a1,detach:l1,resize:d1}[e]||u1;n[e]=r(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),n=i[e];if(!n)return;({attach:jc,detach:jc,resize:jc}[e]||o1)(t,e,n),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,n){return FI(t,e,i,n)}isAttached(t){const e=$c(t);return!!(e&&e.isConnected)}}function Y_(s){return!e_()||typeof OffscreenCanvas<\"u\"&&s instanceof OffscreenCanvas?H_:j_}class p1{constructor(){this._init=[]}notify(t,e,i,n){e===\"beforeInit\"&&(this._init=this._createDescriptors(t,!0),this._notify(this._init,t,\"install\"));const o=n?this._descriptors(t).filter(n):this._descriptors(t),r=this._notify(o,t,e,i);return e===\"afterDestroy\"&&(this._notify(o,t,\"stop\"),this._notify(this._init,t,\"uninstall\")),r}_notify(t,e,i,n){n=n||{};for(const o of t){const r=o.plugin,a=r[i],l=[e,n,o.options];if(G(a,l,r)===!1&&n.cancelable)return!1}return!0}invalidate(){H(this._cache)||(this._oldCache=this._cache,this._cache=void 0)}_descriptors(t){if(this._cache)return this._cache;const e=this._cache=this._createDescriptors(t);return this._notifyStateChanges(t),e}_createDescriptors(t,e){const i=t&&t.config,n=B(i.options&&i.options.plugins,{}),o=f1(i);return n===!1&&!e?[]:g1(t,o,n,e)}_notifyStateChanges(t){const e=this._oldCache||[],i=this._cache,n=(o,r)=>o.filter(a=>!r.some(l=>a.plugin.id===l.plugin.id));this._notify(n(e,i),t,\"stop\"),this._notify(n(i,e),t,\"start\")}}function f1(s){const t={},e=[],i=Object.keys(ne.plugins.items);for(let o=0;o{const l=i[a];if(!V(l))return console.error(`Invalid scale configuration for scale: ${a}`);if(l._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${a}`);const c=Kc(a,l),h=v1(c,n),d=e.scales||{};o[c]=o[c]||a,r[a]=Sn(Object.create(null),[{axis:c},l,d[c],d[h]])}),s.data.datasets.forEach(a=>{const l=a.type||s.type,c=a.indexAxis||Yc(l,t),d=(Di[l]||{}).scales||{};Object.keys(d).forEach(u=>{const p=b1(u,c),f=a[p+\"AxisID\"]||o[p]||p;r[f]=r[f]||Object.create(null),Sn(r[f],[{axis:p},i[f],d[u]])})}),Object.keys(r).forEach(a=>{const l=r[a];Sn(l,[F.scales[l.type],F.scale])}),r}function K_(s){const t=s.options||(s.options={});t.plugins=B(t.plugins,{}),t.scales=T1(s,t)}function U_(s){return s=s||{},s.datasets=s.datasets||[],s.labels=s.labels||[],s}function E1(s){return s=s||{},s.data=U_(s.data),K_(s),s}const X_=new Map,G_=new Set;function aa(s,t){let e=X_.get(s);return e||(e=t(),X_.set(s,e),G_.add(e)),e}const Jn=(s,t,e)=>{const i=ti(t,e);i!==void 0&&s.add(i)};class x1{constructor(t){this._config=E1(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=U_(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),K_(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return aa(t,()=>[[`datasets.${t}`,\"\"]])}datasetAnimationScopeKeys(t,e){return aa(`${t}.transition.${e}`,()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,\"\"]])}datasetElementScopeKeys(t,e){return aa(`${t}-${e}`,()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,\"\"]])}pluginScopeKeys(t){const e=t.id,i=this.type;return aa(`${i}-plugin-${e}`,()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]])}_cachedScopes(t,e){const i=this._scopeCache;let n=i.get(t);return(!n||e)&&(n=new Map,i.set(t,n)),n}getOptionScopes(t,e,i){const{options:n,type:o}=this,r=this._cachedScopes(t,i),a=r.get(e);if(a)return a;const l=new Set;e.forEach(h=>{t&&(l.add(t),h.forEach(d=>Jn(l,t,d))),h.forEach(d=>Jn(l,n,d)),h.forEach(d=>Jn(l,Di[o]||{},d)),h.forEach(d=>Jn(l,F,d)),h.forEach(d=>Jn(l,kc,d))});const c=Array.from(l);return c.length===0&&c.push(Object.create(null)),G_.has(e)&&r.set(e,c),c}chartOptionScopes(){const{options:t,type:e}=this;return[t,Di[e]||{},F.datasets[e]||{},{type:e},F,kc]}resolveNamedOptions(t,e,i,n=[\"\"]){const o={$shared:!0},{resolver:r,subPrefixes:a}=q_(this._resolverCache,t,n);let l=r;if(A1(r,e)){o.$shared=!1,i=ei(i)?i():i;const c=this.createResolver(t,i,a);l=ks(r,i,c)}for(const c of e)o[c]=l[c];return o}createResolver(t,e,i=[\"\"],n){const{resolver:o}=q_(this._resolverCache,t,i);return V(e)?ks(o,e,void 0,n):o}}function q_(s,t,e){let i=s.get(t);i||(i=new Map,s.set(t,i));const n=e.join();let o=i.get(n);return o||(o={resolver:Dc(t,e),subPrefixes:e.filter(a=>!a.toLowerCase().includes(\"hover\"))},i.set(n,o)),o}const C1=s=>V(s)&&Object.getOwnPropertyNames(s).reduce((t,e)=>t||ei(s[e]),!1);function A1(s,t){const{isScriptable:e,isIndexable:i}=Uf(s);for(const n of t){const o=e(n),r=i(n),a=(r||o)&&s[n];if(o&&(ei(a)||C1(a))||r&&Q(a))return!0}return!1}var w1=\"3.9.1\";const k1=[\"top\",\"bottom\",\"left\",\"right\",\"chartArea\"];function Z_(s,t){return s===\"top\"||s===\"bottom\"||k1.indexOf(s)===-1&&t===\"x\"}function Q_(s,t){return function(e,i){return e[s]===i[s]?e[t]-i[t]:e[s]-i[s]}}function J_(s){const t=s.chart,e=t.options.animation;t.notifyPlugins(\"afterRender\"),G(e&&e.onComplete,[s],t)}function S1(s){const t=s.chart,e=t.options.animation;G(e&&e.onProgress,[s],t)}function tg(s){return e_()&&typeof s==\"string\"?s=document.getElementById(s):s&&s.length&&(s=s[0]),s&&s.canvas&&(s=s.canvas),s}const la={},eg=s=>{const t=tg(s);return Object.values(la).filter(e=>e.canvas===t).pop()};function O1(s,t,e){const i=Object.keys(s);for(const n of i){const o=+n;if(o>=t){const r=s[n];delete s[n],(e>0||o>t)&&(s[o+e]=r)}}}function I1(s,t,e,i){return!e||s.type===\"mouseout\"?null:i?t:s}class Uc{constructor(t,e){const i=this.config=new x1(e),n=tg(t),o=eg(n);if(o)throw new Error(\"Canvas is already in use. Chart with ID '\"+o.id+\"' must be destroyed before the canvas with ID '\"+o.canvas.id+\"' can be reused.\");const r=i.createResolver(i.chartOptionScopes(),this.getContext());this.platform=new(i.platform||Y_(n)),this.platform.updateConfig(i);const a=this.platform.acquireContext(n,r.aspectRatio),l=a&&a.canvas,c=l&&l.height,h=l&&l.width;if(this.id=EO(),this.ctx=a,this.canvas=l,this.width=h,this.height=c,this._options=r,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new p1,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=BO(d=>this.update(d),r.resizeDelay||0),this._dataChanges=[],la[this.id]=this,!a||!l){console.error(\"Failed to create chart: can't acquire context from the given item\");return}xe.listen(this,\"complete\",J_),xe.listen(this,\"progress\",S1),this._initialize(),this.attached&&this.update()}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return H(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}_initialize(){return this.notifyPlugins(\"beforeInit\"),this.options.responsive?this.resize():i_(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins(\"afterInit\"),this}clear(){return jf(this.canvas,this.ctx),this}stop(){return xe.stop(this),this}resize(t,e){xe.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,n=this.canvas,o=i.maintainAspectRatio&&this.aspectRatio,r=this.platform.getMaximumSize(n,t,e,o),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),l=this.width?\"resize\":\"attach\";this.width=r.width,this.height=r.height,this._aspectRatio=this.aspectRatio,i_(this,a,!0)&&(this.notifyPlugins(\"resize\",{size:r}),G(i.onResize,[this,r],this),this.attached&&this._doResize(l)&&this.render())}ensureScalesHaveIDs(){const e=this.options.scales||{};U(e,(i,n)=>{i.id=n})}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,n=Object.keys(i).reduce((r,a)=>(r[a]=!1,r),{});let o=[];e&&(o=o.concat(Object.keys(e).map(r=>{const a=e[r],l=Kc(r,a),c=l===\"r\",h=l===\"x\";return{options:a,dposition:c?\"chartArea\":h?\"bottom\":\"left\",dtype:c?\"radialLinear\":h?\"category\":\"linear\"}}))),U(o,r=>{const a=r.options,l=a.id,c=Kc(l,a),h=B(a.type,r.dtype);(a.position===void 0||Z_(a.position,c)!==Z_(r.dposition))&&(a.position=r.dposition),n[l]=!0;let d=null;if(l in i&&i[l].type===h)d=i[l];else{const u=ne.getScale(h);d=new u({id:l,type:h,ctx:this.ctx,chart:this}),i[d.id]=d}d.init(a,t)}),U(n,(r,a)=>{r||delete i[a]}),U(i,r=>{ft.configure(this,r,r.options),ft.addBox(this,r)})}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort((n,o)=>n.index-o.index),i>e){for(let n=e;ne.length&&delete this._stacks,t.forEach((i,n)=>{e.filter(o=>o===i._dataset).length===0&&this._destroyDatasetMeta(n)})}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,n;for(this._removeUnreferencedMetasets(),i=0,n=e.length;i{this.getDatasetMeta(e).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins(\"reset\")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),n=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins(\"beforeUpdate\",{mode:t,cancelable:!0})===!1)return;const o=this.buildOrUpdateControllers();this.notifyPlugins(\"beforeElementsUpdate\");let r=0;for(let c=0,h=this.data.datasets.length;c{c.reset()}),this._updateDatasets(t),this.notifyPlugins(\"afterUpdate\",{mode:t}),this._layers.sort(Q_(\"z\",\"_idx\"));const{_active:a,_lastEvent:l}=this;l?this._eventHandler(l,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){U(this.scales,t=>{ft.removeBox(this,t)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);(!vf(e,i)||!!this._responsiveListeners!==t.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:n,count:o}of e){const r=i===\"_removeElements\"?-o:o;O1(t,n,r)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=o=>new Set(t.filter(r=>r[0]===o).map((r,a)=>a+\",\"+r.splice(1).join(\",\"))),n=i(0);for(let o=1;oo.split(\",\")).map(o=>({method:o[1],start:+o[2],count:+o[3]}))}_updateLayout(t){if(this.notifyPlugins(\"beforeLayout\",{cancelable:!0})===!1)return;ft.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],U(this.boxes,n=>{i&&n.position===\"chartArea\"||(n.configure&&n.configure(),this._layers.push(...n._layers()))},this),this._layers.forEach((n,o)=>{n._idx=o}),this.notifyPlugins(\"afterLayout\")}_updateDatasets(t){if(this.notifyPlugins(\"beforeDatasetsUpdate\",{mode:t,cancelable:!0})!==!1){for(let e=0,i=this.data.datasets.length;e=0;--e)this._drawDataset(t[e]);this.notifyPlugins(\"afterDatasetsDraw\")}_drawDataset(t){const e=this.ctx,i=t._clip,n=!i.disabled,o=this.chartArea,r={meta:t,index:t.index,cancelable:!0};this.notifyPlugins(\"beforeDatasetDraw\",r)!==!1&&(n&&qr(e,{left:i.left===!1?0:o.left-i.left,right:i.right===!1?this.width:o.right+i.right,top:i.top===!1?0:o.top-i.top,bottom:i.bottom===!1?this.height:o.bottom+i.bottom}),t.controller.draw(),n&&Zr(e),r.cancelable=!1,this.notifyPlugins(\"afterDatasetDraw\",r))}isPointInArea(t){return Pn(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,n){const o=L_.modes[e];return typeof o==\"function\"?o(this,t,i,n):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let n=i.filter(o=>o&&o._dataset===e).pop();return n||(n={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(n)),n}getContext(){return this.$context||(this.$context=ni(null,{chart:this,type:\"chart\"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return typeof i.hidden==\"boolean\"?!i.hidden:!e.hidden}setDatasetVisibility(t,e){const i=this.getDatasetMeta(t);i.hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const n=i?\"show\":\"hide\",o=this.getDatasetMeta(t),r=o.controller._resolveAnimations(void 0,n);jt(e)?(o.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),r.update(o,{visible:i}),this.update(a=>a.datasetIndex===t?n:void 0))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xe.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,o,r),t[o]=r},n=(o,r,a)=>{o.offsetX=r,o.offsetY=a,this._eventHandler(o)};U(this.options.events,o=>i(o,n))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(l,c)=>{e.addEventListener(this,l,c),t[l]=c},n=(l,c)=>{t[l]&&(e.removeEventListener(this,l,c),delete t[l])},o=(l,c)=>{this.canvas&&this.resize(l,c)};let r;const a=()=>{n(\"attach\",a),this.attached=!0,this.resize(),i(\"resize\",o),i(\"detach\",r)};r=()=>{this.attached=!1,n(\"resize\",o),this._stop(),this._resize(0,0),i(\"attach\",a)},e.isAttached(this.canvas)?a():r()}unbindEvents(){U(this._listeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._listeners={},U(this._responsiveListeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const n=i?\"set\":\"remove\";let o,r,a,l;for(e===\"dataset\"&&(o=this.getDatasetMeta(t[0].datasetIndex),o.controller[\"_\"+n+\"DatasetHoverStyle\"]()),a=0,l=t.length;a{const a=this.getDatasetMeta(o);if(!a)throw new Error(\"No dataset found at index \"+o);return{datasetIndex:o,element:a.data[r],index:r}});!Fr(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}_updateHoverStyles(t,e,i){const n=this.options.hover,o=(l,c)=>l.filter(h=>!c.some(d=>h.datasetIndex===d.datasetIndex&&h.index===d.index)),r=o(e,t),a=i?t:o(t,e);r.length&&this.updateHoverStyle(r,n.mode,!1),a.length&&n.mode&&this.updateHoverStyle(a,n.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},n=r=>(r.options.events||this.options.events).includes(t.native.type);if(this.notifyPlugins(\"beforeEvent\",i,n)===!1)return;const o=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins(\"afterEvent\",i,n),(o||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:n=[],options:o}=this,r=e,a=this._getActiveElements(t,n,i,r),l=SO(t),c=I1(t,this._lastEvent,i,l);i&&(this._lastEvent=null,G(o.onHover,[t,a,this],this),l&&G(o.onClick,[t,a,this],this));const h=!Fr(a,n);return(h||e)&&(this._active=a,this._updateHoverStyles(a,n,e)),this._lastEvent=c,h}_getActiveElements(t,e,i,n){if(t.type===\"mouseout\")return[];if(!i)return e;const o=this.options.hover;return this.getElementsAtEventForMode(t,o.mode,o,n)}}const ig=()=>U(Uc.instances,s=>s._plugins.invalidate()),ri=!0;Object.defineProperties(Uc,{defaults:{enumerable:ri,value:F},instances:{enumerable:ri,value:la},overrides:{enumerable:ri,value:Di},registry:{enumerable:ri,value:ne},version:{enumerable:ri,value:w1},getChart:{enumerable:ri,value:eg},register:{enumerable:ri,value:(...s)=>{ne.add(...s),ig()}},unregister:{enumerable:ri,value:(...s)=>{ne.remove(...s),ig()}}});function sg(s,t,e){const{startAngle:i,pixelMargin:n,x:o,y:r,outerRadius:a,innerRadius:l}=t;let c=n/a;s.beginPath(),s.arc(o,r,a,i-c,e+c),l>n?(c=n/l,s.arc(o,r,l,e+c,i-c,!0)):s.arc(o,r,n,e+nt,i-nt),s.closePath(),s.clip()}function D1(s){return Ic(s,[\"outerStart\",\"outerEnd\",\"innerStart\",\"innerEnd\"])}function M1(s,t,e,i){const n=D1(s.options.borderRadius),o=(e-t)/2,r=Math.min(o,i*t/2),a=l=>{const c=(e-Math.min(o,l))*i/2;return dt(l,0,Math.min(o,c))};return{outerStart:a(n.outerStart),outerEnd:a(n.outerEnd),innerStart:dt(n.innerStart,0,r),innerEnd:dt(n.innerEnd,0,r)}}function Is(s,t,e,i){return{x:e+s*Math.cos(t),y:i+s*Math.sin(t)}}function Xc(s,t,e,i,n,o){const{x:r,y:a,startAngle:l,pixelMargin:c,innerRadius:h}=t,d=Math.max(t.outerRadius+i+e-c,0),u=h>0?h+i+e+c:0;let p=0;const f=n-l;if(i){const R=h>0?h-i:0,z=d>0?d-i:0,Y=(R+z)/2,Gt=Y!==0?f*Y/(Y+i):f;p=(f-Gt)/2}const b=Math.max(.001,f*d-e/it)/d,v=(f-b)/2,y=l+v+p,T=n-v-p,{outerStart:x,outerEnd:E,innerStart:C,innerEnd:A}=M1(t,u,d,T-y),w=d-x,S=d-E,k=y+x/w,D=T-E/S,I=u+C,M=u+A,P=y+C/I,X=T-A/M;if(s.beginPath(),o){if(s.arc(r,a,d,k,D),E>0){const Y=Is(S,D,r,a);s.arc(Y.x,Y.y,E,D,T+nt)}const R=Is(M,T,r,a);if(s.lineTo(R.x,R.y),A>0){const Y=Is(M,X,r,a);s.arc(Y.x,Y.y,A,T+nt,X+Math.PI)}if(s.arc(r,a,u,T-A/u,y+C/u,!0),C>0){const Y=Is(I,P,r,a);s.arc(Y.x,Y.y,C,P+Math.PI,y-nt)}const z=Is(w,y,r,a);if(s.lineTo(z.x,z.y),x>0){const Y=Is(w,k,r,a);s.arc(Y.x,Y.y,x,y-nt,k)}}else{s.moveTo(r,a);const R=Math.cos(k)*d+r,z=Math.sin(k)*d+a;s.lineTo(R,z);const Y=Math.cos(D)*d+r,Gt=Math.sin(D)*d+a;s.lineTo(Y,Gt)}s.closePath()}function L1(s,t,e,i,n){const{fullCircles:o,startAngle:r,circumference:a}=t;let l=t.endAngle;if(o){Xc(s,t,e,i,r+q,n);for(let c=0;c=q||Dn(o,a,l),b=Ie(r,c+u,h+u);return f&&b}getCenterPoint(t){const{x:e,y:i,startAngle:n,endAngle:o,innerRadius:r,outerRadius:a}=this.getProps([\"x\",\"y\",\"startAngle\",\"endAngle\",\"innerRadius\",\"outerRadius\",\"circumference\"],t),{offset:l,spacing:c}=this.options,h=(n+o)/2,d=(r+a+c+l)/2;return{x:e+Math.cos(h)*d,y:i+Math.sin(h)*d}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,n=(e.offset||0)/2,o=(e.spacing||0)/2,r=e.circular;if(this.pixelMargin=e.borderAlign===\"inner\"?.33:0,this.fullCircles=i>q?Math.floor(i/q):0,i===0||this.innerRadius<0||this.outerRadius<0)return;t.save();let a=0;if(n){a=n/2;const c=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(c)*a,Math.sin(c)*a),this.circumference>=it&&(a=n)}t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor;const l=L1(t,this,a,o,r);R1(t,this,a,o,l,r),t.restore()}}Ds.id=\"arc\",Ds.defaults={borderAlign:\"center\",borderColor:\"#fff\",borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0},Ds.defaultRoutes={backgroundColor:\"backgroundColor\"};function ng(s,t,e=t){s.lineCap=B(e.borderCapStyle,t.borderCapStyle),s.setLineDash(B(e.borderDash,t.borderDash)),s.lineDashOffset=B(e.borderDashOffset,t.borderDashOffset),s.lineJoin=B(e.borderJoinStyle,t.borderJoinStyle),s.lineWidth=B(e.borderWidth,t.borderWidth),s.strokeStyle=B(e.borderColor,t.borderColor)}function P1(s,t,e){s.lineTo(e.x,e.y)}function N1(s){return s.stepped?dI:s.tension||s.cubicInterpolationMode===\"monotone\"?uI:P1}function og(s,t,e={}){const i=s.length,{start:n=0,end:o=i-1}=e,{start:r,end:a}=t,l=Math.max(n,r),c=Math.min(o,a),h=na&&o>a;return{count:i,start:l,loop:t.loop,ilen:c(r+(c?a-E:E))%o,x=()=>{b!==v&&(s.lineTo(h,v),s.lineTo(h,b),s.lineTo(h,y))};for(l&&(p=n[T(0)],s.moveTo(p.x,p.y)),u=0;u<=a;++u){if(p=n[T(u)],p.skip)continue;const E=p.x,C=p.y,A=E|0;A===f?(Cv&&(v=C),h=(d*h+E)/++d):(x(),s.lineTo(E,C),f=A,d=0,b=v=C),y=C}x()}function Gc(s){const t=s.options,e=t.borderDash&&t.borderDash.length;return!s._decimated&&!s._loop&&!t.tension&&t.cubicInterpolationMode!==\"monotone\"&&!t.stepped&&!e?H1:B1}function V1(s){return s.stepped?zI:s.tension||s.cubicInterpolationMode===\"monotone\"?jI:Ni}function F1(s,t,e,i){let n=t._path;n||(n=t._path=new Path2D,t.path(n,e,i)&&n.closePath()),ng(s,t.options),s.stroke(n)}function W1(s,t,e,i){const{segments:n,options:o}=t,r=Gc(t);for(const a of n)ng(s,o,a.style),s.beginPath(),r(s,t,a,{start:e,end:e+i-1})&&s.closePath(),s.stroke()}const z1=typeof Path2D==\"function\";function j1(s,t,e,i){z1&&!t.options.segment?F1(s,t,e,i):W1(s,t,e,i)}class Le extends Xt{constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||i.cubicInterpolationMode===\"monotone\")&&!i.stepped&&!this._pointsUpdated){const n=i.spanGaps?this._loop:this._fullLoop;RI(this._points,i,t,n,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=ZI(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,n=t[e],o=this.points,r=h_(this,{property:e,start:n,end:n});if(!r.length)return;const a=[],l=V1(i);let c,h;for(c=0,h=r.length;cs!==\"borderDash\"&&s!==\"fill\"};function rg(s,t,e,i){const n=s.options,{[e]:o}=s.getProps([e],i);return Math.abs(t-o)=e)return s.slice(t,t+e);const r=[],a=(e-2)/(o-2);let l=0;const c=t+e-1;let h=t,d,u,p,f,b;for(r[l++]=s[h],d=0;dp&&(p=f,u=s[T],b=T);r[l++]=u,h=b}return r[l++]=s[c],r}function Z1(s,t,e,i){let n=0,o=0,r,a,l,c,h,d,u,p,f,b;const v=[],y=t+e-1,T=s[t].x,E=s[y].x-T;for(r=t;rb&&(b=c,u=r),n=(o*n+a.x)/++o;else{const A=r-1;if(!H(d)&&!H(u)){const w=Math.min(d,u),S=Math.max(d,u);w!==p&&w!==A&&v.push({...s[w],x:n}),S!==p&&S!==A&&v.push({...s[S],x:n})}r>0&&A!==p&&v.push(s[A]),v.push(a),h=C,o=0,f=b=c,d=u=p=r}}return v}function cg(s){if(s._decimated){const t=s._data;delete s._decimated,delete s._data,Object.defineProperty(s,\"data\",{value:t})}}function hg(s){s.data.datasets.forEach(t=>{cg(t)})}function Q1(s,t){const e=t.length;let i=0,n;const{iScale:o}=s,{min:r,max:a,minDefined:l,maxDefined:c}=o.getUserBounds();return l&&(i=dt(De(t,o.axis,r).lo,0,e-1)),c?n=dt(De(t,o.axis,a).hi+1,i,e)-i:n=e-i,{start:i,count:n}}var dg={id:\"decimation\",defaults:{algorithm:\"min-max\",enabled:!1},beforeElementsUpdate:(s,t,e)=>{if(!e.enabled){hg(s);return}const i=s.width;s.data.datasets.forEach((n,o)=>{const{_data:r,indexAxis:a}=n,l=s.getDatasetMeta(o),c=r||n.data;if(tt([a,s.options.indexAxis])===\"y\"||!l.controller.supportsDecimation)return;const h=s.scales[l.xAxisID];if(h.type!==\"linear\"&&h.type!==\"time\"||s.options.parsing)return;let{start:d,count:u}=Q1(l,c);const p=e.threshold||4*i;if(u<=p){cg(n);return}H(r)&&(n._data=c,delete n.data,Object.defineProperty(n,\"data\",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(b){this._data=b}}));let f;switch(e.algorithm){case\"lttb\":f=q1(c,d,u,i,e);break;case\"min-max\":f=Z1(c,d,u,i);break;default:throw new Error(`Unsupported decimation algorithm '${e.algorithm}'`)}n._decimated=f})},destroy(s){hg(s)}};function J1(s,t,e){const i=s.segments,n=s.points,o=t.points,r=[];for(const a of i){let{start:l,end:c}=a;c=Jc(l,c,n);const h=Qc(e,n[l],n[c],a.loop);if(!t.segments){r.push({source:a,target:h,start:n[l],end:n[c]});continue}const d=h_(t,h);for(const u of d){const p=Qc(e,o[u.start],o[u.end],u.loop),f=c_(a,n,p);for(const b of f)r.push({source:b,target:u,start:{[e]:ug(h,p,\"start\",Math.max)},end:{[e]:ug(h,p,\"end\",Math.min)}})}}return r}function Qc(s,t,e,i){if(i)return;let n=t[s],o=e[s];return s===\"angle\"&&(n=Vt(n),o=Vt(o)),{property:s,start:n,end:o}}function tM(s,t){const{x:e=null,y:i=null}=s||{},n=t.points,o=[];return t.segments.forEach(({start:r,end:a})=>{a=Jc(r,a,n);const l=n[r],c=n[a];i!==null?(o.push({x:l.x,y:i}),o.push({x:c.x,y:i})):e!==null&&(o.push({x:e,y:l.y}),o.push({x:e,y:c.y}))}),o}function Jc(s,t,e){for(;t>s;t--){const i=e[t];if(!isNaN(i.x)&&!isNaN(i.y))break}return t}function ug(s,t,e,i){return s&&t?i(s[e],t[e]):s?s[e]:t?t[e]:0}function pg(s,t){let e=[],i=!1;return Q(s)?(i=!0,e=s):e=tM(s,t),e.length?new Le({points:e,options:{tension:0},_loop:i,_fullLoop:i}):null}function fg(s){return s&&s.fill!==!1}function eM(s,t,e){let n=s[t].fill;const o=[t];let r;if(!e)return n;for(;n!==!1&&o.indexOf(n)===-1;){if(!rt(n))return n;if(r=s[n],!r)return!1;if(r.visible)return n;o.push(n),n=r.fill}return!1}function iM(s,t,e){const i=rM(s);if(V(i))return isNaN(i.value)?!1:i;let n=parseFloat(i);return rt(n)&&Math.floor(n)===n?sM(i[0],t,n,e):[\"origin\",\"start\",\"end\",\"stack\",\"shape\"].indexOf(i)>=0&&i}function sM(s,t,e,i){return(s===\"-\"||s===\"+\")&&(e=t+e),e===t||e<0||e>=i?!1:e}function nM(s,t){let e=null;return s===\"start\"?e=t.bottom:s===\"end\"?e=t.top:V(s)?e=t.getPixelForValue(s.value):t.getBasePixel&&(e=t.getBasePixel()),e}function oM(s,t,e){let i;return s===\"start\"?i=e:s===\"end\"?i=t.options.reverse?t.min:t.max:V(s)?i=s.value:i=t.getBaseValue(),i}function rM(s){const t=s.options,e=t.fill;let i=B(e&&e.target,e);return i===void 0&&(i=!!t.backgroundColor),i===!1||i===null?!1:i===!0?\"origin\":i}function aM(s){const{scale:t,index:e,line:i}=s,n=[],o=i.segments,r=i.points,a=lM(t,e);a.push(pg({x:null,y:t.bottom},i));for(let l=0;l=0;--r){const a=n[r].$filler;a&&(a.line.updateControlPoints(o,a.axis),i&&a.fill&&th(s.ctx,a,o))}},beforeDatasetsDraw(s,t,e){if(e.drawTime!==\"beforeDatasetsDraw\")return;const i=s.getSortedVisibleDatasetMetas();for(let n=i.length-1;n>=0;--n){const o=i[n].$filler;fg(o)&&th(s.ctx,o,s.chartArea)}},beforeDatasetDraw(s,t,e){const i=t.meta.$filler;!fg(i)||e.drawTime!==\"beforeDatasetDraw\"||th(s.ctx,i,s.chartArea)},defaults:{propagate:!0,drawTime:\"beforeDatasetDraw\"}};const yg=(s,t)=>{let{boxHeight:e=t,boxWidth:i=t}=s;return s.usePointStyle&&(e=Math.min(e,t),i=s.pointStyleWidth||Math.min(i,t)),{boxWidth:i,boxHeight:e,itemHeight:Math.max(t,e)}},bM=(s,t)=>s!==null&&t!==null&&s.datasetIndex===t.datasetIndex&&s.index===t.index;class Tg extends Xt{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=G(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter(i=>t.filter(i,this.chart.data))),t.sort&&(e=e.sort((i,n)=>t.sort(i,n,this.chart.data))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display){this.width=this.height=0;return}const i=t.labels,n=lt(i.font),o=n.size,r=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=yg(i,o);let c,h;e.font=n.string,this.isHorizontal()?(c=this.maxWidth,h=this._fitRows(r,o,a,l)+10):(h=this.maxHeight,c=this._fitCols(r,o,a,l)+10),this.width=Math.min(c,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,n){const{ctx:o,maxWidth:r,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.lineWidths=[0],h=n+a;let d=t;o.textAlign=\"left\",o.textBaseline=\"middle\";let u=-1,p=-h;return this.legendItems.forEach((f,b)=>{const v=i+e/2+o.measureText(f.text).width;(b===0||c[c.length-1]+v+2*a>r)&&(d+=h,c[c.length-(b>0?0:1)]=0,p+=h,u++),l[b]={left:0,top:p,row:u,width:v,height:n},c[c.length-1]+=v+a}),d}_fitCols(t,e,i,n){const{ctx:o,maxHeight:r,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.columnSizes=[],h=r-t;let d=a,u=0,p=0,f=0,b=0;return this.legendItems.forEach((v,y)=>{const T=i+e/2+o.measureText(v.text).width;y>0&&p+n+2*a>h&&(d+=u+a,c.push({width:u,height:p}),f+=u+a,b++,u=p=0),l[y]={left:f,top:p,col:b,width:T,height:n},u=Math.max(u,T),p+=n+a}),d+=u,c.push({width:u,height:p}),d}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:n},rtl:o}}=this,r=Os(o,this.left,this.width);if(this.isHorizontal()){let a=0,l=gt(i,this.left+n,this.right-this.lineWidths[a]);for(const c of e)a!==c.row&&(a=c.row,l=gt(i,this.left+n,this.right-this.lineWidths[a])),c.top+=this.top+t+n,c.left=r.leftForLtr(r.x(l),c.width),l+=c.width+n}else{let a=0,l=gt(i,this.top+t+n,this.bottom-this.columnSizes[a].height);for(const c of e)c.col!==a&&(a=c.col,l=gt(i,this.top+t+n,this.bottom-this.columnSizes[a].height)),c.top=l,c.left+=this.left+n,c.left=r.leftForLtr(r.x(c.left),c.width),l+=c.height+n}}isHorizontal(){return this.options.position===\"top\"||this.options.position===\"bottom\"}draw(){if(this.options.display){const t=this.ctx;qr(t,this),this._draw(),Zr(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:n}=this,{align:o,labels:r}=t,a=F.color,l=Os(t.rtl,this.left,this.width),c=lt(r.font),{color:h,padding:d}=r,u=c.size,p=u/2;let f;this.drawTitle(),n.textAlign=l.textAlign(\"left\"),n.textBaseline=\"middle\",n.lineWidth=.5,n.font=c.string;const{boxWidth:b,boxHeight:v,itemHeight:y}=yg(r,u),T=function(w,S,k){if(isNaN(b)||b<=0||isNaN(v)||v<0)return;n.save();const D=B(k.lineWidth,1);if(n.fillStyle=B(k.fillStyle,a),n.lineCap=B(k.lineCap,\"butt\"),n.lineDashOffset=B(k.lineDashOffset,0),n.lineJoin=B(k.lineJoin,\"miter\"),n.lineWidth=D,n.strokeStyle=B(k.strokeStyle,a),n.setLineDash(B(k.lineDash,[])),r.usePointStyle){const I={radius:v*Math.SQRT2/2,pointStyle:k.pointStyle,rotation:k.rotation,borderWidth:D},M=l.xPlus(w,b/2),P=S+p;Yf(n,I,M,P,r.pointStyleWidth&&b)}else{const I=S+Math.max((u-v)/2,0),M=l.leftForLtr(w,b),P=$i(k.borderRadius);n.beginPath(),Object.values(P).some(X=>X!==0)?Nn(n,{x:M,y:I,w:b,h:v,radius:P}):n.rect(M,I,b,v),n.fill(),D!==0&&n.stroke()}n.restore()},x=function(w,S,k){Li(n,k.text,w,S+y/2,c,{strikethrough:k.hidden,textAlign:l.textAlign(k.textAlign)})},E=this.isHorizontal(),C=this._computeTitleHeight();E?f={x:gt(o,this.left+d,this.right-i[0]),y:this.top+d+C,line:0}:f={x:this.left+d,y:gt(o,this.top+C+d,this.bottom-e[0].height),line:0},o_(this.ctx,t.textDirection);const A=y+d;this.legendItems.forEach((w,S)=>{n.strokeStyle=w.fontColor||h,n.fillStyle=w.fontColor||h;const k=n.measureText(w.text).width,D=l.textAlign(w.textAlign||(w.textAlign=r.textAlign)),I=b+p+k;let M=f.x,P=f.y;l.setWidth(this.width),E?S>0&&M+I+d>this.right&&(P=f.y+=A,f.line++,M=f.x=gt(o,this.left+d,this.right-i[f.line])):S>0&&P+A>this.bottom&&(M=f.x=M+e[f.line].width+d,f.line++,P=f.y=gt(o,this.top+C+d,this.bottom-e[f.line].height));const X=l.x(M);T(X,P,w),M=HO(D,M+b+p,E?M+I:this.right,t.rtl),x(l.x(M),P,w),E?f.x+=I+d:f.y+=A}),r_(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=lt(e.font),n=pt(e.padding);if(!e.display)return;const o=Os(t.rtl,this.left,this.width),r=this.ctx,a=e.position,l=i.size/2,c=n.top+l;let h,d=this.left,u=this.width;if(this.isHorizontal())u=Math.max(...this.lineWidths),h=this.top+c,d=gt(t.align,d,this.right-u);else{const f=this.columnSizes.reduce((b,v)=>Math.max(b,v.height),0);h=c+gt(t.align,this.top,this.bottom-f-t.labels.padding-this._computeTitleHeight())}const p=gt(a,d,d+u);r.textAlign=o.textAlign(yc(a)),r.textBaseline=\"middle\",r.strokeStyle=e.color,r.fillStyle=e.color,r.font=i.string,Li(r,e.text,p,h,i)}_computeTitleHeight(){const t=this.options.title,e=lt(t.font),i=pt(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,n,o;if(Ie(t,this.left,this.right)&&Ie(e,this.top,this.bottom)){for(o=this.legendHitBoxes,i=0;is.chart.options.color,boxWidth:40,padding:10,generateLabels(s){const t=s.data.datasets,{labels:{usePointStyle:e,pointStyle:i,textAlign:n,color:o}}=s.legend.options;return s._getSortedDatasetMetas().map(r=>{const a=r.controller.getStyle(e?0:void 0),l=pt(a.borderWidth);return{text:t[r.index].label,fillStyle:a.backgroundColor,fontColor:o,hidden:!r.visible,lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:(l.width+l.height)/4,strokeStyle:a.borderColor,pointStyle:i||a.pointStyle,rotation:a.rotation,textAlign:n||a.textAlign,borderRadius:0,datasetIndex:r.index}},this)}},title:{color:s=>s.chart.options.color,display:!1,position:\"center\",text:\"\"}},descriptors:{_scriptable:s=>!s.startsWith(\"on\"),labels:{_scriptable:s=>![\"generateLabels\",\"filter\",\"sort\"].includes(s)}}};class eh extends Xt{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=t,this.height=this.bottom=e;const n=Q(i.text)?i.text.length:1;this._padding=pt(i.padding);const o=n*lt(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return t===\"top\"||t===\"bottom\"}_drawArgs(t){const{top:e,left:i,bottom:n,right:o,options:r}=this,a=r.align;let l=0,c,h,d;return this.isHorizontal()?(h=gt(a,i,o),d=e+t,c=o-i):(r.position===\"left\"?(h=i+t,d=gt(a,n,e),l=it*-.5):(h=o-t,d=gt(a,e,n),l=it*.5),c=n-e),{titleX:h,titleY:d,maxWidth:c,rotation:l}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=lt(e.font),o=i.lineHeight/2+this._padding.top,{titleX:r,titleY:a,maxWidth:l,rotation:c}=this._drawArgs(o);Li(t,e.text,0,0,i,{color:e.color,maxWidth:l,rotation:c,textAlign:yc(e.align),textBaseline:\"middle\",translation:[r,a]})}}function yM(s,t){const e=new eh({ctx:s.ctx,options:t,chart:s});ft.configure(s,e,t),ft.addBox(s,e),s.titleBlock=e}var xg={id:\"title\",_element:eh,start(s,t,e){yM(s,e)},stop(s){const t=s.titleBlock;ft.removeBox(s,t),delete s.titleBlock},beforeUpdate(s,t,e){const i=s.titleBlock;ft.configure(s,i,e),i.options=e},defaults:{align:\"center\",display:!1,font:{weight:\"bold\"},fullSize:!0,padding:10,position:\"top\",text:\"\",weight:2e3},defaultRoutes:{color:\"color\"},descriptors:{_scriptable:!0,_indexable:!1}};const ca=new WeakMap;var Cg={id:\"subtitle\",start(s,t,e){const i=new eh({ctx:s.ctx,options:e,chart:s});ft.configure(s,i,e),ft.addBox(s,i),ca.set(s,i)},stop(s){ft.removeBox(s,ca.get(s)),ca.delete(s)},beforeUpdate(s,t,e){const i=ca.get(s);ft.configure(s,i,e),i.options=e},defaults:{align:\"center\",display:!1,font:{weight:\"normal\"},fullSize:!0,padding:0,position:\"top\",text:\"\",weight:1500},defaultRoutes:{color:\"color\"},descriptors:{_scriptable:!0,_indexable:!1}};const to={average(s){if(!s.length)return!1;let t,e,i=0,n=0,o=0;for(t=0,e=s.length;t-1?s.split(`\n`):s}function TM(s,t){const{element:e,datasetIndex:i,index:n}=t,o=s.getDatasetMeta(i).controller,{label:r,value:a}=o.getLabelAndValue(n);return{chart:s,label:r,parsed:o.getParsed(n),raw:s.data.datasets[i].data[n],formattedValue:a,dataset:o.getDataset(),dataIndex:n,datasetIndex:i,element:e}}function Ag(s,t){const e=s.chart.ctx,{body:i,footer:n,title:o}=s,{boxWidth:r,boxHeight:a}=t,l=lt(t.bodyFont),c=lt(t.titleFont),h=lt(t.footerFont),d=o.length,u=n.length,p=i.length,f=pt(t.padding);let b=f.height,v=0,y=i.reduce((E,C)=>E+C.before.length+C.lines.length+C.after.length,0);if(y+=s.beforeBody.length+s.afterBody.length,d&&(b+=d*c.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),y){const E=t.displayColors?Math.max(a,l.lineHeight):l.lineHeight;b+=p*E+(y-p)*l.lineHeight+(y-1)*t.bodySpacing}u&&(b+=t.footerMarginTop+u*h.lineHeight+(u-1)*t.footerSpacing);let T=0;const x=function(E){v=Math.max(v,e.measureText(E).width+T)};return e.save(),e.font=c.string,U(s.title,x),e.font=l.string,U(s.beforeBody.concat(s.afterBody),x),T=t.displayColors?r+2+t.boxPadding:0,U(i,E=>{U(E.before,x),U(E.lines,x),U(E.after,x)}),T=0,e.font=h.string,U(s.footer,x),e.restore(),v+=f.width,{width:v,height:b}}function EM(s,t){const{y:e,height:i}=t;return es.height-i/2?\"bottom\":\"center\"}function xM(s,t,e,i){const{x:n,width:o}=i,r=e.caretSize+e.caretPadding;if(s===\"left\"&&n+o+r>t.width||s===\"right\"&&n-o-r<0)return!0}function CM(s,t,e,i){const{x:n,width:o}=e,{width:r,chartArea:{left:a,right:l}}=s;let c=\"center\";return i===\"center\"?c=n<=(a+l)/2?\"left\":\"right\":n<=o/2?c=\"left\":n>=r-o/2&&(c=\"right\"),xM(c,s,t,e)&&(c=\"center\"),c}function wg(s,t,e){const i=e.yAlign||t.yAlign||EM(s,e);return{xAlign:e.xAlign||t.xAlign||CM(s,t,e,i),yAlign:i}}function AM(s,t){let{x:e,width:i}=s;return t===\"right\"?e-=i:t===\"center\"&&(e-=i/2),e}function wM(s,t,e){let{y:i,height:n}=s;return t===\"top\"?i+=e:t===\"bottom\"?i-=n+e:i-=n/2,i}function kg(s,t,e,i){const{caretSize:n,caretPadding:o,cornerRadius:r}=s,{xAlign:a,yAlign:l}=e,c=n+o,{topLeft:h,topRight:d,bottomLeft:u,bottomRight:p}=$i(r);let f=AM(t,a);const b=wM(t,l,c);return l===\"center\"?a===\"left\"?f+=c:a===\"right\"&&(f-=c):a===\"left\"?f-=Math.max(h,u)+n:a===\"right\"&&(f+=Math.max(d,p)+n),{x:dt(f,0,i.width-t.width),y:dt(b,0,i.height-t.height)}}function ha(s,t,e){const i=pt(e.padding);return t===\"center\"?s.x+s.width/2:t===\"right\"?s.x+s.width-i.right:s.x+i.left}function Sg(s){return Ce([],$e(s))}function kM(s,t,e){return ni(s,{tooltip:t,tooltipItems:e,type:\"tooltip\"})}function Og(s,t){const e=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return e?s.override(e):s}class ih extends Xt{constructor(t){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=t.chart||t._chart,this._chart=this.chart,this.options=t.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(t){this.options=t,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const t=this._cachedAnimations;if(t)return t;const e=this.chart,i=this.options.setContext(this.getContext()),n=i.enabled&&e.options.animation&&i.animations,o=new Pc(this.chart,n);return n._cacheable&&(this._cachedAnimations=Object.freeze(o)),o}getContext(){return this.$context||(this.$context=kM(this.chart.getContext(),this,this._tooltipItems))}getTitle(t,e){const{callbacks:i}=e,n=i.beforeTitle.apply(this,[t]),o=i.title.apply(this,[t]),r=i.afterTitle.apply(this,[t]);let a=[];return a=Ce(a,$e(n)),a=Ce(a,$e(o)),a=Ce(a,$e(r)),a}getBeforeBody(t,e){return Sg(e.callbacks.beforeBody.apply(this,[t]))}getBody(t,e){const{callbacks:i}=e,n=[];return U(t,o=>{const r={before:[],lines:[],after:[]},a=Og(i,o);Ce(r.before,$e(a.beforeLabel.call(this,o))),Ce(r.lines,a.label.call(this,o)),Ce(r.after,$e(a.afterLabel.call(this,o))),n.push(r)}),n}getAfterBody(t,e){return Sg(e.callbacks.afterBody.apply(this,[t]))}getFooter(t,e){const{callbacks:i}=e,n=i.beforeFooter.apply(this,[t]),o=i.footer.apply(this,[t]),r=i.afterFooter.apply(this,[t]);let a=[];return a=Ce(a,$e(n)),a=Ce(a,$e(o)),a=Ce(a,$e(r)),a}_createItems(t){const e=this._active,i=this.chart.data,n=[],o=[],r=[];let a=[],l,c;for(l=0,c=e.length;lt.filter(h,d,u,i))),t.itemSort&&(a=a.sort((h,d)=>t.itemSort(h,d,i))),U(a,h=>{const d=Og(t.callbacks,h);n.push(d.labelColor.call(this,h)),o.push(d.labelPointStyle.call(this,h)),r.push(d.labelTextColor.call(this,h))}),this.labelColors=n,this.labelPointStyles=o,this.labelTextColors=r,this.dataPoints=a,a}update(t,e){const i=this.options.setContext(this.getContext()),n=this._active;let o,r=[];if(!n.length)this.opacity!==0&&(o={opacity:0});else{const a=to[i.position].call(this,n,this._eventPosition);r=this._createItems(i),this.title=this.getTitle(r,i),this.beforeBody=this.getBeforeBody(r,i),this.body=this.getBody(r,i),this.afterBody=this.getAfterBody(r,i),this.footer=this.getFooter(r,i);const l=this._size=Ag(this,i),c=Object.assign({},a,l),h=wg(this.chart,i,c),d=kg(i,c,h,this.chart);this.xAlign=h.xAlign,this.yAlign=h.yAlign,o={opacity:1,x:d.x,y:d.y,width:l.width,height:l.height,caretX:a.x,caretY:a.y}}this._tooltipItems=r,this.$context=void 0,o&&this._resolveAnimations().update(this,o),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,n){const o=this.getCaretPosition(t,i,n);e.lineTo(o.x1,o.y1),e.lineTo(o.x2,o.y2),e.lineTo(o.x3,o.y3)}getCaretPosition(t,e,i){const{xAlign:n,yAlign:o}=this,{caretSize:r,cornerRadius:a}=i,{topLeft:l,topRight:c,bottomLeft:h,bottomRight:d}=$i(a),{x:u,y:p}=t,{width:f,height:b}=e;let v,y,T,x,E,C;return o===\"center\"?(E=p+b/2,n===\"left\"?(v=u,y=v-r,x=E+r,C=E-r):(v=u+f,y=v+r,x=E-r,C=E+r),T=v):(n===\"left\"?y=u+Math.max(l,h)+r:n===\"right\"?y=u+f-Math.max(c,d)-r:y=this.caretX,o===\"top\"?(x=p,E=x-r,v=y-r,T=y+r):(x=p+b,E=x+r,v=y+r,T=y-r),C=x),{x1:v,x2:y,x3:T,y1:x,y2:E,y3:C}}drawTitle(t,e,i){const n=this.title,o=n.length;let r,a,l;if(o){const c=Os(i.rtl,this.x,this.width);for(t.x=ha(this,i.titleAlign,i),e.textAlign=c.textAlign(i.titleAlign),e.textBaseline=\"middle\",r=lt(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=r.string,l=0;lx!==0)?(t.beginPath(),t.fillStyle=o.multiKeyBackground,Nn(t,{x:v,y:b,w:c,h:l,radius:T}),t.fill(),t.stroke(),t.fillStyle=r.backgroundColor,t.beginPath(),Nn(t,{x:y,y:b+1,w:c-2,h:l-2,radius:T}),t.fill()):(t.fillStyle=o.multiKeyBackground,t.fillRect(v,b,c,l),t.strokeRect(v,b,c,l),t.fillStyle=r.backgroundColor,t.fillRect(y,b+1,c-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:n}=this,{bodySpacing:o,bodyAlign:r,displayColors:a,boxHeight:l,boxWidth:c,boxPadding:h}=i,d=lt(i.bodyFont);let u=d.lineHeight,p=0;const f=Os(i.rtl,this.x,this.width),b=function(S){e.fillText(S,f.x(t.x+p),t.y+u/2),t.y+=u+o},v=f.textAlign(r);let y,T,x,E,C,A,w;for(e.textAlign=r,e.textBaseline=\"middle\",e.font=d.string,t.x=ha(this,v,i),e.fillStyle=i.bodyColor,U(this.beforeBody,b),p=a&&v!==\"right\"?r===\"center\"?c/2+h:c+2+h:0,E=0,A=n.length;E0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,n=i&&i.x,o=i&&i.y;if(n||o){const r=to[t.position].call(this,this._active,this._eventPosition);if(!r)return;const a=this._size=Ag(this,t),l=Object.assign({},r,this._size),c=wg(e,t,l),h=kg(t,l,c,e);(n._to!==h.x||o._to!==h.y)&&(this.xAlign=c.xAlign,this.yAlign=c.yAlign,this.width=a.width,this.height=a.height,this.caretX=r.x,this.caretY=r.y,this._resolveAnimations().update(this,h))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const n={width:this.width,height:this.height},o={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const r=pt(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(o,t,n,e),o_(t,e.textDirection),o.y+=r.top,this.drawTitle(o,t,e),this.drawBody(o,t,e),this.drawFooter(o,t,e),r_(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,n=t.map(({datasetIndex:a,index:l})=>{const c=this.chart.getDatasetMeta(a);if(!c)throw new Error(\"Cannot find a dataset at index \"+a);return{datasetIndex:a,element:c.data[l],index:l}}),o=!Fr(i,n),r=this._positionChanged(n,e);(o||r)&&(this._active=n,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const n=this.options,o=this._active||[],r=this._getActiveElements(t,o,e,i),a=this._positionChanged(r,t),l=e||!Fr(r,o)||a;return l&&(this._active=r,(n.enabled||n.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),l}_getActiveElements(t,e,i,n){const o=this.options;if(t.type===\"mouseout\")return[];if(!n)return e;const r=this.chart.getElementsAtEventForMode(t,o.mode,o,i);return o.reverse&&r.reverse(),r}_positionChanged(t,e){const{caretX:i,caretY:n,options:o}=this,r=to[o.position].call(this,t,e);return r!==!1&&(i!==r.x||n!==r.y)}}ih.positioners=to;var Ig={id:\"tooltip\",_element:ih,positioners:to,afterInit(s,t,e){e&&(s.tooltip=new ih({chart:s,options:e}))},beforeUpdate(s,t,e){s.tooltip&&s.tooltip.initialize(e)},reset(s,t,e){s.tooltip&&s.tooltip.initialize(e)},afterDraw(s){const t=s.tooltip;if(t&&t._willRender()){const e={tooltip:t};if(s.notifyPlugins(\"beforeTooltipDraw\",e)===!1)return;t.draw(s.ctx),s.notifyPlugins(\"afterTooltipDraw\",e)}},afterEvent(s,t){if(s.tooltip){const e=t.replay;s.tooltip.handleEvent(t.event,e,t.inChartArea)&&(t.changed=!0)}},defaults:{enabled:!0,external:null,position:\"average\",backgroundColor:\"rgba(0,0,0,0.8)\",titleColor:\"#fff\",titleFont:{weight:\"bold\"},titleSpacing:2,titleMarginBottom:6,titleAlign:\"left\",bodyColor:\"#fff\",bodySpacing:2,bodyFont:{},bodyAlign:\"left\",footerColor:\"#fff\",footerSpacing:2,footerMarginTop:6,footerFont:{weight:\"bold\"},footerAlign:\"left\",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(s,t)=>t.bodyFont.size,boxWidth:(s,t)=>t.bodyFont.size,multiKeyBackground:\"#fff\",displayColors:!0,boxPadding:0,borderColor:\"rgba(0,0,0,0)\",borderWidth:0,animation:{duration:400,easing:\"easeOutQuart\"},animations:{numbers:{type:\"number\",properties:[\"x\",\"y\",\"width\",\"height\",\"caretX\",\"caretY\"]},opacity:{easing:\"linear\",duration:200}},callbacks:{beforeTitle:Oe,title(s){if(s.length>0){const t=s[0],e=t.chart.data.labels,i=e?e.length:0;if(this&&this.options&&this.options.mode===\"dataset\")return t.dataset.label||\"\";if(t.label)return t.label;if(i>0&&t.dataIndexs!==\"filter\"&&s!==\"itemSort\"&&s!==\"external\",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:\"animation\"}},additionalOptionScopes:[\"interaction\"]},Dg=Object.freeze({__proto__:null,Decimation:dg,Filler:vg,Legend:Eg,SubTitle:Cg,Title:xg,Tooltip:Ig});const SM=(s,t,e,i)=>(typeof t==\"string\"?(e=s.push(t)-1,i.unshift({index:e,label:t})):isNaN(t)&&(e=null),e);function OM(s,t,e,i){const n=s.indexOf(t);if(n===-1)return SM(s,t,e,i);const o=s.lastIndexOf(t);return n!==o?e:n}const IM=(s,t)=>s===null?null:dt(Math.round(s),0,t);class eo extends oi{constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const i=this.getLabels();for(const{index:n,label:o}of e)i[n]===o&&i.splice(n,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(H(t))return null;const i=this.getLabels();return e=isFinite(e)&&i[e]===t?e:OM(i,t,B(e,t),this._addedLabels),IM(e,i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:n}=this.getMinMax(!0);this.options.bounds===\"ticks\"&&(t||(i=0),e||(n=this.getLabels().length-1)),this.min=i,this.max=n}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,n=[];let o=this.getLabels();o=t===0&&e===o.length-1?o:o.slice(t,e+1),this._valueRange=Math.max(o.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let r=t;r<=e;r++)n.push({value:r});return n}getLabelForValue(t){const e=this.getLabels();return t>=0&&te.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}}eo.id=\"category\",eo.defaults={ticks:{callback:eo.prototype.getLabelForValue}};function DM(s,t){const e=[],{bounds:n,step:o,min:r,max:a,precision:l,count:c,maxTicks:h,maxDigits:d,includeBounds:u}=s,p=o||1,f=h-1,{min:b,max:v}=t,y=!H(r),T=!H(a),x=!H(c),E=(v-b)/(d+1);let C=Tf((v-b)/f/p)*p,A,w,S,k;if(C<1e-14&&!y&&!T)return[{value:b},{value:v}];k=Math.ceil(v/C)-Math.floor(b/C),k>f&&(C=Tf(k*C/f/p)*p),H(l)||(A=Math.pow(10,l),C=Math.ceil(C*A)/A),n===\"ticks\"?(w=Math.floor(b/C)*C,S=Math.ceil(v/C)*C):(w=b,S=v),y&&T&&o&&MO((a-r)/o,C/1e3)?(k=Math.round(Math.min((a-r)/C,h)),C=(a-r)/k,w=r,S=a):x?(w=y?r:w,S=T?a:S,k=c-1,C=(S-w)/k):(k=(S-w)/C,In(k,Math.round(k),C/1e3)?k=Math.round(k):k=Math.ceil(k));const D=Math.max(xf(C),xf(w));A=Math.pow(10,H(l)?D:l),w=Math.round(w*A)/A,S=Math.round(S*A)/A;let I=0;for(y&&(u&&w!==r?(e.push({value:r}),wn=e?n:l,a=l=>o=i?o:l;if(t){const l=Ee(n),c=Ee(o);l<0&&c<0?a(0):l>0&&c>0&&r(0)}if(n===o){let l=1;(o>=Number.MAX_SAFE_INTEGER||n<=Number.MIN_SAFE_INTEGER)&&(l=Math.abs(o*.05)),a(o+l),t||r(n-l)}this.min=n,this.max=o}getTickLimit(){const t=this.options.ticks;let{maxTicksLimit:e,stepSize:i}=t,n;return i?(n=Math.ceil(this.max/i)-Math.floor(this.min/i)+1,n>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${i} would result generating up to ${n} ticks. Limiting to 1000.`),n=1e3)):(n=this.computeTickLimit(),e=e||11),e&&(n=Math.min(e,n)),n}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n={maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:e.includeBounds!==!1},o=this._range||this,r=DM(n,o);return t.bounds===\"ticks\"&&Ef(r,this,\"value\"),t.reverse?(r.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),r}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const n=(i-e)/Math.max(t.length-1,1)/2;e-=n,i+=n}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return Bn(t,this.chart.options.locale,this.options.ticks.format)}}class ua extends da{determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=rt(t)?t:0,this.max=rt(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=se(this.options.ticks.minRotation),n=(t?Math.sin(i):Math.cos(i))||.001,o=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,o.lineHeight/n))}getPixelForValue(t){return t===null?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}ua.id=\"linear\",ua.defaults={ticks:{callback:Yn.formatters.numeric}};function Lg(s){return s/Math.pow(10,Math.floor(Yt(s)))===1}function MM(s,t){const e=Math.floor(Yt(t.max)),i=Math.ceil(t.max/Math.pow(10,e)),n=[];let o=zt(s.min,Math.pow(10,Math.floor(Yt(t.min)))),r=Math.floor(Yt(o)),a=Math.floor(o/Math.pow(10,r)),l=r<0?Math.pow(10,Math.abs(r)):1;do n.push({value:o,major:Lg(o)}),++a,a===10&&(a=1,++r,l=r>=0?1:l),o=Math.round(a*Math.pow(10,r)*l)/l;while(r0?i:null}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=rt(t)?Math.max(0,t):null,this.max=rt(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,n=this.max;const o=l=>i=t?i:l,r=l=>n=e?n:l,a=(l,c)=>Math.pow(10,Math.floor(Yt(l))+c);i===n&&(i<=0?(o(1),r(10)):(o(a(i,-1)),r(a(n,1)))),i<=0&&o(a(n,-1)),n<=0&&r(a(i,1)),this._zero&&this.min!==this._suggestedMin&&i===a(this.min,0)&&o(a(i,-1)),this.min=i,this.max=n}buildTicks(){const t=this.options,e={min:this._userMin,max:this._userMax},i=MM(e,this);return t.bounds===\"ticks\"&&Ef(i,this,\"value\"),t.reverse?(i.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),i}getLabelForValue(t){return t===void 0?\"0\":Bn(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=Yt(t),this._valueRange=Yt(this.max)-Yt(t)}getPixelForValue(t){return(t===void 0||t===0)&&(t=this.min),t===null||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(Yt(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}pa.id=\"logarithmic\",pa.defaults={ticks:{callback:Yn.formatters.logarithmic,major:{enabled:!0}}};function sh(s){const t=s.ticks;if(t.display&&s.display){const e=pt(t.backdropPadding);return B(t.font&&t.font.size,F.font.size)+e.height}return 0}function LM(s,t,e){return e=Q(e)?e:[e],{w:hI(s,t.string,e),h:e.length*t.lineHeight}}function $g(s,t,e,i,n){return s===i||s===n?{start:t-e/2,end:t+e/2}:sn?{start:t-e,end:t}:{start:t,end:t+e}}function $M(s){const t={l:s.left+s._padding.left,r:s.right-s._padding.right,t:s.top+s._padding.top,b:s.bottom-s._padding.bottom},e=Object.assign({},t),i=[],n=[],o=s._pointLabels.length,r=s.options.pointLabels,a=r.centerPointLabels?it/o:0;for(let l=0;lt.r&&(a=(i.end-t.r)/o,s.r=Math.max(s.r,t.r+a)),n.startt.b&&(l=(n.end-t.b)/r,s.b=Math.max(s.b,t.b+l))}function PM(s,t,e){const i=[],n=s._pointLabels.length,o=s.options,r=sh(o)/2,a=s.drawingArea,l=o.pointLabels.centerPointLabels?it/n:0;for(let c=0;c270||e<90)&&(s-=t),s}function VM(s,t){const{ctx:e,options:{pointLabels:i}}=s;for(let n=t-1;n>=0;n--){const o=i.setContext(s.getPointLabelContext(n)),r=lt(o.font),{x:a,y:l,textAlign:c,left:h,top:d,right:u,bottom:p}=s._pointLabelItems[n],{backdropColor:f}=o;if(!H(f)){const b=$i(o.borderRadius),v=pt(o.backdropPadding);e.fillStyle=f;const y=h-v.left,T=d-v.top,x=u-h+v.width,E=p-d+v.height;Object.values(b).some(C=>C!==0)?(e.beginPath(),Nn(e,{x:y,y:T,w:x,h:E,radius:b}),e.fill()):e.fillRect(y,T,x,E)}Li(e,s._pointLabels[n],a,l+r.lineHeight/2,r,{color:o.color,textAlign:c,textBaseline:\"middle\"})}}function Rg(s,t,e,i){const{ctx:n}=s;if(e)n.arc(s.xCenter,s.yCenter,t,0,q);else{let o=s.getPointPosition(0,t);n.moveTo(o.x,o.y);for(let r=1;r{const n=G(this.options.pointLabels.callback,[e,i],this);return n||n===0?n:\"\"}).filter((e,i)=>this.chart.getDataVisibility(i))}fit(){const t=this.options;t.display&&t.pointLabels.display?$M(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,n){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-n)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,n))}getIndexAngle(t){const e=q/(this._pointLabels.length||1),i=this.options.startAngle||0;return Vt(t*e+se(i))}getDistanceFromCenterForValue(t){if(H(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(H(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t{if(h!==0){a=this.getDistanceFromCenterForValue(c.value);const d=n.setContext(this.getContext(h-1));FM(this,d,a,o)}}),i.display){for(t.save(),r=o-1;r>=0;r--){const c=i.setContext(this.getPointLabelContext(r)),{color:h,lineWidth:d}=c;!d||!h||(t.lineWidth=d,t.strokeStyle=h,t.setLineDash(c.borderDash),t.lineDashOffset=c.borderDashOffset,a=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(r,a),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const n=this.getIndexAngle(0);let o,r;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(n),t.textAlign=\"center\",t.textBaseline=\"middle\",this.ticks.forEach((a,l)=>{if(l===0&&!e.reverse)return;const c=i.setContext(this.getContext(l)),h=lt(c.font);if(o=this.getDistanceFromCenterForValue(this.ticks[l].value),c.showLabelBackdrop){t.font=h.string,r=t.measureText(a.label).width,t.fillStyle=c.backdropColor;const d=pt(c.backdropPadding);t.fillRect(-r/2-d.left,-o-h.size/2-d.top,r+d.width,h.size+d.height)}Li(t,a.label,0,-o,h,{color:c.color})}),t.restore()}drawTitle(){}}$s.id=\"radialLinear\",$s.defaults={display:!0,animate:!0,position:\"chartArea\",angleLines:{display:!0,lineWidth:1,borderDash:[],borderDashOffset:0},grid:{circular:!1},startAngle:0,ticks:{showLabelBackdrop:!0,callback:Yn.formatters.numeric},pointLabels:{backdropColor:void 0,backdropPadding:2,display:!0,font:{size:10},callback(s){return s},padding:5,centerPointLabels:!1}},$s.defaultRoutes={\"angleLines.color\":\"borderColor\",\"pointLabels.color\":\"color\",\"ticks.color\":\"color\"},$s.descriptors={angleLines:{_fallback:\"grid\"}};const fa={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},It=Object.keys(fa);function zM(s,t){return s-t}function Pg(s,t){if(H(t))return null;const e=s._adapter,{parser:i,round:n,isoWeekday:o}=s._parseOpts;let r=t;return typeof i==\"function\"&&(r=i(r)),rt(r)||(r=typeof i==\"string\"?e.parse(r,i):e.parse(r)),r===null?null:(n&&(r=n===\"week\"&&(As(o)||o===!0)?e.startOf(r,\"isoWeek\",o):e.startOf(r,n)),+r)}function Ng(s,t,e,i){const n=It.length;for(let o=It.indexOf(s);o=It.indexOf(e);o--){const r=It[o];if(fa[r].common&&s._adapter.diff(n,i,r)>=t-1)return r}return It[e?It.indexOf(e):0]}function YM(s){for(let t=It.indexOf(s)+1,e=It.length;t=t?e[i]:e[n];s[o]=!0}}function KM(s,t,e,i){const n=s._adapter,o=+n.startOf(t[0].value,i),r=t[t.length-1].value;let a,l;for(a=o;a<=r;a=+n.add(a,1,i))l=e[a],l>=0&&(t[l].major=!0);return t}function Hg(s,t,e){const i=[],n={},o=t.length;let r,a;for(r=0;r+t.value))}initOffsets(t){let e=0,i=0,n,o;this.options.offset&&t.length&&(n=this.getDecimalForValue(t[0]),t.length===1?e=1-n:e=(this.getDecimalForValue(t[1])-n)/2,o=this.getDecimalForValue(t[t.length-1]),t.length===1?i=o:i=(o-this.getDecimalForValue(t[t.length-2]))/2);const r=t.length<3?.5:.25;e=dt(e,0,r),i=dt(i,0,r),this._offsets={start:e,end:i,factor:1/(e+1+i)}}_generate(){const t=this._adapter,e=this.min,i=this.max,n=this.options,o=n.time,r=o.unit||Ng(o.minUnit,e,i,this._getLabelCapacity(e)),a=B(o.stepSize,1),l=r===\"week\"?o.isoWeekday:!1,c=As(l)||l===!0,h={};let d=e,u,p;if(c&&(d=+t.startOf(d,\"isoWeek\",l)),d=+t.startOf(d,c?\"day\":r),t.diff(i,e,r)>1e5*a)throw new Error(e+\" and \"+i+\" are too far apart with stepSize of \"+a+\" \"+r);const f=n.ticks.source===\"data\"&&this.getDataTimestamps();for(u=d,p=0;ub-v).map(b=>+b)}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}_tickFormatFunction(t,e,i,n){const o=this.options,r=o.time.displayFormats,a=this._unit,l=this._majorUnit,c=a&&r[a],h=l&&r[l],d=i[e],u=l&&h&&d&&d.major,p=this._adapter.format(t,n||(u?h:c)),f=o.ticks.callback;return f?G(f,[p,e,i],this):p}generateTickLabels(t){let e,i,n;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t=this._cache.data||[],e,i;if(t.length)return t;const n=this.getMatchingVisibleMetas();if(this._normalized&&n.length)return this._cache.data=n[0].controller.getAllParsedValues(this);for(e=0,i=n.length;e=s[i].pos&&t<=s[n].pos&&({lo:i,hi:n}=De(s,\"pos\",t)),{pos:o,time:a}=s[i],{pos:r,time:l}=s[n]):(t>=s[i].time&&t<=s[n].time&&({lo:i,hi:n}=De(s,\"time\",t)),{time:o,pos:a}=s[i],{time:r,pos:l}=s[n]);const c=r-o;return c?a+(l-a)*(t-o)/c:a}class ga extends Rs{constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=_a(e,this.min),this._tableRange=_a(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,n=[],o=[];let r,a,l,c,h;for(r=0,a=t.length;r=e&&c<=i&&n.push(c);if(n.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(r=0,a=n.length;re.right&&(i|=zg),te.bottom&&(i|=jg),i}function qM(s,t){for(var e=s.x0,i=s.y0,n=s.x1,o=s.y1,r=ma(e,i,t),a=ma(n,o,t),l,c,h;!(!(r|a)||r&a);)l=r||a,l&Yg?(c=e+(n-e)*(t.top-i)/(o-i),h=t.top):l&jg?(c=e+(n-e)*(t.bottom-i)/(o-i),h=t.bottom):l&zg?(h=i+(o-i)*(t.right-e)/(n-e),c=t.right):l&Wg&&(h=i+(o-i)*(t.left-e)/(n-e),c=t.left),l===r?(e=c,i=h,r=ma(e,i,t)):(n=c,o=h,a=ma(n,o,t));return{x0:e,x1:n,y0:i,y1:o}}function ba(s,t){var e=t.anchor,i=s,n,o;return t.clamp&&(i=qM(i,t.area)),e===\"start\"?(n=i.x0,o=i.y0):e===\"end\"?(n=i.x1,o=i.y1):(n=(i.x0+i.x1)/2,o=(i.y0+i.y1)/2),XM(n,o,s.vx,s.vy,t.align)}var va={arc:function(s,t){var e=(s.startAngle+s.endAngle)/2,i=Math.cos(e),n=Math.sin(e),o=s.innerRadius,r=s.outerRadius;return ba({x0:s.x+i*o,y0:s.y+n*o,x1:s.x+i*r,y1:s.y+n*r,vx:i,vy:n},t)},point:function(s,t){var e=nh(s,t.origin),i=e.x*s.options.radius,n=e.y*s.options.radius;return ba({x0:s.x-i,y0:s.y-n,x1:s.x+i,y1:s.y+n,vx:e.x,vy:e.y},t)},bar:function(s,t){var e=nh(s,t.origin),i=s.x,n=s.y,o=0,r=0;return s.horizontal?(i=Math.min(s.x,s.base),o=Math.abs(s.base-s.x)):(n=Math.min(s.y,s.base),r=Math.abs(s.base-s.y)),ba({x0:i,y0:n+r,x1:i+o,y1:n,vx:e.x,vy:e.y},t)},fallback:function(s,t){var e=nh(s,t.origin);return ba({x0:s.x,y0:s.y,x1:s.x+(s.width||0),y1:s.y+(s.height||0),vx:e.x,vy:e.y},t)}},Re=io.rasterize;function ZM(s){var t=s.borderWidth||0,e=s.padding,i=s.size.height,n=s.size.width,o=-n/2,r=-i/2;return{frame:{x:o-e.left-t,y:r-e.top-t,w:n+e.width+t*2,h:i+e.height+t*2},text:{x:o,y:r,w:n,h:i}}}function QM(s,t){var e=t.chart.getDatasetMeta(t.datasetIndex).vScale;if(!e)return null;if(e.xCenter!==void 0&&e.yCenter!==void 0)return{x:e.xCenter,y:e.yCenter};var i=e.getBasePixel();return s.horizontal?{x:i,y:null}:{x:null,y:i}}function JM(s){return s instanceof Ds?va.arc:s instanceof Ms?va.point:s instanceof Ls?va.bar:va.fallback}function tL(s,t,e,i,n,o){var r=Math.PI/2;if(o){var a=Math.min(o,n/2,i/2),l=t+a,c=e+a,h=t+i-a,d=e+n-a;s.moveTo(t,c),li.x+i.w+e*2||s.y>i.y+i.h+e*2)},intersects:function(s){var t=this._points(),e=s._points(),i=[ya(t[0],t[1]),ya(t[0],t[3])],n,o,r;for(this._rotation!==s._rotation&&i.push(ya(e[0],e[1]),ya(e[0],e[3])),n=0;n=0;--e)for(n=s[e].$layout,i=e-1;i>=0&&n._visible;--i)o=s[i].$layout,o._visible&&n._box.intersects(o._box)&&t(n,o);return s}function lL(s){var t,e,i,n,o,r,a;for(t=0,e=s.length;tl.getProps([c],!0)[c]}),o=i.geometry(),r=Gg(a,i.model(),o),n._box.update(r,o,i.rotation()));return aL(s,function(l,c){var h=l._hidable,d=c._hidable;h&&d||d?c._visible=!1:h&&(l._visible=!1)})}var no={prepare:function(s){var t=[],e,i,n,o,r;for(e=0,n=s.length;e=0;--e)if(i=s[e].$layout,i&&i._visible&&i._box.contains(t))return s[e];return null},draw:function(s,t){var e,i,n,o,r,a;for(e=0,i=t.length;e:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\\[0\\.5rem\\]{border-radius:.5rem}.rounded-\\[0\\.6rem\\]{border-radius:.6rem}.rounded-\\[0\\.25rem\\]{border-radius:.25rem}.rounded-\\[10px\\]{border-radius:10px}.rounded-\\[16px\\]{border-radius:16px}.rounded-\\[50\\%\\]{border-radius:50%}.rounded-\\[100\\%\\]{border-radius:100%}.rounded-\\[999px\\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\\[0\\.6rem\\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\\[0\\.25rem\\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\\[0\\.25rem\\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\\!border-\\[3px\\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\\[\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[0\\.15em\\]{border-style:var(--tw-border-style);border-width:.15em}.border-\\[0\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[1px\\]{border-style:var(--tw-border-style);border-width:1px}.border-\\[14px\\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\\[0\\.125rem\\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\\!border-\\[\\#14a44d\\]{border-color:#14a44d!important}.\\!border-\\[\\#b2b3b4\\]{border-color:#b2b3b4!important}.\\!border-\\[\\#dc4c64\\]{border-color:#dc4c64!important}.border-\\[\\#3b71ca\\]{border-color:#3b71ca}.border-\\[\\#14a44d\\]{border-color:#14a44d}.border-\\[\\#dc4c64\\]{border-color:#dc4c64}.border-\\[\\#eee\\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\\!bg-\\[\\#858585\\]{background-color:#858585!important}.\\!bg-danger-100{background-color:#fae5e9!important}.\\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\\!bg-primary-100{background-color:#e3ebf7!important}.\\!bg-success-100{background-color:#d6fae4!important}.bg-\\[\\#000000e6\\]{background-color:#000000e6}.bg-\\[\\#3b71ca\\]{background-color:#3b71ca}.bg-\\[\\#6d6d6d\\]{background-color:#6d6d6d}.bg-\\[\\#00000012\\]{background-color:#00000012}.bg-\\[\\#00000066\\]{background-color:#0006}.bg-\\[\\#aaa\\]{background-color:#aaa}.bg-\\[\\#eceff1\\]{background-color:#eceff1}.bg-\\[\\#eee\\]{background-color:#eee}.bg-\\[rgba\\(0\\,0\\,0\\,0\\.4\\)\\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\\[\\#336dec\\]{fill:#336dec}.fill-\\[\\#afafaf\\]{fill:#afafaf}.fill-current{fill:currentColor}.\\!p-0{padding:calc(var(--spacing) * 0)!important}.p-0{padding:calc(var(--spacing) * 0)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\\[1rem\\]{padding:1rem}.p-\\[5px\\]{padding:5px}.p-\\[auto\\]{padding:auto}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\\[0\\.4rem\\]{padding-inline:.4rem}.px-\\[1\\.4rem\\]{padding-inline:1.4rem}.px-\\[10px\\]{padding-inline:10px}.px-\\[12px\\]{padding-inline:12px}.px-\\[auto\\]{padding-inline:auto}.\\!py-0{padding-block:calc(var(--spacing) * 0)!important}.\\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:calc(var(--spacing) * 0)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\\[0\\.4rem\\]{padding-block:.4rem}.py-\\[0\\.32rem\\]{padding-block:.32rem}.py-\\[0\\.33rem\\]{padding-block:.33rem}.py-\\[0\\.4375rem\\]{padding-block:.4375rem}.py-\\[1px\\]{padding-block:1px}.py-\\[5px\\]{padding-block:5px}.py-\\[10px\\]{padding-block:10px}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\\[0\\.37rem\\]{padding-top:.37rem}.pt-\\[6px\\]{padding-top:6px}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\\[24px\\]{padding-right:24px}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\\[5px\\]{padding-bottom:5px}.pl-0{padding-left:calc(var(--spacing) * 0)}.pl-1{padding-left:calc(var(--spacing) * 1)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\\[1\\.5rem\\]{padding-left:1.5rem}.pl-\\[8px\\]{padding-left:8px}.pl-\\[18px\\]{padding-left:18px}.pl-\\[50px\\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\\[-0\\.125em\\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[0\\.8rem\\]{font-size:.8rem}.text-\\[0\\.9rem\\]{font-size:.9rem}.text-\\[1\\.1rem\\]{font-size:1.1rem}.text-\\[2\\.5rem\\]{font-size:2.5rem}.text-\\[3\\.75rem\\]{font-size:3.75rem}.text-\\[10px\\]{font-size:10px}.text-\\[12px\\]{font-size:12px}.text-\\[13px\\]{font-size:13px}.text-\\[16px\\]{font-size:16px}.text-\\[18px\\]{font-size:18px}.text-\\[34px\\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\\[1\\.2\\]{--tw-leading:1.2;line-height:1.2}.leading-\\[1\\.5\\]{--tw-leading:1.5;line-height:1.5}.leading-\\[1\\.6\\]{--tw-leading:1.6;line-height:1.6}.leading-\\[2\\.15\\]{--tw-leading:2.15;line-height:2.15}.leading-\\[40px\\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\\[-0\\.00833em\\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\\[\\.1rem\\],.tracking-\\[0\\.1rem\\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\\[1\\.7px\\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\\!text-\\[\\#14a44d\\]{color:#14a44d!important}.\\!text-\\[\\#dc4c64\\]{color:#dc4c64!important}.\\!text-danger-700{color:#b0233a!important}.\\!text-gray-50{color:var(--color-gray-50)!important}.\\!text-primary{color:#3b71ca!important}.\\!text-primary-700{color:#285192!important}.\\!text-success-700{color:#0e7537!important}.text-\\[\\#3b71ca\\]{color:#3b71ca}.text-\\[\\#4f4f4f\\]{color:#4f4f4f}.text-\\[\\#14a44d\\]{color:#14a44d}.text-\\[\\#212529\\]{color:#212529}.text-\\[\\#b3afaf\\]{color:#b3afaf}.text-\\[\\#b3b3b3\\]{color:#b3b3b3}.text-\\[\\#dc4c64\\]{color:#dc4c64}.text-\\[\\#ffffff8a\\]{color:#ffffff8a}.text-\\[rgb\\(220\\,76\\,100\\)\\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\\/\\[64\\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\\/\\[64\\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\\!opacity-0{opacity:0!important}.\\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\\[\\.53\\]{opacity:.53}.opacity-\\[\\.54\\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_0px_3px_0_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_2px_2px_0_rgba\\(0\\,0\\,0\\,0\\.04\\)\\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_2px_5px_0_rgba\\(0\\,0\\,0\\,0\\.16\\)\\,_0_2px_10px_0_rgba\\(0\\,0\\,0\\,0\\.12\\)\\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_4px_9px_-4px_\\#3b71ca\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_10px_15px_-3px_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_4px_6px_-2px_rgba\\(0\\,0\\,0\\,0\\.05\\)\\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0px_2px_15px_-3px_rgba\\(0\\,0\\,0\\,\\.07\\)\\,_0px_10px_20px_-2px_rgba\\(0\\,0\\,0\\,\\.04\\)\\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\\/login,.shadow\\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,_opacity\\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,box-shadow\\,border\\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[height\\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[opacity\\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,_opacity\\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,height\\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[width\\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\\[0ms\\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\\[150ms\\]{--tw-duration:.15s;transition-duration:.15s}.duration-\\[200ms\\]{--tw-duration:.2s;transition-duration:.2s}.duration-\\[250ms\\]{--tw-duration:.25s;transition-duration:.25s}.duration-\\[350ms\\]{--tw-duration:.35s;transition-duration:.35s}.duration-\\[400ms\\]{--tw-duration:.4s;transition-duration:.4s}.duration-\\[1000ms\\]{--tw-duration:1s;transition-duration:1s}.ease-\\[cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\,_cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\\[cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)\\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\)\\],.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\.0\\)\\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\\[ease\\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\\!\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)!important}.\\[bash\\:1221\\]{bash:1221}.\\[check\\:5737\\]{check:5737}.\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)}.\\[direction\\:ltr\\]{direction:ltr}.\\[drm\\:hdmiphy_enable\\.part\\.0\\]{drm:hdmiphy enable.part0}.\\[drm\\:samsung_dsim_host_attach\\]{drm:samsung dsim host attach}.\\[overflow-anchor\\:none\\]{overflow-anchor:none}.\\[pid\\:5118\\,cpu4\\,QThread\\,0\\]{pid:5118,cpu4,QThread,0}.\\[pid\\:5118\\,cpu4\\,QThread\\,1\\]{pid:5118,cpu4,QThread,1}.\\[pid\\:5118\\,cpu4\\,QThread\\,2\\]{pid:5118,cpu4,QThread,2}.\\[pid\\:5118\\,cpu4\\,QThread\\,3\\]{pid:5118,cpu4,QThread,3}.\\[pid\\:5118\\,cpu4\\,QThread\\,4\\]{pid:5118,cpu4,QThread,4}.\\[pid\\:5118\\,cpu4\\,QThread\\,9\\]{pid:5118,cpu4,QThread,9}.\\[transition\\:background-color_\\.2s_linear\\,_height_\\.2s_ease-in-out\\]{transition:background-color .2s linear,height .2s ease-in-out}.\\[transition\\:background-color_\\.2s_linear\\,_width_\\.2s_ease-in-out\\,_opacity\\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\\[transition\\:background-color_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,box-shadow_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,border_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\\/ps\\:opacity-60:is(:where(.group\\/ps):hover *){opacity:.6}.group-hover\\/x\\:h-\\[11px\\]:is(:where(.group\\/x):hover *){height:11px}.group-hover\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):hover *){background-color:#999}.group-hover\\/y\\:w-\\[11px\\]:is(:where(.group\\/y):hover *){width:11px}.group-hover\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):hover *){background-color:#999}}.group-focus\\/ps\\:opacity-60:is(:where(.group\\/ps):focus *){opacity:.6}.group-focus\\/ps\\:opacity-100:is(:where(.group\\/ps):focus *){opacity:1}.group-focus\\/x\\:h-\\[0\\.6875rem\\]:is(:where(.group\\/x):focus *){height:.6875rem}.group-focus\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):focus *){background-color:#999}.group-focus\\/y\\:w-\\[0\\.6875rem\\]:is(:where(.group\\/y):focus *){width:.6875rem}.group-focus\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):focus *){background-color:#999}.group-active\\/ps\\:opacity-100:is(:where(.group\\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:calc(var(--spacing) * 0)}.group-data-te-collapse-collapsed\\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\\:fill-\\[\\#212529\\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\\[te-input-focused\\]\\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-focused\\]\\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-focused\\]\\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-focused\\]\\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-focused\\]\\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-focused\\]\\:border-\\[\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\\[te-input-focused\\]\\:border-\\[\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\\[te-input-focused\\]\\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\\[te-input-focused\\]\\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\\[te-input-focused\\]\\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-state-active\\]\\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-state-active\\]\\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-state-active\\]\\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-state-active\\]\\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-state-active\\]\\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-state-active\\]\\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\\[te-select-option-group-ref\\]\\/opt\\:pl-7:is(:where(.group\\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\\[te-was-validated\\]\\/validation\\:mb-4:is(:where(.group\\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-x *){display:block}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-x *){background-color:#0000}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-y *){display:block}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-y *){background-color:#0000}.group-\\[\\&\\.ps--clicking\\]\\/x\\:h-\\[11px\\]:is(:where(.group\\/x).ps--clicking *){height:11px}.group-\\[\\&\\.ps--clicking\\]\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--clicking\\]\\/y\\:w-\\[11px\\]:is(:where(.group\\/y).ps--clicking *){width:11px}.group-\\[\\&\\.ps--clicking\\]\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--scrolling-x\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-x *),.group-\\[\\&\\.ps--scrolling-y\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-y *){opacity:.6}.group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:text-green-600:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:text-\\[rgb\\(220\\,76\\,100\\)\\]:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:scale-\\[0\\.8\\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\\:\\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\\[te-input-focused\\]\\:\\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\\[te-input-focused\\]\\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\\:bg-transparent ::selection{background-color:#0000}.selection\\:bg-transparent::selection{background-color:#0000}.before\\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\\:absolute:before{content:var(--tw-content);position:absolute}.before\\:h-\\[0\\.875rem\\]:before{content:var(--tw-content);height:.875rem}.before\\:w-\\[0\\.875rem\\]:before{content:var(--tw-content);width:.875rem}.before\\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\\:opacity-0:before{content:var(--tw-content);opacity:0}.before\\:shadow-\\[0px_0px_0px_13px_transparent\\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\\:content-\\[\\'\\'\\]:before{--tw-content:\"\";content:var(--tw-content)}.odd\\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\\:\\!border-\\[\\#14a44d\\]:checked{border-color:#14a44d!important}.checked\\:\\!border-\\[\\#dc4c64\\]:checked{border-color:#dc4c64!important}.checked\\:border-primary:checked{border-color:#3b71ca}.checked\\:\\!bg-\\[\\#14a44d\\]:checked{background-color:#14a44d!important}.checked\\:\\!bg-\\[\\#dc4c64\\]:checked{background-color:#dc4c64!important}.checked\\:bg-primary:checked{background-color:#3b71ca}.checked\\:before\\:opacity-\\[0\\.16\\]:checked:before{content:var(--tw-content);opacity:.16}.checked\\:after\\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\\:after\\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\\:after\\:ml-\\[0\\.25rem\\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\\:after\\:block:checked:after{content:var(--tw-content);display:block}.checked\\:after\\:h-\\[0\\.8125rem\\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\\:after\\:w-\\[0\\.375rem\\]:checked:after{content:var(--tw-content);width:.375rem}.checked\\:after\\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\\:after\\:border-\\[0\\.125rem\\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:after\\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:after\\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:after\\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:after\\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:after\\:\\!bg-\\[\\#14a44d\\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\\:after\\:\\!bg-\\[\\#dc4c64\\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\\:after\\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\\:after\\:content-\\[\\'\\'\\]:checked:after{--tw-content:\"\";content:var(--tw-content)}.empty\\:hidden:empty{display:none}@media (hover:hover){.hover\\:z-2:hover{z-index:2}.hover\\:cursor-pointer:hover{cursor:pointer}.hover\\:rounded-\\[50\\%\\]:hover{border-radius:50%}.hover\\:\\!bg-\\[\\#eee\\]:hover{background-color:#eee!important}.hover\\:bg-\\[\\#00000014\\]:hover{background-color:#00000014}.hover\\:bg-\\[\\#00000026\\]:hover{background-color:#00000026}.hover\\:bg-\\[unset\\]:hover{background-color:unset}.hover\\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\\:bg-primary-600:hover{background-color:#3061af}.hover\\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\\:fill-\\[\\#8b8b8b\\]:hover{fill:#8b8b8b}.hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.hover\\:text-\\[\\#8b8b8b\\]:hover{color:#8b8b8b}.hover\\:text-primary:hover{color:#3b71ca}.hover\\:text-primary-600:hover{color:#3061af}.hover\\:text-white:hover{color:var(--color-white)}.hover\\:\\!opacity-90:hover{opacity:.9!important}.hover\\:opacity-100:hover{opacity:1}.hover\\:\\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\\:before\\:opacity-\\[0\\.04\\]:hover:before{content:var(--tw-content);opacity:.04}.hover\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\\:z-3:focus{z-index:3}.focus\\:rounded-\\[50\\%\\]:focus{border-radius:50%}.focus\\:\\!border-\\[\\#14a44d\\]:focus{border-color:#14a44d!important}.focus\\:\\!border-\\[\\#dc4c64\\]:focus{border-color:#dc4c64!important}.focus\\:border-primary:focus{border-color:#3b71ca}.focus\\:\\!bg-\\[\\#eee\\]:focus{background-color:#eee!important}.focus\\:bg-\\[\\#00000014\\]:focus{background-color:#00000014}.focus\\:bg-\\[\\#00000026\\]:focus{background-color:#00000026}.focus\\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\\:bg-primary-600:focus{background-color:#3061af}.focus\\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.focus\\:text-gray-700:focus{color:var(--color-gray-700)}.focus\\:text-primary:focus{color:#3b71ca}.focus\\:text-primary-600:focus{color:#3061af}.focus\\:text-white:focus{color:var(--color-white)}.focus\\:\\!opacity-90:focus{opacity:.9!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#14a44d\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#dc4c64\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\\:transition-\\[border-color_0\\.2s\\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\\:placeholder\\:opacity-100:focus::placeholder{opacity:1}.focus\\:before\\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\\:before\\:opacity-\\[0\\.12\\]:focus:before{content:var(--tw-content);opacity:.12}.focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:after\\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\\:after\\:z-\\[1\\]:focus:after{content:var(--tw-content);z-index:1}.focus\\:after\\:block:focus:after{content:var(--tw-content);display:block}.focus\\:after\\:h-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);height:.875rem}.focus\\:after\\:w-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);width:.875rem}.focus\\:after\\:rounded-\\[0\\.125rem\\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\\:after\\:content-\\[\\'\\'\\]:focus:after{--tw-content:\"\";content:var(--tw-content)}.checked\\:focus\\:before\\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\\:focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\\:focus\\:after\\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\\:focus\\:after\\:ml-\\[0\\.25rem\\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\\:focus\\:after\\:h-\\[0\\.8125rem\\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\\:focus\\:after\\:w-\\[0\\.375rem\\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\\:focus\\:after\\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\\:focus\\:after\\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\\:focus\\:after\\:border-\\[0\\.125rem\\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:focus\\:after\\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:focus\\:after\\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:focus\\:after\\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:focus\\:after\\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:focus\\:after\\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\\:z-60:active{z-index:60}.active\\:bg-\\[\\#c4d4ef\\]:active{background-color:#c4d4ef}.active\\:bg-\\[\\#cacfd1\\]:active{background-color:#cacfd1}.active\\:bg-primary-700:active{background-color:#285192}.active\\:bg-primary-accent-200:active{background-color:#cedbee}.active\\:text-primary-700:active{color:#285192}.active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\\:grid[data-te-dropdown-show]{display:grid}.data-\\[data-te-autocomplete-option-disabled\\]\\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\\[data-te-autocomplete-option-disabled\\]\\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\\[popper-reference-hidden\\]\\:hidden[data-popper-reference-hidden]{display:none}.data-\\[te-active\\]\\:-top-\\[38px\\][data-te-active]{top:-38px}.data-\\[te-active\\]\\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-autocomplete-state-open\\]\\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-state-open\\]\\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\\[te-carousel-fade\\]\\:z-0[data-te-carousel-fade]{z-index:0}.data-\\[te-carousel-fade\\]\\:z-\\[1\\][data-te-carousel-fade]{z-index:1}.data-\\[te-carousel-fade\\]\\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\\[te-carousel-fade\\]\\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\\[te-carousel-fade\\]\\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\\[te-carousel-fade\\]\\:duration-\\[600ms\\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\\[te-datepicker-cell-disabled\\]\\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\\[te-datepicker-cell-disabled\\]\\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\\[te-datepicker-cell-disabled\\]\\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\\[te-datepicker-cell-disabled\\]\\:hover\\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\\[\\[data-te-datepicker-cell-focused\\]\\]\\:data-\\[te-datepicker-cell-selected\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\\[te-input-disabled\\]\\:cursor-default[data-te-input-disabled]{cursor:default}.data-\\[te-input-disabled\\]\\:bg-\\[\\#e9ecef\\][data-te-input-disabled]{background-color:#e9ecef}.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:block[data-te-input-state-active]{display:block}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\][data-te-input-state-active]{scale:.8}.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:placeholder\\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\\[te-select-open\\]\\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-select-open\\]\\:opacity-100[data-te-select-open]{opacity:1}.data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\\:transform-none{transform:none}.motion-reduce\\:animate-\\[spin_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spin}.motion-reduce\\:animate-\\[spinner-grow_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\\:animate-none{animation:none}.motion-reduce\\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\\:block{display:block}.sm\\:grid{display:grid}.sm\\:hidden{display:none}.sm\\:w-40{width:calc(var(--spacing) * 40)}.sm\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\\:grid-cols-\\[10\\%_90\\%\\]{grid-template-columns:10% 90%}.sm\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\\:break-words{overflow-wrap:break-word}.sm\\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\\:order-none{order:0}.md\\:my-0{margin-block:calc(var(--spacing) * 0)}.md\\:mb-0{margin-bottom:calc(var(--spacing) * 0)}.md\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\\:flex-row{flex-direction:row}.md\\:pr-1{padding-right:calc(var(--spacing) * 1)}.md\\:pr-\\[17px\\]{padding-right:17px}}@media (min-width:64rem){.lg\\:sticky{position:sticky}.lg\\:block{display:block}.lg\\:grid{display:grid}.lg\\:hidden{display:none}.lg\\:w-32{width:calc(var(--spacing) * 32)}.lg\\:w-36{width:calc(var(--spacing) * 36)}.lg\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\\:w-52{width:calc(var(--spacing) * 52)}.xl\\:grid-flow-col{grid-auto-flow:column}.xl\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:h-auto{height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[305px\\]{min-height:305px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[auto\\]{min-height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-w-\\[auto\\]{min-width:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!flex-row{flex-direction:row!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:flex-col{flex-direction:column}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!justify-around{justify-content:space-around!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:overflow-y-auto{overflow-y:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-lg{border-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-tr-none{border-top-right-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-none{border-bottom-left-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:p-\\[10px\\]{padding:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:pr-\\[10px\\]{padding-right:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-\\[3rem\\]{font-size:3rem}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\\:max-md\\:landscape\\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\\:max-md\\:landscape\\:h-8{height:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:h-\\[360px\\]{height:360px}.xs\\:max-md\\:landscape\\:h-full{height:100%}.xs\\:max-md\\:landscape\\:w-8{width:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:w-\\[475px\\]{width:475px}.xs\\:max-md\\:landscape\\:flex-row{flex-direction:row}}}}.rtl\\:\\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\\:\\!origin-\\[50\\%_50\\%_0\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\\:\\[direction\\:rtl\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\\:border-\\[\\#4f4f4f\\]{border-color:#4f4f4f}.dark\\:border-\\[\\#14a44d\\]{border-color:#14a44d}.dark\\:border-\\[\\#dc4c64\\]{border-color:#dc4c64}.dark\\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\\:border-primary-400{border-color:#8faee0}.dark\\:\\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\\:bg-\\[\\#4f4f4f\\]{background-color:#4f4f4f}.dark\\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\\:bg-primary-600{background-color:#3061af}.dark\\:bg-transparent{background-color:#0000}.dark\\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\\:bg-zinc-600\\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\\:bg-zinc-600\\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\\:fill-gray-400{fill:var(--color-gray-400)}.dark\\:\\!text-primary-400{color:#8faee0!important}.dark\\:text-gray-200{color:var(--color-gray-200)}.dark\\:text-gray-300{color:var(--color-gray-300)}.dark\\:text-neutral-200{color:var(--color-neutral-200)}.dark\\:text-neutral-300{color:var(--color-neutral-300)}.dark\\:text-neutral-400{color:var(--color-neutral-400)}.dark\\:text-primary-400{color:#8faee0}.dark\\:text-white{color:var(--color-white)}.dark\\:shadow-\\[0_4px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.5\\)\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\\:group-\\[\\[data-te-datepicker-cell-disabled\\]\\]\\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\\:peer-focus\\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\\:peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\\:placeholder\\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\\:checked\\:border-primary:checked{border-color:#3b71ca}.dark\\:checked\\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\\:hover\\:\\!bg-\\[\\#555\\]:hover{background-color:#555!important}.dark\\:hover\\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\\:hover\\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\\:hover\\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\\:hover\\:bg-white\\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:bg-white\\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:hover\\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\\:hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.dark\\:hover\\:text-primary-400:hover{color:#8faee0}.dark\\:hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\\:focus\\:\\!bg-\\[\\#555\\]:focus{background-color:#555!important}.dark\\:focus\\:bg-white\\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:focus\\:bg-white\\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.dark\\:focus\\:text-primary-400:focus{color:#8faee0}.dark\\:focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(255\\,255\\,255\\,0\\.4\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:disabled\\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\\:disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-buttons-timepicker\\]\\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\\:data-\\[te-input-disabled\\]\\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\\:block{display:block}.print\\:hidden{display:none}.print\\:border-none{--tw-border-style:none;border-style:none}.print\\:border-black{border-color:var(--color-black)}.print\\:bg-white{background-color:var(--color-white)}.print\\:text-left{text-align:left}.print\\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#eee\\].ps--clicking{background-color:#eee!important}.\\[\\&\\.ps--clicking\\]\\:\\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#555\\].ps--clicking{background-color:#555!important}}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:h-1::-webkit-scrollbar{height:calc(var(--spacing) * 1)}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:w-1::-webkit-scrollbar{width:calc(var(--spacing) * 1)}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:block::-webkit-scrollbar-button{display:block}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:h-0::-webkit-scrollbar-button{height:calc(var(--spacing) * 0)}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:h-\\[50px\\]::-webkit-scrollbar-thumb{height:50px}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:bg-\\[\\#999\\]::-webkit-scrollbar-thumb{background-color:#999}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:\\[box-shadow\\:inset_0_-1px_0_rgba\\(229\\,231\\,235\\)\\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\\[\\&\\:not\\(\\[data-te-input-placeholder-active\\]\\)\\]\\:placeholder\\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:nth-child\\(odd\\)\\]\\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\\[\\&\\:nth-child\\(odd\\)\\]\\:dark\\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\\[\\&\\>svg\\]\\:pointer-events-none>svg{pointer-events:none}.\\[\\&\\>svg\\]\\:mx-auto>svg{margin-inline:auto}.\\[\\&\\>svg\\]\\:h-4>svg{height:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:h-5>svg{height:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:h-6>svg{height:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:w-4>svg{width:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:w-5>svg{width:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:w-6>svg{width:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:rotate-180>svg{rotate:180deg}.\\[\\&\\>svg\\]\\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\>svg\\]\\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:\"\";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:\"\";inherits:false;initial-value:0}@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-z{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-pan-x{syntax:\"*\";inherits:false}@property --tw-pan-y{syntax:\"*\";inherits:false}@property --tw-pinch-zoom{syntax:\"*\";inherits:false}@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-tracking{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@property --tw-drop-shadow-color{syntax:\"*\";inherits:false}@property --tw-drop-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:\"*\";inherits:false}@property --tw-duration{syntax:\"*\";inherits:false}@property --tw-ease{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}}" as const; \ No newline at end of file +export const css = "/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:\"\"}}}@layer theme{:root,:host{--color-red-700:oklch(50.5% .213 27.518);--color-green-600:oklch(62.7% .194 149.214);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-900:oklch(37.9% .146 265.522);--color-slate-300:oklch(86.9% .022 252.894);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-50:oklch(98.5% 0 none);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-normal:0em;--leading-normal:1.5;--leading-loose:2;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-webkit-slider-thumb{background:#8faee0}input[type=range]:disabled::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(70.8% 0 none)}input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:focus::-webkit-slider-thumb{background:oklch(55.6% 0 none)}.dark input[type=range]:disabled:active::-webkit-slider-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-thumb{appearance:none;cursor:pointer;background:#3b71ca;border:0;border-radius:9999px;width:1rem;height:1rem}.dark input[type=range]::-moz-range-thumb{background:#8faee0}input[type=range]:disabled::-moz-range-thumb{background:oklch(70.8% 0 none)}.dark input[type=range]:disabled::-moz-range-thumb{background:oklch(55.6% 0 none)}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}.dark input[type=range]::-moz-range-progress{background:#6590d5}.dark input[type=range]::-ms-fill-lower{background:#6590d5}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}.dark input[type=range]:focus::-webkit-slider-thumb{background:#6590d5}.dark input[type=range]:active::-webkit-slider-thumb{background:#3061af}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.\\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.\\!absolute{position:absolute!important}.\\!fixed{position:fixed!important}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-\\[18px\\]{top:-18px}.-top-\\[21px\\]{top:-21px}.-top-\\[35px\\]{top:-35px}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-\\[11px\\]{top:11px}.top-\\[13px\\]{top:13px}.top-\\[50\\%\\]{top:50%}.top-\\[50px\\]{top:50px}.top-full{top:100%}.right-0{right:0}.right-0\\.5{right:calc(var(--spacing) * .5)}.right-1{right:var(--spacing)}.right-1\\.5{right:calc(var(--spacing) * 1.5)}.right-3{right:calc(var(--spacing) * 3)}.right-9{right:calc(var(--spacing) * 9)}.-bottom-\\[47px\\]{bottom:-47px}.bottom-0{bottom:0}.bottom-0\\.5{bottom:calc(var(--spacing) * .5)}.bottom-1{bottom:var(--spacing)}.bottom-1\\/2{bottom:50%}.-left-\\[15px\\]{left:-15px}.-left-\\[9999px\\]{left:-9999px}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.left-\\[50\\%\\]{left:50%}.left-\\[50px\\]{left:50px}.left-\\[calc\\(50\\%-1px\\)\\]{left:calc(50% - 1px)}.isolate{isolation:isolate}.\\!z-40{z-index:40!important}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\\[2\\]{z-index:2}.z-\\[999\\]{z-index:999}.z-\\[1035\\]{z-index:1035}.z-\\[1040\\]{z-index:1040}.z-\\[1065\\]{z-index:1065}.z-\\[1066\\]{z-index:1066}.z-\\[1070\\]{z-index:1070}.z-\\[1080\\]{z-index:1080}.z-\\[1100\\]{z-index:1100}.order-1{order:1}.order-2{order:2}.order-3{order:3}.float-left{float:left}.float-right{float:right}.container{width:100%}@media (min-width:320px){.container{max-width:320px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\\!{width:100%!important}@media (min-width:320px){.container\\!{max-width:320px!important}}@media (min-width:40rem){.container\\!{max-width:40rem!important}}@media (min-width:48rem){.container\\!{max-width:48rem!important}}@media (min-width:64rem){.container\\!{max-width:64rem!important}}@media (min-width:80rem){.container\\!{max-width:80rem!important}}@media (min-width:96rem){.container\\!{max-width:96rem!important}}.\\!-m-px{margin:-1px!important}.-m-px{margin:-1px}.m-0{margin:0}.m-1{margin:var(--spacing)}.m-auto{margin:auto}.mx-0{margin-inline:0}.mx-1{margin-inline:var(--spacing)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-6{margin-inline:calc(var(--spacing) * 6)}.mx-\\[10px\\]{margin-inline:10px}.mx-auto{margin-inline:auto}.\\!my-0{margin-block:0!important}.my-0{margin-block:0}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-\\[5px\\]{margin-block:5px}.me-auto{margin-inline-end:auto}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.mt-0{margin-top:0}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-11{margin-top:calc(var(--spacing) * 11)}.mt-\\[0\\.15rem\\]{margin-top:.15rem}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-8{margin-right:calc(var(--spacing) * 8)}.mr-\\[6px\\]{margin-right:6px}.mr-\\[8px\\]{margin-right:8px}.mr-auto{margin-right:auto}.mb-0{margin-bottom:0}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-\\[0\\.125rem\\]{margin-bottom:.125rem}.mb-\\[10px\\]{margin-bottom:10px}.-ml-\\[1\\.5rem\\]{margin-left:-1.5rem}.ml-0{margin-left:0}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-\\[3px\\]{margin-left:3px}.ml-\\[30px\\]{margin-left:30px}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.\\!block{display:block!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.\\!h-0{height:0!important}.\\!h-px{height:1px!important}.h-0{height:0}.h-1{height:var(--spacing)}.h-1\\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\\/5{height:40%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-14{height:calc(var(--spacing) * 14)}.h-\\[0\\.9375rem\\]{height:.9375rem}.h-\\[1\\.4rem\\]{height:1.4rem}.h-\\[1\\.125rem\\]{height:1.125rem}.h-\\[2px\\]{height:2px}.h-\\[4px\\]{height:4px}.h-\\[6px\\]{height:6px}.h-\\[10px\\]{height:10px}.h-\\[30px\\]{height:30px}.h-\\[32px\\]{height:32px}.h-\\[40px\\]{height:40px}.h-\\[42px\\]{height:42px}.h-\\[48px\\]{height:48px}.h-\\[50px\\]{height:50px}.h-\\[56px\\]{height:56px}.h-\\[72px\\]{height:72px}.h-\\[100px\\]{height:100px}.h-\\[120px\\]{height:120px}.h-\\[160px\\]{height:160px}.h-\\[260px\\]{height:260px}.h-\\[380px\\]{height:380px}.h-\\[512px\\]{height:512px}.h-\\[calc\\(100\\%-100px\\)\\]{height:calc(100% - 100px)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\\[calc\\(100\\%-64px\\)\\]{max-height:calc(100% - 64px)}.max-h-full{max-height:100%}.min-h-\\[1\\.5rem\\]{min-height:1.5rem}.min-h-\\[40px\\]{min-height:40px}.min-h-\\[305px\\]{min-height:305px}.min-h-\\[325px\\]{min-height:325px}.min-h-\\[auto\\]{min-height:auto}.\\!w-px{width:1px!important}.w-0{width:0}.w-1{width:var(--spacing)}.w-1\\.5{width:calc(var(--spacing) * 1.5)}.w-1\\/2{width:50%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\\[0\\.9375rem\\]{width:.9375rem}.w-\\[1\\.4rem\\]{width:1.4rem}.w-\\[1\\.125rem\\]{width:1.125rem}.w-\\[2px\\]{width:2px}.w-\\[4px\\]{width:4px}.w-\\[6px\\]{width:6px}.w-\\[15px\\]{width:15px}.w-\\[30px\\]{width:30px}.w-\\[32px\\]{width:32px}.w-\\[45\\%\\]{width:45%}.w-\\[50px\\]{width:50px}.w-\\[70px\\]{width:70px}.w-\\[72px\\]{width:72px}.w-\\[76px\\]{width:76px}.w-\\[150px\\]{width:150px}.w-\\[160px\\]{width:160px}.w-\\[260px\\]{width:260px}.w-\\[300px\\]{width:300px}.w-\\[304px\\]{width:304px}.w-\\[328px\\]{width:328px}.w-\\[calc\\(100\\%-100px\\)\\]{width:calc(100% - 100px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-\\[90\\%\\]{max-width:90%}.max-w-\\[200px\\]{max-width:200px}.max-w-\\[267px\\]{max-width:267px}.max-w-\\[325px\\]{max-width:325px}.max-w-\\[calc\\(100\\%-1rem\\)\\]{max-width:calc(100% - 1rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-\\[48px\\]{min-width:48px}.min-w-\\[64px\\]{min-width:64px}.min-w-\\[100px\\]{min-width:100px}.min-w-\\[310px\\]{min-width:310px}.min-w-full{min-width:100%}.flex-auto{flex:auto}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:calc(var(--spacing) * 2);border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-\\[0_0\\]{transform-origin:0 0}.origin-\\[50\\%_50\\%\\]{transform-origin:50%}.origin-\\[center_bottom_0\\]{transform-origin:center bottom 0}.origin-bottom{transform-origin:bottom}.-translate-x-1{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-1\\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\\[6px\\]{--tw-translate-x:calc(6px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-\\[50\\%\\]{--tw-translate-x:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\\[150\\%\\]{--tw-translate-x:150%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-\\[50\\%\\]{--tw-translate-y:calc(50% * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\\[6px\\]{--tw-translate-y:6px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-y-\\[0\\.8\\]{--tw-scale-y:.8;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\\[0\\.25\\]{scale:.25}.scale-\\[1\\.02\\]{scale:1.02}.-rotate-45{rotate:-45deg}.rotate-45{rotate:45deg}.rotate-180{rotate:180deg}.rotate-\\[-180deg\\]{rotate:-180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.transform-none{transform:none}.animate-\\[fade-in_0\\.3s_both\\]{animation:.3s both fade-in}.animate-\\[fade-in_0\\.15s_both\\]{animation:.15s both fade-in}.animate-\\[fade-in_350ms_ease-in-out\\]{animation:.35s ease-in-out fade-in}.animate-\\[fade-out_0\\.3s_both\\]{animation:.3s both fade-out}.animate-\\[fade-out_0\\.15s_both\\]{animation:.15s both fade-out}.animate-\\[fade-out_350ms_ease-in-out\\]{animation:.35s ease-in-out fade-out}.animate-\\[progress_3s_ease-in-out_infinite\\]{animation:3s ease-in-out infinite progress}.animate-\\[show-up-clock_350ms_linear\\]{animation:.35s linear show-up-clock}.animate-\\[slide-in-left_0\\.8s_both\\]{animation:.8s both slide-in-left}.animate-\\[slide-in-right_0\\.8s_both\\]{animation:.8s both slide-in-right}.animate-\\[slide-out-left_0\\.8s_both\\]{animation:.8s both slide-out-left}.animate-\\[slide-out-right_0\\.8s_both\\]{animation:.8s both slide-out-right}.animate-\\[spinner-grow_0\\.75s_linear_infinite\\]{animation:.75s linear infinite spinner-grow}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-row{grid-auto-flow:row}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-\\[0\\.5rem\\]{border-radius:.5rem}.rounded-\\[0\\.6rem\\]{border-radius:.6rem}.rounded-\\[0\\.25rem\\]{border-radius:.25rem}.rounded-\\[10px\\]{border-radius:10px}.rounded-\\[16px\\]{border-radius:16px}.rounded-\\[50\\%\\]{border-radius:50%}.rounded-\\[100\\%\\]{border-radius:100%}.rounded-\\[999px\\]{border-radius:999px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\\[0\\.6rem\\]{border-top-left-radius:.6rem;border-top-right-radius:.6rem}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-l-\\[0\\.25rem\\]{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl-2xl{border-top-left-radius:var(--radius-2xl)}.rounded-r-\\[0\\.25rem\\]{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-2xl{border-top-right-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-2xl{border-bottom-right-radius:var(--radius-2xl)}.rounded-bl-none{border-bottom-left-radius:0}.\\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.\\!border-\\[3px\\]{border-style:var(--tw-border-style)!important;border-width:3px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\\[\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[0\\.15em\\]{border-style:var(--tw-border-style);border-width:.15em}.border-\\[0\\.125rem\\]{border-style:var(--tw-border-style);border-width:.125rem}.border-\\[1px\\]{border-style:var(--tw-border-style);border-width:1px}.border-\\[14px\\]{border-style:var(--tw-border-style);border-width:14px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-\\[0\\.125rem\\]{border-left-style:var(--tw-border-style);border-left-width:.125rem}.\\!border-solid{--tw-border-style:solid!important;border-style:solid!important}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.\\!border-\\[\\#14a44d\\]{border-color:#14a44d!important}.\\!border-\\[\\#b2b3b4\\]{border-color:#b2b3b4!important}.\\!border-\\[\\#dc4c64\\]{border-color:#dc4c64!important}.border-\\[\\#3b71ca\\]{border-color:#3b71ca}.border-\\[\\#14a44d\\]{border-color:#14a44d}.border-\\[\\#dc4c64\\]{border-color:#dc4c64}.border-\\[\\#eee\\]{border-color:#eee}.border-black{border-color:var(--color-black)}.border-current{border-color:currentColor}.border-gray-300{border-color:var(--color-gray-300)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-primary{border-color:#3b71ca}.border-transparent{border-color:#0000}.border-r-transparent{border-right-color:#0000}.\\!bg-\\[\\#858585\\]{background-color:#858585!important}.\\!bg-danger-100{background-color:#fae5e9!important}.\\!bg-neutral-100{background-color:var(--color-neutral-100)!important}.\\!bg-primary-100{background-color:#e3ebf7!important}.\\!bg-success-100{background-color:#d6fae4!important}.bg-\\[\\#000000e6\\]{background-color:#000000e6}.bg-\\[\\#3b71ca\\]{background-color:#3b71ca}.bg-\\[\\#6d6d6d\\]{background-color:#6d6d6d}.bg-\\[\\#00000012\\]{background-color:#00000012}.bg-\\[\\#00000066\\]{background-color:#0006}.bg-\\[\\#aaa\\]{background-color:#aaa}.bg-\\[\\#eceff1\\]{background-color:#eceff1}.bg-\\[\\#eee\\]{background-color:#eee}.bg-\\[rgba\\(0\\,0\\,0\\,0\\.4\\)\\]{background-color:#0006}.bg-black{background-color:var(--color-black)}.bg-black\\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-current{background-color:currentColor}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-inherit{background-color:inherit}.bg-primary{background-color:#3b71ca}.bg-primary-100{background-color:#e3ebf7}.bg-primary-400{background-color:#8faee0}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-clip-padding{background-clip:padding-box}.fill-\\[\\#336dec\\]{fill:#336dec}.fill-\\[\\#afafaf\\]{fill:#afafaf}.fill-current{fill:currentColor}.\\!p-0{padding:0!important}.p-0{padding:0}.p-2{padding:calc(var(--spacing) * 2)}.p-2\\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-\\[1rem\\]{padding:1rem}.p-\\[5px\\]{padding:5px}.p-\\[auto\\]{padding:auto}.px-0{padding-inline:0}.px-0\\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\\[0\\.4rem\\]{padding-inline:.4rem}.px-\\[1\\.4rem\\]{padding-inline:1.4rem}.px-\\[10px\\]{padding-inline:10px}.px-\\[12px\\]{padding-inline:12px}.px-\\[auto\\]{padding-inline:auto}.\\!py-0{padding-block:0!important}.\\!py-2{padding-block:calc(var(--spacing) * 2)!important}.py-0{padding-block:0}.py-1{padding-block:var(--spacing)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-\\[0\\.4rem\\]{padding-block:.4rem}.py-\\[0\\.32rem\\]{padding-block:.32rem}.py-\\[0\\.33rem\\]{padding-block:.33rem}.py-\\[0\\.4375rem\\]{padding-block:.4375rem}.py-\\[1px\\]{padding-block:1px}.py-\\[5px\\]{padding-block:5px}.py-\\[10px\\]{padding-block:10px}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-\\[0\\.37rem\\]{padding-top:.37rem}.pt-\\[6px\\]{padding-top:6px}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-\\[24px\\]{padding-right:24px}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-\\[5px\\]{padding-bottom:5px}.pl-0{padding-left:0}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\\[1\\.5rem\\]{padding-left:1.5rem}.pl-\\[8px\\]{padding-left:8px}.pl-\\[18px\\]{padding-left:18px}.pl-\\[50px\\]{padding-left:50px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\\[-0\\.125em\\]{vertical-align:-.125em}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[0\\.8rem\\]{font-size:.8rem}.text-\\[0\\.9rem\\]{font-size:.9rem}.text-\\[1\\.1rem\\]{font-size:1.1rem}.text-\\[2\\.5rem\\]{font-size:2.5rem}.text-\\[3\\.75rem\\]{font-size:3.75rem}.text-\\[10px\\]{font-size:10px}.text-\\[12px\\]{font-size:12px}.text-\\[13px\\]{font-size:13px}.text-\\[16px\\]{font-size:16px}.text-\\[18px\\]{font-size:18px}.text-\\[34px\\]{font-size:34px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-9{--tw-leading:calc(var(--spacing) * 9);line-height:calc(var(--spacing) * 9)}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\\[1\\.2\\]{--tw-leading:1.2;line-height:1.2}.leading-\\[1\\.5\\]{--tw-leading:1.5;line-height:1.5}.leading-\\[1\\.6\\]{--tw-leading:1.6;line-height:1.6}.leading-\\[2\\.15\\]{--tw-leading:2.15;line-height:2.15}.leading-\\[40px\\]{--tw-leading:40px;line-height:40px}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\\[-0\\.00833em\\]{--tw-tracking:-.00833em;letter-spacing:-.00833em}.tracking-\\[\\.1rem\\],.tracking-\\[0\\.1rem\\]{--tw-tracking:.1rem;letter-spacing:.1rem}.tracking-\\[1\\.7px\\]{--tw-tracking:1.7px;letter-spacing:1.7px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.\\!whitespace-nowrap{white-space:nowrap!important}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\\!text-\\[\\#14a44d\\]{color:#14a44d!important}.\\!text-\\[\\#dc4c64\\]{color:#dc4c64!important}.\\!text-danger-700{color:#b0233a!important}.\\!text-gray-50{color:var(--color-gray-50)!important}.\\!text-primary{color:#3b71ca!important}.\\!text-primary-700{color:#285192!important}.\\!text-success-700{color:#0e7537!important}.text-\\[\\#3b71ca\\]{color:#3b71ca}.text-\\[\\#4f4f4f\\]{color:#4f4f4f}.text-\\[\\#14a44d\\]{color:#14a44d}.text-\\[\\#212529\\]{color:#212529}.text-\\[\\#b3afaf\\]{color:#b3afaf}.text-\\[\\#b3b3b3\\]{color:#b3b3b3}.text-\\[\\#dc4c64\\]{color:#dc4c64}.text-\\[\\#ffffff8a\\]{color:#ffffff8a}.text-\\[rgb\\(220\\,76\\,100\\)\\]{color:#dc4c64}.text-black{color:var(--color-black)}.text-black\\/50{color:#00000080}@supports (color:color-mix(in lab, red, red)){.text-black\\/50{color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.text-black\\/\\[64\\]{color:#000}@supports (color:color-mix(in lab, red, red)){.text-black\\/\\[64\\]{color:color-mix(in oklab, var(--color-black) 6400%, transparent)}}.text-danger{color:#dc4c64}.text-gray-50{color:var(--color-gray-50)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-green-600{color:var(--color-green-600)}.text-info{color:#54b4d3}.text-neutral-50{color:var(--color-neutral-50)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-primary{color:#3b71ca}.text-primary-700{color:#285192}.text-red-700{color:var(--color-red-700)}.text-secondary{color:#9fa6b2}.text-success{color:#14a44d}.text-warning{color:#e4a11b}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-auto{text-underline-offset:auto}.\\!opacity-0{opacity:0!important}.\\!opacity-100{opacity:1!important}.opacity-0{opacity:0}.opacity-1{opacity:.01}.opacity-50{opacity:.5}.opacity-100{opacity:1}.opacity-\\[\\.53\\]{opacity:.53}.opacity-\\[\\.54\\]{opacity:.54}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_0px_3px_0_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_2px_2px_0_rgba\\(0\\,0\\,0\\,0\\.04\\)\\]{--tw-shadow:0 0px 3px 0 var(--tw-shadow-color,#00000012), 0 2px 2px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_2px_5px_0_rgba\\(0\\,0\\,0\\,0\\.16\\)\\,_0_2px_10px_0_rgba\\(0\\,0\\,0\\,0\\.12\\)\\]{--tw-shadow:0 2px 5px 0 var(--tw-shadow-color,#00000029), 0 2px 10px 0 var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_4px_9px_-4px_\\#3b71ca\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0_10px_15px_-3px_rgba\\(0\\,0\\,0\\,0\\.07\\)\\,0_4px_6px_-2px_rgba\\(0\\,0\\,0\\,0\\.05\\)\\]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#00000012), 0 4px 6px -2px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\\[0px_2px_15px_-3px_rgba\\(0\\,0\\,0\\,\\.07\\)\\,_0px_10px_20px_-2px_rgba\\(0\\,0\\,0\\,\\.04\\)\\]{--tw-shadow:0px 2px 15px -3px var(--tw-shadow-color,#00000012), 0px 10px 20px -2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm,.shadow\\/login,.shadow\\/passwd{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,_opacity\\]{transition-property:background-color,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[background-color\\,box-shadow\\,border\\]{transition-property:background-color,box-shadow,border;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[height\\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[opacity\\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,_opacity\\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[transform\\,height\\]{transition-property:transform,height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\\[width\\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-\\[0ms\\]{transition-delay:0s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-\\[150ms\\]{--tw-duration:.15s;transition-duration:.15s}.duration-\\[200ms\\]{--tw-duration:.2s;transition-duration:.2s}.duration-\\[250ms\\]{--tw-duration:.25s;transition-duration:.25s}.duration-\\[350ms\\]{--tw-duration:.35s;transition-duration:.35s}.duration-\\[400ms\\]{--tw-duration:.4s;transition-duration:.4s}.duration-\\[1000ms\\]{--tw-duration:1s;transition-duration:1s}.ease-\\[cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\,_cubic-bezier\\(0\\,0\\,0\\.15\\,1\\)\\]{--tw-ease:cubic-bezier(0,0,.15,1), cubic-bezier(0,0,.15,1);transition-timing-function:cubic-bezier(0,0,.15,1),cubic-bezier(0,0,.15,1)}.ease-\\[cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)\\]{--tw-ease:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\)\\],.ease-\\[cubic-bezier\\(0\\.25\\,0\\.1\\,0\\.25\\,1\\.0\\)\\]{--tw-ease:cubic-bezier(.25,.1,.25,1);transition-timing-function:ease}.ease-\\[ease\\]{--tw-ease:ease;transition-timing-function:ease}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\\!\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)!important}.\\[bash\\:1221\\]{bash:1221}.\\[check\\:5737\\]{check:5737}.\\[clip\\:rect\\(0\\,0\\,0\\,0\\)\\]{clip:rect(0,0,0,0)}.\\[direction\\:ltr\\]{direction:ltr}.\\[drm\\:hdmiphy_enable\\.part\\.0\\]{drm:hdmiphy enable.part0}.\\[drm\\:samsung_dsim_host_attach\\]{drm:samsung dsim host attach}.\\[overflow-anchor\\:none\\]{overflow-anchor:none}.\\[pid\\:5118\\,cpu4\\,QThread\\,0\\]{pid:5118,cpu4,QThread,0}.\\[pid\\:5118\\,cpu4\\,QThread\\,1\\]{pid:5118,cpu4,QThread,1}.\\[pid\\:5118\\,cpu4\\,QThread\\,2\\]{pid:5118,cpu4,QThread,2}.\\[pid\\:5118\\,cpu4\\,QThread\\,3\\]{pid:5118,cpu4,QThread,3}.\\[pid\\:5118\\,cpu4\\,QThread\\,4\\]{pid:5118,cpu4,QThread,4}.\\[pid\\:5118\\,cpu4\\,QThread\\,9\\]{pid:5118,cpu4,QThread,9}.\\[transition\\:background-color_\\.2s_linear\\,_height_\\.2s_ease-in-out\\]{transition:background-color .2s linear,height .2s ease-in-out}.\\[transition\\:background-color_\\.2s_linear\\,_width_\\.2s_ease-in-out\\,_opacity\\]{transition:background-color .2s linear,width .2s ease-in-out,opacity}.\\[transition\\:background-color_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,box-shadow_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\,border_250ms_cubic-bezier\\(0\\.4\\,0\\,0\\.2\\,1\\)_0ms\\]{transition:background-color .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border .25s cubic-bezier(.4,0,.2,1)}@media (hover:hover){.group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\\/ps\\:opacity-60:is(:where(.group\\/ps):hover *){opacity:.6}.group-hover\\/x\\:h-\\[11px\\]:is(:where(.group\\/x):hover *){height:11px}.group-hover\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):hover *){background-color:#999}.group-hover\\/y\\:w-\\[11px\\]:is(:where(.group\\/y):hover *){width:11px}.group-hover\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):hover *){background-color:#999}}.group-focus\\/ps\\:opacity-60:is(:where(.group\\/ps):focus *){opacity:.6}.group-focus\\/ps\\:opacity-100:is(:where(.group\\/ps):focus *){opacity:1}.group-focus\\/x\\:h-\\[0\\.6875rem\\]:is(:where(.group\\/x):focus *){height:.6875rem}.group-focus\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x):focus *){background-color:#999}.group-focus\\/y\\:w-\\[0\\.6875rem\\]:is(:where(.group\\/y):focus *){width:.6875rem}.group-focus\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y):focus *){background-color:#999}.group-active\\/ps\\:opacity-100:is(:where(.group\\/ps):active *){opacity:1}.group-data-te-collapse-collapsed\\:mr-0:is(:where(.group)[data-te-collapse-collapsed] *){margin-right:0}.group-data-te-collapse-collapsed\\:rotate-0:is(:where(.group)[data-te-collapse-collapsed] *){rotate:0deg}.group-data-te-collapse-collapsed\\:fill-\\[\\#212529\\]:is(:where(.group)[data-te-collapse-collapsed] *){fill:#212529}.group-data-\\[te-input-focused\\]\\:border-x-0:is(:where(.group)[data-te-input-focused] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-focused\\]\\:border-t:is(:where(.group)[data-te-input-focused] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-focused\\]\\:border-r-0:is(:where(.group)[data-te-input-focused] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-focused\\]\\:border-l-0:is(:where(.group)[data-te-input-focused] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-focused\\]\\:border-solid:is(:where(.group)[data-te-input-focused] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-focused\\]\\:border-\\[\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){border-color:#14a44d}.group-data-\\[te-input-focused\\]\\:border-\\[\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){border-color:#dc4c64}.group-data-\\[te-input-focused\\]\\:border-primary:is(:where(.group)[data-te-input-focused] *){border-color:#3b71ca}.group-data-\\[te-input-focused\\]\\:border-white:is(:where(.group)[data-te-input-focused] *){border-color:var(--color-white)}.group-data-\\[te-input-focused\\]\\:border-t-transparent:is(:where(.group)[data-te-input-focused] *){border-top-color:#0000}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:-1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#3b71ca\\,_0_-1px_0_0_\\#3b71ca\\,_0_1px_0_0_\\#3b71ca\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#3b71ca), 0 -1px 0 0 var(--tw-shadow-color,#3b71ca), 0 1px 0 0 var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#14a44d\\,_0_-1px_0_0_\\#14a44d\\,_0_1px_0_0_\\#14a44d\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#14a44d), 0 -1px 0 0 var(--tw-shadow-color,#14a44d), 0 1px 0 0 var(--tw-shadow-color,#14a44d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#dc4c64\\,_0_-1px_0_0_\\#dc4c64\\,_0_1px_0_0_\\#dc4c64\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#dc4c64), 0 -1px 0 0 var(--tw-shadow-color,#dc4c64), 0 1px 0 0 var(--tw-shadow-color,#dc4c64);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-focused\\]\\:shadow-\\[1px_0_0_\\#ffffff\\,_0_-1px_0_0_\\#ffffff\\,_0_1px_0_0_\\#ffffff\\]:is(:where(.group)[data-te-input-focused] *){--tw-shadow:1px 0 0 var(--tw-shadow-color,#fff), 0 -1px 0 0 var(--tw-shadow-color,#fff), 0 1px 0 0 var(--tw-shadow-color,#fff);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\\[te-input-state-active\\]\\:border-x-0:is(:where(.group)[data-te-input-state-active] *){border-inline-style:var(--tw-border-style);border-inline-width:0}.group-data-\\[te-input-state-active\\]\\:border-t:is(:where(.group)[data-te-input-state-active] *){border-top-style:var(--tw-border-style);border-top-width:1px}.group-data-\\[te-input-state-active\\]\\:border-r-0:is(:where(.group)[data-te-input-state-active] *){border-right-style:var(--tw-border-style);border-right-width:0}.group-data-\\[te-input-state-active\\]\\:border-l-0:is(:where(.group)[data-te-input-state-active] *){border-left-style:var(--tw-border-style);border-left-width:0}.group-data-\\[te-input-state-active\\]\\:border-solid:is(:where(.group)[data-te-input-state-active] *){--tw-border-style:solid;border-style:solid}.group-data-\\[te-input-state-active\\]\\:border-t-transparent:is(:where(.group)[data-te-input-state-active] *){border-top-color:#0000}.group-data-\\[te-select-option-group-ref\\]\\/opt\\:pl-7:is(:where(.group\\/opt)[data-te-select-option-group-ref] *){padding-left:calc(var(--spacing) * 7)}.group-data-\\[te-was-validated\\]\\/validation\\:mb-4:is(:where(.group\\/validation)[data-te-was-validated] *){margin-bottom:calc(var(--spacing) * 4)}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-x *){display:block}.group-\\[\\&\\.ps--active-x\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-x *){background-color:#0000}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:block:is(:where(.group\\/ps).ps--active-y *){display:block}.group-\\[\\&\\.ps--active-y\\]\\/ps\\:bg-transparent:is(:where(.group\\/ps).ps--active-y *){background-color:#0000}.group-\\[\\&\\.ps--clicking\\]\\/x\\:h-\\[11px\\]:is(:where(.group\\/x).ps--clicking *){height:11px}.group-\\[\\&\\.ps--clicking\\]\\/x\\:bg-\\[\\#999\\]:is(:where(.group\\/x).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--clicking\\]\\/y\\:w-\\[11px\\]:is(:where(.group\\/y).ps--clicking *){width:11px}.group-\\[\\&\\.ps--clicking\\]\\/y\\:bg-\\[\\#999\\]:is(:where(.group\\/y).ps--clicking *){background-color:#999}.group-\\[\\&\\.ps--scrolling-x\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-x *),.group-\\[\\&\\.ps--scrolling-y\\]\\/ps\\:opacity-60:is(:where(.group\\/ps).ps--scrolling-y *){opacity:.6}.group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-neutral-300:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:var(--color-neutral-300)}.group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-neutral-100:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:var(--color-neutral-100)}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border:is(:where(.group)[data-te-datepicker-cell-current] *){border-style:var(--tw-border-style);border-width:1px}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-solid:is(:where(.group)[data-te-datepicker-cell-current] *){--tw-border-style:solid;border-style:solid}.group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-black:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-black)}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-selected] *){background-color:#3b71ca}.group-\\[\\[data-te-datepicker-cell-selected\\]\\]\\:text-white:is(:where(.group)[data-te-datepicker-cell-selected] *){color:var(--color-white)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-valid\\:text-green-600:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):valid~*){color:var(--color-green-600)}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:block:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){display:block}.group-data-\\[te-was-validated\\]\\/validation\\:peer-invalid\\:text-\\[rgb\\(220\\,76\\,100\\)\\]:is(:where(.group\\/validation)[data-te-was-validated] *):is(:where(.peer):invalid~*){color:#dc4c64}.peer-focus\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer):focus~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus\\:scale-\\[0\\.8\\]:is(:where(.peer):focus~*){scale:.8}.peer-focus\\:\\!text-white:is(:where(.peer):focus~*){color:var(--color-white)!important}.peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.peer-data-\\[te-input-focused\\]\\:\\!text-white:is(:where(.peer)[data-te-input-focused]~*){color:var(--color-white)!important}.peer-data-\\[te-input-focused\\]\\:text-primary:is(:where(.peer)[data-te-input-focused]~*){color:#3b71ca}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\]:is(:where(.peer)[data-te-input-state-active]~*){--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\]:is(:where(.peer)[data-te-input-state-active]~*){scale:.8}.selection\\:bg-transparent ::selection{background-color:#0000}.selection\\:bg-transparent::selection{background-color:#0000}.before\\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\\:absolute:before{content:var(--tw-content);position:absolute}.before\\:h-\\[0\\.875rem\\]:before{content:var(--tw-content);height:.875rem}.before\\:w-\\[0\\.875rem\\]:before{content:var(--tw-content);width:.875rem}.before\\:scale-0:before{content:var(--tw-content);--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.before\\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\\:opacity-0:before{content:var(--tw-content);opacity:0}.before\\:shadow-\\[0px_0px_0px_13px_transparent\\]:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,transparent);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.before\\:content-\\[\\'\\'\\]:before{--tw-content:\"\";content:var(--tw-content)}.odd\\:bg-gray-100:nth-child(odd){background-color:var(--color-gray-100)}.even\\:bg-white:nth-child(2n){background-color:var(--color-white)}.checked\\:\\!border-\\[\\#14a44d\\]:checked{border-color:#14a44d!important}.checked\\:\\!border-\\[\\#dc4c64\\]:checked{border-color:#dc4c64!important}.checked\\:border-primary:checked{border-color:#3b71ca}.checked\\:\\!bg-\\[\\#14a44d\\]:checked{background-color:#14a44d!important}.checked\\:\\!bg-\\[\\#dc4c64\\]:checked{background-color:#dc4c64!important}.checked\\:bg-primary:checked{background-color:#3b71ca}.checked\\:before\\:opacity-\\[0\\.16\\]:checked:before{content:var(--tw-content);opacity:.16}.checked\\:after\\:absolute:checked:after{content:var(--tw-content);position:absolute}.checked\\:after\\:-mt-px:checked:after{content:var(--tw-content);margin-top:-1px}.checked\\:after\\:ml-\\[0\\.25rem\\]:checked:after{content:var(--tw-content);margin-left:.25rem}.checked\\:after\\:block:checked:after{content:var(--tw-content);display:block}.checked\\:after\\:h-\\[0\\.8125rem\\]:checked:after{content:var(--tw-content);height:.8125rem}.checked\\:after\\:w-\\[0\\.375rem\\]:checked:after{content:var(--tw-content);width:.375rem}.checked\\:after\\:rotate-45:checked:after{content:var(--tw-content);rotate:45deg}.checked\\:after\\:border-\\[0\\.125rem\\]:checked:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:after\\:border-t-0:checked:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:after\\:border-l-0:checked:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:after\\:border-solid:checked:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:after\\:border-white:checked:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:after\\:\\!bg-\\[\\#14a44d\\]:checked:after{content:var(--tw-content);background-color:#14a44d!important}.checked\\:after\\:\\!bg-\\[\\#dc4c64\\]:checked:after{content:var(--tw-content);background-color:#dc4c64!important}.checked\\:after\\:bg-transparent:checked:after{content:var(--tw-content);background-color:#0000}.checked\\:after\\:content-\\[\\'\\'\\]:checked:after{--tw-content:\"\";content:var(--tw-content)}.empty\\:hidden:empty{display:none}@media (hover:hover){.hover\\:z-2:hover{z-index:2}.hover\\:cursor-pointer:hover{cursor:pointer}.hover\\:rounded-\\[50\\%\\]:hover{border-radius:50%}.hover\\:\\!bg-\\[\\#eee\\]:hover{background-color:#eee!important}.hover\\:bg-\\[\\#00000014\\]:hover{background-color:#00000014}.hover\\:bg-\\[\\#00000026\\]:hover{background-color:#00000026}.hover\\:bg-\\[unset\\]:hover{background-color:unset}.hover\\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\\:bg-primary-600:hover{background-color:#3061af}.hover\\:bg-primary-accent-100:hover{background-color:#d9e4f3}.hover\\:fill-\\[\\#8b8b8b\\]:hover{fill:#8b8b8b}.hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.hover\\:text-\\[\\#8b8b8b\\]:hover{color:#8b8b8b}.hover\\:text-primary:hover{color:#3b71ca}.hover\\:text-primary-600:hover{color:#3061af}.hover\\:text-white:hover{color:var(--color-white)}.hover\\:\\!opacity-90:hover{opacity:.9!important}.hover\\:opacity-100:hover{opacity:1}.hover\\:\\!shadow-none:hover{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\\:ease-in-out:hover{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.hover\\:outline-none:hover{--tw-outline-style:none;outline-style:none}.hover\\:before\\:opacity-\\[0\\.04\\]:hover:before{content:var(--tw-content);opacity:.04}.hover\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:hover:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\\:z-3:focus{z-index:3}.focus\\:rounded-\\[50\\%\\]:focus{border-radius:50%}.focus\\:\\!border-\\[\\#14a44d\\]:focus{border-color:#14a44d!important}.focus\\:\\!border-\\[\\#dc4c64\\]:focus{border-color:#dc4c64!important}.focus\\:border-primary:focus{border-color:#3b71ca}.focus\\:\\!bg-\\[\\#eee\\]:focus{background-color:#eee!important}.focus\\:bg-\\[\\#00000014\\]:focus{background-color:#00000014}.focus\\:bg-\\[\\#00000026\\]:focus{background-color:#00000026}.focus\\:bg-neutral-200:focus{background-color:var(--color-neutral-200)}.focus\\:bg-primary-600:focus{background-color:#3061af}.focus\\:bg-primary-accent-100:focus{background-color:#d9e4f3}.focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.focus\\:text-gray-700:focus{color:var(--color-gray-700)}.focus\\:text-primary:focus{color:#3b71ca}.focus\\:text-primary-600:focus{color:#3061af}.focus\\:text-white:focus{color:var(--color-white)}.focus\\:\\!opacity-90:focus{opacity:.9!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#14a44d\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#14a44d)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:\\!shadow-\\[inset_0_0_0_1px_\\#dc4c64\\]:focus{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#dc4c64)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\\:transition-\\[border-color_0\\.2s\\]:focus{transition-property:border-color .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\\:placeholder\\:opacity-100:focus::placeholder{opacity:1}.focus\\:before\\:scale-100:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.focus\\:before\\:opacity-\\[0\\.12\\]:focus:before{content:var(--tw-content);opacity:.12}.focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(0\\,0\\,0\\,0\\.6\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\\:after\\:absolute:focus:after{content:var(--tw-content);position:absolute}.focus\\:after\\:z-\\[1\\]:focus:after{content:var(--tw-content);z-index:1}.focus\\:after\\:block:focus:after{content:var(--tw-content);display:block}.focus\\:after\\:h-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);height:.875rem}.focus\\:after\\:w-\\[0\\.875rem\\]:focus:after{content:var(--tw-content);width:.875rem}.focus\\:after\\:rounded-\\[0\\.125rem\\]:focus:after{content:var(--tw-content);border-radius:.125rem}.focus\\:after\\:content-\\[\\'\\'\\]:focus:after{--tw-content:\"\";content:var(--tw-content)}.checked\\:focus\\:before\\:scale-100:checked:focus:before{content:var(--tw-content);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.checked\\:focus\\:before\\:transition-\\[box-shadow_0\\.2s\\,transform_0\\.2s\\]:checked:focus:before{content:var(--tw-content);transition-property:box-shadow .2s,transform .2s;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.checked\\:focus\\:after\\:-mt-px:checked:focus:after{content:var(--tw-content);margin-top:-1px}.checked\\:focus\\:after\\:ml-\\[0\\.25rem\\]:checked:focus:after{content:var(--tw-content);margin-left:.25rem}.checked\\:focus\\:after\\:h-\\[0\\.8125rem\\]:checked:focus:after{content:var(--tw-content);height:.8125rem}.checked\\:focus\\:after\\:w-\\[0\\.375rem\\]:checked:focus:after{content:var(--tw-content);width:.375rem}.checked\\:focus\\:after\\:rotate-45:checked:focus:after{content:var(--tw-content);rotate:45deg}.checked\\:focus\\:after\\:rounded-none:checked:focus:after{content:var(--tw-content);border-radius:0}.checked\\:focus\\:after\\:border-\\[0\\.125rem\\]:checked:focus:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:.125rem}.checked\\:focus\\:after\\:border-t-0:checked:focus:after{content:var(--tw-content);border-top-style:var(--tw-border-style);border-top-width:0}.checked\\:focus\\:after\\:border-l-0:checked:focus:after{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:0}.checked\\:focus\\:after\\:border-solid:checked:focus:after{content:var(--tw-content);--tw-border-style:solid;border-style:solid}.checked\\:focus\\:after\\:border-white:checked:focus:after{content:var(--tw-content);border-color:var(--color-white)}.checked\\:focus\\:after\\:bg-transparent:checked:focus:after{content:var(--tw-content);background-color:#0000}.active\\:z-60:active{z-index:60}.active\\:bg-\\[\\#c4d4ef\\]:active{background-color:#c4d4ef}.active\\:bg-\\[\\#cacfd1\\]:active{background-color:#cacfd1}.active\\:bg-primary-700:active{background-color:#285192}.active\\:bg-primary-accent-200:active{background-color:#cedbee}.active\\:text-primary-700:active{color:#285192}.active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.3\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.2\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca4d), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca33);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.disabled\\:text-slate-300:disabled{color:var(--color-slate-300)}@media (hover:hover){.disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.data-te-dropdown-show\\:grid[data-te-dropdown-show]{display:grid}.data-\\[data-te-autocomplete-option-disabled\\]\\:cursor-default[data-data-te-autocomplete-option-disabled]{cursor:default}.data-\\[data-te-autocomplete-option-disabled\\]\\:text-gray-400[data-data-te-autocomplete-option-disabled]{color:var(--color-gray-400)}.data-\\[popper-reference-hidden\\]\\:hidden[data-popper-reference-hidden]{display:none}.data-\\[te-active\\]\\:-top-\\[38px\\][data-te-active]{top:-38px}.data-\\[te-active\\]\\:scale-100[data-te-active]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-autocomplete-item-active\\]\\:bg-black\\/5[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-autocomplete-state-open\\]\\:scale-y-100[data-te-autocomplete-state-open]{--tw-scale-y:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-autocomplete-state-open\\]\\:opacity-100[data-te-autocomplete-state-open]{opacity:1}.data-\\[te-carousel-fade\\]\\:z-0[data-te-carousel-fade]{z-index:0}.data-\\[te-carousel-fade\\]\\:z-\\[1\\][data-te-carousel-fade]{z-index:1}.data-\\[te-carousel-fade\\]\\:opacity-0[data-te-carousel-fade]{opacity:0}.data-\\[te-carousel-fade\\]\\:opacity-100[data-te-carousel-fade]{opacity:1}.data-\\[te-carousel-fade\\]\\:delay-600[data-te-carousel-fade]{transition-delay:.6s}.data-\\[te-carousel-fade\\]\\:duration-\\[600ms\\][data-te-carousel-fade]{--tw-duration:.6s;transition-duration:.6s}.data-\\[te-datepicker-cell-disabled\\]\\:pointer-events-none[data-te-datepicker-cell-disabled]{pointer-events:none}.data-\\[te-datepicker-cell-disabled\\]\\:cursor-default[data-te-datepicker-cell-disabled]{cursor:default}.data-\\[te-datepicker-cell-disabled\\]\\:text-neutral-300[data-te-datepicker-cell-disabled]{color:var(--color-neutral-300)}@media (hover:hover){.data-\\[te-datepicker-cell-disabled\\]\\:hover\\:cursor-default[data-te-datepicker-cell-disabled]:hover{cursor:default}}.group-\\[\\[data-te-datepicker-cell-focused\\]\\]\\:data-\\[te-datepicker-cell-selected\\]\\:bg-primary:is(:where(.group)[data-te-datepicker-cell-focused] *)[data-te-datepicker-cell-selected]{background-color:#3b71ca}.data-\\[te-input-disabled\\]\\:cursor-default[data-te-input-disabled]{cursor:default}.data-\\[te-input-disabled\\]\\:bg-\\[\\#e9ecef\\][data-te-input-disabled]{background-color:#e9ecef}.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-multiple-active\\]\\:bg-black\\/5[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:block[data-te-input-state-active]{display:block}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.9rem\\][data-te-input-state-active]{--tw-translate-y:calc(.9rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[0\\.75rem\\][data-te-input-state-active]{--tw-translate-y:calc(.75rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:-translate-y-\\[1\\.15rem\\][data-te-input-state-active]{--tw-translate-y:calc(1.15rem * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[te-input-state-active\\]\\:scale-\\[0\\.8\\][data-te-input-state-active]{scale:.8}.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-input-state-active\\]\\:placeholder\\:opacity-100[data-te-input-state-active]::placeholder{opacity:1}.data-\\[te-select-open\\]\\:scale-100[data-te-select-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\\[te-select-open\\]\\:opacity-100[data-te-select-open]{opacity:1}.data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-option-disabled]{cursor:default}.data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:bg-black\\/\\[0\\.02\\][data-te-select-option-selected]{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-black\\/5[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:cursor-default[data-te-select-selected][data-te-select-option-disabled]{cursor:default}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:bg-transparent[data-te-select-selected][data-te-select-option-disabled]{background-color:#0000}.data-\\[te-select-selected\\]\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-selected][data-te-select-option-disabled]{color:var(--color-gray-400)}@media (prefers-reduced-motion:reduce){.motion-reduce\\:transform-none{transform:none}.motion-reduce\\:animate-\\[spin_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spin}.motion-reduce\\:animate-\\[spinner-grow_1\\.5s_linear_infinite\\]{animation:1.5s linear infinite spinner-grow}.motion-reduce\\:animate-none{animation:none}.motion-reduce\\:transition-none{transition-property:none}}@media (min-width:40rem){.sm\\:block{display:block}.sm\\:grid{display:grid}.sm\\:hidden{display:none}.sm\\:w-40{width:calc(var(--spacing) * 40)}.sm\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\\:grid-cols-\\[10\\%_90\\%\\]{grid-template-columns:10% 90%}.sm\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\\:break-words{overflow-wrap:break-word}.sm\\:no-underline{text-decoration-line:none}}@media (min-width:48rem){.md\\:order-none{order:0}.md\\:my-0{margin-block:0}.md\\:mb-0{margin-bottom:0}.md\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\\:flex-row{flex-direction:row}.md\\:pr-1{padding-right:var(--spacing)}.md\\:pr-\\[17px\\]{padding-right:17px}}@media (min-width:64rem){.lg\\:sticky{position:sticky}.lg\\:block{display:block}.lg\\:grid{display:grid}.lg\\:hidden{display:none}.lg\\:w-32{width:calc(var(--spacing) * 32)}.lg\\:w-36{width:calc(var(--spacing) * 36)}.lg\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\\:pl-9{padding-left:calc(var(--spacing) * 9)}.lg\\:break-normal{overflow-wrap:normal;word-break:normal}}@media (min-width:80rem){.xl\\:w-52{width:calc(var(--spacing) * 52)}.xl\\:grid-flow-col{grid-auto-flow:column}.xl\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:320px){@media not all and (min-width:825px){@media (orientation:landscape){.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:h-auto{height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[305px\\]{min-height:305px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-h-\\[auto\\]{min-height:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:min-w-\\[auto\\]{min-width:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!flex-row{flex-direction:row!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:flex-col{flex-direction:column}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:\\!justify-around{justify-content:space-around!important}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:overflow-y-auto{overflow-y:auto}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-lg{border-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-tr-none{border-top-right-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:rounded-bl-none{border-bottom-left-radius:0}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:p-\\[10px\\]{padding:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:pr-\\[10px\\]{padding-right:10px}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:text-\\[3rem\\]{font-size:3rem}.min-\\[320px\\]\\:max-\\[825px\\]\\:landscape\\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}}}@media not all and (min-width:48rem){@media (orientation:landscape){.xs\\:max-md\\:landscape\\:mt-24{margin-top:calc(var(--spacing) * 24)}.xs\\:max-md\\:landscape\\:h-8{height:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:h-\\[360px\\]{height:360px}.xs\\:max-md\\:landscape\\:h-full{height:100%}.xs\\:max-md\\:landscape\\:w-8{width:calc(var(--spacing) * 8)}.xs\\:max-md\\:landscape\\:w-\\[475px\\]{width:475px}.xs\\:max-md\\:landscape\\:flex-row{flex-direction:row}}}}.rtl\\:\\!left-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *){left:auto!important}.rtl\\:\\!origin-\\[50\\%_50\\%_0\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50% 50% 0!important}.rtl\\:\\[direction\\:rtl\\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){direction:rtl}@media (prefers-color-scheme:dark){.dark\\:border-0{border-style:var(--tw-border-style);border-width:0}.dark\\:border-\\[\\#4f4f4f\\]{border-color:#4f4f4f}.dark\\:border-\\[\\#14a44d\\]{border-color:#14a44d}.dark\\:border-\\[\\#dc4c64\\]{border-color:#dc4c64}.dark\\:border-neutral-400{border-color:var(--color-neutral-400)}.dark\\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\\:border-primary-400{border-color:#8faee0}.dark\\:\\!bg-neutral-600{background-color:var(--color-neutral-600)!important}.dark\\:bg-\\[\\#4f4f4f\\]{background-color:#4f4f4f}.dark\\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\\:bg-primary-600{background-color:#3061af}.dark\\:bg-transparent{background-color:#0000}.dark\\:bg-zinc-500{background-color:var(--color-zinc-500)}.dark\\:bg-zinc-600\\/50{background-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.dark\\:bg-zinc-600\\/50{background-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.dark\\:bg-zinc-700{background-color:var(--color-zinc-700)}.dark\\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\\:fill-gray-400{fill:var(--color-gray-400)}.dark\\:\\!text-primary-400{color:#8faee0!important}.dark\\:text-gray-200{color:var(--color-gray-200)}.dark\\:text-gray-300{color:var(--color-gray-300)}.dark\\:text-neutral-200{color:var(--color-neutral-200)}.dark\\:text-neutral-300{color:var(--color-neutral-300)}.dark\\:text-neutral-400{color:var(--color-neutral-400)}.dark\\:text-primary-400{color:#8faee0}.dark\\:text-white{color:var(--color-white)}.dark\\:shadow-\\[0_4px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.5\\)\\]{--tw-shadow:0 4px 9px -4px var(--tw-shadow-color,#3b71ca80);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-disabled\\]\\)\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\:hover\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-disabled]):not([data-te-datepicker-cell-selected]):hover *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:group-\\[\\:not\\(\\[data-te-datepicker-cell-selected\\]\\)\\[data-te-datepicker-cell-focused\\]\\]\\:bg-white\\/10:is(:where(.group):not([data-te-datepicker-cell-selected])[data-te-datepicker-cell-focused] *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:group-\\[\\[data-te-datepicker-cell-current\\]\\]\\:border-white:is(:where(.group)[data-te-datepicker-cell-current] *){border-color:var(--color-white)}.dark\\:group-\\[\\[data-te-datepicker-cell-disabled\\]\\]\\:text-neutral-500:is(:where(.group)[data-te-datepicker-cell-disabled] *){color:var(--color-neutral-500)}.dark\\:peer-focus\\:text-gray-200:is(:where(.peer):focus~*){color:var(--color-gray-200)}.dark\\:peer-focus\\:text-primary:is(:where(.peer):focus~*){color:#3b71ca}.dark\\:placeholder\\:text-gray-200::placeholder{color:var(--color-gray-200)}.dark\\:checked\\:border-primary:checked{border-color:#3b71ca}.dark\\:checked\\:bg-primary:checked{background-color:#3b71ca}@media (hover:hover){.dark\\:hover\\:\\!bg-\\[\\#555\\]:hover{background-color:#555!important}.dark\\:hover\\:bg-neutral-500:hover{background-color:var(--color-neutral-500)}.dark\\:hover\\:bg-neutral-600:hover{background-color:var(--color-neutral-600)}.dark\\:hover\\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\\:hover\\:bg-white\\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:bg-white\\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:hover\\:fill-gray-100:hover{fill:var(--color-gray-100)}.dark\\:hover\\:text-\\[\\#3b71ca\\]:hover{color:#3b71ca}.dark\\:hover\\:text-primary-400:hover{color:#8faee0}.dark\\:hover\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:hover{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.dark\\:focus\\:\\!bg-\\[\\#555\\]:focus{background-color:#555!important}.dark\\:focus\\:bg-white\\/10:focus{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\\:focus\\:bg-white\\/10:focus{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\\:focus\\:text-\\[\\#3b71ca\\]:focus{color:#3b71ca}.dark\\:focus\\:text-primary-400:focus{color:#8faee0}.dark\\:focus\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:focus{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_rgba\\(255\\,255\\,255\\,0\\.4\\)\\]:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:checked\\:focus\\:before\\:shadow-\\[0px_0px_0px_13px_\\#3b71ca\\]:checked:focus:before{content:var(--tw-content);--tw-shadow:0px 0px 0px 13px var(--tw-shadow-color,#3b71ca);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:active\\:shadow-\\[0_8px_9px_-4px_rgba\\(59\\,113\\,202\\,0\\.2\\)\\,0_4px_18px_0_rgba\\(59\\,113\\,202\\,0\\.1\\)\\]:active{--tw-shadow:0 8px 9px -4px var(--tw-shadow-color,#3b71ca33), 0 4px 18px 0 var(--tw-shadow-color,#3b71ca1a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\\:disabled\\:text-neutral-600:disabled{color:var(--color-neutral-600)}@media (hover:hover){.dark\\:disabled\\:hover\\:bg-transparent:disabled:hover{background-color:#0000}}.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-autocomplete-item-active\\]\\:bg-white\\/30[data-te-autocomplete-item-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-buttons-timepicker\\]\\:bg-zinc-700[data-te-buttons-timepicker]{background-color:var(--color-zinc-700)}.dark\\:data-\\[te-input-disabled\\]\\:bg-zinc-600[data-te-input-disabled]{background-color:var(--color-zinc-600)}.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-multiple-active\\]\\:bg-white\\/30[data-te-input-multiple-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.dark\\:data-\\[te-select-option-disabled\\]\\:text-gray-400[data-te-select-option-disabled]{color:var(--color-gray-400)}.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:data-\\[te-select-option-selected\\]\\:data-\\[te-input-state-active\\]\\:bg-white\\/30[data-te-select-option-selected][data-te-input-state-active]{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}@media print{.print\\:block{display:block}.print\\:hidden{display:none}.print\\:border-none{--tw-border-style:none;border-style:none}.print\\:border-black{border-color:var(--color-black)}.print\\:bg-white{background-color:var(--color-white)}.print\\:text-left{text-align:left}.print\\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}.\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#eee\\].ps--clicking{background-color:#eee!important}.\\[\\&\\.ps--clicking\\]\\:\\!opacity-90.ps--clicking{opacity:.9!important}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\.ps--clicking\\]\\:\\!bg-\\[\\#555\\].ps--clicking{background-color:#555!important}}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:h-1::-webkit-scrollbar{height:var(--spacing)}.\\[\\&\\:\\:-webkit-scrollbar\\]\\:w-1::-webkit-scrollbar{width:var(--spacing)}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:block::-webkit-scrollbar-button{display:block}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:h-0::-webkit-scrollbar-button{height:0}.\\[\\&\\:\\:-webkit-scrollbar-button\\]\\:bg-transparent::-webkit-scrollbar-button{background-color:#0000}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:h-\\[50px\\]::-webkit-scrollbar-thumb{height:50px}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:rounded::-webkit-scrollbar-thumb{border-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-thumb\\]\\:bg-\\[\\#999\\]::-webkit-scrollbar-thumb{background-color:#999}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-none::-webkit-scrollbar-track-piece{border-radius:0}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:rounded-l::-webkit-scrollbar-track-piece{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.\\[\\&\\:\\:-webkit-scrollbar-track-piece\\]\\:bg-transparent::-webkit-scrollbar-track-piece{background-color:#0000}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-autocomplete-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-autocomplete-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:bg-blue-200:not([data-te-collapse-collapsed]){background-color:var(--color-blue-200)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:text-blue-900:not([data-te-collapse-collapsed]){color:var(--color-blue-900)}.\\[\\&\\:not\\(\\[data-te-collapse-collapsed\\]\\)\\]\\:\\[box-shadow\\:inset_0_-1px_0_rgba\\(229\\,231\\,235\\)\\]:not([data-te-collapse-collapsed]){box-shadow:inset 0 -1px #e5e7eb}.\\[\\&\\:not\\(\\[data-te-input-placeholder-active\\]\\)\\]\\:placeholder\\:opacity-0:not([data-te-input-placeholder-active])::placeholder{opacity:0}@media (hover:hover){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-black\\/5:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.dark\\:hover\\:\\[\\&\\:not\\(\\[data-te-select-option-disabled\\]\\)\\]\\:bg-white\\/30:hover:not([data-te-select-option-disabled]){background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}}}.\\[\\&\\:nth-child\\(odd\\)\\]\\:bg-neutral-50:nth-child(odd){background-color:var(--color-neutral-50)}@media (prefers-color-scheme:dark){.\\[\\&\\:nth-child\\(odd\\)\\]\\:dark\\:bg-neutral-700:nth-child(odd){background-color:var(--color-neutral-700)}}.\\[\\&\\>svg\\]\\:pointer-events-none>svg{pointer-events:none}.\\[\\&\\>svg\\]\\:mx-auto>svg{margin-inline:auto}.\\[\\&\\>svg\\]\\:h-4>svg{height:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:h-5>svg{height:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:h-6>svg{height:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:w-4>svg{width:calc(var(--spacing) * 4)}.\\[\\&\\>svg\\]\\:w-5>svg{width:calc(var(--spacing) * 5)}.\\[\\&\\>svg\\]\\:w-6>svg{width:calc(var(--spacing) * 6)}.\\[\\&\\>svg\\]\\:rotate-180>svg{rotate:180deg}.\\[\\&\\>svg\\]\\:fill-neutral-500>svg{fill:var(--color-neutral-500)}@media (prefers-color-scheme:dark){.dark\\:\\[\\&\\>svg\\]\\:fill-white>svg{fill:var(--color-white)}}}@property --tw-border-spacing-x{syntax:\"\";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:\"\";inherits:false;initial-value:0}@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-z{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-pan-x{syntax:\"*\";inherits:false}@property --tw-pan-y{syntax:\"*\";inherits:false}@property --tw-pinch-zoom{syntax:\"*\";inherits:false}@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-tracking{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@property --tw-drop-shadow-color{syntax:\"*\";inherits:false}@property --tw-drop-shadow-alpha{syntax:\"\";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:\"*\";inherits:false}@property --tw-duration{syntax:\"*\";inherits:false}@property --tw-ease{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@keyframes spin{to{transform:rotate(360deg)}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-out{0%{opacity:1}to{opacity:0}}@keyframes slide-in-left{0%{visibility:visible;transform:translate(-100%)}to{transform:translate(0)}}@keyframes slide-in-right{0%{visibility:visible;transform:translate(100%)}to{transform:translate(0)}}@keyframes slide-out-left{0%{transform:translate(0)}to{visibility:hidden;transform:translate(-100%)}}@keyframes slide-out-right{0%{transform:translate(0)}to{visibility:hidden;transform:translate(100%)}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes show-up-clock{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@keyframes progress{0%{transform:translate(-45%)}to{transform:translate(100%)}}" as const; \ No newline at end of file diff --git a/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts b/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts index 3ff7bc8440..080ec1b00b 100644 --- a/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts +++ b/libs/hdf-converters/src/converters-from-hdf/html/html-types.ts @@ -1,17 +1,17 @@ // Interface for HTML template data rendering -import {ContextualizedControl} from 'inspecjs'; +import type {ContextualizedControl} from 'inspecjs'; // Basic info for exported files; lvl 1 -export interface IFileInfo { +export type IFileInfo = { filename: string; toolVersion: string; platform: string; duration: string; -} +}; // Info used for profile status reporting; lvl 1 -export interface IStatistics { +export type IStatistics = { passed: number; failed: number; notApplicable: number; @@ -22,44 +22,44 @@ export interface IStatistics { passingTestsFailedResult: number; failedTests: number; totalTests: number; -} +}; // Info used for profile result severity reporting; lvl 1 -export interface ISeverity { +export type ISeverity = { none: number; low: number; medium: number; high: number; critical: number; -} +}; // Info used for profile compliance reporting; lvl 1 -export interface ICompliance { +export type ICompliance = { level: string; color: string; -} +}; // Container for specific info on each result; lvl 2 -export interface IDetail { +export type IDetail = { name: string; value: string; class?: string; -} +}; // Status of a specific result; lvl 2 -export interface IResultStatus { +export type IResultStatus = { status: string; icon: string; -} +}; // Severity of a specific result; lvl 2 -export interface IResultSeverity { +export type IResultSeverity = { severity: string; icon: string; -} +}; // Container for all results; lvl 1 -export interface IResultSet { +export type IResultSet = { filename: string; fileID: string; results: (ContextualizedControl & {details: IDetail[]} & { @@ -67,15 +67,13 @@ export interface IResultSet { } & {resultStatus: IResultStatus} & {resultSeverity: IResultSeverity} & { controlTags: string[]; })[]; -} +}; // All used icons; lvl 1 -export interface IIcons { - [key: string]: string; -} +export type IIcons = Record; // Top level interface; lvl 0 -export interface IOutputData { +export type IOutputData = { tailwindStyles: string; tailwindElements: string; files: IFileInfo[]; @@ -87,4 +85,4 @@ export interface IOutputData { showCode: boolean; exportType: string; icons: IIcons; -} +}; diff --git a/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts index f6015e49bb..a903499713 100644 --- a/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/html/reverse-html-mapper.ts @@ -7,11 +7,12 @@ import { mdiEqualBox, mdiMinusCircle } from '@mdi/js'; -import { +import type { ContextualizedControl, ContextualizedEvaluation, + HDFControlSegment} from 'inspecjs'; +import { convertFileContextual, - HDFControlSegment, isContextualizedEvaluation } from 'inspecjs'; import _ from 'lodash'; @@ -19,7 +20,7 @@ import Mustache from 'mustache'; import sanitize from 'sanitize-html'; import {formatCompliance, translateCompliance} from '../../utils/compliance'; import {html, js, css} from './embedded-assets'; -import { +import type { IDetail, IOutputData, IResultSeverity, @@ -50,7 +51,74 @@ export enum FileExportTypes { // Illegal characters which are not accepted by HTML id attribute // Generally includes everything that is not alphanumeric or characters [-,_] // Expand as needed -const ILLEGAL_CHARACTER_SET = [['\\.', '___PERIOD___']]; +const ILLEGAL_CHARACTER_SET: [RegExp, string][] = [[/\./g, '___PERIOD___']]; + +type StatusTally = { + passed: number; + failed: number; + notApplicable: number; + notReviewed: number; + profileError: number; + passedTests: number; + failedTests: number; + passingTestsFailedResult: number; +}; + +type SeverityTally = { + none: number; + low: number; + medium: number; + high: number; + critical: number; +}; + +function countStatus(result: ContextualizedControl, tally: StatusTally): void { + switch (result.root.hdf.status) { + case 'Passed': + tally.passed++; + tally.passedTests += (result.root.hdf.segments || []).length; + break; + case 'Failed': + tally.failed++; + tally.passingTestsFailedResult += ( + result.root.hdf.segments || [] + ).filter((subStatus) => subStatus.status === 'passed').length; + tally.failedTests += (result.root.hdf.segments || []).filter( + (subStatus) => subStatus.status === 'failed' + ).length; + break; + case 'Not Applicable': + tally.notApplicable++; + break; + case 'Not Reviewed': + tally.notReviewed++; + break; + case 'Profile Error': + tally.profileError++; + } +} + +function countSeverity( + result: ContextualizedControl, + tally: SeverityTally +): void { + switch (result.root.hdf.severity) { + case 'none': + tally.none++; + break; + case 'low': + tally.low++; + break; + case 'medium': + tally.medium++; + break; + case 'high': + tally.high++; + break; + case 'critical': + tally.critical++; + } +} export class FromHDFToHTMLMapper { // Generated injectable HTML for icons @@ -193,8 +261,8 @@ export class FromHDFToHTMLMapper { // Set file profile data this.outputData.files.push({ filename: file.fileName, - toolVersion: _.get(file.data, 'data.version') as unknown as string, - platform: _.get(file.data, 'data.platform.name') as unknown as string, + toolVersion: _.get(file.data, 'data.version'), + platform: _.get(file.data, 'data.platform.name'), duration: _.get( file.data, 'data.statistics.duration' @@ -207,16 +275,10 @@ export class FromHDFToHTMLMapper { // Pull out results from file const filteredControlsSet = file.filteredControls ? new Set(file.filteredControls) : null; - const allResultLevels = file.data.contains.reduce( - (acc, profile) => { - const matchingResults = profile.contains.filter( - (result) => !filteredControlsSet || filteredControlsSet.has(result.data.id) - ); - - acc.push(...matchingResults); - return acc; - }, - [] + const allResultLevels = file.data.contains.flatMap((profile) => + profile.contains.filter( + (result) => !filteredControlsSet || filteredControlsSet.has(result.data.id) + ) ); // Begin filling out outpuData object to pass into HTML template @@ -240,87 +302,58 @@ export class FromHDFToHTMLMapper { // Set attributes for high level generalized profile details addProfileDetails(results: ContextualizedControl[]) { - let passed = 0; - let failed = 0; - let notApplicable = 0; - let notReviewed = 0; - let profileError = 0; - let none = 0; - let low = 0; - let medium = 0; - let high = 0; - let critical = 0; - let passedTests = 0; - let failedTests = 0; - let passingTestsFailedResult = 0; - // Count out statuses and sub-statuses + const statusTally: StatusTally = { + passed: 0, + failed: 0, + notApplicable: 0, + notReviewed: 0, + profileError: 0, + passedTests: 0, + failedTests: 0, + passingTestsFailedResult: 0 + }; + const severityTally: SeverityTally = { + none: 0, + low: 0, + medium: 0, + high: 0, + critical: 0 + }; + // Count out statuses, sub-statuses, and severities for (const result of results) { - switch (result.root.hdf.status) { - case 'Passed': - passed++; - passedTests += (result.root.hdf.segments || []).length; - break; - case 'Failed': - failed++; - passingTestsFailedResult += (result.root.hdf.segments || []).filter( - (subStatus) => subStatus.status === 'passed' - ).length; - failedTests += (result.root.hdf.segments || []).filter( - (subStatus) => subStatus.status === 'failed' - ).length; - break; - case 'Not Applicable': - notApplicable++; - break; - case 'Not Reviewed': - notReviewed++; - break; - case 'Profile Error': - profileError++; - } - // Count out severities - switch (result.root.hdf.severity) { - case 'none': - none++; - break; - case 'low': - low++; - break; - case 'medium': - medium++; - break; - case 'high': - high++; - break; - case 'critical': - critical++; - } + countStatus(result, statusTally); + countSeverity(result, severityTally); } // Set following attributes from existing file data this.outputData.statistics = { - passed: this.outputData.statistics.passed + passed, - failed: this.outputData.statistics.failed + failed, - notApplicable: this.outputData.statistics.notApplicable + notApplicable, - notReviewed: this.outputData.statistics.notReviewed + notReviewed, - profileError: this.outputData.statistics.profileError + profileError, + passed: this.outputData.statistics.passed + statusTally.passed, + failed: this.outputData.statistics.failed + statusTally.failed, + notApplicable: + this.outputData.statistics.notApplicable + statusTally.notApplicable, + notReviewed: + this.outputData.statistics.notReviewed + statusTally.notReviewed, + profileError: + this.outputData.statistics.profileError + statusTally.profileError, totalResults: this.outputData.statistics.totalResults + results.length, - passedTests: this.outputData.statistics.passedTests + passedTests, + passedTests: + this.outputData.statistics.passedTests + statusTally.passedTests, passingTestsFailedResult: this.outputData.statistics.passingTestsFailedResult + - passingTestsFailedResult, - failedTests: this.outputData.statistics.failedTests + failedTests, + statusTally.passingTestsFailedResult, + failedTests: + this.outputData.statistics.failedTests + statusTally.failedTests, totalTests: this.outputData.statistics.totalTests + - passingTestsFailedResult + - failedTests + statusTally.passingTestsFailedResult + + statusTally.failedTests }; this.outputData.severity = { - none: this.outputData.severity.none + none, - low: this.outputData.severity.low + low, - medium: this.outputData.severity.medium + medium, - high: this.outputData.severity.high + high, - critical: this.outputData.severity.critical + critical + none: this.outputData.severity.none + severityTally.none, + low: this.outputData.severity.low + severityTally.low, + medium: this.outputData.severity.medium + severityTally.medium, + high: this.outputData.severity.high + severityTally.high, + critical: this.outputData.severity.critical + severityTally.critical }; // Calculate & set compliance level and color from result statuses @@ -500,16 +533,14 @@ export class FromHDFToHTMLMapper { // Replace all found illegal characters in string with compliant string equivalent replaceIllegalCharacters(text: string): string { for (const illegalCharacter of ILLEGAL_CHARACTER_SET) { - text = text.replace( - new RegExp(`${illegalCharacter[0]}`, 'g'), - illegalCharacter[1] - ); + text = text.replaceAll(illegalCharacter[0], () => illegalCharacter[1]); } return text; } // Prompt HTML generation from data pulled from file during constructor initialization - async toHTML(): Promise { + // Promise-returning for API compatibility; the rendering itself is synchronous. + toHTML(): Promise { // Pull export template + styles and create outputData object containing data to fill template with const template = html; this.outputData.tailwindStyles = css; @@ -519,6 +550,6 @@ export class FromHDFToHTMLMapper { '' ); // Render template and return generated HTML file - return Mustache.render(template, this.outputData); + return Promise.resolve(Mustache.render(template, this.outputData)); } } diff --git a/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts b/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts index 82695cce70..3a2109eefa 100644 --- a/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts +++ b/libs/hdf-converters/src/converters-from-hdf/reverse-any-base-converter.ts @@ -1,5 +1,6 @@ -import {MappedReform, ObjectEntryValue} from '../base-converter'; -import {FromHdfBaseConverter, ILookupPathFH} from './reverse-base-converter'; +import type {MappedReform, ObjectEntryValue} from '../base-converter'; +import type { ILookupPathFH} from './reverse-base-converter'; +import {FromHdfBaseConverter} from './reverse-base-converter'; // Base converter used to support conversions from HDF to Any Format export class FromAnyBaseConverter extends FromHdfBaseConverter { @@ -17,7 +18,7 @@ export class FromAnyBaseConverter extends FromHdfBaseConverter { } // Preforms fn() on all entries inside the passed obj - objectMap, V>( + objectMap( obj: T, fn: (v: ObjectEntryValue) => V ): {[K in keyof T]: V} { diff --git a/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts b/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts index 05d5a0ed8f..52664ddcdb 100644 --- a/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts +++ b/libs/hdf-converters/src/converters-from-hdf/reverse-base-converter.ts @@ -1,21 +1,21 @@ -import {ExecJSON} from 'inspecjs'; +import type {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; -import { +import type { MappedReform, MappedTransform, ObjectEntryValue } from '../base-converter'; -export interface ILookupPathFH { +export type ILookupPathFH = { path?: string; transformer?: (value: any, context?: any) => unknown; arrayTransformer?: (value: unknown[], file: ExecJSON.Execution) => unknown[]; key?: string; passParent?: boolean; default?: any; -} +}; -//Base converter used to support conversions from HDF to Any Format +// Base converter used to support conversions from HDF to Any Format export class FromHdfBaseConverter { data: ExecJSON.Execution; mappings?: MappedTransform; @@ -30,7 +30,7 @@ export class FromHdfBaseConverter { this.mappings = mappings; } - //Called over and over to iterate through objects assigned to keys too + // Called over and over to iterate through objects assigned to keys too convertInternal(file: object, fields: T): MappedReform { const result = this.objectMap(fields as T[], (v) => this.evaluate(file, v as T & object & ILookupPathFH) @@ -39,7 +39,7 @@ export class FromHdfBaseConverter { } // Preforms fn() on all entries inside the passed obj - objectMap, V>( + objectMap( obj: T, fn: (v: ObjectEntryValue) => V ): {[K in keyof T]: V} { @@ -48,11 +48,11 @@ export class FromHdfBaseConverter { ) as Record; } - //Used to get the data located at the paths + // Used to get the data located at the paths evaluate( file: object, - v: T | Array - ): T | Array | MappedReform { + v: T | T[] + ): T | T[] | MappedReform { const transformer = _.get(v, 'transformer') as any; if (Array.isArray(v)) { return this.handleArray(file, v); @@ -69,17 +69,17 @@ export class FromHdfBaseConverter { } if (typeof transformer === 'function') { - if (!v.path) { + if (v.path) { if (v.passParent) { - return transformer(file, this); + return transformer(this.handlePath(file, v.path), this); } else { - return transformer(file); + return transformer(this.handlePath(file, v.path)); } } else { if (v.passParent) { - return transformer(this.handlePath(file, v.path), this); + return transformer(file, this); } else { - return transformer(this.handlePath(file, v.path)); + return transformer(file); } } } else { @@ -92,13 +92,13 @@ export class FromHdfBaseConverter { handleArray( file: object, - v: Array - ): Array { - const resultingData: Array = []; + v: (T & ILookupPathFH)[] + ): T[] { + const resultingData: T[] = []; // Looks through parsed data file using the mapping setup in V if (v[0] && !v[0].path) { - const arrayTransformer = v[0].arrayTransformer; //does nothing since null - let output: Array = v.map( + const arrayTransformer = v[0].arrayTransformer; // does nothing since null + let output: T[] = v.map( (element) => this.evaluate(file, element) as T ); if (arrayTransformer) { @@ -110,7 +110,7 @@ export class FromHdfBaseConverter { const arrayTransformer = v[0].arrayTransformer; const transformer = v[0].transformer; if (this.hasPath(file, path)) { - const pathVal = this.handlePath(file, path); //Any matches in the path even if more than one, will grab an array of results + const pathVal = this.handlePath(file, path); // Any matches in the path even if more than one, will grab an array of results if (Array.isArray(pathVal)) { v = pathVal.map( (element: Record) => @@ -135,7 +135,7 @@ export class FromHdfBaseConverter { const uniqueResults: T[] = []; resultingData.forEach((result) => { if ( - !uniqueResults.some((uniqueResult) => _.isEqual(result, uniqueResult)) + uniqueResults.every((uniqueResult) => !_.isEqual(result, uniqueResult)) ) { uniqueResults.push(result); } @@ -143,7 +143,7 @@ export class FromHdfBaseConverter { return uniqueResults; } - //Gets the value at the path using lodash and path stored in object + // Gets the value at the path using lodash and path stored in object handlePath(file: object, path: string): unknown { if (path.startsWith('$.')) { return _.get(this.data, path.slice(2)); @@ -151,6 +151,7 @@ export class FromHdfBaseConverter { return _.get(file, path); } } + hasPath(file: object, path: string): boolean { if (path.startsWith('$.')) { return _.has(this.data, path.slice(2)); diff --git a/libs/hdf-converters/src/converters-from-hdf/splunk/Schemas.md b/libs/hdf-converters/src/converters-from-hdf/splunk/Schemas.md index 4bd1eb5a99..22b8798fae 100644 --- a/libs/hdf-converters/src/converters-from-hdf/splunk/Schemas.md +++ b/libs/hdf-converters/src/converters-from-hdf/splunk/Schemas.md @@ -5,7 +5,7 @@ hdf2Splunk has the following 3 schemas for importing data into Splunk. ## Previewing HDF Data Within Splunk A full raw search query: -``` +```text index="<>" meta.subtype=control | stats values(meta.filename) values(meta.filetype) list(meta.profile_sha256) values(meta.hdf_splunk_schema) first(meta.status) list(meta.status) list(meta.is_baseline) values(title) last(code) list(code) values(desc) values(descriptions.*) values(id) values(impact) list(refs{}.*) list(results{}.*) list(source_location{}.*) values(tags.*) by meta.guid id | join meta.guid [search index="hdf" meta.subtype=header | stats values(meta.filename) values(meta.filetype) values(meta.hdf_splunk_schema) list(statistics.duration) list(platform.*) list(version) by meta.guid] @@ -14,7 +14,7 @@ index="<>" meta.subtype=control | stats values(meta.filename) value ``` A formatted table search query: -``` +```text index="<>" meta.subtype=control | stats values(meta.filename) values(meta.filetype) list(meta.profile_sha256) values(meta.hdf_splunk_schema) first(meta.status) list(meta.status) list(meta.is_baseline) values(title) last(code) list(code) values(desc) values(descriptions.*) values(id) values(impact) list(refs{}.*) list(results{}.*) list(source_location{}.*) values(tags.*) by meta.guid id | join meta.guid [search index="hdf" meta.subtype=header | stats values(meta.filename) values(meta.filetype) values(meta.hdf_splunk_schema) list(statistics.duration) list(platform.*) list(version) by meta.guid] @@ -25,7 +25,7 @@ index="<>" meta.subtype=control | stats values(meta.filename) value ``` ### Control -``` +```json { "meta": { // This field is consistent accross all events per upload, i.e you can get all data related to a results set by querying meta.guid="<>" @@ -112,7 +112,7 @@ index="<>" meta.subtype=control | stats values(meta.filename) value ``` ### Profile -``` +```json { "meta": { // This field is consistent across all events per upload, i.e you can get all data related to a results set by querying meta.guid="<>" @@ -194,7 +194,7 @@ index="<>" meta.subtype=control | stats values(meta.filename) value ``` ### (Execution) Header -``` +```json { "meta": { diff --git a/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts index aedbe0dcbc..c492eb21bc 100644 --- a/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/splunk/reverse-splunk-mapper.ts @@ -1,17 +1,18 @@ -import axios, {AxiosInstance, AxiosResponse} from 'axios'; -import { +import type {AxiosInstance, AxiosResponse} from 'axios'; +import axios from 'axios'; +import type { ContextualizedControl, ContextualizedEvaluation, ContextualizedProfile, ExecJSON } from 'inspecjs'; import * as _ from 'lodash'; -import {Logger} from 'winston'; -import {SplunkConfig} from '../../../types/splunk-config-types'; -import {SplunkControl} from '../../../types/splunk-control-types'; -import {SplunkProfile} from '../../../types/splunk-profile-types'; -import {SplunkReport} from '../../../types/splunk-report-types'; -import {MappedTransform} from '../../base-converter'; +import type {Logger} from 'winston'; +import type {SplunkConfig} from '../../../types/splunk-config-types'; +import type {SplunkControl} from '../../../types/splunk-control-types'; +import type {SplunkProfile} from '../../../types/splunk-profile-types'; +import type {SplunkReport} from '../../../types/splunk-report-types'; +import type {MappedTransform} from '../../base-converter'; import { createWinstonLogger, ensureContextualizedEvaluation @@ -22,7 +23,7 @@ import { handleSplunkErrorResponse } from '../../utils/splunk-tools'; import {FromAnyBaseConverter} from '../reverse-any-base-converter'; -import {ILookupPathFH} from '../reverse-base-converter'; +import type {ILookupPathFH} from '../reverse-base-converter'; const HDF_SPLUNK_SCHEMA = '1.1'; const MAPPER_NAME = 'HDF2Splunk'; @@ -34,7 +35,6 @@ export type SplunkData = { reports: SplunkReport[]; }; -let logger = createWinstonLogger('HDF2Splunk', 'INFO'); export function createGUID(length: number) { let result = ''; @@ -77,8 +77,8 @@ export function getDependencies( if (profile.data.depends) { for (const dependency of profile.data.depends) { if (dependency.name) { - dependencies.push(dependency.name); dependencies.push( + dependency.name, ...getDependencies( execution.contains.find( (execProfile) => execProfile.data.name === dependency.name @@ -115,7 +115,7 @@ export function createProfileMapping( is_baseline: { path: 'data.depends[0].name', transformer: (value?: string) => { - return !Boolean(value); + return !value; } }, profile_sha256: { @@ -187,7 +187,7 @@ export function createControlMapping( transformer: (data: ContextualizedControl) => { if ( data.hdf.segments?.length === 0 && - data.extendsFrom.length !== 0 + data.extendsFrom.length > 0 ) { return 'Overlaid Control'; } else { @@ -213,7 +213,7 @@ export function createControlMapping( const descObjects: Record = {}; if (Array.isArray(data)) { for (const item of data) { - descObjects[item['label']] = item['data']; + descObjects[item.label] = item.data; } } return descObjects; @@ -277,20 +277,18 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { declare mappings?: MappedTransform; contextualizedEvaluation?: ContextualizedEvaluation; axiosInstance: AxiosInstance; + logger: Logger; constructor( data: ExecJSON.Execution | ContextualizedEvaluation, logService?: Logger, loggingLevel?: string ) { - if (logService) { - logger = logService; - } else { - logger = createWinstonLogger(MAPPER_NAME, loggingLevel || 'debug'); - } super(ensureContextualizedEvaluation(data)); + this.logger = + logService ?? createWinstonLogger(MAPPER_NAME, loggingLevel || 'debug'); this.axiosInstance = axios.create({params: {output_mode: 'json'}}); - logger.debug(`Initialized ${this.constructor.name} successfully`); + this.logger.debug(`Initialized ${this.constructor.name} successfully`); } createSplunkData(guid: string, filename: string) { @@ -335,19 +333,19 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { splunkData: SplunkData ): Promise { const hostname = generateHostname(config); - this.axiosInstance.defaults.params['sourcetype'] = MAPPER_NAME; - this.axiosInstance.defaults.params['index'] = targetIndex.name; + this.axiosInstance.defaults.params.sourcetype = MAPPER_NAME; + this.axiosInstance.defaults.params.index = targetIndex.name; try { // Upload execution event - const execEvents = splunkData.reports.map((report) => { - return this.axiosInstance - .post(`${hostname}/services/receivers/simple`, JSON.stringify(report)) - .then(() => { - logger.verbose( - `Successfully uploaded execution for ${report.meta.filename}` - ); - }); + const execEvents = splunkData.reports.map(async (report) => { + await this.axiosInstance.post( + `${hostname}/services/receivers/simple`, + JSON.stringify(report) + ); + this.logger.verbose( + `Successfully uploaded execution for ${report.meta.filename}` + ); }); await Promise.all(execEvents); @@ -358,26 +356,25 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { `${hostname}/services/receivers/simple`, splunkData.profiles.map((profile) => JSON.stringify(profile)).join('\n') ); - logger.verbose( + this.logger.verbose( `Successfully uploaded ${splunkData.profiles.length} profile layer(s)` ); // Upload control event(s) const controlEvents = _.chunk(splunkData.controls, UPLOAD_CHUNK_SIZE).map( - (chunk) => { - return this.axiosInstance - .post( - `${hostname}/services/receivers/simple`, - chunk.map((control) => JSON.stringify(control)).join('\n') - ) - .then(() => - logger.verbose(`Successfully uploaded ${chunk.length} control(s)`) - ); + async (chunk) => { + await this.axiosInstance.post( + `${hostname}/services/receivers/simple`, + chunk.map((control) => JSON.stringify(control)).join('\n') + ); + this.logger.verbose( + `Successfully uploaded ${chunk.length} control(s)` + ); } ); await Promise.all(controlEvents); } catch (error) { - throw new Error(handleSplunkErrorResponse(error)); + throw new Error(handleSplunkErrorResponse(error), {cause: error}); } } @@ -388,16 +385,16 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { const returnCount = 0; let indexResponse: AxiosResponse; - logger.info( + this.logger.info( `Logging into Splunk instance at ${hostname} with user ${config.username}` ); - logger.verbose(`Found designated file to transfer: ${filename}`); + this.logger.verbose(`Found designated file to transfer: ${filename}`); const guid = createGUID(30); - logger.verbose(`Using GUID: ${guid}`); + this.logger.verbose(`Using GUID: ${guid}`); // Attempt to authenticate using given credentials const authResponse = await checkSplunkCredentials(config); - this.axiosInstance.defaults.headers.common['Authorization'] = + this.axiosInstance.defaults.headers.common.Authorization = `Bearer ${authResponse}`; // Request all available indexes @@ -410,7 +407,8 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { ); } catch (error) { throw new Error( - `Failed to request indexes - ${handleSplunkErrorResponse(error)}` + `Failed to request indexes - ${handleSplunkErrorResponse(error)}`, + {cause: error} ); } @@ -423,7 +421,7 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { // Report provided indexes const indexes = indexResponse.data.entry; - if (indexes.length <= 0) { + if (indexes.length === 0) { throw new Error( 'Unable to retrieve available indexes, double-check your scheme configuration and try again' ); @@ -431,14 +429,14 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { const indexNames: string[] = indexes.map( (index: {name: string}) => index.name ); - logger.verbose(`Available indexes: ${indexNames.join(', ')}`); + this.logger.verbose(`Available indexes: ${indexNames.join(', ')}`); // Parse available indexes for user desired index if (indexNames.includes(config.index)) { - const targetIndex = indexes.filter( + const targetIndex = indexes.find( (index: {name: string}) => index.name === config.index - )[0]; - logger.verbose(`Found index: ${targetIndex.name}`); + )!; + this.logger.verbose(`Found index: ${targetIndex.name}`); // Post given file(s) to identified index const splunkData = this.createSplunkData(guid, filename); @@ -447,10 +445,11 @@ export class FromHDFToSplunkMapper extends FromAnyBaseConverter { await this.uploadSplunkData(config, targetIndex, splunkData); } catch (error) { throw new Error( - `Failed to upload to Splunk - ${handleSplunkErrorResponse(error)}` + `Failed to upload to Splunk - ${handleSplunkErrorResponse(error)}`, + {cause: error} ); } - logger.info(`Successfully uploaded to ${config.index}`); + this.logger.info(`Successfully uploaded to ${config.index}`); return guid; } else { throw new Error(`Invalid index - ${config.index}`); diff --git a/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts b/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts index 39307f61b5..8010d30da5 100644 --- a/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts +++ b/libs/hdf-converters/src/converters-from-hdf/xccdf/reverse-xccdf-mapper.ts @@ -3,7 +3,7 @@ import * as _ from 'lodash'; import moment from 'moment'; import Mustache from 'mustache'; import {version as HeimdallToolsVersion} from '../../../package.json'; -import { +import type { MappedXCCDFtoHDF, TestResultStatus, XCCDFSeverity @@ -15,7 +15,7 @@ const TESTING_DATE_OVERRIDE = '1970-01-01'; const TESTING_DATETIME_OVERRIDE = '2022-05-06T21:46:47.939Z'; function arrayifyObjectDescriptions( - descriptions?: {[key: string]: any} | ExecJSON.ControlDescription[] | null + descriptions?: Record | ExecJSON.ControlDescription[] | null ): ExecJSON.ControlDescription[] { if (!descriptions) { return []; @@ -41,15 +41,27 @@ function getXCCDFResult(control: ExecJSON.Control): TestResultStatus { return 'unknown'; } - if (control.results.every((result) => result.status === 'passed')) { + if ( + control.results.every( + (result) => result.status === ExecJSON.ControlResultStatus.Passed + ) + ) { return 'pass'; } - if (control.results.every((result) => result.status === 'skipped')) { + if ( + control.results.every( + (result) => result.status === ExecJSON.ControlResultStatus.Skipped + ) + ) { return 'notchecked'; } - if (control.results.some((result) => result.status === 'failed')) { + if ( + control.results.some( + (result) => result.status === ExecJSON.ControlResultStatus.Failed + ) + ) { return 'fail'; } @@ -65,13 +77,13 @@ function getXCCDFResultMessageSeverity(segments: ExecJSON.ControlResult[]) { function toMessageLine(segment: ExecJSON.ControlResult): string { switch (segment.status) { - case 'skipped': + case ExecJSON.ControlResultStatus.Skipped: return `SKIPPED -- ${segment.skip_message}\n`; - case 'failed': + case ExecJSON.ControlResultStatus.Failed: return `FAILED -- Test: ${segment.code_desc}\nMessage: ${segment.message}\n`; - case 'passed': + case ExecJSON.ControlResultStatus.Passed: return `PASSED -- ${segment.code_desc}"`; - case 'error': + case ExecJSON.ControlResultStatus.Error: return `ERROR -- Test: ${segment.code_desc}\nMessage: ${segment.message}`; default: return `Exception: ${segment.exception}`; @@ -79,7 +91,7 @@ function toMessageLine(segment: ExecJSON.ControlResult): string { } function getMessages(segments: ExecJSON.ControlResult[]) { - return segments.map(toMessageLine).join('\n\n'); + return segments.map((segment) => toMessageLine(segment)).join('\n\n'); } export class FromHDFToXCCDFMapper { @@ -106,7 +118,7 @@ export class FromHDFToXCCDFMapper { } getControlInfo(control: ExecJSON.Control) { - const knownDescriptions = [ + const knownDescriptions = new Set([ 'default', 'check', 'fix', @@ -118,18 +130,18 @@ export class FromHDFToXCCDFMapper { 'satisfies', 'fix_id', 'documentable' - ]; + ]); return { groupId: 'xccdf_hdf_group_' + control.id - .replace(/_/g, '-') // Prevents STIG Viewer from parsing IDs incorrectly when there is underscores after group_ - .replace(/[^\w-.]/g, '_'), // Change everything that isn't a word, underscore, or dash into an underscore + .replaceAll('_', '-') // Prevents STIG Viewer from parsing IDs incorrectly when there is underscores after group_ + .replaceAll(/[^\w\-.]/g, '_'), // Change everything that isn't a word, underscore, or dash into an underscore id: 'xccdf_hdf_rule_' + (control.tags.rid || - control.id.replace(/_/g, '-').replace(/[^\w-.]/g, '_') + '_rule'), + control.id.replaceAll('_', '-').replaceAll(/[^\w\-.]/g, '_') + '_rule'), version: control.tags.stig_id || '', gtitle: control.tags.gtitle || control.title, title: control.title || '', @@ -143,7 +155,7 @@ export class FromHDFToXCCDFMapper { '', documentable: control.tags.documentable || false, descriptions: arrayifyObjectDescriptions(control.descriptions).filter( - (description) => !knownDescriptions.includes(description.label) + (description) => !knownDescriptions.has(description.label) ), waiver: control.waiver_data ? JSON.stringify(control.waiver_data) : '', checkContent: @@ -151,7 +163,7 @@ export class FromHDFToXCCDFMapper { control.tags.check || '', tags: Object.entries(control.tags) - .filter(([key]) => !knownDescriptions.includes(key)) + .filter(([key]) => !knownDescriptions.has(key)) .map(([key, value]) => `${key}: ${value}`), code: control.code || '', fixid: control.tags.fix_id, @@ -202,8 +214,8 @@ export class FromHDFToXCCDFMapper { 'xccdf_hdf_rule_' + (control.tags.rid || control.id - .replace(/_/g, '-') // Prevent STIG Viewer from parsing IDs incorrectly when there is underscores after rule_ - .replace(/[^\w-.]/g, '_') + '_rule'), + .replaceAll('_', '-') // Prevent STIG Viewer from parsing IDs incorrectly when there is underscores after rule_ + .replaceAll(/[^\w\-.]/g, '_') + '_rule'), result: getXCCDFResult(control), message: getMessages(control.results), messageType: getXCCDFResultMessageSeverity(control.results), @@ -216,7 +228,7 @@ export class FromHDFToXCCDFMapper { let passthroughString = ''; if (typeof passthrough === 'object') { passthroughString = JSON.stringify(passthrough); - } else if (typeof passthrough !== 'undefined') { + } else if (passthrough !== undefined) { passthroughString = String(passthrough); } @@ -252,7 +264,7 @@ export class FromHDFToXCCDFMapper { id: 'xccdf_mitre.hdf-converters_profile_hdf2xccdf_' + // Replace all non-word characters and spaces with underscores - (profile.title?.replace(/[^\w-.]/g, '_') || 'profile_missing_title'), + (profile.title?.replaceAll(/[^\w\-.]/g, '_') || 'profile_missing_title'), title: profile.title || '', description: profile.description || '', // All control IDs @@ -260,7 +272,7 @@ export class FromHDFToXCCDFMapper { (control) => 'xccdf_hdf_rule_' + (control.tags.rid || - control.id.replace(/_/g, '-').replace(/[^\w-.]/g, '_') + '_rule') + control.id.replaceAll('_', '-').replaceAll(/[^\w\-.]/g, '_') + '_rule') ) }); mappedData.Benchmark.TestResult.attributes.push( diff --git a/libs/hdf-converters/src/conveyor-mapper.ts b/libs/hdf-converters/src/conveyor-mapper.ts index 1e8a99331e..41847a0190 100644 --- a/libs/hdf-converters/src/conveyor-mapper.ts +++ b/libs/hdf-converters/src/conveyor-mapper.ts @@ -1,7 +1,8 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import { DEFAULT_STATIC_CODE_ANALYSIS_CCI_TAGS, DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS @@ -14,6 +15,14 @@ enum scannerType { Stigma = 'Stigma' } +// Enum values widened to strings for comparison with scan-supplied names. +const FULL_DESC_SCANNERS = new Set([ + scannerType.Moldy, + scannerType.Stigma, + scannerType.ClamAV +]); +const CODE_QUALITY_SCANNER: string = scannerType.CodeQuality; + /* Uses score to determine pass or fail. Non-zero score is fail */ @@ -46,7 +55,7 @@ function collateShaAndFilenames( const shaFilePairs: string[][] = []; for (const [sha, file] of Object.entries(currLevel)) { if (_.has(file, 'name')) { - //name always array of size 1 + // name always array of size 1 const name: string = _.get(file, 'name[0]') || ''; shaFilePairs.push([sha, name]); } @@ -88,30 +97,26 @@ function createDescription( endTime: string ): Record { const desc = () => { - if ( - scannerName === scannerType.Moldy || - scannerName === scannerType.Stigma || - scannerName === scannerType.ClamAV - ) { + if (FULL_DESC_SCANNERS.has(scannerName)) { return `title_text:${_.get(data, 'title_text') as string} - body:${_.get(data, 'body')} + body:${String(_.get(data, 'body'))} body_format:${_.get(data, 'body_format') as string} classificaton:${_.get(data, 'classification') as string} depth:${_.get(data, 'depth') as string} heuristic_heur_id:${_.get(data, 'heuristic.heur_id') as string} heuristic_score:${_.get(data, 'heuristic.score') as string} heuristic_name:${_.get(data, 'heuristic.name') as string}`.replace( - '\\"', + String.raw`\"`, '' ); - } else if (scannerName === scannerType.CodeQuality) { - return `body:${_.get(data, 'body')} + } else if (scannerName === CODE_QUALITY_SCANNER) { + return `body:${String(_.get(data, 'body'))} body_format:${_.get(data, 'body_format') as string} classificaton:${_.get(data, 'classification') as string} depth:${_.get(data, 'depth') as string} - title_text:${_.get(data, 'title_text') as string}`.replace('\\"', ''); + title_text:${_.get(data, 'title_text') as string}`.replace(String.raw`\"`, ''); } else { - return JSON.stringify(data).replace('\\"', ''); + return JSON.stringify(data).replace(String.raw`\"`, ''); } }; return { @@ -145,7 +150,7 @@ function preprocessObject( _.get(result, 'result.sections') as Record[], (section) => createDescription( - section as Record, + section, _.get(result, 'result.score') as number, _.get(result, 'response.milestones.service_started') as string, _.get(result, 'response.service_name') as string, @@ -241,6 +246,7 @@ export class ConveyorMapper extends BaseConverter { } ] }; + constructor( remappedConveyorResults: Record, data: Record, diff --git a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts index 62f77fb1ee..4d9be732ab 100644 --- a/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts +++ b/libs/hdf-converters/src/cyclonedx-sbom-mapper.ts @@ -1,10 +1,11 @@ import {ExecJSON} from 'inspecjs'; import _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import {filterString, getCCIsForNISTTags} from './utils/global'; -import { +import type { CycloneDXSoftwareBillOfMaterialSpecification, CycloneDXSoftwareBillOfMaterialsStandard, CycloneDXBillOfMaterialsStandardVulnerability, @@ -24,6 +25,8 @@ import { } from '../types/cyclonedx'; const cvssMethods = ['CVSSv2', 'CVSSv3', 'CVSSv31', 'CVSSv4'] as const; +// tags.ratings joins entries as `severity - method, severity - method`. +const RATING_SEPARATOR = / - |, /; type CVSSMethodEnum = Extract; type IntermediaryComponent = Omit< @@ -52,34 +55,29 @@ type DataStorage = { const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; -const IMPACT_MAPPING: Map = new Map([ - ['critical', 1.0], +const IMPACT_MAPPING = new Map([ + ['critical', 1], ['high', 0.7], ['medium', 0.5], ['low', 0.3], ['info', 0.5], - ['none', 0.0], + ['none', 0], ['unknown', 0.5] ]); +// Both CycloneDX schema variants declare the same shape for a vulnerability's +// CWE list, so these mappers take one alias rather than a duplicated union. +type VulnerabilityCwes = CycloneDXBillOfMaterialsStandardVulnerability['cwes']; + // Convert object type to string[] and prepend `CWE` if used directly for tag display -function formatCWETags( - input: - | CycloneDXBillOfMaterialsStandardVulnerability['cwes'] - | CycloneDXSoftwareBillOfMaterialsStandardVulnerability['cwes'], - addPrefix = true -): string[] { +function formatCWETags(input: VulnerabilityCwes, withPrefix = true): string[] { return input && Array.isArray(input) - ? input.map((cwe) => (addPrefix ? `CWE-${cwe}` : `${cwe}`)) + ? input.map((cwe) => (withPrefix ? `CWE-${cwe}` : String(cwe))) : []; } // Convert gathered CWEs to corresponding NIST 800-53s -function getNISTTags( - input: - | CycloneDXBillOfMaterialsStandardVulnerability['cwes'] - | CycloneDXSoftwareBillOfMaterialsStandardVulnerability['cwes'] -): string[] { +function getNISTTags(input: VulnerabilityCwes): string[] { return CWE_NIST_MAPPING.nistFilter( formatCWETags(input, false), DEFAULT_NIST_TAG @@ -89,8 +87,9 @@ function getNISTTags( // A single SBOM vulnerability can contain multiple security ratings // Find the max of any existing ratings and then pass to `impact` function maxImpact(ratings: FluffyRating[] | PurpleRating[]): number { - return ratings - .map((rating) => + return Math.max( + 0, + ...ratings.map((rating) => rating.score && rating.method && cvssMethods.includes(rating.method as CVSSMethodEnum) // cast required since .includes expects the parameter to be a subtype @@ -99,12 +98,7 @@ function maxImpact(ratings: FluffyRating[] | PurpleRating[]): number { : // Else interpret it from `severity` field, defaulting to medium/0.5 (IMPACT_MAPPING.get(rating.severity?.toLowerCase() ?? '') ?? 0.5) ) - .reduce( - (maxValue, newValue) => - // Find max of existing ratings - maxValue > newValue ? maxValue : newValue, - 0 - ); + ); } // If the highest rating severity for a control is `info` or `unknown`, set the results to skipped and request a manual review @@ -113,23 +107,25 @@ function skipSeverityInfoOrUnknown(controls: unknown[]): unknown[] { (controls as ExecJSON.Control[]) // Filter to controls whose highest rating severity is either `info` or `unknown` .filter((control) => { - const ratings = (_.get(control, 'tags.ratings', '') as string).split( - / - |, / + const ratings = new Set( + (_.get(control, 'tags.ratings', '') as string).split( + RATING_SEPARATOR + ) ); return ( - (ratings.includes('info') || ratings.includes('unknown')) && + (ratings.has('info') || ratings.has('unknown')) && !( - ratings.includes('critical') || - ratings.includes('high') || - ratings.includes('medium') || - ratings.includes('low') || - ratings.includes('none') + ratings.has('critical') || + ratings.has('high') || + ratings.has('medium') || + ratings.has('low') || + ratings.has('none') ) ); }) // For every result contained by that control, set the status to skipped and request a manual review - .map((control) => - control.results.map((result) => { + .forEach((control) => + control.results.forEach((result) => { result.status = ExecJSON.ControlResultStatus.Skipped; result.skip_message = 'Manual review required because a CycloneDX rating severity is set to `info` or `unknown`.'; @@ -170,19 +166,24 @@ export class CycloneDXSBOMResults { // Flatten any arbitrarily nested components list flattenComponents(data: DataStorage) { // Pull components from raw data - data.components = _.cloneDeep( + data.components = structuredClone( data.raw.components ) as IntermediaryComponent[]; - // Look through every component at the top level of the list - for (const component of data.components) { - // Identify if subcomponents exist + // Flatten the tree breadth-first. Subcomponents join the queue and are + // visited in turn, which is the algorithm rather than an accidental + // mutation of the list being walked. + const queue = [...data.components]; + const flattened: IntermediaryComponent[] = []; + while (queue.length > 0) { + const component = queue.shift()!; if (component.components) { - // If so, pull out the subcomponents and push them to end of top level component list for further flattening - data.components.push(...component.components); + queue.push(...component.components); delete component.components; } + flattened.push(component); } + data.components = flattened; } /* @@ -217,7 +218,7 @@ export class CycloneDXSBOMResults { */ generateIntermediary(data: DataStorage) { // Pull vulnerabilities from raw data - data.vulnerabilities = _.cloneDeep( + data.vulnerabilities = structuredClone( data.raw.vulnerabilities ) as IntermediaryVulnerability[]; @@ -225,12 +226,12 @@ export class CycloneDXSBOMResults { vulnerability.affectedComponents = []; vulnerability.affectedComponents.push( - ...Array.from(data.components.entries()) + ...[...data.components.entries()] // Find every component that is affected via listed bom-refs .filter(([_index, component]) => vulnerability.affects - ?.map((id) => id.ref.toString()) - .includes(component['bom-ref'] as string) + ?.map((id) => id.ref) + .includes(component['bom-ref']!) ) // Add the index of that affected component to the corresponding vulnerability object .map(([index, _component]) => index) @@ -238,12 +239,14 @@ export class CycloneDXSBOMResults { // Also record the ID of the vulnerability in the component for use in bidirectional traversal for (const index of vulnerability.affectedComponents) { - if (!data.components[index].affectingVulnerabilities) { - data.components[index].affectingVulnerabilities = []; + // Indices were derived from data.components itself just above, so + // .at() cannot miss; the guard states that invariant. + const component = data.components.at(index); + if (component !== undefined) { + (component.affectingVulnerabilities ??= []).push( + _.get(vulnerability, 'bom-ref') as unknown as string + ); } - (data.components[index].affectingVulnerabilities as string[]).push( - _.get(vulnerability, 'bom-ref') as unknown as string - ); } } } @@ -253,17 +256,17 @@ export class CycloneDXSBOMResults { formatVEX(data: DataStorage) { // Pull vulnerabilities from raw data data.vulnerabilities = [ - ...(_.cloneDeep(data.raw.vulnerabilities) as + ...(structuredClone(data.raw.vulnerabilities) as | CycloneDXBillOfMaterialsStandardVulnerability[] | CycloneDXSoftwareBillOfMaterialsStandardVulnerability[]) - ] as unknown as IntermediaryVulnerability[]; + ]; for (const vulnerability of data.vulnerabilities) { vulnerability.affectedComponents = vulnerability.affects?.map((id) => { // Build a dummy component for each bom-ref identified as being affected by the vulnerability const dummy: IntermediaryComponent = { - name: `${id.ref}`, - 'bom-ref': `${id.ref}`, + name: id.ref, + 'bom-ref': id.ref, isDummy: true, type: 'application' // a type must be provided, and "application" is the default classification }; @@ -283,14 +286,6 @@ export class CycloneDXSBOMResults { export class CycloneDXSBOMMapper extends BaseConverter { withRaw: boolean; - // Pull any keys from a given index for the stored components listing - getComponentValueAtIndex( - index: number, - keys: string[] - ): Record { - return _.pick(this.data.components[index], keys); - } - mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, ILookupPath @@ -332,13 +327,13 @@ export class CycloneDXSBOMMapper extends BaseConverter { ): string | undefined => { // Find organization of authors if possible const manufacturer = _.has(input, 'manufacturer') - ? ` (${(input.manufacturer as Record).name})` + ? ` (${String((input.manufacturer as Record).name)})` : ''; // Check through every single possible field which may hold ownership over this component if (_.has(input, 'authors')) { // Join list of component authors return (input.authors as Record[]) - .map((author) => `${author.name}${manufacturer}`) + .map((author) => `${String(author.name)}${manufacturer}`) .join(', '); } else if (input.author) { // `author` is deprecated in v1.6 but may still appear @@ -367,12 +362,8 @@ export class CycloneDXSBOMMapper extends BaseConverter { // Certain license reports only provide the license name in the `name` field // Check there first and then default to `id` return input.licenses - ?.map((license) => - license?.license?.name - ? license.license.name - : license?.license?.id - ) - .filter((identifier) => identifier) + ?.map((license) => license?.license?.name || license?.license?.id) + .filter(Boolean) .join(', '); } }, @@ -391,11 +382,8 @@ export class CycloneDXSBOMMapper extends BaseConverter { }, cci: { path: 'cwes', - transformer: ( - input: - | CycloneDXBillOfMaterialsStandardVulnerability['cwes'] - | CycloneDXSoftwareBillOfMaterialsStandardVulnerability['cwes'] - ): string[] => getCCIsForNISTTags(getNISTTags(input)) + transformer: (input: VulnerabilityCwes): string[] => + getCCIsForNISTTags(getNISTTags(input)) }, cwe: {path: 'cwes', transformer: formatCWETags}, 'bom-ref': { @@ -408,14 +396,12 @@ export class CycloneDXSBOMMapper extends BaseConverter { input: FluffyRating[] | PurpleRating[] ): string | undefined => input - ? [...input] - .map((rating) => { - const ratingSource = rating.source?.name - ? `${rating.source?.name} - ` - : 'Unidentified Source - '; - return `${ratingSource}${rating.severity}`; - }) - .join(', ') + ? Array.from(input, (rating) => { + const ratingSource = rating.source?.name + ? `${rating.source?.name} - ` + : 'Unidentified Source - '; + return `${ratingSource}${rating.severity}`; + }).join(', ') : undefined }, created: { @@ -440,11 +426,14 @@ export class CycloneDXSBOMMapper extends BaseConverter { transformer: ( input: FluffyCredits | PurpleCredits ): string | undefined => + // No template wrap: the optional chain is string | undefined, + // and the old wrap rendered the missing case as the literal + // string 'undefined' instead of leaving the field unset. input - ? `${input.individuals + ? input.individuals ?.map((individual) => individual.name) - .filter((name) => name) - .join(', ')}` + .filter(Boolean) + .join(', ') : undefined }, tools: { @@ -462,7 +451,7 @@ export class CycloneDXSBOMMapper extends BaseConverter { if (Array.isArray(input)) { return input .map((tool) => tool.name) - .filter((name) => name) + .filter(Boolean) .join(', '); } return [ @@ -530,7 +519,7 @@ export class CycloneDXSBOMMapper extends BaseConverter { label: 'check' } : undefined - ].filter((subdescription) => subdescription); + ].filter(Boolean); } } as unknown as ExecJSON.ControlDescription[], refs: [ @@ -540,7 +529,7 @@ export class CycloneDXSBOMMapper extends BaseConverter { ): Record => { const searchFor = ['source', 'references', 'advisories']; const ref = searchFor - .filter((key) => input.hasOwnProperty(key)) + .filter((key) => Object.hasOwn(input, key)) .map((key) => _.pick(input, key)); return {ref: ref}; } @@ -554,7 +543,7 @@ export class CycloneDXSBOMMapper extends BaseConverter { | CycloneDXBillOfMaterialsStandardVulnerability | CycloneDXSoftwareBillOfMaterialsStandardVulnerability ): string => - input.description ? `${input.description}` : `${input.id}` + input.description || String(input.id) }, id: {path: 'id'}, desc: { @@ -597,12 +586,12 @@ export class CycloneDXSBOMMapper extends BaseConverter { ['group', 'version', 'name'] ); const group = _.has(selectComponentValues, 'group') - ? `${selectComponentValues.group}/` + ? `${String(selectComponentValues.group)}/` : ''; const version = _.has(selectComponentValues, 'version') - ? `@${selectComponentValues.version}` + ? `@${String(selectComponentValues.version)}` : ''; - return `Component ${group}${_.get(selectComponentValues, 'name')}${version} is vulnerable`; + return `Component ${group}${String(_.get(selectComponentValues, 'name'))}${version} is vulnerable`; } }, message: { @@ -627,11 +616,11 @@ export class CycloneDXSBOMMapper extends BaseConverter { 'copyright' ] ); - const msg = Object.keys(selectComponentValues) - .map((key) => { - return Array.isArray(selectComponentValues[key]) - ? `\n\n- ${_.capitalize(key)}: ${JSON.stringify(selectComponentValues[key], null, 2)}` - : `\n\n- ${_.capitalize(key)}: ${selectComponentValues[key]}`; + const msg = Object.entries(selectComponentValues) + .map(([key, value]) => { + return Array.isArray(value) + ? `\n\n- ${_.capitalize(key)}: ${JSON.stringify(value, null, 2)}` + : `\n\n- ${_.capitalize(key)}: ${String(value)}`; }) .join(''); return `-Component Summary-${msg}`; @@ -656,7 +645,7 @@ export class CycloneDXSBOMMapper extends BaseConverter { auxiliary_data: [ { name: 'SBOM', - components: components.length ? components : undefined, + components: components.length > 0 ? components : undefined, dependencies: _.get(input, 'raw.dependencies'), data: _.omit(input.raw, [ 'components', @@ -670,8 +659,19 @@ export class CycloneDXSBOMMapper extends BaseConverter { } } }; + constructor(exportJson: DataStorage, withRaw = false) { super(exportJson, true); this.withRaw = withRaw; } + + // Pull any keys from a given index for the stored components listing + getComponentValueAtIndex( + index: number, + keys: string[] + ): Record { + // _.pick tolerates undefined (returns {}), matching what the old typed + // lie produced for an out-of-range index. + return _.pick(this.data.components.at(index), keys); + } } diff --git a/libs/hdf-converters/src/dbprotect-mapper.ts b/libs/hdf-converters/src/dbprotect-mapper.ts index 9cd67a35ab..7811863295 100644 --- a/libs/hdf-converters/src/dbprotect-mapper.ts +++ b/libs/hdf-converters/src/dbprotect-mapper.ts @@ -1,11 +1,12 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform} from './base-converter'; import { BaseConverter, - ILookupPath, impactMapping, - MappedTransform, parseXml } from './base-converter'; import { @@ -13,7 +14,7 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3], @@ -43,23 +44,23 @@ function compileFindings( return Object.fromEntries([['data', output]]); } function formatSummary(entry: unknown): string { - const text = []; - text.push(`Organization : ${_.get(entry, 'Organization')}`); - text.push(`Asset : ${_.get(entry, 'Check Asset')}`); - text.push(`Asset Type : ${_.get(entry, 'Asset Type')}`); - text.push(`IP Address, Port, Instance : ${_.get(entry, 'Asset Type')}`); - text.push( + const text = [ + `Organization : ${_.get(entry, 'Organization')}`, + `Asset : ${_.get(entry, 'Check Asset')}`, + `Asset Type : ${_.get(entry, 'Asset Type')}`, + `IP Address, Port, Instance : ${_.get(entry, 'Asset Type')}`, `IP Address, Port, Instance : ${_.get( entry, 'IP Address, Port, Instance' )} ` - ); + ]; return text.join('\n'); } function formatDesc(entry: unknown): string { - const text = []; - text.push(`Task : ${_.get(entry, 'Task')}`); - text.push(`Check Category : ${_.get(entry, 'Check Category')}`); + const text = [ + `Task : ${_.get(entry, 'Task')}`, + `Check Category : ${_.get(entry, 'Check Category')}` + ]; return text.join('; '); } function getStatus(input: unknown): ExecJSON.ControlResultStatus { @@ -144,6 +145,7 @@ export class DBProtectMapper extends BaseConverter { } } }; + constructor(dbProtectXml: string, withRaw = false) { super(compileFindings(parseXml(dbProtectXml))); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/dependency-track-mapper.ts b/libs/hdf-converters/src/dependency-track-mapper.ts index b0414da003..b828acad75 100644 --- a/libs/hdf-converters/src/dependency-track-mapper.ts +++ b/libs/hdf-converters/src/dependency-track-mapper.ts @@ -1,24 +1,26 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import { DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS, getCCIsForNISTTags } from './utils/global'; -interface ICweEntry { +type ICweEntry = { cweId: number; name: string; -} +}; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], @@ -42,7 +44,8 @@ function getVersion(file: unknown): string { function getTitle(finding: unknown) { const title = _.get(finding, 'vulnerability.title'); - return `${_.get(finding, 'component.purl')}${title ? ' - ' + title : ''}`; + const titleSuffix = title ? ` - ${String(title)}` : ''; + return `${_.get(finding, 'component.purl')}${titleSuffix}`; } function getCweIds(cwes: ICweEntry[] | undefined) { @@ -115,7 +118,7 @@ export class DependencyTrackMapper extends BaseConverter { vulnerabilitySubtitle: {path: 'vulnerability.subtitle'}, vulnerabilityAliases: { path: 'vulnerability.aliases', - transformer: (aliases: Array>): string => + transformer: (aliases: Record[]): string => JSON.stringify(aliases, null, 2) }, vulnerabilityCvssV2BaseScore: { @@ -196,6 +199,7 @@ export class DependencyTrackMapper extends BaseConverter { } } }; + constructor(dtJson: string, withRaw = false) { super(JSON.parse(dtJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/fortify-mapper.ts b/libs/hdf-converters/src/fortify-mapper.ts index 37ad1b6e5c..8127c7b8ec 100644 --- a/libs/hdf-converters/src/fortify-mapper.ts +++ b/libs/hdf-converters/src/fortify-mapper.ts @@ -1,10 +1,12 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, MappedTransform, + ParseHtmlFunc} from './base-converter'; +import { + BaseConverter, buildParseHtmlFunc, parseXml } from './base-converter'; @@ -13,17 +15,16 @@ import {getCCIsForNISTTags} from './utils/global'; const NIST_REFERENCE_NAME = 'Standards Mapping - NIST Special Publication 800-53 Revision 4'; const DEFAULT_NIST_TAG: string[] = []; - -let parseHtml: (input: unknown) => string; +const NIST_CONTROL_PATTERN = /[a-z]{2}-\d{1,2}/i; function impactMapping(input: Record, id: string): number { if (Array.isArray(input)) { const matches = input.find((element) => { return _.get(element, 'ClassInfo.ClassID') === id; }); - return parseFloat(_.get(matches, 'ClassInfo.DefaultSeverity')) / 5; + return Number(_.get(matches, 'ClassInfo.DefaultSeverity')) / 5; } else { - return parseFloat(_.get(input, 'ClassInfo.DefaultSeverity') as string) / 5; + return Number(_.get(input, 'ClassInfo.DefaultSeverity')) / 5; } } @@ -39,19 +40,20 @@ function nistTag(rule: Record): string[] { if (tag === null || tag === undefined) { return DEFAULT_NIST_TAG; } else { - return _.get(tag, 'Title').match(/[a-zA-Z][a-zA-Z]-\d{1,2}/); + return _.get(tag, 'Title').match(NIST_CONTROL_PATTERN); } } return []; } function processEntry(input: unknown): string { - const output = []; - output.push(`${_.get(input, 'id')}<=SNIPPET`); - output.push(`\nPath: ${_.get(input, 'File')}\n`); - output.push(`StartLine: ${_.get(input, 'StartLine')}, `); - output.push(`EndLine: ${_.get(input, 'EndLine')}\n`); - output.push(`Code:\n${(_.get(input, 'Text') as unknown as string).trim()}`); + const output = [ + `${_.get(input, 'id')}<=SNIPPET`, + `\nPath: ${_.get(input, 'File')}\n`, + `StartLine: ${_.get(input, 'StartLine')}, `, + `EndLine: ${_.get(input, 'EndLine')}\n`, + `Code:\n${(_.get(input, 'Text') as unknown as string).trim()}` + ]; return output.join(''); } @@ -123,15 +125,16 @@ export class FortifyResults { constructor(readonly fvdl: string, readonly withRaw = false) {} async toHdf(): Promise { - parseHtml = await buildParseHtmlFunc(); + const parseHtml = await buildParseHtmlFunc(); - return (new FortifyMapper(this.fvdl, this.withRaw)).toHdf(); + return new FortifyMapper(this.fvdl, parseHtml, this.withRaw).toHdf(); } } export class FortifyMapper extends BaseConverter { startTime: string; withRaw: boolean; + parseHtml: ParseHtmlFunc; mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, @@ -151,7 +154,7 @@ export class FortifyMapper extends BaseConverter { summary: { path: 'FVDL.UUID', transformer: (uuid: unknown): string => { - return `Fortify Static Analyzer Scan of UUID: ${uuid}`; + return `Fortify Static Analyzer Scan of UUID: ${String(uuid)}`; } }, supports: [], @@ -172,9 +175,16 @@ export class FortifyMapper extends BaseConverter { }, refs: [], source_location: {}, - title: {path: 'Abstract', transformer: parseHtml}, // there are embedded nodes that do not show up properly + title: { + path: 'Abstract', + // there are embedded nodes that do not show up properly + transformer: (input: unknown) => this.parseHtml(input) + }, id: {path: 'classID'}, - desc: {path: 'Explanation', transformer: parseHtml}, + desc: { + path: 'Explanation', + transformer: (input: unknown) => this.parseHtml(input) + }, impact: {path: '$.FVDL.Vulnerabilities.Vulnerability'}, code: { transformer: (vulnerability: Record): string => { @@ -222,15 +232,16 @@ export class FortifyMapper extends BaseConverter { } } }; - constructor(fvdl: string, withRaw = false) { + + constructor(fvdl: string, parseHtml: ParseHtmlFunc, withRaw = false) { super( parseXml(fvdl, { stopNodes: ['FVDL.Description.Abstract', 'FVDL.Description.Explanation'] }) ); - this.startTime = `${_.get(this.data, 'FVDL.CreatedTS.date')} ${_.get( - this.data, - 'FVDL.CreatedTS.time' + this.parseHtml = parseHtml; + this.startTime = `${String(_.get(this.data, 'FVDL.CreatedTS.date'))} ${String( + _.get(this.data, 'FVDL.CreatedTS.time') )}`; this.withRaw = withRaw; } diff --git a/libs/hdf-converters/src/gosec-mapper.ts b/libs/hdf-converters/src/gosec-mapper.ts index b358c0a809..3c13a1e1ec 100644 --- a/libs/hdf-converters/src/gosec-mapper.ts +++ b/libs/hdf-converters/src/gosec-mapper.ts @@ -1,31 +1,33 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3] ]); function nistTag(input: Record): string[] { - const cwe = [`${_.get(input, 'id')}`]; + const cwe = [String(_.get(input, 'id'))]; return CWE_NIST_MAPPING.nistFilter(cwe, DEFAULT_NIST_TAG); } // Check `nosec` and `suppressions` fields which denote whether the gosec rule violation should be suppressed/skipped function formatStatus(input: Record): string { - return `${_.get(input, 'nosec')}` === 'false' && - `${_.get(input, 'suppressions')}` === 'null' + return String(_.get(input, 'nosec')) === 'false' && + String(_.get(input, 'suppressions')) === 'null' ? ExecJSON.ControlResultStatus.Failed : ExecJSON.ControlResultStatus.Skipped; } @@ -35,7 +37,7 @@ function formatSkipMessage(input: Record): string | undefined { const suppressions = _.get(input, 'suppressions'); // If test is not skipped - if (`${suppressions}` === 'null') { + if (String(suppressions) === 'null') { return undefined; } @@ -47,19 +49,19 @@ function formatSkipMessage(input: Record): string | undefined { return suppressions .map( (suppression) => - `${suppression.justification ? suppression.justification : 'No justification provided'} (${suppression.kind})` + `${suppression.justification || 'No justification provided'} (${suppression.kind})` ) .join('\n'); } // Report gosec rule violation and violation location function formatCodeDesc(input: Record): string { - return `Rule ${_.get(input, 'rule_id')} violation detected at:\nFile: ${_.get(input, 'file')}\nLine: ${_.get(input, 'line')}\nColumn: ${_.get(input, 'column')}`; + return `Rule ${String(_.get(input, 'rule_id'))} violation detected at:\nFile: ${String(_.get(input, 'file'))}\nLine: ${String(_.get(input, 'line'))}\nColumn: ${String(_.get(input, 'column'))}`; } // Report confidence of violation and specific offending code function formatMessage(input: Record): string { - return `${_.get(input, 'confidence')} confidence of rule violation at:\n${_.get(input, 'code')}`; + return `${String(_.get(input, 'confidence'))} confidence of rule violation at:\n${String(_.get(input, 'code'))}`; } export class GosecMapper extends BaseConverter { @@ -135,6 +137,7 @@ export class GosecMapper extends BaseConverter { } } }; + constructor(gosecJson: string, withRaw = false) { super(JSON.parse(gosecJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/ionchannel-mapper.ts b/libs/hdf-converters/src/ionchannel-mapper.ts index 8157f50751..1d456f3bf8 100644 --- a/libs/hdf-converters/src/ionchannel-mapper.ts +++ b/libs/hdf-converters/src/ionchannel-mapper.ts @@ -1,16 +1,18 @@ -import axios, {AxiosInstance} from 'axios'; -import {ExecJSON} from 'inspecjs'; +import type {AxiosInstance} from 'axios'; +import axios from 'axios'; +import type {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { +import type { ContextualizedDependency, Dependency, IonChannelAnalysisResponse, ScanSummary } from '../types/ionchannelAnalysis'; -import {Project} from '../types/ionchannelProjects'; -import {Team} from '../types/ionchannelTeams'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type {Project} from '../types/ionchannelProjects'; +import type {Team} from '../types/ionchannelTeams'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import { DEFAULT_INFORMATION_SYSTEM_COMPONENT_MANAGEMENT_NIST_TAGS, getCCIsForNISTTags @@ -20,11 +22,12 @@ import { function extractAllDependencies( dependency: Dependency ): ContextualizedDependency[] { - const result: ContextualizedDependency[] = []; - result.push({ - ...dependency, - parentDependencies: [] - }); + const result: ContextualizedDependency[] = [ + { + ...dependency, + parentDependencies: [] + } + ]; if (Array.isArray(dependency.dependencies)) { dependency.dependencies.forEach((subDependency) => { result.push(...extractAllDependencies(subDependency)); @@ -58,7 +61,7 @@ function preprocessIonChannelData(ionchannelData: string) { result.metadata = _.omit(parsed, 'scan_summaries'); if (!Array.isArray(scanSummaries)) { - throw new Error( + throw new TypeError( `Ion Channel scan_summaries invalid summary data (expecting array, got ${typeof scanSummaries})` ); } @@ -126,9 +129,9 @@ export class IonChannelAPIMapper { this.analysisId = analysisId; this.apiClient = axios.create(); - this.apiClient.defaults.headers.common['Authorization'] = + this.apiClient.defaults.headers.common.Authorization = `Bearer ${this.apiKey}`; - this.apiClient.defaults.headers.common['Accept'] = + this.apiClient.defaults.headers.common.Accept = 'application/json, text/plain, */*'; } @@ -157,9 +160,10 @@ export class IonChannelAPIMapper { if (!this.apiKey) { throw new Error('No API-Key Set'); } - return this.apiClient - .get('https://api.ionchannel.io/v1/teams/getTeams') - .then(({data}) => data.data); + const {data} = await this.apiClient.get( + 'https://api.ionchannel.io/v1/teams/getTeams' + ); + return data.data; } async setProject(projectName: string) { @@ -185,13 +189,15 @@ export class IonChannelAPIMapper { if (!this.teamId) { throw new Error('No Team ID Defined'); } - return this.apiClient - .get('https://api.ionchannel.io/v1/report/getProjects', { + const {data} = await this.apiClient.get( + 'https://api.ionchannel.io/v1/report/getProjects', + { params: { team_id: this.teamId } - }) - .then(({data}) => data.data); + } + ); + return data.data; } async getAnalysis(): Promise { @@ -207,15 +213,17 @@ export class IonChannelAPIMapper { if (!this.analysisId) { throw new Error('No Analysis ID Defined'); } - return this.apiClient - .get('https://api.ionchannel.io/v1/report/getAnalysis', { + const {data} = await this.apiClient.get( + 'https://api.ionchannel.io/v1/report/getAnalysis', + { params: { project_id: this.projectId, team_id: this.teamId, analysis_id: this.analysisId } - }) - .then(({data}) => data.data); + } + ); + return data.data; } } @@ -270,7 +278,7 @@ export class IonChannelMapper extends BaseConverter { DEFAULT_INFORMATION_SYSTEM_COMPONENT_MANAGEMENT_NIST_TAGS ), dependencies: dependency.dependencies.map( - (subDependency) => `${subDependency.name}` + (subDependency) => subDependency.name ) } : { @@ -321,7 +329,7 @@ export class IonChannelMapper extends BaseConverter { } }, desc: '', - impact: 0.0, + impact: 0, code: { transformer: (dependency: Dependency) => JSON.stringify(dependency, null, 2) diff --git a/libs/hdf-converters/src/jfrog-xray-mapper.ts b/libs/hdf-converters/src/jfrog-xray-mapper.ts index 03c1cea40e..92995612e9 100644 --- a/libs/hdf-converters/src/jfrog-xray-mapper.ts +++ b/libs/hdf-converters/src/jfrog-xray-mapper.ts @@ -1,12 +1,14 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform +} from './base-converter'; import { BaseConverter, generateHash, - ILookupPath, - impactMapping, - MappedTransform + impactMapping } from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import { @@ -15,7 +17,7 @@ import { } from './utils/global'; // Constants -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3] @@ -29,7 +31,7 @@ const CWE_NIST_MAPPING = new CweNistMapping(); function hashId(vulnerability: unknown): string { if (_.get(vulnerability, 'id') === '') { return generateHash( - (_.get(vulnerability, 'summary') as unknown as string).toString(), + _.get(vulnerability, 'summary') as unknown as string, 'md5' ); } else { @@ -39,27 +41,25 @@ function hashId(vulnerability: unknown): string { function formatDesc(vulnerability: unknown): string { const text = []; if (_.has(vulnerability, 'description')) { - text.push( - (_.get(vulnerability, 'description') as unknown as string).toString() - ); + text.push(_.get(vulnerability, 'description')); } if (_.has(vulnerability, 'cves')) { - const re1 = /":/gi; - const re2 = /,/gi; + const re1 = /":/g; + const re2 = /,/g; text.push( `cves: ${JSON.stringify(_.get(vulnerability, 'cves')) - .replace(re1, '"=>') - .replace(re2, ', ')}` + .replaceAll(re1, '"=>') + .replaceAll(re2, ', ')}` ); } return text.join('
    '); } function formatCodeDesc(vulnerability: unknown): string { const codeDescArray: string[] = []; - const re = /,/gi; + const re = /,/g; if (_.has(vulnerability, 'source_comp_id')) { codeDescArray.push( - `source_comp_id : ${_.get(vulnerability, 'source_comp_id')}` + `source_comp_id : ${String(_.get(vulnerability, 'source_comp_id'))}` ); } else { codeDescArray.push('source_comp_id : '); @@ -83,23 +83,25 @@ function formatCodeDesc(vulnerability: unknown): string { codeDescArray.push('fixed_versions : '); } if (_.has(vulnerability, 'issue_type')) { - codeDescArray.push(`issue_type : ${_.get(vulnerability, 'issue_type')}`); + codeDescArray.push( + `issue_type : ${String(_.get(vulnerability, 'issue_type'))}` + ); } else { codeDescArray.push('issue_type : '); } if (_.has(vulnerability, 'provider')) { - codeDescArray.push(`provider : ${_.get(vulnerability, 'provider')}`); + codeDescArray.push(`provider : ${String(_.get(vulnerability, 'provider'))}`); } else { codeDescArray.push('provider : '); } - return codeDescArray.join('\n').replace(re, ', '); + return codeDescArray.join('\n').replaceAll(re, ', '); } function nistTag(identifier: Record): string[] { const identifiers: string[] = []; if (Array.isArray(identifier)) { identifier.forEach((element) => { - if (element.split('CWE-')[1]) { - identifiers.push(element.split('CWE-')[1]); + if (element.split('CWE-', 2)[1]) { + identifiers.push(element.split('CWE-', 2)[1]); } }); } @@ -191,6 +193,7 @@ export class JfrogXrayMapper extends BaseConverter { } } }; + constructor(xrayJson: string, withRaw = false) { super(JSON.parse(xrayJson), true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/mappings/AwsConfigMapping.ts b/libs/hdf-converters/src/mappings/AwsConfigMapping.ts index 2836df9434..18aef2cd4a 100644 --- a/libs/hdf-converters/src/mappings/AwsConfigMapping.ts +++ b/libs/hdf-converters/src/mappings/AwsConfigMapping.ts @@ -26,7 +26,7 @@ export class AwsConfigMapping { if ( identifier.toLowerCase().toLowerCase().includes(awsConfigRuleName) ) { - matches = matches.concat(NISTTags); + matches = [...matches, ...NISTTags]; } }); } @@ -40,7 +40,7 @@ export class AwsConfigMapping { .toLowerCase() .includes(awsConfigRuleSourceIdentifier.toLowerCase()) ) { - matches = matches.concat(NISTTags); + matches = [...matches, ...NISTTags]; } }); } diff --git a/libs/hdf-converters/src/mappings/CciNistMapping.ts b/libs/hdf-converters/src/mappings/CciNistMapping.ts index a3349e1169..3525bdc946 100644 --- a/libs/hdf-converters/src/mappings/CciNistMapping.ts +++ b/libs/hdf-converters/src/mappings/CciNistMapping.ts @@ -1,9 +1,13 @@ import {XMLParser} from 'fast-xml-parser'; import _ from 'lodash'; -import {CCI_List} from '../utils/CCI_List'; +import {CCI_List} from '../utils/cci-list'; import {data} from './CciNistMappingData'; import {CciNistMappingItem} from './CciNistMappingItem'; +// two letters, hyphen, then one to three digits — the control-family prefix +// of a NIST control identifier +const CONTROL_FAMILY_PREFIX = /\w{2}-\d{1,3}/; + type Reference = { '@_creator': string; '@_title': string; @@ -53,48 +57,15 @@ export class CciNistTwoWayMapper { data: CciNistData; constructor() { - const alwaysArray = ['cci_item', 'reference']; + const alwaysArray = new Set(['cci_item', 'reference']); const options = { ignoreAttributes: false, - isArray: (tagName: string) => { - if (alwaysArray.includes(tagName)) { - return true; - } else { - return false; - } - } + isArray: (tagName: string) => alwaysArray.has(tagName) }; const parser = new XMLParser(options); this.data = parser.parse(CCI_List); } - nistFilter( - identifiers: string[], - defaultNist: string[], - collapse = true - ): string[] { - const DEFAULT_NIST_TAGS = defaultNist; - let matches: string[] = []; - for (const id of identifiers) { - const nistRef = this.findHighestVersionNistControlByCci(id); - if (nistRef) { - matches.push(nistRef); - } - } - if (collapse) { - matches = _.uniq(matches); - } - return matches ?? DEFAULT_NIST_TAGS; - } - - cciFilter(identifiers: string[], defaultCci: string[]): string[] { - const matches: string[] = []; - for (const id of identifiers) { - matches.push(...this.findMatchingCciIdsByNistControl(id)); - } - return matches ?? defaultCci; - } - private findHighestVersionNistControlByCci(targetId: string): string | null { let highestVersionControl: string | null = null; let highestVersion = -1; @@ -104,7 +75,7 @@ export class CciNistTwoWayMapper { if (targetItem) { for (const reference of targetItem.references.reference) { - const version = parseFloat(reference['@_version']); + const version = Number(reference['@_version']); if (version > highestVersion) { highestVersion = version; highestVersionControl = reference['@_index']; @@ -114,40 +85,76 @@ export class CciNistTwoWayMapper { return highestVersionControl; } + // Whether any of the item's references match: first the pattern as is (a + // literal prefix match on the index), then — only while nothing has + // matched anywhere yet — its two-letters-hyphen-digits control-family + // prefix. + private itemReferencesMatch( + item: CciNistData['cci_list']['cci_items']['cci_item'][number], + pattern: string, + allowPrefixFallback: boolean + ): boolean { + for (const reference of item.references.reference) { + if ( + reference['@_index'].startsWith(pattern) && + item.type === 'technical' + ) { + return true; + } + if (allowPrefixFallback) { + const editedPattern = CONTROL_FAMILY_PREFIX.exec(pattern)?.[0]; + if ( + editedPattern !== undefined && + reference['@_index'].startsWith(editedPattern) && + item.type === 'technical' + ) { + return true; + } + } + } + return false; + } + private findMatchingCciIdsByNistControl(pattern: string): string[] { const matchingIds: string[] = []; const {cci_item} = this.data.cci_list.cci_items; for (const item of cci_item) { - for (const reference of item.references.reference) { - // first try the pattern as is - const regexPattern = new RegExp(`^${pattern}`); - if ( - RegExp(regexPattern).exec(reference['@_index']) && - item.type === 'technical' - ) { - matchingIds.push(item['@_id']); - break; - } - // if there were no matches using the original pattern, try using only 2 letters hyphen followed by one or two numbers - if (matchingIds.length === 0) { - const regexEditedPattern = new RegExp( - `${/\w\w-\d\d?\d?/g.exec(pattern)}` - ); - if ( - RegExp(regexEditedPattern).exec(reference['@_index']) && - item.type === 'technical' - ) { - matchingIds.push(item['@_id']); - break; - } - } + if (this.itemReferencesMatch(item, pattern, matchingIds.length === 0)) { + matchingIds.push(item['@_id']); } } return matchingIds; } + + nistFilter( + identifiers: string[], + defaultNist: string[], + collapse = true + ): string[] { + const DEFAULT_NIST_TAGS = defaultNist; + let matches: string[] = []; + for (const id of identifiers) { + const nistRef = this.findHighestVersionNistControlByCci(id); + if (nistRef) { + matches.push(nistRef); + } + } + if (collapse) { + matches = _.uniq(matches); + } + return matches ?? DEFAULT_NIST_TAGS; + } + + cciFilter(identifiers: string[], defaultCci: string[]): string[] { + const matches: string[] = []; + for (const id of identifiers) { + matches.push(...this.findMatchingCciIdsByNistControl(id)); + } + return matches ?? defaultCci; + } } export class CciNistMapping { @@ -172,9 +179,9 @@ export class CciNistMapping { const matches: string[] = []; identifiers.forEach((id) => { const item = this.data.find((element) => element.cci === id); - if (item && item.nistId) { + if (item?.nistId) { if (collapse) { - if (matches.indexOf(item.nistId) === -1) { + if (!matches.includes(item.nistId)) { matches.push(item.nistId); } } else { diff --git a/libs/hdf-converters/src/mappings/CweNistMapping.ts b/libs/hdf-converters/src/mappings/CweNistMapping.ts index ec43ffe8f7..cc3706d67a 100644 --- a/libs/hdf-converters/src/mappings/CweNistMapping.ts +++ b/libs/hdf-converters/src/mappings/CweNistMapping.ts @@ -1,13 +1,13 @@ import {data} from './CweNistMappingData'; import {CweNistMappingItem} from './CweNistMappingItem'; -export interface ICWEJSONID { +export type ICWEJSONID = { 'CWE-ID': number; 'CWE Name': string; 'NIST-ID': string; Rev: number; 'NIST Name': string; -} +}; export class CweNistMapping { data: CweNistMappingItem[]; @@ -21,16 +21,17 @@ export class CweNistMapping { }); } } + nistFilter(identifiers: string[] | string, defaultNist?: string[]): string[] { const DEFAULT_NIST_TAG = defaultNist; if (!Array.isArray(identifiers)) { identifiers = [identifiers]; } if (identifiers.length === 0) { - if (DEFAULT_NIST_TAG !== undefined) { - return DEFAULT_NIST_TAG; - } else { + if (DEFAULT_NIST_TAG === undefined) { return []; + } else { + return DEFAULT_NIST_TAG; } } else { const matches: string[] = []; @@ -41,7 +42,7 @@ export class CweNistMapping { item !== null && item !== undefined && item.nistId !== '' && - matches.indexOf(item.nistId) === -1 + !matches.includes(item.nistId) ) { matches.push(item.nistId); } diff --git a/libs/hdf-converters/src/mappings/CweNistMappingItem.ts b/libs/hdf-converters/src/mappings/CweNistMappingItem.ts index 336e9baf43..c56d83ce23 100644 --- a/libs/hdf-converters/src/mappings/CweNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/CweNistMappingItem.ts @@ -1,4 +1,4 @@ -import {ICWEJSONID} from './CweNistMapping'; +import type {ICWEJSONID} from './CweNistMapping'; export class CweNistMappingItem { id: number; diff --git a/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts b/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts index 5979f3dce0..bb6eb7b52b 100644 --- a/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts +++ b/libs/hdf-converters/src/mappings/NessusPluginsNistMapping.ts @@ -1,11 +1,11 @@ import {data} from './NessusPluginNistMappingData'; import {NessusPluginsNistMappingItem} from './NessusPluginsNistMappingItem'; -export interface INESSUSJSONID { +export type INESSUSJSONID = { pluginFamily: string; pluginID: string | number; 'NIST-ID': string; -} +}; export class NessusPluginsNistMapping { data: NessusPluginsNistMappingItem[]; @@ -19,6 +19,7 @@ export class NessusPluginsNistMapping { }); } } + nistFilter(family: string, id: string, defaultNist: string[]): string[] { const DEFAULT_NIST_TAG = defaultNist; const matches: string[] = []; @@ -34,7 +35,7 @@ export class NessusPluginsNistMapping { item !== null && item !== undefined && item.nistId !== '' && - matches.indexOf(item.nistId) === -1 + !matches.includes(item.nistId) ) { item.nistId.split('|').forEach((element) => { matches.push(element); diff --git a/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts b/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts index 1ecca5c66c..1a34b80e70 100644 --- a/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/NessusPluginsNistMappingItem.ts @@ -1,4 +1,4 @@ -import {INESSUSJSONID} from './NessusPluginsNistMapping'; +import type {INESSUSJSONID} from './NessusPluginsNistMapping'; export class NessusPluginsNistMappingItem { pluginFamily: string; @@ -6,18 +6,18 @@ export class NessusPluginsNistMappingItem { nistId: string; constructor(values: INESSUSJSONID) { - if (values['pluginFamily'] === undefined) { + if (values.pluginFamily === undefined) { throw new Error( 'Nessus Plugins Nist Mapping Data must contain a plugin family.' ); } else { - this.pluginFamily = values['pluginFamily']; + this.pluginFamily = values.pluginFamily; } // Could be a string "*" or a number - if (typeof values['pluginID'] === 'string') { - this.pluginId = values['pluginID']; + if (typeof values.pluginID === 'string') { + this.pluginId = values.pluginID; } else { - this.pluginId = values['pluginID'].toString(); + this.pluginId = values.pluginID.toString(); } if (values['NIST-ID'] === undefined) { this.nistId = ''; diff --git a/libs/hdf-converters/src/mappings/NiktoNistMapping.ts b/libs/hdf-converters/src/mappings/NiktoNistMapping.ts index a364137a2d..d60b2bbdc0 100644 --- a/libs/hdf-converters/src/mappings/NiktoNistMapping.ts +++ b/libs/hdf-converters/src/mappings/NiktoNistMapping.ts @@ -1,23 +1,27 @@ import {data} from './NiktoNistMappingData'; -export interface INIKJSONID { +export type INIKJSONID = { 'NIKTO-ID': number; 'PLUGIN-CATEGORY': string; 'NIST-ID': string; OSVDB: number; -} +}; const DEFAULT_NIST_TAG = ['AC-3', 'SA-11', 'RA-5']; +// Map view over the generated table: the id arrives from the scan file, and +// the old `id in data` guard consulted the PROTOTYPE chain — 'constructor' +// passed it, and bracket access then returned a function as the NIST tag. +// Map.get answers undefined for unknown and prototype keys alike. +const NIKTO_NIST_MAPPING = new Map( + Object.entries(data as Record), +); + export class NiktoNistMapping { nistTag(id: string): string[] { if (id === '' || id === undefined) { return DEFAULT_NIST_TAG; - } else { - if (id in data) { - return [(data as Record)[id]]; - } else { - return DEFAULT_NIST_TAG; - } } + const tag = NIKTO_NIST_MAPPING.get(id); + return tag === undefined ? DEFAULT_NIST_TAG : [tag]; } } diff --git a/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts b/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts index 9f6294b994..8ab9ac73be 100644 --- a/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/NiktoNistMappingItem.ts @@ -1,4 +1,4 @@ -import {INIKJSONID} from './NiktoNistMapping'; +import type {INIKJSONID} from './NiktoNistMapping'; export class NiktoNistMappingItem { id: number; @@ -24,6 +24,6 @@ export class NiktoNistMappingItem { } else { this.nistId = values['NIST-ID']; } - this.osvdb = values['OSVDB']; + this.osvdb = values.OSVDB; } } diff --git a/libs/hdf-converters/src/mappings/OwaspNistMapping.ts b/libs/hdf-converters/src/mappings/OwaspNistMapping.ts index d17215aa5d..5d78ed91e4 100644 --- a/libs/hdf-converters/src/mappings/OwaspNistMapping.ts +++ b/libs/hdf-converters/src/mappings/OwaspNistMapping.ts @@ -2,13 +2,13 @@ import {data} from './OwaspNistMappingData'; import * as _ from 'lodash'; import {OwaspNistMappingItem} from './OwaspNistMappingItem'; -export interface IOWASPJSONID { +export type IOWASPJSONID = { 'OWASP-ID': string; 'OWASP Name': string; 'NIST-ID': string; Rev: number; 'NIST Name': string; -} +}; export class OwaspNistMapping { data: OwaspNistMappingItem[]; @@ -20,12 +20,9 @@ export class OwaspNistMapping { } nistFilterNoDefault(identifiers: string | string[]): string[] { - let ids: string[] = []; - if (Array.isArray(identifiers)) { - ids = identifiers; - } else { - ids = [identifiers]; - } + const ids: string[] = Array.isArray(identifiers) + ? identifiers + : [identifiers]; return _.uniq( _.compact( diff --git a/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts b/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts index 6fb0486fdc..8c518270ee 100644 --- a/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/OwaspNistMappingItem.ts @@ -1,4 +1,4 @@ -import {IOWASPJSONID} from './OwaspNistMapping'; +import type {IOWASPJSONID} from './OwaspNistMapping'; export class OwaspNistMappingItem { id: string; @@ -23,7 +23,7 @@ export class OwaspNistMappingItem { } else { this.nistId = values['NIST-ID']; } - this.rev = values['Rev']; + this.rev = values.Rev; if (values['NIST Name'] === undefined) { throw new Error('OWASP Nist Mapping Data must contain a nist name.'); } else { diff --git a/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts b/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts index 7bceecf61c..845dd62f62 100644 --- a/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts +++ b/libs/hdf-converters/src/mappings/ScoutsuiteNistMapping.ts @@ -2,10 +2,10 @@ import {DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS} from '../utils/global'; import {data} from './ScoutsuiteNistMappingData'; import {ScoutsuiteNistMappingItem} from './ScoutsuiteNistMappingItem'; -export interface ISCOUTSUITEJSONID { +export type ISCOUTSUITEJSONID = { RULE: string; 'NIST-ID': string; -} +}; export class ScoutsuiteNistMapping { data: ScoutsuiteNistMappingItem[]; @@ -17,6 +17,7 @@ export class ScoutsuiteNistMapping { this.data = data.map((line) => new ScoutsuiteNistMappingItem(line)); } } + nistTag(rule: string): string[] { if (rule === '' || rule === undefined) { return DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS; diff --git a/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts b/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts index 5f2f238d4d..b81658acee 100644 --- a/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts +++ b/libs/hdf-converters/src/mappings/ScoutsuiteNistMappingItem.ts @@ -1,14 +1,14 @@ -import {ISCOUTSUITEJSONID} from './ScoutsuiteNistMapping'; +import type {ISCOUTSUITEJSONID} from './ScoutsuiteNistMapping'; export class ScoutsuiteNistMappingItem { rule: string; nistId: string; constructor(values: ISCOUTSUITEJSONID) { - if (values['RULE'] === undefined) { + if (values.RULE === undefined) { throw new Error('Scoutsuite Nist Mapping Data must contain a rule.'); } else { - this.rule = values['RULE']; + this.rule = values.RULE; } if (values['NIST-ID'] === undefined) { this.nistId = ''; diff --git a/libs/hdf-converters/src/msft-secure-score-mapper.ts b/libs/hdf-converters/src/msft-secure-score-mapper.ts index f10faf3a3b..1728f0f06d 100644 --- a/libs/hdf-converters/src/msft-secure-score-mapper.ts +++ b/libs/hdf-converters/src/msft-secure-score-mapper.ts @@ -1,11 +1,12 @@ -import { +import type { SecureScore, ControlScore, SecureScoreControlProfile } from '@microsoft/microsoft-graph-types'; import {ExecJSON} from 'inspecjs'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import * as _ from 'lodash'; import { conditionallyProvideAttribute, @@ -62,19 +63,6 @@ export class MsftSecureScoreMapper extends BaseConverter { rawData: CombinedResponse; getProfiles: (controlName: string) => SecureScoreControlProfile[]; - memoizedGetProfiles(): (controlName: string) => SecureScoreControlProfile[] { - const cache: Record = {}; - - return (controlName: string): SecureScoreControlProfile[] => { - if (Object.prototype.hasOwnProperty.call(cache, controlName)) { - return cache[controlName]; - } - return (cache[controlName] = this.rawData.profiles.value.filter( - (profile) => profile.id === controlName - )); - }; - } - mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, ILookupPath @@ -113,7 +101,7 @@ export class MsftSecureScoreMapper extends BaseConverter { return titles.join('\n'); } else { return [data.controlCategory || '', data.controlName || ''] - .filter((title) => title) + .filter(Boolean) .join(':'); } } @@ -131,7 +119,7 @@ export class MsftSecureScoreMapper extends BaseConverter { } const highMaxScore = Math.max(...knownMaxScores); - return highMaxScore / 10.0; + return highMaxScore / 10; } }, refs: [], @@ -145,7 +133,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.controlCategory) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ), @@ -157,7 +145,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.maxScore) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ), @@ -169,7 +157,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.rank) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ), @@ -181,7 +169,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.tier) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ), @@ -195,7 +183,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.threats) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ), @@ -209,7 +197,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.service) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ), @@ -223,7 +211,7 @@ export class MsftSecureScoreMapper extends BaseConverter { (() => { const result = this.getProfiles(data.controlName || '') .map((profile) => profile.userImpact) - .filter((v) => Boolean(v)); + .filter(Boolean); return result.length > 0; })() ) @@ -342,6 +330,7 @@ export class MsftSecureScoreMapper extends BaseConverter { } } }; + constructor(secureScore_and_profiles_combined: string, withRaw = false) { const rawParams = JSON.parse(secureScore_and_profiles_combined); super(rawParams.secureScore.value[0]); @@ -349,4 +338,23 @@ export class MsftSecureScoreMapper extends BaseConverter { this.rawData = rawParams; this.getProfiles = this.memoizedGetProfiles(); } + + memoizedGetProfiles(): (controlName: string) => SecureScoreControlProfile[] { + // Map, not Record: controlName arrives from scan data, and writing a key + // like '__proto__' to a plain object hits the prototype setter instead of + // storing — the old hasOwnProperty guard protected reads but not writes. + const cache = new Map(); + + return (controlName: string): SecureScoreControlProfile[] => { + const cached = cache.get(controlName); + if (cached !== undefined) { + return cached; + } + const profiles = this.rawData.profiles.value.filter( + (profile) => profile.id === controlName + ); + cache.set(controlName, profiles); + return profiles; + }; + } } diff --git a/libs/hdf-converters/src/nessus-mapper.ts b/libs/hdf-converters/src/nessus-mapper.ts index f7f84ab44f..f7523d80ae 100644 --- a/libs/hdf-converters/src/nessus-mapper.ts +++ b/libs/hdf-converters/src/nessus-mapper.ts @@ -1,11 +1,13 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform, + ParseHtmlFunc} from './base-converter'; import { BaseConverter, - ILookupPath, impactMapping, - MappedTransform, buildParseHtmlFunc, parseXml } from './base-converter'; @@ -13,7 +15,7 @@ import {CciNistMapping} from './mappings/CciNistMapping'; import {NessusPluginsNistMapping} from './mappings/NessusPluginsNistMapping'; // Constants -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['4', 0.9], ['3', 0.7], ['i', 0.7], @@ -21,7 +23,7 @@ const IMPACT_MAPPING: Map = new Map([ ['ii', 0.5], ['1', 0.3], ['iii', 0.3], - ['0', 0.0] + ['0', 0] ]); const COMPLIANCE_PATH = 'compliance-reference'; const COMPLIANCE_CHECK_NAME = 'compliance-check-name'; @@ -34,22 +36,10 @@ const NESSUS_PLUGINS_NIST_MAPPING = new NessusPluginsNistMapping(); const CCI_NIST_MAPPING = new CciNistMapping(); const DEFAULT_NIST_TAG: string[] = []; -let parseHtml: (input: unknown) => string; - -let policyName: string; -let version: string; - -function getPolicyName(): string { - return 'Nessus ' + policyName; -} -function getVersion(): string { - return version; -} - function getId(item: unknown): string { if (_.has(item, COMPLIANCE_PATH)) { return parseRef( - _.get(item, COMPLIANCE_PATH) as unknown as string, + _.get(item, COMPLIANCE_PATH), 'Vuln-ID' )[0]; } else { @@ -58,12 +48,12 @@ function getId(item: unknown): string { } function getTitle(item: unknown): string { if (_.has(item, COMPLIANCE_CHECK_NAME)) { - return _.get(item, COMPLIANCE_CHECK_NAME) as unknown as string; + return _.get(item, COMPLIANCE_CHECK_NAME); } else { return _.get(item, 'pluginName') as unknown as string; } } -function getDesc(item: unknown): string { +function getDesc(parseHtml: ParseHtmlFunc, item: unknown): string { if (_.has(item, COMPLIANCE_INFO)) { return parseHtml(_.get(item, COMPLIANCE_INFO)); } else { @@ -71,10 +61,11 @@ function getDesc(item: unknown): string { } } function formatDesc(issue: unknown): string { - const desc = []; - desc.push(`Plugin Family: ${_.get(issue, 'pluginFamily')}`); - desc.push(`Port: ${_.get(issue, 'port')}`); - desc.push(`Protocol: ${_.get(issue, 'protocol')}`); + const desc = [ + `Plugin Family: ${_.get(issue, 'pluginFamily')}`, + `Port: ${_.get(issue, 'port')}`, + `Protocol: ${_.get(issue, 'protocol')}` + ]; return desc.join('; ') + ';'; } function pluginNistTag(item: unknown): string[] { @@ -89,12 +80,12 @@ function cciNistTag(input: string): string[] { function parseRef(input: string, key: string): string[] { const matches = input.split(',').filter((element) => element.startsWith(key)); - return matches.map((element) => element.split('|')[1]); + return matches.map((element) => element.split('|', 2)[1]); } function getImpact(item: unknown): number { if (_.has(item, COMPLIANCE_PATH)) { return impactMapping(IMPACT_MAPPING)( - parseRef(_.get(item, COMPLIANCE_PATH) as unknown as string, 'CAT').join( + parseRef(_.get(item, COMPLIANCE_PATH), 'CAT').join( '' ) ); @@ -103,7 +94,7 @@ function getImpact(item: unknown): number { } } -function getCheck(item: unknown): string { +function getCheck(parseHtml: ParseHtmlFunc, item: unknown): string { if (_.has(item, COMPLIANCE_SOLUTION)) { return parseHtml(_.get(item, COMPLIANCE_SOLUTION)); } else { @@ -121,14 +112,14 @@ function getFix(item: unknown): string { function getNist(item: unknown): string[] { if (_.has(item, COMPLIANCE_PATH)) { - return cciNistTag(_.get(item, COMPLIANCE_PATH) as unknown as string); + return cciNistTag(_.get(item, COMPLIANCE_PATH)); } else { return pluginNistTag(item); } } function getCci(item: unknown): string[] { if (_.has(item, COMPLIANCE_PATH)) { - return parseRef(_.get(item, COMPLIANCE_PATH) as unknown as string, 'CCI'); + return parseRef(_.get(item, COMPLIANCE_PATH), 'CCI'); } else { return []; } @@ -136,7 +127,7 @@ function getCci(item: unknown): string[] { function getRid(item: unknown): string { if (_.has(item, COMPLIANCE_PATH)) { return parseRef( - _.get(item, COMPLIANCE_PATH) as unknown as string, + _.get(item, COMPLIANCE_PATH), 'Rule-ID' ).join(','); } else { @@ -146,7 +137,7 @@ function getRid(item: unknown): string { function getStig(item: unknown): string { if (_.has(item, COMPLIANCE_PATH)) { return parseRef( - _.get(item, COMPLIANCE_PATH) as unknown as string, + _.get(item, COMPLIANCE_PATH), 'STIG-ID' ).join(','); } else { @@ -166,7 +157,7 @@ function getStatus(item: unknown): ExecJSON.ControlResultStatus { return ExecJSON.ControlResultStatus.Failed; } } -function formatCodeDesc(item: unknown): string { +function formatCodeDesc(parseHtml: ParseHtmlFunc, item: unknown): string { if (_.has(item, 'description')) { return parseHtml(_.get(item, 'description') || NA_PLUGIN_OUTPUT); } else { @@ -189,22 +180,23 @@ function getStartTime(tag: unknown): string { function cleanData(control: unknown[]): ExecJSON.Control[] { const filteredControl = control as ExecJSON.Control[]; filteredControl.forEach((element) => { - if (element instanceof Object) { - if (_.get(element.tags, 'cci').length === 0) { - element.tags = _.omit(element.tags, 'cci'); - } - if (_.get(element.tags, 'rid') === '') { - element.tags = _.omit(element.tags, 'rid'); - } - if (_.get(element.tags, 'stig_id') === '') { - element.tags = _.omit(element.tags, 'stig_id'); - } - element.refs = element.refs.filter((ref) => ref.url); - if (element.descriptions !== undefined && element.descriptions !== null) { - element.descriptions = element.descriptions.filter( - (description) => description && description.data - ); - } + if (!(element instanceof Object)) { + return; + } + if (_.get(element.tags, 'cci').length === 0) { + element.tags = _.omit(element.tags, 'cci'); + } + if (_.get(element.tags, 'rid') === '') { + element.tags = _.omit(element.tags, 'rid'); + } + if (_.get(element.tags, 'stig_id') === '') { + element.tags = _.omit(element.tags, 'stig_id'); + } + element.refs = element.refs.filter((ref) => ref.url); + if (element.descriptions !== undefined && element.descriptions !== null) { + element.descriptions = element.descriptions.filter( + (description) => description?.data + ); } }); return filteredControl; @@ -219,10 +211,10 @@ export class NessusResults { } async toHdf(): Promise { - parseHtml = await buildParseHtmlFunc(); + const parseHtml = await buildParseHtmlFunc(); const results: ExecJSON.Execution[] = []; - policyName = _.get( + const policyName = _.get( this.data, 'NessusClientData_v2.Policy.policyName' ) as string; @@ -230,6 +222,7 @@ export class NessusResults { this.data, 'NessusClientData_v2.Policy.Preferences.ServerPreferences.preference' ); + let version: string | undefined; if (Array.isArray(preference)) { version = _.get( @@ -245,7 +238,13 @@ export class NessusResults { ); if (Array.isArray(reportHost)) { reportHost.forEach((element: Record) => { - const entry = new NessusMapper(element, this.withRaw); + const entry = new NessusMapper( + element, + parseHtml, + policyName, + version, + this.withRaw + ); if (this.customMapping !== undefined) { entry.setMappings(this.customMapping); } @@ -255,6 +254,9 @@ export class NessusResults { } else { const result = new NessusMapper( reportHost as Record, + parseHtml, + policyName, + version, this.withRaw ); if (this.customMapping !== undefined) { @@ -267,6 +269,9 @@ export class NessusResults { export class NessusMapper extends BaseConverter { withRaw: boolean; + parseHtml: ParseHtmlFunc; + policyName: string; + version: string | undefined; mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, @@ -281,10 +286,10 @@ export class NessusMapper extends BaseConverter { statistics: {}, profiles: [ { - name: {transformer: getPolicyName}, - version: {transformer: getVersion}, - title: {transformer: getPolicyName}, - summary: {transformer: getPolicyName}, + name: {transformer: () => this.getPolicyName()}, + version: {transformer: () => this.version}, + title: {transformer: () => this.getPolicyName()}, + summary: {transformer: () => this.getPolicyName()}, supports: [], attributes: [], groups: [], @@ -316,10 +321,12 @@ export class NessusMapper extends BaseConverter { source_location: {}, title: {transformer: getTitle}, id: {transformer: getId}, - desc: {transformer: getDesc}, + desc: {transformer: (item: unknown) => getDesc(this.parseHtml, item)}, descriptions: [ { - data: {transformer: getCheck}, + data: { + transformer: (item: unknown) => getCheck(this.parseHtml, item) + }, label: 'check' }, { @@ -335,14 +342,28 @@ export class NessusMapper extends BaseConverter { results: [ { status: {transformer: getStatus}, - code_desc: {transformer: formatCodeDesc}, + code_desc: { + transformer: (item: unknown) => + formatCodeDesc(this.parseHtml, item) + }, message: { path: ['plugin_output', COMPLIANCE_ACTUAL_VALUE], transformer: (value: unknown) => { if (value === null || value === undefined) { return value; } - return String(value); + // A structured plugin_output renders as JSON rather than + // the useless '[object Object]'. + if (typeof value === 'string') { + return value; + } + if ( + typeof value === 'number' || + typeof value === 'boolean' + ) { + return String(value); + } + return JSON.stringify(value); } }, start_time: { @@ -370,8 +391,22 @@ export class NessusMapper extends BaseConverter { } } }; - constructor(nessusJson: Record, withRaw = false) { + + constructor( + nessusJson: Record, + parseHtml: ParseHtmlFunc, + policyName: string, + version: string | undefined, + withRaw = false + ) { super(nessusJson); + this.parseHtml = parseHtml; + this.policyName = policyName; + this.version = version; this.withRaw = withRaw; } + + getPolicyName(): string { + return 'Nessus ' + this.policyName; + } } diff --git a/libs/hdf-converters/src/netsparker-mapper.ts b/libs/hdf-converters/src/netsparker-mapper.ts index 970dab8728..347ece80ea 100644 --- a/libs/hdf-converters/src/netsparker-mapper.ts +++ b/libs/hdf-converters/src/netsparker-mapper.ts @@ -1,11 +1,13 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform, + ParseHtmlFunc} from './base-converter'; import { BaseConverter, - ILookupPath, impactMapping, - MappedTransform, buildParseHtmlFunc, parseXml } from './base-converter'; @@ -16,19 +18,18 @@ import { getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ - ['critical', 1.0], +const IMPACT_MAPPING = new Map([ + ['critical', 1], ['high', 0.7], ['medium', 0.5], ['low', 0.3], - ['best_practice', 0.0], - ['information', 0.0] + ['best_practice', 0], + ['information', 0] ]); const CWE_NIST_MAPPING = new CweNistMapping(); const OWASP_NIST_MAPPING = new OwaspNistMapping(); - -let parseHtml: (input: unknown) => string; +const FIRST_CHARACTER = /^./; function nistTag(classification: Record): string[] { let cweTag = _.get(classification, 'cwe'); @@ -41,8 +42,8 @@ function nistTag(classification: Record): string[] { } const cwe = CWE_NIST_MAPPING.nistFilter(cweTag as string[]); const owasp = OWASP_NIST_MAPPING.nistFilterNoDefault(owaspTag as string[]); - const result = cwe.concat(owasp); - if (result.length !== 0) { + const result = [...cwe, ...owasp]; + if (result.length > 0) { return result; } else { return DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS; @@ -56,13 +57,13 @@ function formatControlDesc(vulnerability: unknown): string { } const exploitationSkills = _.get(vulnerability, 'exploitation-skills'); if (exploitationSkills) { - text.push(`Exploitation-skills: ${exploitationSkills}`); + text.push(`Exploitation-skills: ${String(exploitationSkills)}`); } const extraInformation = _.get(vulnerability, 'extra-information'); if (extraInformation) { text.push( - `Extra-information: ${JSON.stringify(extraInformation).replace( - /:/gi, + `Extra-information: ${JSON.stringify(extraInformation).replaceAll( + ':', '=>' )}` ); @@ -70,44 +71,47 @@ function formatControlDesc(vulnerability: unknown): string { const classification = _.get(vulnerability, 'classification'); if (classification) { text.push( - `Classification: ${JSON.stringify(classification).replace(/:/gi, '=>')}` + `Classification: ${JSON.stringify(classification).replaceAll(':', '=>')}` ); } const impact = _.get(vulnerability, 'impact'); if (impact) { - text.push(`Impact: ${impact}`); + text.push(`Impact: ${String(impact)}`); } const firstSeenDate = _.get(vulnerability, 'FirstSeenDate'); if (firstSeenDate) { - text.push(`FirstSeenDate: ${firstSeenDate}`); + text.push(`FirstSeenDate: ${String(firstSeenDate)}`); } const lastSeenDate = _.get(vulnerability, 'LastSeenDate'); if (lastSeenDate) { - text.push(`LastSeenDate: ${lastSeenDate}`); + text.push(`LastSeenDate: ${String(lastSeenDate)}`); } const certainty = _.get(vulnerability, 'certainty'); if (certainty) { - text.push(`Certainty: ${certainty}`); + text.push(`Certainty: ${String(certainty)}`); } const type = _.get(vulnerability, 'type'); if (type) { - text.push(`Type: ${type}`); + text.push(`Type: ${String(type)}`); } const confirmed = _.get(vulnerability, 'confirmed'); if (confirmed) { - text.push(`Confirmed: ${confirmed}`); + text.push(`Confirmed: ${String(confirmed)}`); } return text.join('
    '); } -function formatCheck(vulnerability: unknown): string { +function formatCheck( + parseHtml: ParseHtmlFunc, + vulnerability: unknown +): string { const text: string[] = []; const exploitationSkills = _.get(vulnerability, 'exploitation-skills'); if (exploitationSkills) { - text.push(`Exploitation-skills: ${exploitationSkills}`); + text.push(`Exploitation-skills: ${String(exploitationSkills)}`); } const proofOfConcept = _.get(vulnerability, 'proof-of-concept'); if (proofOfConcept) { - text.push(`Proof-of-concept: ${proofOfConcept}`); + text.push(`Proof-of-concept: ${String(proofOfConcept)}`); } return parseHtml(text.join('
    ')); } @@ -115,29 +119,31 @@ function formatFix(vulnerability: unknown): string { const text: string[] = []; const remedialActions = _.get(vulnerability, 'remedial-actions'); if (remedialActions) { - text.push(`Remedial-actions: ${remedialActions}`); + text.push(`Remedial-actions: ${String(remedialActions)}`); } const remedialProcedure = _.get(vulnerability, 'remedial-procedure'); if (remedialProcedure) { - text.push(`Remedial-procedure: ${remedialProcedure}`); + text.push(`Remedial-procedure: ${String(remedialProcedure)}`); } const remedyReferences = _.get(vulnerability, 'remedy-references'); if (remedyReferences) { - text.push(`Remedy-references: ${remedyReferences}`); + text.push(`Remedy-references: ${String(remedyReferences)}`); } return text.join('
    '); } function formatCodeDesc(request: unknown): string { - const text: string[] = []; - text.push(`http-request : ${_.get(request, 'content')}`); - text.push(`method : ${_.get(request, 'method')}`); + const text: string[] = [ + `http-request : ${_.get(request, 'content')}`, + `method : ${_.get(request, 'method')}` + ]; return text.join('\n'); } function formatMessage(response: unknown): string { - const text: string[] = []; - text.push(`http-response : ${_.get(response, 'content')}`); - text.push(`duration : ${_.get(response, 'duration')}`); - text.push(`status-code : ${_.get(response, 'status-code')}`); + const text: string[] = [ + `http-response : ${_.get(response, 'content')}`, + `duration : ${_.get(response, 'duration')}`, + `status-code : ${_.get(response, 'status-code')}` + ]; return text.join('\n'); } @@ -145,19 +151,41 @@ export class NetsparkerResults { constructor(readonly netsparkerXml: string, readonly withRaw = false) {} async toHdf(): Promise { - parseHtml = await buildParseHtmlFunc(); + const parseHtml = await buildParseHtmlFunc(); - return (new NetsparkerMapper(this.netsparkerXml, this.withRaw)).toHdf(); + return new NetsparkerMapper( + this.netsparkerXml, + parseHtml, + this.withRaw + ).toHdf(); } } export class NetsparkerMapper extends BaseConverter { withRaw: boolean; + parseHtml: ParseHtmlFunc; + + constructor( + netsparkerXml: string, + parseHtml: ParseHtmlFunc, + withRaw = false + ) { + super(parseXml(netsparkerXml)); + this.parseHtml = parseHtml; + this.withRaw = withRaw; + this.setMappings( + this.defineMappings( + Object.keys(this.data).some((k) => k.includes('netsparker')) + ? 'netsparker' + : 'invicti' + ) + ); + } defineMappings( toolname: string ): MappedTransform { - const capitalizedToolname = toolname.replace(/^./, (firstLetter) => + const capitalizedToolname = toolname.replace(FIRST_CHARACTER, (firstLetter) => firstLetter.toUpperCase() ); return { @@ -174,9 +202,7 @@ export class NetsparkerMapper extends BaseConverter { title: { path: `${toolname}-enterprise.target`, transformer: (input: unknown): string => { - return `${toolname.replace(/^./, (firstLetter) => - firstLetter.toUpperCase() - )} Enterprise Scan ID: ${_.get(input, 'scan-id')} URL: ${_.get( + return `${capitalizedToolname} Enterprise Scan ID: ${_.get(input, 'scan-id')} URL: ${_.get( input, 'url' )}`; @@ -206,7 +232,10 @@ export class NetsparkerMapper extends BaseConverter { desc: {transformer: formatControlDesc}, descriptions: [ { - data: {transformer: formatCheck}, + data: { + transformer: (vulnerability: unknown) => + formatCheck(this.parseHtml, vulnerability) + }, label: 'check' }, { @@ -269,15 +298,4 @@ export class NetsparkerMapper extends BaseConverter { } }; } - constructor(netsparkerXml: string, withRaw = false) { - super(parseXml(netsparkerXml)); - this.withRaw = withRaw; - this.setMappings( - this.defineMappings( - Object.keys(this.data).some((k) => k.includes('netsparker')) - ? 'netsparker' - : 'invicti' - ) - ); - } } diff --git a/libs/hdf-converters/src/neuvector-mapper.ts b/libs/hdf-converters/src/neuvector-mapper.ts index 49b1b4c16f..4b35f7c1ca 100644 --- a/libs/hdf-converters/src/neuvector-mapper.ts +++ b/libs/hdf-converters/src/neuvector-mapper.ts @@ -1,10 +1,11 @@ import {ExecJSON} from 'inspecjs'; import _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import {DEFAULT_UPDATE_REMEDIATION_NIST_TAGS} from './utils/global'; -import { +import type { NeuVectorScanJson, RESTModuleCve, RESTScanModule, @@ -36,22 +37,6 @@ export class NeuVectorMapper extends BaseConverter { rawData: NeuVectorScanJson; getModules: (moduleName: string) => RESTScanModule['source'] | undefined; - memoizedGetModules(): ( - moduleName: string - ) => RESTScanModule['source'] | undefined { - const cache: Record = {}; - - return (moduleName: string) => { - if (Object.prototype.hasOwnProperty.call(cache, moduleName)) { - return cache[moduleName]; - } - cache[moduleName] = (this.data as NeuVectorScanJson).report.modules?.find( - (value) => value.name === moduleName - )?.source; - return cache[moduleName]; - }; - } - mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, ILookupPath @@ -187,6 +172,7 @@ export class NeuVectorMapper extends BaseConverter { } } }; + constructor(exportJson: string, withRaw = false) { const rawParams = JSON.parse(exportJson); super(rawParams); @@ -194,4 +180,25 @@ export class NeuVectorMapper extends BaseConverter { this.rawData = rawParams; this.getModules = this.memoizedGetModules(); } + + memoizedGetModules(): ( + moduleName: string + ) => RESTScanModule['source'] | undefined { + // Map, not Record: moduleName arrives from scan data, and writing a key + // like '__proto__' to a plain object hits the prototype setter instead of + // storing. has() rather than a get-undefined check because undefined is a + // legitimate cached result here. + const cache = new Map(); + + return (moduleName: string) => { + if (cache.has(moduleName)) { + return cache.get(moduleName); + } + const source = (this.data as NeuVectorScanJson).report.modules?.find( + (value) => value.name === moduleName + )?.source; + cache.set(moduleName, source); + return source; + }; + } } diff --git a/libs/hdf-converters/src/nikto-mapper.ts b/libs/hdf-converters/src/nikto-mapper.ts index b36d43d7c8..6bea2d0f4d 100644 --- a/libs/hdf-converters/src/nikto-mapper.ts +++ b/libs/hdf-converters/src/nikto-mapper.ts @@ -1,7 +1,8 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import {NiktoNistMapping} from './mappings/NiktoNistMapping'; import {getCCIsForNISTTags} from './utils/global'; @@ -44,7 +45,7 @@ export class NiktoMapper extends BaseConverter { summary: { path: 'banner', transformer: (input: unknown): string => { - return `Banner: ${input}`; + return `Banner: ${String(input)}`; } }, supports: [], @@ -99,6 +100,7 @@ export class NiktoMapper extends BaseConverter { } } }; + constructor(niktoJson: string, withRaw = false) { super(JSON.parse(niktoJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/prisma-mapper.ts b/libs/hdf-converters/src/prisma-mapper.ts index 69c165d65b..8405149a7c 100644 --- a/libs/hdf-converters/src/prisma-mapper.ts +++ b/libs/hdf-converters/src/prisma-mapper.ts @@ -1,10 +1,11 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform} from './base-converter'; import { BaseConverter, - ILookupPath, - MappedTransform, parseCsv } from './base-converter'; import { @@ -26,20 +27,23 @@ export type PrismaControl = { Cause?: string; }; -const SEVERITY_LOOKUP: Record = { - low: 0.3, - moderate: 0.5, - high: 0.7, - important: 0.9, - critical: 1 -}; +// Map, not Record: the key arrives from the scan file, and bracket access on +// a plain object would resolve prototype keys — a severity of "constructor" +// would return a function as the impact. Map.get answers undefined for +// unknown and prototype keys alike, identical to the old behavior for every +// real severity string. +const SEVERITY_LOOKUP = new Map([ + ['low', 0.3], + ['moderate', 0.5], + ['high', 0.7], + ['important', 0.9], + ['critical', 1] +]); export function nistTag(cveTag: string | undefined) { - if (!cveTag) { - return DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS; - } else { - return DEFAULT_UPDATE_REMEDIATION_NIST_TAGS; - } + return cveTag + ? DEFAULT_UPDATE_REMEDIATION_NIST_TAGS + : DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS; } export class PrismaControlMapper extends BaseConverter { @@ -105,7 +109,7 @@ export class PrismaControlMapper extends BaseConverter { path: 'Severity', transformer: (severity: string) => { if (severity) { - return SEVERITY_LOOKUP[severity]; + return SEVERITY_LOOKUP.get(severity); } else { return 0.5; } @@ -121,14 +125,12 @@ export class PrismaControlMapper extends BaseConverter { transformer: (obj: PrismaControl) => { let result = ''; if (obj.Type === 'image') { - if (obj['Packages'] !== '') { - result += `Version check of package: ${obj['Packages']}`; + if (obj.Packages !== '') { + result += `Version check of package: ${obj.Packages}`; } } else if (obj.Type === 'linux') { if (obj.Distro !== '') { result += `Configuration check for ${obj.Distro}`; - } else { - result += ``; } } else { result += `${obj.Type} check for ${obj.Hostname}`; @@ -140,14 +142,14 @@ export class PrismaControlMapper extends BaseConverter { message: { transformer: (obj: PrismaControl) => { let result = ''; - if (obj['Fix Status'] !== '' && obj.Cause !== '') { - result += `Fix Status: ${obj['Fix Status']}\n\n${obj.Cause}`; - } else if (obj['Fix Status'] !== '') { + if (obj['Fix Status'] === '' && obj.Cause === '') { + result += 'Unknown'; + } else if (obj.Cause === '') { result += `Fix Status: ${obj['Fix Status']}`; - } else if (obj.Cause !== '') { + } else if (obj['Fix Status'] === '') { result += `Cause: ${obj.Cause}`; } else { - result += 'Unknown'; + result += `Fix Status: ${obj['Fix Status']}\n\n${obj.Cause}`; } return result; } @@ -170,13 +172,16 @@ export class PrismaControlMapper extends BaseConverter { export class PrismaMapper { data: PrismaControl[] = []; + constructor(prismaCsv: string) { + this.data = parseCsv(prismaCsv) as PrismaControl[]; + } + toHdf(): ExecJSON.Execution[] { const executions: ExecJSON.Execution[] = []; const hostnameToControls: Record = {}; this.data.forEach((record: PrismaControl) => { - hostnameToControls[record['Hostname']] = - hostnameToControls[record['Hostname']] || []; - hostnameToControls[record['Hostname']].push(record); + hostnameToControls[record.Hostname] ||= []; + hostnameToControls[record.Hostname].push(record); }); Object.entries(hostnameToControls).forEach(([hostname, controls]) => { const converted = new PrismaControlMapper(controls).toHdf(); @@ -185,8 +190,4 @@ export class PrismaMapper { }); return executions; } - - constructor(prismaCsv: string) { - this.data = parseCsv(prismaCsv) as PrismaControl[]; - } } diff --git a/libs/hdf-converters/src/sarif-mapper.ts b/libs/hdf-converters/src/sarif-mapper.ts index c45188ae7e..1d4423f47b 100644 --- a/libs/hdf-converters/src/sarif-mapper.ts +++ b/libs/hdf-converters/src/sarif-mapper.ts @@ -1,14 +1,15 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import { DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS, getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['error', 0.7], ['warning', 0.5], ['note', 0.3] @@ -17,9 +18,9 @@ const MESSAGE_TEXT = 'message.text'; const CWE_NIST_MAPPING = new CweNistMapping(); function extractCwe(text: string): string[] { - let output = text.split('(').slice(-1)[0].slice(0, -2).split(', '); + let output = text.split('(').at(-1)!.slice(0, -2).split(', '); if (output.length === 1) { - output = text.split('(').slice(-1)[0].slice(0, -2).split('!/'); + output = text.split('(').at(-1)!.slice(0, -2).split('!/'); } return output; } @@ -31,15 +32,16 @@ function impactMapping(severity: unknown): number { } } function formatCodeDesc(input: unknown): string { - const output = []; - output.push(`URL : ${_.get(input, 'artifactLocation.uri')}`); - output.push(`LINE : ${_.get(input, 'region.startLine')}`); - output.push(`COLUMN : ${_.get(input, 'region.startColumn')}`); + const output = [ + `URL : ${_.get(input, 'artifactLocation.uri')}`, + `LINE : ${_.get(input, 'region.startLine')}`, + `COLUMN : ${_.get(input, 'region.startColumn')}` + ]; return output.join(' '); } function nistTag(text: string): string[] { let identifiers = extractCwe(text); - identifiers = identifiers.map((element) => element.split('-')[1]); + identifiers = identifiers.map((element) => element.split('-', 2)[1]); return CWE_NIST_MAPPING.nistFilter( identifiers, DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS @@ -107,7 +109,7 @@ export class SarifMapper extends BaseConverter { path: MESSAGE_TEXT, transformer: (text: unknown): string => { if (typeof text === 'string') { - return text.split(': ')[0]; + return text.split(': ', 1)[0]; } else { return ''; } @@ -118,7 +120,7 @@ export class SarifMapper extends BaseConverter { path: MESSAGE_TEXT, transformer: (text: unknown): string => { if (typeof text === 'string') { - return text.split(': ')[1]; + return text.split(': ', 2)[1]; } else { return ''; } @@ -168,6 +170,7 @@ export class SarifMapper extends BaseConverter { } } }; + constructor(sarifJson: string, withRaw = false) { super(JSON.parse(sarifJson)); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/scoutsuite-mapper.ts b/libs/hdf-converters/src/scoutsuite-mapper.ts index 46736b958a..741b3614ff 100644 --- a/libs/hdf-converters/src/scoutsuite-mapper.ts +++ b/libs/hdf-converters/src/scoutsuite-mapper.ts @@ -1,12 +1,14 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; import {ScoutsuiteNistMapping} from './mappings/ScoutsuiteNistMapping'; import {getCCIsForNISTTags} from './utils/global'; @@ -19,7 +21,7 @@ const INSPEC_INPUTS_MAPPING = { boolean: 'Boolean', any: 'Any' }; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['danger', 0.7], ['warning', 0.5] ]); @@ -291,6 +293,7 @@ export class ScoutsuiteMapper extends BaseConverter { } } }; + constructor(scoutsuiteJson: string, withRaw = false) { super(collapseServices(JSON.parse(scoutsuiteJson.split('\n', 2)[1]))); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/snyk-mapper.ts b/libs/hdf-converters/src/snyk-mapper.ts index 81e904db14..478e63bbb7 100644 --- a/libs/hdf-converters/src/snyk-mapper.ts +++ b/libs/hdf-converters/src/snyk-mapper.ts @@ -1,19 +1,21 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import { DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS, getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['high', 0.7], ['medium', 0.5], ['low', 0.3] @@ -90,9 +92,9 @@ export class SnykMapper extends BaseConverter { title: { transformer: (data: Record): string => { const projectName = _.has(data, 'projectName') - ? `Snyk Project: ${_.get(data, 'projectName')} ` + ? `Snyk Project: ${String(_.get(data, 'projectName'))} ` : ''; - return `${projectName}Snyk Path: ${_.get(data, 'path')}`; + return `${projectName}Snyk Path: ${String(_.get(data, 'path'))}`; } }, maintainer: null, @@ -172,6 +174,7 @@ export class SnykMapper extends BaseConverter { } } }; + constructor(snykJson: Record) { super(snykJson); } diff --git a/libs/hdf-converters/src/sonarqube-mapper.ts b/libs/hdf-converters/src/sonarqube-mapper.ts index 115db60f94..548945e236 100644 --- a/libs/hdf-converters/src/sonarqube-mapper.ts +++ b/libs/hdf-converters/src/sonarqube-mapper.ts @@ -1,16 +1,19 @@ -import axios, {AxiosError, AxiosInstance} from 'axios'; +import type {AxiosInstance} from 'axios'; +import axios, {isAxiosError} from 'axios'; import * as rax from 'retry-axios'; import * as _ from 'lodash'; import {coerce, lt} from 'semver'; import {ExecJSON} from 'inspecjs'; import {inspect} from 'util'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; import {OwaspNistMapping} from './mappings/OwaspNistMapping'; import { @@ -72,13 +75,13 @@ function isSonarqubeVersionTen( function isSonarqubeVersionTwenty_five( version: string ): version is SonarqubeVersion.Twenty_five { - const nextHigherVersion = '2026.0.0'; // using 26 for now, but I am unsure what the actual next major version will be - this function can be changed once we identify the next version that contains impactful breaking changes const v = coerce(version); if (v === null) { throw new Error( `Was not able to coerce ${version} into a semver compatible version string` ); } + const nextHigherVersion = '2026.0.0'; // using 26 for now, but I am unsure what the actual next major version will be - this function can be changed once we identify the next version that contains impactful breaking changes return lt(v, nextHigherVersion); } @@ -321,23 +324,29 @@ type Data = { }; // https://docs.sonarsource.com/sonarqube-server/latest/user-guide/rules/overview/#how-severities-are-assigned -const IMPACT_MAPPING: Map = new Map([ - ['blocker', 1.0], +const IMPACT_MAPPING = new Map([ + ['blocker', 1], ['critical', 0.7], ['major', 0.5], ['minor', 0.3], - ['info', 0.0] + ['info', 0] ]); const CWE_NIST_MAPPING = new CweNistMapping(); const OWASP_NIST_MAPPING = new OwaspNistMapping(); +// concat-equivalent append for paged-result merging: adds srcValue to the +// accumulated array, flattening when it is itself an array. +function appendMerged(target: unknown[], addition: unknown): unknown[] { + return [...target, ...(_.isArray(addition) ? addition : [addition])]; +} + function parseOwaspInSysTags( issue: SonarqubeVersionMapping[T]['issue'] & IssueExtensions ): string[] { return issue.ruleInformation.rule.sysTags .filter((s) => s.toLowerCase().startsWith('owasp-')) - .map((t) => t.substring('owasp-'.length).toUpperCase()); // this will just look like 'A3' + .map((t) => t.slice('owasp-'.length).toUpperCase()); // this will just look like 'A3' } function parseOwaspTags( @@ -351,18 +360,26 @@ function parseOwaspTags( if (rule.descriptionSections) { searchSpace += rule.descriptionSections.map((s) => s.content).join(''); } - const searchSpaceMatches = [ - ...searchSpace.matchAll(/> ?OWASP.*?(Top .*?A\d\d?)/gu) - ].map((m) => m[1]); // get the capture group which looks like 'Top 10 2021 Category A1' + const searchSpaceMatches = Array.from( + searchSpace.matchAll(/> ?OWASP.*?(?Top .*?A\d\d?)/gu), + (m) => m.groups!.category + ); // looks like 'Top 10 2021 Category A1' const sysTagMatches = parseOwaspInSysTags(issue); - const totalMatches = searchSpaceMatches.concat(sysTagMatches); + const totalMatches = [...searchSpaceMatches, ...sysTagMatches]; - if (totalMatches.length) { + if (totalMatches.length > 0) { return totalMatches; } return undefined; } +// Prefixes each line of a fetched snippet with its 1-based line number. +const applyLineNumber = (snippet: string): string => + snippet + .split('\n') + .map((l, i) => `${i + 1} ${l}`) + .join('\n'); + function parseCweTags( issue: SonarqubeVersionMapping[T]['issue'] & IssueExtensions ): string[] | undefined { @@ -374,9 +391,9 @@ function parseCweTags( if (rule.descriptionSections) { searchSpace += rule.descriptionSections.map((s) => s.content).join(''); } - const uniqueCwes = _.uniq(searchSpace.match(/CWE-\d\d\d?\d?\d?\d?\d/gi)); // CWE IDs are embedded inside of the HTML + const uniqueCwes = _.uniq(searchSpace.match(/cwe-\d{3,7}/gi)); // CWE IDs are embedded inside of the HTML - if (uniqueCwes.length) { + if (uniqueCwes.length > 0) { return uniqueCwes; } return undefined; @@ -385,18 +402,17 @@ function parseCweTags( function parseNistTags( issue: SonarqubeVersionMapping[T]['issue'] & IssueExtensions ): string[] | undefined { - const uniqueNist = _.uniq( - (parseCweTags(issue) ?? []) - .flatMap((t) => CWE_NIST_MAPPING.nistFilter(t.split('-')[1])) - .concat( - // adding in the systags' owasp tag since in older sonarqube versions sometimes no other guidance alignment is provided - (parseOwaspInSysTags(issue) ?? []).flatMap((t) => - OWASP_NIST_MAPPING.nistFilterNoDefault(t) - ) - ) - ); - - if (uniqueNist.length) { + const uniqueNist = _.uniq([ + ...(parseCweTags(issue) ?? []).flatMap((t) => + CWE_NIST_MAPPING.nistFilter(t.split('-', 2)[1]) + ), + // adding in the systags' owasp tag since in older sonarqube versions sometimes no other guidance alignment is provided + ...parseOwaspInSysTags(issue).flatMap((t) => + OWASP_NIST_MAPPING.nistFilterNoDefault(t) + ) + ]); + + if (uniqueNist.length > 0) { return uniqueNist; } @@ -434,7 +450,7 @@ export class SonarqubeMapper extends BaseConverter< const org = data.organization ? ` organization ${data.organization}` : ''; - return `SonarQube Scan of project ${data.projectKey} on ${data.sonarqubeHost} at ${new Date().toISOString()}${data.branchName || data.pullRequestID || data.organization ? ' using' : ''}${[branch, pullrequest, org].filter((s) => s).join(',')}`; + return `SonarQube Scan of project ${data.projectKey} on ${data.sonarqubeHost} at ${new Date().toISOString()}${data.branchName || data.pullRequestID || data.organization ? ' using' : ''}${[branch, pullrequest, org].filter(Boolean).join(',')}`; } }, supports: [], @@ -515,7 +531,7 @@ export class SonarqubeMapper extends BaseConverter< ...conditionallyProvideAttribute( 'Actives', issue.ruleInformation.actives, - issue.ruleInformation.actives.length !== 0 + issue.ruleInformation.actives.length > 0 ), ...conditionallyProvideAttribute( 'Clean Code Attribute', @@ -843,6 +859,12 @@ enum AuthenticationMethod { } export class SonarqubeResults { + // Default statuses to exclude from results (deny-list approach) + // Pre-10.4 legacy: CLOSED issues are end-of-life (rule deleted/disabled or component removed) + // 10.4+: FALSE_POSITIVE (user says not real), FIXED (no longer in code, purged after 30 days) + static readonly DEFAULT_DENY_LIST_LEGACY = ['CLOSED']; + static readonly DEFAULT_DENY_LIST_MODERN = ['FALSE_POSITIVE', 'FIXED']; + authMethod?: AuthenticationMethod; axiosClient: AxiosInstance; constructor( @@ -859,7 +881,7 @@ export class SonarqubeResults { const MAX_RETRIES = 5; this.axiosClient.defaults.raxConfig = { retry: MAX_RETRIES, - onError: async (e) => { + onError: (e) => { const cfg = rax.getConfig(e); if ( cfg?.currentRetryAttempt !== null && @@ -876,7 +898,14 @@ export class SonarqubeResults { rax.attach(this.axiosClient); } - logAxiosError(e: AxiosError): void { + // unknown, not AxiosError: callers hand this whatever their request path + // rejected with, and non-axios failures (programming errors, aborted + // sockets) must still get logged rather than crash the logger. + logAxiosError(e: unknown): void { + if (!isAxiosError(e)) { + logger.debug('Error', inspect(e, {depth: 3})); + return; + } if (e.response) { logger.debug('response'); logger.debug(e.response.status); @@ -892,12 +921,6 @@ export class SonarqubeResults { } } - // Default statuses to exclude from results (deny-list approach) - // Pre-10.4 legacy: CLOSED issues are end-of-life (rule deleted/disabled or component removed) - // 10.4+: FALSE_POSITIVE (user says not real), FIXED (no longer in code, purged after 30 days) - static readonly DEFAULT_DENY_LIST_LEGACY = ['CLOSED']; - static readonly DEFAULT_DENY_LIST_MODERN = ['FALSE_POSITIVE', 'FIXED']; - async discoverIssueStatuses(sonarqubeVersion: string): Promise { const isLegacy = isBeforeSonarqubeVersion(sonarqubeVersion, '10.4.0'); const statusParamKey = isLegacy ? 'statuses' : 'issueStatuses'; @@ -941,7 +964,7 @@ export class SonarqubeResults { `Raw param data: ${JSON.stringify(statusParam)}` ); } - } catch (e) { + } catch (error) { // Step 2: Fallback to hardcoded full status list if discovery fails allStatuses = isLegacy ? ['OPEN', 'REOPENED', 'CONFIRMED', 'RESOLVED', 'CLOSED'] @@ -956,7 +979,7 @@ export class SonarqubeResults { logger.warn( `Could not discover statuses from server, using fallback: ${allStatuses.join(',')}` ); - logger.debug(inspect(e, {depth: 3})); + logger.debug(inspect(error, {depth: 3})); } // Step 3: Determine which deny-list to use @@ -1018,7 +1041,7 @@ export class SonarqubeResults { async getSearchResults( sonarqubeVersion: string ): Promise> { - const UPPER_LIMIT = 10000; // there is an upper limit of 10000 search results provided for any given search query (i.e. everything aside from the paging information): https://community.sonarsource.com/t/cannot-get-more-than-10000-results-through-web-api/3662 + const UPPER_LIMIT = 10_000; // there is an upper limit of 10000 search results provided for any given search query (i.e. everything aside from the paging information): https://community.sonarsource.com/t/cannot-get-more-than-10000-results-through-web-api/3662 const discoveredStatuses = await this.discoverIssueStatuses(sonarqubeVersion); const PAGE_SIZE = 100; @@ -1092,20 +1115,23 @@ export class SonarqubeResults { }; while (sizeCheck ? page === 1 : paging) { console.log(results); - await createSearch(component, page) - .then(({data}) => { - _.mergeWith(results, data, (objValue, srcValue) => - _.isArray(objValue) ? objValue.concat(srcValue) : undefined - ); - // only need to check if it exceeds the upper limit, if it's less than the upper limit and we request a page that goes past the page total then it just returns fewer results without throwing an error - paging = - data.paging.pageIndex * data.paging.pageSize <= data.paging.total; - page += 1; - }) - .catch((e) => { - this.logAxiosError(e); - throw new Error('Failed at retrieving Sonarqube issues'); + let response: Awaited>; + try { + response = await createSearch(component, page); + } catch (error) { + this.logAxiosError(error); + throw new Error('Failed at retrieving Sonarqube issues', { + cause: error }); + } + const {data} = response; + _.mergeWith(results, data, (objValue, srcValue) => + _.isArray(objValue) ? appendMerged(objValue, srcValue) : undefined + ); + // only need to check if it exceeds the upper limit, if it's less than the upper limit and we request a page that goes past the page total then it just returns fewer results without throwing an error + paging = + data.paging.pageIndex * data.paging.pageSize <= data.paging.total; + page += 1; if (page * PAGE_SIZE > UPPER_LIMIT) { logger.warn( `Exceeded SonarQube cap of ${UPPER_LIMIT} results for findings of or under the ${component} component. Remaining findings may be truncated.` @@ -1132,19 +1158,22 @@ export class SonarqubeResults { components: [] }; while (paging) { - await createComponentSearch(component, page) - .then(({data}) => { - _.mergeWith(results, data, (objValue, srcValue) => - _.isArray(objValue) ? objValue.concat(srcValue) : undefined - ); - paging = - data.paging.pageIndex * data.paging.pageSize <= data.paging.total; - page += 1; - }) - .catch((e) => { - this.logAxiosError(e); - throw new Error('Failed at retrieving the list of components'); + let response: Awaited>; + try { + response = await createComponentSearch(component, page); + } catch (error) { + this.logAxiosError(error); + throw new Error('Failed at retrieving the list of components', { + cause: error }); + } + const {data} = response; + _.mergeWith(results, data, (objValue, srcValue) => + _.isArray(objValue) ? appendMerged(objValue, srcValue) : undefined + ); + paging = + data.paging.pageIndex * data.paging.pageSize <= data.paging.total; + page += 1; if (page * PAGE_SIZE > UPPER_LIMIT) { logger.warn( `Exceeded SonarQube cap of ${UPPER_LIMIT} results for the search for children of the ${component} component. Remaining set of components may be truncated.` @@ -1172,7 +1201,7 @@ export class SonarqubeResults { const componentResults = await collectPagedSearch(component); _.mergeWith(results, componentResults, (objValue, srcValue) => - _.isArray(objValue) ? objValue.concat(srcValue) : objValue + _.isArray(objValue) ? appendMerged(objValue, srcValue) : objValue ); } @@ -1196,44 +1225,51 @@ export class SonarqubeResults { issues: SonarqubeVersionMapping[T]['issue'][] ): Promise { const getFullFile = async (component: string): Promise => { - return this.axiosClient - .get(`${this.sonarqubeHost}/api/sources/raw`, { - ...(this.authMethod === AuthenticationMethod.TokenAsUsername && { - auth: {username: this.userToken, password: ''} - }), - ...(this.authMethod === AuthenticationMethod.BearerToken && { - headers: {Authorization: `Bearer ${this.userToken}`} - }), - params: { - key: component, - ...(this.branchName && {branch: this.branchName}), - ...(this.pullRequestID && {pullRequest: this.pullRequestID}) - }, - responseType: 'text' - }) - .then(({data}) => data) - .catch((e) => { - this.logAxiosError(e); - return Promise.reject( - new Error( - `Failed at getting Sonarqube code snippet for ${component}` - ) - ); - }); + try { + const {data} = await this.axiosClient.get( + `${this.sonarqubeHost}/api/sources/raw`, + { + ...(this.authMethod === AuthenticationMethod.TokenAsUsername && { + auth: {username: this.userToken, password: ''} + }), + ...(this.authMethod === AuthenticationMethod.BearerToken && { + headers: {Authorization: `Bearer ${this.userToken}`} + }), + params: { + key: component, + ...(this.branchName && {branch: this.branchName}), + ...(this.pullRequestID && {pullRequest: this.pullRequestID}) + }, + responseType: 'text' + } + ); + return data; + } catch (error) { + this.logAxiosError(error); + throw new Error( + `Failed at getting Sonarqube code snippet for ${component}`, + {cause: error} + ); + } }; - const applyLineNumber = (snippet: string): string => - snippet - .split('\n') - .map((l, i) => `${i + 1} ${l}`) - .join('\n'); const getContextualizedSnippet = ( - fullFiles: Record, + // Map, not Record: component keys arrive from the SonarQube API, and + // bracket access on a plain object resolves prototype keys. + fullFiles: Map, component: string, startLine: number, endLine: number, msg?: string ): string => { - const linenumberedFile = applyLineNumber(fullFiles[component]); + const fullFile = fullFiles.get(component); + if (fullFile === undefined) { + // The old typed lie crashed inside applyLineNumber on a missing + // component; same failure condition, now stated. + throw new TypeError( + `SonarQube returned no source for component ${component}` + ); + } + const linenumberedFile = applyLineNumber(fullFile); const snippet = linenumberedFile .split('\n') .slice(Math.max(startLine - 3, 0), endLine + 3) // slice wraps around if the start is less than 0 so we want to put a bounds check there to ensure we start at the top of the file; however, if the end is past the end of the array then it just goes until the end of the array so no bounds check is required there @@ -1246,7 +1282,7 @@ export class SonarqubeResults { const components = _.uniq( issues.flatMap((issue) => - issue.flows.length + issue.flows.length > 0 ? issue.flows.flatMap((flow) => flow.locations.map((location) => location.component) ) @@ -1256,10 +1292,12 @@ export class SonarqubeResults { const fullFilePromises = await Promise.all( components.map((component) => getFullFile(component)) ); - const fullFiles = Object.fromEntries(_.zip(components, fullFilePromises)); + const fullFiles = new Map( + _.zip(components, fullFilePromises) as [string, string | undefined][] + ); const snippets = issues.map((issue) => { - if (issue.flows.length) { + if (issue.flows.length > 0) { return issue.flows .flatMap((flow) => flow.locations.map((location) => @@ -1293,33 +1331,37 @@ export class SonarqubeResults { const getRule = async ( rule: string, organization?: string - ): Promise> => - this.axiosClient - .get>(`${this.sonarqubeHost}/api/rules/show`, { - ...(this.authMethod === AuthenticationMethod.TokenAsUsername && { - auth: {username: this.userToken, password: ''} - }), - ...(this.authMethod === AuthenticationMethod.BearerToken && { - headers: {Authorization: `Bearer ${this.userToken}`} - }), - params: { - key: rule, - ...((organization || this.organization) && { - organization: organization || this.organization - }) // seems to be required for sonarcloud at least + ): Promise> => { + try { + const {data} = await this.axiosClient.get>( + `${this.sonarqubeHost}/api/rules/show`, + { + ...(this.authMethod === AuthenticationMethod.TokenAsUsername && { + auth: {username: this.userToken, password: ''} + }), + ...(this.authMethod === AuthenticationMethod.BearerToken && { + headers: {Authorization: `Bearer ${this.userToken}`} + }), + params: { + key: rule, + ...((organization || this.organization) && { + organization: organization || this.organization + }) // seems to be required for sonarcloud at least + } } - }) - .then(({data}) => data) - .catch((e) => { - this.logAxiosError(e); - return Promise.reject( - new Error(`Failed at getting Sonarqube rule: ${rule}`) - ); + ); + return data; + } catch (error) { + this.logAxiosError(error); + throw new Error(`Failed at getting Sonarqube rule: ${rule}`, { + cause: error }); + } + }; const rulesAndOrgs: [string, string | undefined][] = _.uniqWith( issues.map((issue) => [issue.rule, issue.organization]), - _.isEqual + (a, b) => _.isEqual(a, b) ); const fullRulePromises = await Promise.all( rulesAndOrgs.map((ruleAndOrg) => getRule(...ruleAndOrg)) @@ -1355,20 +1397,28 @@ export class SonarqubeResults { organization: this.organization, search: { ...searchResults, - issues: searchResults.issues.map((issue, index) => ({ - ...issue, - codeSnippet: codeSnippets[index], - ruleInformation: rules[index] - })) + issues: searchResults.issues.map((issue, index) => { + // Parallel arrays awaited from the same issues list; the guard + // states that invariant (the old indexing let undefined flow + // silently into the output on divergence). + const codeSnippet = codeSnippets.at(index); + const ruleInformation = rules.at(index); + if (codeSnippet === undefined || ruleInformation === undefined) { + throw new TypeError( + 'SonarQube issue, snippet and rule arrays diverged' + ); + } + return {...issue, codeSnippet, ruleInformation}; + }) } }; return new SonarqubeMapper(data, this.withRaw).toHdf(); } async toHdf(): Promise { - const sonarqubeVersion = await this.axiosClient - .get(`${this.sonarqubeHost}/api/server/version`) - .then(({data}) => data); + const {data: sonarqubeVersion} = await this.axiosClient.get( + `${this.sonarqubeHost}/api/server/version` + ); logger.debug( `Generating HDF for ${this.sonarqubeHost} version: ${sonarqubeVersion}` ); diff --git a/libs/hdf-converters/src/splunk-mapper.ts b/libs/hdf-converters/src/splunk-mapper.ts index 9c3c9001f5..1550c7710b 100644 --- a/libs/hdf-converters/src/splunk-mapper.ts +++ b/libs/hdf-converters/src/splunk-mapper.ts @@ -1,9 +1,10 @@ -import axios, {AxiosInstance, AxiosResponse} from 'axios'; -import {ExecJSON} from 'inspecjs'; +import type {AxiosInstance, AxiosResponse} from 'axios'; +import axios from 'axios'; +import type {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; -import {Logger} from 'winston'; -import {SplunkConfig} from '../types/splunk-config-types'; -import {SplunkReport} from '../types/splunk-report-types'; +import type {Logger} from 'winston'; +import type {SplunkConfig} from '../types/splunk-config-types'; +import type {SplunkReport} from '../types/splunk-report-types'; import {createWinstonLogger} from './utils/global'; import { checkSplunkCredentials, @@ -11,7 +12,7 @@ import { handleSplunkErrorResponse } from './utils/splunk-tools'; -export type Hash = {[key: string]: T}; +export type Hash = Record; export type SplunkConfigNoIndex = Omit; @@ -32,54 +33,56 @@ export type FileMetaData = { const MAPPER_NAME = 'Splunk2HDF'; -let logger = createWinstonLogger('Splunk2HDF'); - // Groups items by using the provided key function export function groupBy( - items: Array, + items: T[], keyGetter: (v: T) => string -): Hash> { - const result: Hash> = {}; +): Hash { + // Grouped through a Map because the keys come from Splunk data: on a plain + // object a key like 'constructor' resolves a prototype FUNCTION (whose + // .push then throws), and '__proto__' hits the prototype setter instead of + // storing. Object.fromEntries emits own data properties, so the returned + // Hash keeps the published shape while every key stays inert data. + const result = new Map(); for (const i of items) { - // Get the items key const key = keyGetter(i); - - // Get the list it should go in - const corrList = result[key]; + const corrList = result.get(key); if (corrList) { - // If list exists, place corrList.push(i); } else { - // List does not exist; create and put - result[key] = [i]; + result.set(key, [i]); } } - return result; + return Object.fromEntries(result); } // Maps a hash to a new hash, with the same keys but each value replaced with a new (mapped) value export function mapHash(old: Hash, mapFunction: (v: T) => G): Hash { - const result: Hash = {}; - for (const key in old) { - result[key] = mapFunction(old[key]); - } - return result; + // Object.entries iterates OWN keys only (for-in also walked inherited + // enumerables) and fromEntries writes own data properties — no computed + // write remains for a hostile key to abuse. + return Object.fromEntries( + Object.entries(old).map(([key, value]) => [key, mapFunction(value)]) + ); } export function consolidatePayloads( - payloads: SplunkReport[] + payloads: SplunkReport[], + logger: Logger = createWinstonLogger(MAPPER_NAME) ): ExecJSON.Execution[] { // Group by exec id const grouped = groupBy(payloads, (pl) => pl.meta.guid); - const built = mapHash(grouped, consolidateFilePayloads); + const built = mapHash(grouped, (filePayloads) => + consolidateFilePayloads(filePayloads, logger) + ); return Object.values(built); } export function replaceKeyValueDescriptions( controls: (ExecJSON.Control & GenericPayloadWithMetaData & { - descriptions?: {[key: string]: string} | ExecJSON.ControlDescription[]; + descriptions?: Record | ExecJSON.ControlDescription[]; })[] ) { return controls.map((control) => { @@ -95,16 +98,17 @@ export function replaceKeyValueDescriptions( } function consolidateFilePayloads( - filePayloads: SplunkReport[] + filePayloads: SplunkReport[], + logger: Logger ): ExecJSON.Execution { // In the end we wish to produce a single evaluation EventPayload which in fact contains all data for the guid // Group by subtype const subtypes = groupBy(filePayloads, (event) => event.meta.subtype); - const execEvents = (subtypes['header'] || + const execEvents = (subtypes.header || []) as Partial[]; - const profileEvents = (subtypes['profile'] || + const profileEvents = (subtypes.profile || []) as unknown as (ExecJSON.Profile & GenericPayloadWithMetaData)[]; - const controlEvents = (subtypes['control'] || + const controlEvents = (subtypes.control || []) as unknown as (ExecJSON.Control & GenericPayloadWithMetaData)[]; logger.debug(`Have ${execEvents.length} execution events`); @@ -125,22 +129,25 @@ function consolidateFilePayloads( exec.profiles?.push(...profileEvents); // Group controls, and then put them into the profiles - const shaGroupedControls = groupBy( - controlEvents, - (ctrl) => ctrl.meta.profile_sha256 + // Map view for the dynamic read below: the sha comes from Splunk data, and + // bracket access on the Hash would resolve prototype keys. + const shaGroupedControls = new Map( + Object.entries( + groupBy(controlEvents, (ctrl) => ctrl.meta.profile_sha256) + ) ); for (const profile of profileEvents) { profile.controls = []; // Get the corresponding controls, and put them into the profile const sha = profile.meta.profile_sha256; logger.debug(`Adding controls for profile with SHA256: ${sha}`); - const corrControls = shaGroupedControls[sha] || []; + const corrControls = shaGroupedControls.get(sha) ?? []; profile.controls.push( ...replaceKeyValueDescriptions( corrControls as unknown as (ExecJSON.Control & GenericPayloadWithMetaData & { descriptions?: - | {[key: string]: string} + | Record | ExecJSON.ControlDescription[]; })[] ) @@ -154,14 +161,17 @@ function consolidateFilePayloads( } function unixTimeToDate(unixTime: string): Date { - // Splunk only currently returns ints but this could be a decimal for more precision - return new Date(parseFloat(unixTime) * 1000); + // Splunk only currently returns ints but this could be a decimal for more + // precision. Number('') is 0, but a missing timestamp must stay an invalid + // date so the caller's fallback fires — hence the explicit empty guard. + return new Date(unixTime ? Number(unixTime) * 1000 : NaN); } export class SplunkMapper { config: SplunkConfig; axiosInstance: AxiosInstance; hostname: string; + logger: Logger; constructor( config: SplunkConfig, @@ -171,16 +181,13 @@ export class SplunkMapper { this.config = config; this.axiosInstance = axios.create({params: {output_mode: 'json'}}); this.hostname = generateHostname(config); - if (logService) { - logger = logService; - } else { - logger = createWinstonLogger(MAPPER_NAME, loggingLevel || 'debug'); - } - logger.debug(`Initialized ${this.constructor.name} successfully`); + this.logger = + logService ?? createWinstonLogger(MAPPER_NAME, loggingLevel || 'debug'); + this.logger.debug(`Initialized ${this.constructor.name} successfully`); } async createJob(query: string): Promise { - logger.debug(`Creating job for query: ${query}`); + this.logger.debug(`Creating job for query: ${query}`); // Post to {host}/services/search/jobs endpoint to queue search job for given query let jobSID: AxiosResponse; try { @@ -190,7 +197,9 @@ export class SplunkMapper { ); } catch (error) { const errorCode = handleSplunkErrorResponse(error); - throw new Error(`Failed to create search job - ${errorCode}`); + throw new Error(`Failed to create search job - ${errorCode}`, { + cause: error + }); } // Return unique search ID (SID) assigned to that search job for future reference @@ -203,6 +212,11 @@ export class SplunkMapper { } } + // queryData awaits this before fetching results, so it must not resolve + // until Splunk reports the job DONE. The previous implementation resolved + // immediately while a detached setInterval kept polling — results could be + // fetched for an unfinished job, and every throw inside the timer + // callbacks was an unhandled rejection the caller never saw. async trackJob(job: string): Promise { // All documented potential error states for a search job // Per https://docs.splunk.com/Documentation/Splunk/latest/RESTTUT/RESTsearches#Tips_on_accessing_searches @@ -216,81 +230,72 @@ export class SplunkMapper { ]); // Arbitrary time values for waiting (in ms), change as necessary // Time to wait until killing search job - const searchJobTimeout = 120000; + const searchJobTimeout = 120_000; // Time interval between checking on status of search job const searchJobPing = 50; - let queryStatus: AxiosResponse; - let continuePing = true; - - // Kill query after 2 minute of waiting - // Arbitrary time used, change as needed - const queryTimer = setTimeout(() => { - continuePing = false; - clearTimeout(queryTimer); - throw new Error('Search job timed out - Unable to retrieve query'); - }, searchJobTimeout); - - // Ping Splunk instance every 50 ms on status of search job - const awaitJob = setInterval(async () => { + const deadline = Date.now() + searchJobTimeout; + + while (Date.now() < deadline) { + let queryStatus: AxiosResponse; try { queryStatus = await this.axiosInstance.get( - `${this.hostname}/services/search/jobs/${job}` + `${this.hostname}/services/search/jobs/${job}`, + // Bound each poll by the remaining budget so a hung request cannot + // outlive the overall search job timeout. + {timeout: Math.max(1, deadline - Date.now())} ); } catch (error) { - clearTimeout(queryTimer); - clearInterval(awaitJob); + if ( + _.get(error, 'code') === 'ECONNABORTED' || + _.get(error, 'code') === 'ETIMEDOUT' + ) { + throw new Error('Search job timed out - Unable to retrieve query', { + cause: error + }); + } throw new Error( - `Failed search job - ${handleSplunkErrorResponse(error)}` + `Failed search job - ${handleSplunkErrorResponse(error)}`, + {cause: error} ); } // Check if response schema is malformed - if (_.has(queryStatus, 'data.entry[0].content')) { - if (queryStatus.data.entry.length !== 1) { - clearTimeout(queryTimer); - clearInterval(awaitJob); - throw new Error( - `Failed search job - Detected malformed entry field length ${queryStatus.data.entry.length}` - ); - } - - // If search job is complete, kill interval loop and exit - if ( - queryStatus.data.entry[0].content.dispatchState === 'DONE' && - queryStatus.data.entry[0].content.isDone - ) { - clearTimeout(queryTimer); - clearInterval(awaitJob); - } else if ( - badState.has(queryStatus.data.entry[0].content.dispatchState) - ) { - // If search job returns a bad state result, kill interval loop and fail the query - clearTimeout(queryTimer); - clearInterval(awaitJob); - throw new Error( - `Failed search job - Detected dispatch state ${queryStatus.data.entry[0].content.dispatchState}` - ); - } - } else { - clearTimeout(queryTimer); - clearInterval(awaitJob); + if (!_.has(queryStatus, 'data.entry[0].content')) { throw new Error( 'Failed search job - Malformed search job response received' ); } + if (queryStatus.data.entry.length !== 1) { + throw new Error( + `Failed search job - Detected malformed entry field length ${queryStatus.data.entry.length}` + ); + } - // Kill loop if search job times out - if (!continuePing) { - clearInterval(awaitJob); + const {dispatchState, isDone} = queryStatus.data.entry[0].content; + // If search job is complete, exit + if (dispatchState === 'DONE' && isDone) { + return; } - }, searchJobPing); + // If search job returns a bad state result, fail the query + if (badState.has(dispatchState)) { + throw new Error( + `Failed search job - Detected dispatch state ${dispatchState}` + ); + } + + await new Promise((resolve) => setTimeout(resolve, searchJobPing)); + } + throw new Error('Search job timed out - Unable to retrieve query'); } parseSplunkResponse( query: string, - results: {fields: string[]; rows: string[]} + // rows is an array of ROWS (each an array of column strings) — the old + // string[] annotation only compiled because indexing a string also + // typechecks; JSON.parse below consumes a whole column value. + results: {fields: string[]; rows: string[][]} ): SplunkReport[] { - logger.info(`Got results for query: ${query}`); + this.logger.info(`Got results for query: ${query}`); // Our data parsed as Key/Value pairs const objects: SplunkReport[] = []; @@ -300,11 +305,11 @@ export class SplunkMapper { ); if (rawDataIndex === -1) { - logger.error(`Field _raw not found, using default index 3`); + this.logger.error(`Field _raw not found, using default index 3`); rawDataIndex = 3; } - logger.debug(`Got field _raw at index ${rawDataIndex}`); + this.logger.debug(`Got field _raw at index ${rawDataIndex}`); // Find _indextime, this is when the data was imported into splunk let indexTimeIndex = results?.fields.findIndex( @@ -312,16 +317,20 @@ export class SplunkMapper { ); if (indexTimeIndex === -1) { - logger.error(`Field _indextime not found, using default index 2`); + this.logger.error(`Field _indextime not found, using default index 2`); indexTimeIndex = 2; } - logger.debug(`Got field _indextime at index ${indexTimeIndex}`); - logger.verbose(`Parsing data returned by Splunk and appending timestamps`); + this.logger.debug(`Got field _indextime at index ${indexTimeIndex}`); + this.logger.verbose( + `Parsing data returned by Splunk and appending timestamps` + ); for (const value of results.rows) { let object; try { - object = JSON.parse(value[rawDataIndex]); + // .at() with a '' fallback: an out-of-range index lands in this + // same catch exactly as the old undefined-coercion path did. + object = JSON.parse(value.at(rawDataIndex) ?? ''); } catch { throw new Error( 'Unable to parse file. Have you configured EVENT_BREAKER? See https://github.com/mitre/saf/wiki/Splunk-Configuration' @@ -333,7 +342,7 @@ export class SplunkMapper { _.set( object, 'meta.parse_time', - unixTimeToDate(value[indexTimeIndex]).toISOString() + unixTimeToDate(value.at(indexTimeIndex) ?? '').toISOString() ); } catch { // Parsing dates can be tricky sometimes @@ -342,7 +351,7 @@ export class SplunkMapper { objects.push(object); } - logger.debug('Successfully parsed and added timestamps'); + this.logger.debug('Successfully parsed and added timestamps'); return objects; } @@ -351,7 +360,7 @@ export class SplunkMapper { // Request session key for Axios instance const authToken = await checkSplunkCredentials(this.config); - this.axiosInstance.defaults.headers.common['Authorization'] = + this.axiosInstance.defaults.headers.common.Authorization = `Bearer ${authToken}`; // Create new search job from given query @@ -374,7 +383,8 @@ export class SplunkMapper { ); } catch (error) { throw new Error( - `Failed search job - ${handleSplunkErrorResponse(error)}` + `Failed search job - ${handleSplunkErrorResponse(error)}`, + {cause: error} ); } @@ -389,19 +399,19 @@ export class SplunkMapper { } async toHdf(guid: string): Promise { - logger.info(`Starting conversion of GUID ${guid}`); + this.logger.info(`Starting conversion of GUID ${guid}`); // Preliminary check of credentials // Not used for later logins await checkSplunkCredentials(this.config); - logger.info(`Credentials valid, querying data for ${guid}`); + this.logger.info(`Credentials valid, querying data for ${guid}`); // Start search job for query const executionData = await this.queryData( `search index="*" meta.guid="${guid}"` ); - logger.info( + this.logger.info( `Data received, consolidating payloads for ${executionData.length} items` ); - return consolidatePayloads(executionData)[0]; + return consolidatePayloads(executionData, this.logger)[0]; } } diff --git a/libs/hdf-converters/src/trufflehog-mapper.ts b/libs/hdf-converters/src/trufflehog-mapper.ts index 4871d96a58..7bb59962bd 100644 --- a/libs/hdf-converters/src/trufflehog-mapper.ts +++ b/libs/hdf-converters/src/trufflehog-mapper.ts @@ -1,16 +1,18 @@ import {ExecJSON} from 'inspecjs'; import _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import {BaseConverter, ILookupPath, MappedTransform} from './base-converter'; +import type { ILookupPath, MappedTransform} from './base-converter'; +import {BaseConverter} from './base-converter'; +import {stringifyOrUndefinedString} from './utils/global'; export class TrufflehogResults { data: Record; withRaw: boolean; constructor(trufflehogJson: string, withRaw = false) { - let parsedData = {}; + let parsedData: unknown; try { parsedData = JSON.parse(trufflehogJson.trim()); - } catch (e) { + } catch { parsedData = trufflehogJson .trim() .split('\n') @@ -47,7 +49,7 @@ export class TrufflehogMapper extends BaseConverter { name: { path: 'wrapper[0]', transformer: (data: Record): string => - `Source ID: ${_.get(data, 'SourceID')}, Source Name: ${_.get(data, 'SourceName')}` + `Source ID: ${String(_.get(data, 'SourceID'))}, Source Name: ${String(_.get(data, 'SourceName'))}` }, title: {path: 'wrapper[0].SourceName'}, supports: [], @@ -67,11 +69,11 @@ export class TrufflehogMapper extends BaseConverter { source_location: {}, title: { transformer: (data: Record): string => - `Found ${_.get(data, 'DetectorName')} secret using ${_.get(data, 'DecoderName')} decoder` + `Found ${String(_.get(data, 'DetectorName'))} secret using ${String(_.get(data, 'DecoderName'))} decoder` }, id: { transformer: (data: Record): string => - `${_.get(data, 'DetectorName')} ${_.get(data, 'DecoderName')}` + `${String(_.get(data, 'DetectorName'))} ${String(_.get(data, 'DecoderName'))}` }, impact: 0.5, results: [ @@ -79,11 +81,11 @@ export class TrufflehogMapper extends BaseConverter { status: ExecJSON.ControlResultStatus.Failed, code_desc: { transformer: (data: Record): string => - `${JSON.stringify(_.get(data, 'SourceMetadata'), null, 2)}` + stringifyOrUndefinedString(_.get(data, 'SourceMetadata')) }, message: { transformer: (data: Record): string => - `${JSON.stringify( + JSON.stringify( _.omitBy( _.pick(data, [ 'Verified', @@ -98,7 +100,7 @@ export class TrufflehogMapper extends BaseConverter { ), null, 2 - )}` + ) }, start_time: '' } @@ -116,6 +118,7 @@ export class TrufflehogMapper extends BaseConverter { } } }; + constructor(trufflehogJson: Record, withRaw = false) { super(trufflehogJson, true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/twistlock-mapper.ts b/libs/hdf-converters/src/twistlock-mapper.ts index 7dd38f6e39..ab03e6e7e5 100644 --- a/libs/hdf-converters/src/twistlock-mapper.ts +++ b/libs/hdf-converters/src/twistlock-mapper.ts @@ -1,18 +1,20 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, - impactMapping, MappedTransform } from './base-converter'; +import { + BaseConverter, + impactMapping +} from './base-converter'; import { DEFAULT_UPDATE_REMEDIATION_NIST_TAGS, getCCIsForNISTTags } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['important', 0.9], ['high', 0.7], @@ -69,18 +71,16 @@ export class TwistlockMapper extends BaseConverter { const projectName = Array.isArray(projectArr) ? projectArr.join(' / ') : projectArr; - return `Twistlock Project: ${projectName}`; + return `Twistlock Project: ${String(projectName)}`; } }, summary: { transformer: (data: Record): string => { const vulnerabilityTotal = _.has(data, 'vulnerabilityDistribution') - ? `${JSON.stringify( - _.get(data, 'vulnerabilityDistribution.total') - )}` + ? JSON.stringify(_.get(data, 'vulnerabilityDistribution.total')) : 'N/A'; const complianceTotal = _.has(data, 'complianceDistribution') - ? `${JSON.stringify(_.get(data, 'complianceDistribution.total'))}` + ? JSON.stringify(_.get(data, 'complianceDistribution.total')) : 'N/A'; return `Package Vulnerability Summary: ${vulnerabilityTotal} Application Compliance Issue Total: ${complianceTotal}`; } @@ -118,10 +118,10 @@ export class TwistlockMapper extends BaseConverter { code_desc: { transformer: (data: Record): string => { const packageName = _.has(data, 'packageName') - ? `${JSON.stringify(_.get(data, 'packageName'))}` + ? JSON.stringify(_.get(data, 'packageName')) : 'N/A'; const impactedVersions = _.has(data, 'impactedVersions') - ? `${JSON.stringify(_.get(data, 'impactedVersions'))}` + ? JSON.stringify(_.get(data, 'impactedVersions')) : 'N/A'; return `Package ${packageName} should be updated to latest version above impacted versions ${impactedVersions}`; } @@ -129,10 +129,10 @@ export class TwistlockMapper extends BaseConverter { message: { transformer: (data: Record): string => { const packageName = _.has(data, 'packageName') - ? `${JSON.stringify(_.get(data, 'packageName'))}` + ? JSON.stringify(_.get(data, 'packageName')) : 'N/A'; const packageVersion = _.has(data, 'packageVersion') - ? `${JSON.stringify(_.get(data, 'packageVersion'))}` + ? JSON.stringify(_.get(data, 'packageVersion')) : 'N/A'; return `Expected latest version of ${packageName}\nDetected vulnerable version ${packageVersion} of ${packageName}`; } @@ -174,6 +174,7 @@ export class TwistlockMapper extends BaseConverter { } } }; + constructor(twistlockJson: Record, withRaw = false) { super(twistlockJson, true); this.withRaw = withRaw; diff --git a/libs/hdf-converters/src/utils/attestations.ts b/libs/hdf-converters/src/utils/attestations.ts index 6e633a440d..a4cc62cd0c 100644 --- a/libs/hdf-converters/src/utils/attestations.ts +++ b/libs/hdf-converters/src/utils/attestations.ts @@ -1,6 +1,6 @@ import * as XLSX from '@e965/xlsx'; import {ExecJSON} from 'inspecjs'; -import { +import type { AttestationData, ControlResultStatus, ControlAttestationStatus @@ -13,6 +13,11 @@ export type Attestation = Omit & { status: `${ControlAttestationStatus}`; }; +// a number followed by d/w/m/y, with or without spaces in between +// 10 character limit on number of digits and characters to prevent security issues with regex +const UPDATE_FREQUENCY_PATTERN = + /(?\d{1,10}(?:\.\d{0,10})?)\s{0,10}(?[a-z])/; + export function advanceDate( date: moment.Moment, frequency: string @@ -46,10 +51,7 @@ export function advanceDate( date.add(1, 'day'); break; default: { - // a number followed by d/w/m/y, with or without spaces in between - // 10 character limit on number of digits and characters to prevent security issues with regex - const re = /(\d{1,10}(?:.\d{0,10})?)(\s{0,10})([a-z])/; - const match = re.exec(frequency); + const match = UPDATE_FREQUENCY_PATTERN.exec(frequency); if (!match) { throw new Error( @@ -59,8 +61,7 @@ export function advanceDate( ); } - const number = match[1]; - const unit = match[3]; + const {number, unit} = match.groups!; // add inputted amount of time switch (unit) { case 'd': @@ -136,6 +137,28 @@ export function convertAttestationToSegment( } } +function applyAttestationToControls( + attestation: Attestation, + controls: ExecJSON.Control[] +): boolean { + let foundControl = false; + for (const control of controls) { + if (!attestationCanBeAdded(attestation, control)) { + continue; + } + foundControl = true; + if (['passed', 'failed'].includes(attestation.status)) { + control.attestation_data = attestation as unknown as AttestationData; + control.results.push(convertAttestationToSegment(attestation)); + } else { + console.error( + `Invalid attestation status for Control ${control.id}: ${attestation.status} - Status must be passed or failed. To make this control 'not applicable', use a waiver.` + ); + } + } + return foundControl; +} + export function addAttestationToHDF( hdf: ExecJSON.Execution, attestations: Attestation[] @@ -143,19 +166,8 @@ export function addAttestationToHDF( for (const attestation of attestations) { let found_control = false; for (const profile of hdf.profiles) { - for (const control of profile.controls) { - if (attestationCanBeAdded(attestation, control)) { - found_control = true; - if (['passed', 'failed'].includes(attestation.status)) { - control.attestation_data = - attestation as unknown as AttestationData; - control.results.push(convertAttestationToSegment(attestation)); - } else { - console.error( - `Invalid attestation status for Control ${control.id}: ${attestation.status} - Status must be passed or failed. To make this control 'not applicable', use a waiver.` - ); - } - } + if (applyAttestationToControls(attestation, profile.controls)) { + found_control = true; } } if (!found_control) { @@ -174,12 +186,12 @@ export async function parseXLSXAttestations( const workbook = XLSX.read(attestationXLSX, { cellDates: true }); - const sheet = workbook.Sheets['attestations']; + const sheet = workbook.Sheets.attestations; const data: Record[] = XLSX.utils.sheet_to_json(sheet); const attestations: Attestation[] = data.map((attestation) => { const lowerAttestation = _.mapKeys(attestation, (_v, k) => { - return k.toLowerCase().replace(/\s/g, '_'); + return k.toLowerCase().replaceAll(/\s/g, '_'); }); return { control_id: getFirstPath(lowerAttestation, [ @@ -212,7 +224,7 @@ function attestationCanBeAdded( return false; } - if (control.results[0].status === 'skipped') { + if (control.results[0].status === ExecJSON.ControlResultStatus.Skipped) { // The attestation can be added if the control results show 'skipped', meaning it needs Manual Review. return true; } @@ -234,7 +246,8 @@ function getFirstPath( `Attestation is missing one of these paths: ${paths.join(', ')}` ); } - const stringOrDate = _.get(object, paths[index]); + // findIndex's -1 case threw above, so .at() cannot miss here. + const stringOrDate = _.get(object, paths.at(index) ?? ''); if (_.isString(stringOrDate)) { return stringOrDate; } diff --git a/libs/hdf-converters/src/utils/CCI_List.ts b/libs/hdf-converters/src/utils/cci-list.ts similarity index 100% rename from libs/hdf-converters/src/utils/CCI_List.ts rename to libs/hdf-converters/src/utils/cci-list.ts diff --git a/libs/hdf-converters/src/utils/compliance.ts b/libs/hdf-converters/src/utils/compliance.ts index 251144a58e..933aa53a43 100644 --- a/libs/hdf-converters/src/utils/compliance.ts +++ b/libs/hdf-converters/src/utils/compliance.ts @@ -26,7 +26,7 @@ export function formatCompliance( // >=90 is high compliance, >= 60 is medium compliance, <60 is low compliance // Mainly for HTML export export function translateCompliance(rawCompliance: string): string { - const compliance = Number.parseFloat(rawCompliance.slice(0, -1)); + const compliance = Number(rawCompliance.slice(0, -1)); if (compliance >= 90) { return 'high'; diff --git a/libs/hdf-converters/src/utils/fingerprinting.ts b/libs/hdf-converters/src/utils/fingerprinting.ts index cb5dcf6fb6..e47e9eaead 100644 --- a/libs/hdf-converters/src/utils/fingerprinting.ts +++ b/libs/hdf-converters/src/utils/fingerprinting.ts @@ -1,5 +1,10 @@ import * as _ from 'lodash'; +// Keys matching (hopefully) all xccdf formats +const XCCDF_XMLNS_PATTERN = /xmlns.*http.*\/xccdf/; +const NETSPARKER_GENERATED_PATTERN = //; +const INVICTI_GENERATED_PATTERN = //; + export enum INPUT_TYPES { ASFF = 'asff', BURP = 'burp', @@ -112,21 +117,17 @@ export function fingerprint(guessOptions: { try { const parsed = JSON.parse(guessOptions.data); const object = Array.isArray(parsed) ? parsed[0] : parsed; - // Find the fingerprints that have the most matches - const fingerprinted = Object.entries(fileTypeFingerprints).reduce( - (a, b) => { - return a[1].filter((value) => _.get(object, value)).length > - b[1].filter((value) => _.get(object, value)).length - ? {...a, count: a[1].filter((value) => _.get(object, value)).length} - : { - ...b, - count: b[1].filter((value) => _.get(object, value)).length - }; + // Find the fingerprint that has the most matches; >= keeps the reduce's + // later-entry-wins-ties behavior + let best: {type: INPUT_TYPES; count: number} | undefined; + for (const [type, paths] of Object.entries(fileTypeFingerprints)) { + const count = paths.filter((value) => _.get(object, value)).length; + if (best === undefined || count >= best.count) { + best = {type: type as INPUT_TYPES, count}; } - ) as unknown as INPUT_TYPES[] & {count: number}; - const result = fingerprinted[0]; - if (fingerprinted.count !== 0) { - return result; + } + if (best && best.count !== 0) { + return best.type; } } catch { const splitLines = guessOptions.data.trim().split('\n'); @@ -134,30 +135,30 @@ export function fingerprint(guessOptions: { if (guessOptions.filename.toLowerCase().endsWith('.nessus')) { return INPUT_TYPES.NESSUS; } else if ( - guessOptions.data.match(/xmlns.*http.*\/xccdf/) || // Keys matching (hopefully) all xccdf formats - guessOptions.filename.toLowerCase().indexOf('xccdf') !== -1 + XCCDF_XMLNS_PATTERN.test(guessOptions.data) || + guessOptions.filename.toLowerCase().includes('xccdf') ) { return INPUT_TYPES.XCCDF; } else if ( - guessOptions.data.match(//) || - guessOptions.data.match(//) + NETSPARKER_GENERATED_PATTERN.test(guessOptions.data) || + INVICTI_GENERATED_PATTERN.test(guessOptions.data) ) { return INPUT_TYPES.NETSPARKER; } else if (guessOptions.filename.toLowerCase().endsWith('.fvdl')) { return INPUT_TYPES.FORTIFY; } else if ( - guessOptions.data.indexOf('"AwsAccountId"') !== -1 && - guessOptions.data.indexOf('"SchemaVersion"') !== -1 + guessOptions.data.includes('"AwsAccountId"') && + guessOptions.data.includes('"SchemaVersion"') ) { return INPUT_TYPES.ASFF; - } else if (guessOptions.data.indexOf('issues burpVersion') !== -1) { + } else if (guessOptions.data.includes('issues burpVersion')) { return INPUT_TYPES.BURP; - } else if (guessOptions.data.indexOf('scoutsuite_results') !== -1) { + } else if (guessOptions.data.includes('scoutsuite_results')) { return INPUT_TYPES.SCOUTSUITE; } else if ( - guessOptions.data.indexOf('Policy') !== -1 && - guessOptions.data.indexOf('Job Name') !== -1 && - guessOptions.data.indexOf('Check ID') !== -1 && + guessOptions.data.includes('Policy') && + guessOptions.data.includes('Job Name') && + guessOptions.data.includes('Check ID') && guessOptions.data.indexOf('Result Status') ) { return INPUT_TYPES.DB_PROTECT; @@ -178,14 +179,14 @@ export function fingerprint(guessOptions: { ) { return INPUT_TYPES.TRUFFLEHOG; } else if ( - guessOptions.data.indexOf('veracode') !== -1 && - guessOptions.data.indexOf('detailedreport') !== -1 + guessOptions.data.includes('veracode') && + guessOptions.data.includes('detailedreport') ) { return INPUT_TYPES.VERACODE; } else if ( - guessOptions.data.indexOf('') !== -1 && - guessOptions.data.indexOf('') !== -1 && - guessOptions.data.indexOf('') !== -1 + guessOptions.data.includes('') && + guessOptions.data.includes('') && + guessOptions.data.includes('') ) { return INPUT_TYPES.CHECKLIST; } diff --git a/libs/hdf-converters/src/utils/global.ts b/libs/hdf-converters/src/utils/global.ts index 5e73800e4c..4ac08458a0 100644 --- a/libs/hdf-converters/src/utils/global.ts +++ b/libs/hdf-converters/src/utils/global.ts @@ -1,8 +1,10 @@ -import { +import type { ContextualizedEvaluation, - contextualizeEvaluation, ExecJSON } from 'inspecjs'; +import { + contextualizeEvaluation +} from 'inspecjs'; import * as _ from 'lodash'; import {createLogger, format, transports} from 'winston'; import {data as NistCciMappingData} from '../mappings/NistCciMappingData'; @@ -11,8 +13,13 @@ import {data as NistCciMappingData} from '../mappings/NistCciMappingData'; // SA-11 (DEVELOPER SECURITY TESTING AND EVALUATION) - RA-5 (VULNERABILITY SCANNING) export const DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS = ['SA-11', 'RA-5']; -export const DEFAULT_STATIC_CODE_ANALYSIS_CCI_TAGS = - DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS.flatMap((tag) => NistCciMappingData[tag]); +// Literal keys, deliberately mirroring DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS +// above: the tags are this module's own constants, so no dynamic key ever +// reaches the generated table. +export const DEFAULT_STATIC_CODE_ANALYSIS_CCI_TAGS = [ + ...NistCciMappingData['SA-11'], + ...NistCciMappingData['RA-5'] +]; // REMEDIATION_NIST_TAG the set of default applicable NIST 800-53 controls for ensuring up-to-date packages. // SI-2 (FLAW REMEDIATION) - RA-5 (VULNERABILITY SCANNING) @@ -24,7 +31,7 @@ export const DEFAULT_INFORMATION_SYSTEM_COMPONENT_MANAGEMENT_NIST_TAGS = [ ]; // The "Types" field of ASFF only supports a maximum of 2 slashes, and will get replaced with this text. Note that the default AWS CLI doesn't support UTF-8 encoding -export const FROM_ASFF_TYPES_SLASH_REPLACEMENT = /{{{SLASH}}}/gi; +export const FROM_ASFF_TYPES_SLASH_REPLACEMENT = /\{\{\{slash\}\}\}/gi; export function createWinstonLogger(mapperName: string, level = 'debug') { return createLogger({ @@ -35,7 +42,7 @@ export function createWinstonLogger(mapperName: string, level = 'debug') { format: 'MMM-DD-YYYY HH:mm:ss Z' }), format.printf( - (info) => `[${[info.timestamp]}] ${mapperName} ${info.message}` + (info) => `[${String([info.timestamp])}] ${mapperName} ${String(info.message)}` ) ) }); @@ -44,9 +51,7 @@ export function createWinstonLogger(mapperName: string, level = 'debug') { /** Get description from Array of descriptions or Key/Value pairs */ export function getDescription( descriptions: - | { - [key: string]: string; - } + | Record | ExecJSON.ControlDescription[], key: string ): string | undefined { @@ -63,14 +68,17 @@ export function getDescription( return found; } +// Two letters, a hyphen, and one to three digits — the control-family prefix. +const NIST_BASE_TAG = /\w{2}-\d{1,3}/; + export function getCCIsForNISTTags(nistTags: string[]): string[] { const cciTags: string[] = []; for (const nistTag of nistTags) { - const baseTag = /\w\w-\d\d?\d?/g.exec(nistTag); + const baseTag = NIST_BASE_TAG.exec(nistTag); if ( Array.isArray(baseTag) && baseTag.length > 0 && - baseTag[0] in NistCciMappingData + Object.hasOwn(NistCciMappingData, baseTag[0]) ) { cciTags.push(...NistCciMappingData[baseTag[0]]); } @@ -90,6 +98,14 @@ export function conditionallyProvideAttribute( return {[attributeName]: attribute}; } +// Renders a value exactly as `${JSON.stringify(value, null, 2)}` historically +// did: an absent value becomes the literal string 'undefined'. That garbage +// output is fixture-pinned; heimdall2-vf4 tracks removing the class +// deliberately, with fixture regeneration. +export function stringifyOrUndefinedString(value: unknown): string { + return value === undefined ? 'undefined' : JSON.stringify(value, null, 2); +} + export function ensureContextualizedEvaluation( data: ExecJSON.Execution | ContextualizedEvaluation ) { diff --git a/libs/hdf-converters/src/utils/parseJson.ts b/libs/hdf-converters/src/utils/parseJson.ts index e23856bab1..0272fc078b 100644 --- a/libs/hdf-converters/src/utils/parseJson.ts +++ b/libs/hdf-converters/src/utils/parseJson.ts @@ -1,4 +1,4 @@ -import {Result} from './result'; +import type {Result} from './result'; export type JSONValue = | string @@ -11,11 +11,11 @@ export type JSONValue = export function parseJson(str: string): Result { try { return {ok: true, value: JSON.parse(str)}; - } catch (e) { - if (e instanceof Error) { - return {ok: false, error: e}; + } catch (error) { + if (error instanceof Error) { + return {ok: false, error: error}; } else { - return {ok: false, error: new Error(String(e))}; + return {ok: false, error: new Error(String(error))}; } } } diff --git a/libs/hdf-converters/src/utils/splunk-tools.ts b/libs/hdf-converters/src/utils/splunk-tools.ts index 6d16bc8733..e96237f47d 100644 --- a/libs/hdf-converters/src/utils/splunk-tools.ts +++ b/libs/hdf-converters/src/utils/splunk-tools.ts @@ -1,6 +1,7 @@ -import axios, {AxiosResponse} from 'axios'; +import type {AxiosResponse} from 'axios'; +import axios from 'axios'; import * as _ from 'lodash'; -import {SplunkConfig} from '../../types/splunk-config-types'; +import type {SplunkConfig} from '../../types/splunk-config-types'; // Helper function to generate a parseable hostname for HTTP requests export function generateHostname(config: SplunkConfig): string { @@ -74,7 +75,8 @@ export async function checkSplunkCredentials( ) { // Fail query if request takes too long to respond throw new Error( - 'Login timed out - Please check your CORS configuration or validate that you have inputted the correct domain' + 'Login timed out - Please check your CORS configuration or validate that you have inputted the correct domain', + {cause: error} ); } @@ -84,10 +86,11 @@ export async function checkSplunkCredentials( const errorCode = handleSplunkErrorResponse(error); if (errorCode === 'Unexpected error') { throw new Error( - `Failed to login - Please check your CORS configuration and validate that your input has the correct domain: ${error}` + `Failed to login - Please check your CORS configuration and validate that your input has the correct domain: ${error}`, + {cause: error} ); } - throw new Error(`Failed to login - ${errorCode}`); + throw new Error(`Failed to login - ${errorCode}`, {cause: error}); } finally { // Kill timer since request has failed clearTimeout(timeoutId); diff --git a/libs/hdf-converters/src/veracode-mapper.ts b/libs/hdf-converters/src/veracode-mapper.ts index 43ae05d641..b994bb6753 100644 --- a/libs/hdf-converters/src/veracode-mapper.ts +++ b/libs/hdf-converters/src/veracode-mapper.ts @@ -1,10 +1,11 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform} from './base-converter'; import { BaseConverter, - ILookupPath, - MappedTransform, parseXml } from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; @@ -14,13 +15,13 @@ const SEVERITY = 'detailedreport.severity'; const FILE_PATH_VALUE = 'file_paths.file_path.@_.value'; const CWE_NIST_MAPPING = new CweNistMapping(); const DEFAULT_NIST_TAG = ['SI-2', 'RA-5']; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['5', 0.9], ['4', 0.7], ['3', 0.5], ['2', 0.3], ['1', 0.1], - ['0', 0.0] + ['0', 0] ]); function impactMapping(severity: number | string): number { @@ -45,7 +46,7 @@ function formatRecommendations(input: Record): string { const text: string[] = []; if (_.has(input, 'recommendations.para')) { if (_.has(input, 'recommendations.para.@_.text')) { - text.push(`${_.get(input, 'recommendations.para.@_.text')}`); + text.push(String(_.get(input, 'recommendations.para.@_.text'))); } else { text.push( ...( @@ -56,19 +57,20 @@ function formatRecommendations(input: Record): string { ); } } - if (_.has(input, 'recommendations.para.bulletitem')) { - if (Array.isArray(_.get(input, `recommendations.para.bulletitem`))) { - text.push( - ...( - _.get(input, `recommendations.para.bulletitem`) as Record< - string, - unknown - >[] - ).map( - (value: Record) => _.get(value, '@_.text') as string - ) - ); - } + if ( + _.has(input, 'recommendations.para.bulletitem') && + Array.isArray(_.get(input, `recommendations.para.bulletitem`)) + ) { + text.push( + ...( + _.get(input, `recommendations.para.bulletitem`) as Record< + string, + unknown + >[] + ).map( + (value: Record) => _.get(value, '@_.text') as string + ) + ); } return text.join('\n'); } @@ -77,7 +79,7 @@ function formatDesc(input: Record): string { const text = []; if (_.has(input, 'desc.para')) { if (_.has(input, 'desc.para.@_.text')) { - text.push(`${_.get(input, 'desc.para.@_.text')}`); + text.push(String(_.get(input, 'desc.para.@_.text'))); } else { text.push( ...(_.get(input, `desc.para`) as Record[]).map( @@ -108,13 +110,13 @@ function formatCweData(input: Record): string { } text.push( ...(cweInput as Record[]).map((cweinfo) => { - let cwe = `CWE-${_.get(cweinfo, '@_.cweid')}: `; - cwe += `${_.get(cweinfo, '@_.cwename')}`; + let cwe = `CWE-${String(_.get(cweinfo, '@_.cweid'))}: `; + cwe += String(_.get(cweinfo, '@_.cwename')); cwe += categories .map((value: string) => { if (_.has(cweinfo, `@_.${value}`)) { const val = _.get(cweinfo, `@_.${value}`); - return `${value}: ${val}\n`; + return `${value}: ${String(val)}\n`; } else { return ''; } @@ -137,10 +139,9 @@ function formatCweDesc(input: Record): string { text.push( ...(cwe as Record[]).map( (value: Record) => - `CWE-${_.get(value, '@_.cweid')}: ${_.get( - value, - '@_.cwename' - )} Description: ${_.get(value, 'description.text.@_.text')}; ` + `CWE-${String(_.get(value, '@_.cweid'))}: ${String( + _.get(value, '@_.cwename') + )} Description: ${String(_.get(value, 'description.text.@_.text'))}; ` ) ); } @@ -186,12 +187,12 @@ function formatCodeDesc(input: Record[]): string { ['Function Relative Location', 'functionrelativelocation'] ]; if (_.has(input, '@_.sourcefilepath')) { - flawDesc = `Sourcefile Path: ${_.get(input, '@_.sourcefilepath')}\n`; + flawDesc = `Sourcefile Path: ${String(_.get(input, '@_.sourcefilepath'))}\n`; flawDesc += categories .map(([title, name]) => { if (_.has(input, `@_.${name}`)) { const nameVal = _.get(input, `@_.${name}`); - return `${title}: ${nameVal}\n`; + return `${title}: ${String(nameVal)}\n`; } else { return ''; } @@ -216,19 +217,19 @@ function formatSCACodeDesc(input: Record): string { 'component_affects_policy_compliance' ]; if (_.has(input, '@_.component_id')) { - flawDesc = `component_id: ${_.get(input, '@_.component_id')}\n`; + flawDesc = `component_id: ${String(_.get(input, '@_.component_id'))}\n`; flawDesc += _.compact( categories.map((value: string) => { if (_.has(input, `@_.${value}`)) { const val = _.get(input, `@_.${value}`); - return `${value}: ${val}`; + return `${value}: ${String(val)}`; } else { return ''; } }) ).join('\n'); if (_.has(input, FILE_PATH_VALUE)) { - flawDesc += `\nfile_path: ${_.get(input, FILE_PATH_VALUE)}\n`; + flawDesc += `\nfile_path: ${String(_.get(input, FILE_PATH_VALUE))}\n`; } } return flawDesc; @@ -240,10 +241,10 @@ function formatSourceLocation(input: Record[]): string { input = [input]; } for (const value of input) { - if (!Array.isArray(_.get(value, STATIC_FLAWS))) { - flawArr.push(_.get(value, STATIC_FLAWS) as string); - } else { + if (Array.isArray(_.get(value, STATIC_FLAWS))) { flawArr.push(...(_.get(value, STATIC_FLAWS) as string[])); + } else { + flawArr.push(_.get(value, STATIC_FLAWS) as string); } } return flawArr.map((value) => _.get(value, '@_.sourcefile')).join('\n'); @@ -257,7 +258,7 @@ function componentListCreate(input: unknown): Record[] { if (!Array.isArray(component)) { component = [component]; } - for (const value of component as Record[]) { + for (const value of component) { if (_.get(value, '@_.vulnerabilities') !== '0') { componentList.push(value); } @@ -268,8 +269,8 @@ function componentListCreate(input: unknown): Record[] { function componentTransform(input: unknown): Record[] { const componentList: Record[] = componentListCreate(input); - const vulns: Record[] = componentList - .map((component) => { + const flattened: Record[] = componentList + .flatMap((component) => { let vulnerability = _.get(component, 'vulnerabilities.vulnerability') as | Record | Record[]; @@ -281,21 +282,22 @@ function componentTransform(input: unknown): Record[] { components: [component] })); return vulnerability; - }) - .flat() - .reduce((acc: Record[], cur: Record) => { - const cveId = _.get(cur, '@_.cve_id'); - const index = acc.findIndex((vuln) => cveId === _.get(vuln, '@_.cve_id')); - if (index === -1) { - return [...acc, cur]; - } else { - (_.get(acc[index], 'components') as Record[]).push( - ...(_.get(cur, 'components') as Record[]) - ); - return acc; - } - }, []); - return vulns; + }); + // Deduplicate by CVE id, merging components into the first occurrence; the + // Map's insertion order keeps the original first-seen ordering. + const vulnsByCveId = new Map>(); + for (const cur of flattened) { + const cveId = _.get(cur, '@_.cve_id'); + const existing = vulnsByCveId.get(cveId); + if (existing === undefined) { + vulnsByCveId.set(cveId, cur); + } else { + (_.get(existing, 'components') as Record[]).push( + ...(_.get(cur, 'components') as Record[]) + ); + } + } + return [...vulnsByCveId.values()]; } function controlMappingCve(): MappedTransform< @@ -318,7 +320,7 @@ function controlMappingCve(): MappedTransform< } return CWE_NIST_MAPPING.nistFilter( - value.map((val: string) => val.substring(4)), + value.map((val: string) => val.slice(4)), DEFAULT_NIST_TAG ); } @@ -394,12 +396,7 @@ function componentPass(component: Record) { const vulnList: string[] = []; _.set(component, 'control_ids', vulnList); if (_.get(component, 'vulnerabilities') !== '') { - if (!Array.isArray(_.get(component, 'vulnerabilities.vulnerability'))) { - vulnList.push( - _.get(component, 'vulnerabilities.vulnerability.@_.cve_id') as string - ); - _.set(component, 'control_ids', vulnList); - } else { + if (Array.isArray(_.get(component, 'vulnerabilities.vulnerability'))) { vulnList.push( ...( _.get(component, 'vulnerabilities.vulnerability') as Record< @@ -410,14 +407,46 @@ function componentPass(component: Record) { (vuln: Record) => _.get(vuln, '@_.cve_id') as string ) ); - _.set(component, 'control_ids', vulnList); + } else { + vulnList.push( + _.get(component, 'vulnerabilities.vulnerability.@_.cve_id') as string + ); } + _.set(component, 'control_ids', vulnList); } return _.omit(component, 'vulnerabilities'); } export class VeracodeMapper extends BaseConverter { originalData: unknown; + + constructor(xml: string, withRaw = false) { + // the default textNodeName that we're using ('text') clobbers any attributes that also are named 'text' of which there are many in this format + // the attribute group names are necessary since there are many times that attributes and inner tags share the same name within a tag (ex. 'vulnerabilities' the attribute is a count whereas as an inner tag it is an array detailing the vulnerabilities) where it seems that the attribute clobbers the inner tag + const parsedXML = parseXml(xml, { + attributesGroupName: '@_', + textNodeName: 'text_' + }); + if (_.has(parsedXML, 'summaryreport')) { + throw new Error('Current mapper does not accept summary reports'); + } + const arrayedControls = (_.get(parsedXML, SEVERITY) as []).map( + (control: {category: unknown; level: string}) => { + if (Array.isArray(control.category)) { + return {level: control.level, category: control.category}; + } else if (control.category) { + return {level: control.level, category: [control.category]}; + } else { + return {level: control.level}; + } + } + ); + _.set(parsedXML, SEVERITY, arrayedControls); + super(parsedXML); + this.originalData = xml; + this.setMappings(this.defaultMapping(withRaw)); + } + defaultMapping( withRaw = false ): MappedTransform { @@ -514,30 +543,4 @@ export class VeracodeMapper extends BaseConverter { ] }; } - constructor(xml: string, withRaw = false) { - // the default textNodeName that we're using ('text') clobbers any attributes that also are named 'text' of which there are many in this format - // the attribute group names are necessary since there are many times that attributes and inner tags share the same name within a tag (ex. 'vulnerabilities' the attribute is a count whereas as an inner tag it is an array detailing the vulnerabilities) where it seems that the attribute clobbers the inner tag - const parsedXML = parseXml(xml, { - attributesGroupName: '@_', - textNodeName: 'text_' - }); - if (_.has(parsedXML, 'summaryreport')) { - throw new Error('Current mapper does not accept summary reports'); - } - const arrayedControls = (_.get(parsedXML, SEVERITY) as []).map( - (control: {category: unknown; level: string}) => { - if (Array.isArray(control.category)) { - return {level: control.level, category: control.category}; - } else if (!control.category) { - return {level: control.level}; - } else { - return {level: control.level, category: [control.category]}; - } - } - ); - _.set(parsedXML, SEVERITY, arrayedControls); - super(parsedXML); - this.originalData = xml; - this.setMappings(this.defaultMapping(withRaw)); - } } diff --git a/libs/hdf-converters/src/xccdf-results-mapper.ts b/libs/hdf-converters/src/xccdf-results-mapper.ts index 96b329b6b4..fe036fb870 100644 --- a/libs/hdf-converters/src/xccdf-results-mapper.ts +++ b/libs/hdf-converters/src/xccdf-results-mapper.ts @@ -1,11 +1,13 @@ import {ExecJSON, is_control, parse_nist} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; +import type { + ILookupPath, + MappedTransform, + ParseHtmlFunc} from './base-converter'; import { BaseConverter, - ILookupPath, impactMapping, - MappedTransform, buildParseHtmlFunc, parseXml } from './base-converter'; @@ -15,7 +17,7 @@ import { DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS } from './utils/global'; -const IMPACT_MAPPING: Map = new Map([ +const IMPACT_MAPPING = new Map([ ['critical', 0.9], ['high', 0.7], ['medium', 0.5], @@ -23,8 +25,8 @@ const IMPACT_MAPPING: Map = new Map([ ]); const CCI_NIST_MAPPING = new CciNistMapping(); - -let parseHtml: (input: unknown) => string; +// Used only as an existence test — nothing consumes the digits. +const CCI_REGEX = /CCI-\d*/; function asArray(arg: T | T[]): T[] { if (Array.isArray(arg)) { @@ -120,7 +122,7 @@ function getProfiles( | Record[] ); if ( - selects.find( + selects.some( (select) => ids.includes(_.get(select, 'idref') as string) && _.get(select, 'selected') === 'true' @@ -132,20 +134,18 @@ function getProfiles( return matchingProfiles; } -interface IIdent { +type IIdent = { system: string; text: string; -} +}; function extractCci(input: IIdent | IIdent[]): string[] { const inputArray = asArray(input); - const CCI_REGEX = /CCI-(\d*)/; - const output: string[] = []; for (const element of inputArray) { const text = _.get(element, 'text'); - if (!!text && CCI_REGEX.exec(text)) { + if (!!text && CCI_REGEX.test(text)) { output.push(text); } } @@ -153,21 +153,20 @@ function extractCci(input: IIdent | IIdent[]): string[] { } function nistTag(input: IIdent | IIdent[]): string[] { - return _.uniq( - CCI_NIST_MAPPING.nistFilter( + return _.uniq([ + ...CCI_NIST_MAPPING.nistFilter( extractCci(input), DEFAULT_STATIC_CODE_ANALYSIS_NIST_TAGS, false - ).concat( - asArray(input) - .filter((x) => !!x) - .map((x) => x.text) - .map(parse_nist) - .filter((x) => !!x) - .filter(is_control) - .map((x) => x.canonize()) - ) - ); + ), + ...asArray(input) + .filter((x) => !!x) + .map((x) => x.text) + .map((text) => parse_nist(text)) + .filter((x) => !!x) + .filter(is_control) + .map((x) => x.canonize()) + ]); } /** @@ -231,14 +230,19 @@ export class XCCDFResultsResults { constructor(readonly scapXml: string, readonly withRaw = false) {} async toHdf(): Promise { - parseHtml = await buildParseHtmlFunc(); + const parseHtml = await buildParseHtmlFunc(); - return (new XCCDFResultsMapper(this.scapXml, this.withRaw)).toHdf(); + return new XCCDFResultsMapper( + this.scapXml, + parseHtml, + this.withRaw + ).toHdf(); } } export class XCCDFResultsMapper extends BaseConverter { withRaw: boolean; + parseHtml: ParseHtmlFunc; mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, @@ -263,7 +267,7 @@ export class XCCDFResultsMapper extends BaseConverter { }, summary: { path: ['Benchmark.description.text', 'Benchmark.description'], - transformer: parseHtml + transformer: (input: unknown) => this.parseHtml(input) }, description: { path: 'Benchmark', @@ -301,20 +305,26 @@ export class XCCDFResultsMapper extends BaseConverter { ['TestResult.title'], ['TestResult.version'] ]; - const fullDescription: Record = {}; + // Collected as entries and materialized with fromEntries — + // own data properties only, no computed assignment (the paths + // are the literal list above; last write wins either way). + const descriptionEntries: [string, unknown][] = []; for (const paths of descriptionPaths) { for (const path of paths) { const item = _.get(input, path); if (item !== undefined) { - if (typeof item === 'string') { - fullDescription[path] = parseHtml(item); - } else { - fullDescription[path] = item; - } + descriptionEntries.push([ + path, + typeof item === 'string' ? this.parseHtml(item) : item + ]); } } } - return JSON.stringify(fullDescription, null, 2); + return JSON.stringify( + Object.fromEntries(descriptionEntries), + null, + 2 + ); } }, license: {path: 'Benchmark.notice.id'}, @@ -344,12 +354,12 @@ export class XCCDFResultsMapper extends BaseConverter { description: { path: ['description.text', 'description'], transformer: (description: string): string => - parseHtml( + this.parseHtml( _.get( parseXml(description), 'VulnDiscussion', description - ) as string + ) ) }, group_id: {path: 'group.id'}, @@ -357,12 +367,12 @@ export class XCCDFResultsMapper extends BaseConverter { group_description: { path: ['group.description.text', 'group.description'], transformer: (description: string): string => - parseHtml( + this.parseHtml( _.get( parseXml(description), 'GroupDescription', description - ) as string + ) ) }, rule_id: {path: 'id'}, @@ -375,7 +385,7 @@ export class XCCDFResultsMapper extends BaseConverter { fix_id: {path: 'fix.id'}, fixtext_fixref: { path: ['fixtext.fixref.text', 'fixtext.fixref'], - transformer: (text: string) => parseHtml(text) || undefined + transformer: (text: string) => this.parseHtml(text) || undefined }, ident: { path: 'ident', @@ -396,12 +406,12 @@ export class XCCDFResultsMapper extends BaseConverter { description: { path: ['description.text', 'description'], transformer: (description: string): string => - parseHtml( + this.parseHtml( _.get( parseXml(description), 'ProfileDescription', description - ) as string + ) ) }, title: {path: ['title.text', 'title']} @@ -417,11 +427,11 @@ export class XCCDFResultsMapper extends BaseConverter { ) => asArray(values).map((value) => ({ title: _.get(value, 'title.text') || _.get(value, 'title'), - description: parseHtml( + description: this.parseHtml( _.get(value, 'description.text') || _.get(value, 'description') ), - warning: parseHtml( + warning: this.parseHtml( _.get(value, 'warning.text') || _.get(value, 'warning') ), value: _.get(value, 'value'), @@ -490,12 +500,12 @@ export class XCCDFResultsMapper extends BaseConverter { desc: { path: ['description.text', 'description'], transformer: (description: string): string => - parseHtml( + this.parseHtml( _.get( parseXml(description), 'ProfileDescription', description - ) as string + ) ) }, descriptions: [ @@ -513,7 +523,7 @@ export class XCCDFResultsMapper extends BaseConverter { transformer: ( data: string | string[] ): ExecJSON.ControlDescription => ({ - data: asArray(data).map(parseHtml).join('\n'), + data: asArray(data).map((element) => this.parseHtml(element)).join('\n'), label: 'fix' }) } as unknown as ExecJSON.ControlDescription, @@ -522,7 +532,7 @@ export class XCCDFResultsMapper extends BaseConverter { transformer: ( data: string | string[] ): ExecJSON.ControlDescription => ({ - data: asArray(data).map(parseHtml).join('\n'), + data: asArray(data).map((element) => this.parseHtml(element)).join('\n'), label: 'rationale' }) } as unknown as ExecJSON.ControlDescription, @@ -531,7 +541,7 @@ export class XCCDFResultsMapper extends BaseConverter { transformer: ( data: string | string[] ): ExecJSON.ControlDescription => ({ - data: asArray(data).map(parseHtml).join('\n'), + data: asArray(data).map((element) => this.parseHtml(element)).join('\n'), label: 'warning' }) } as unknown as ExecJSON.ControlDescription @@ -545,9 +555,9 @@ export class XCCDFResultsMapper extends BaseConverter { if (ruleResult) { const result = _.get(ruleResult, 'result') as string; if ( - result === 'notselected' || - result === 'notapplicable' || - result === 'informational' + ['notselected', 'notapplicable', 'informational'].includes( + result + ) ) { return 0; } @@ -579,12 +589,12 @@ export class XCCDFResultsMapper extends BaseConverter { code_desc: { path: ['description.text', 'description'], transformer: (description: string): string => - parseHtml( + this.parseHtml( _.get( parseXml(description), 'VulnDiscussion', description - ) as string + ) ) }, start_time: { @@ -630,7 +640,8 @@ export class XCCDFResultsMapper extends BaseConverter { } } }; - constructor(scapXml: string, withRaw = false) { + + constructor(scapXml: string, parseHtml: ParseHtmlFunc, withRaw = false) { super( parseXml(scapXml, { stopNodes: [ @@ -643,6 +654,7 @@ export class XCCDFResultsMapper extends BaseConverter { ] }) ); + this.parseHtml = parseHtml; this.withRaw = withRaw; } } diff --git a/libs/hdf-converters/src/zap-mapper.ts b/libs/hdf-converters/src/zap-mapper.ts index 947cb894cd..0406524299 100644 --- a/libs/hdf-converters/src/zap-mapper.ts +++ b/libs/hdf-converters/src/zap-mapper.ts @@ -1,10 +1,12 @@ import {ExecJSON} from 'inspecjs'; import * as _ from 'lodash'; import {version as HeimdallToolsVersion} from '../package.json'; -import { - BaseConverter, +import type { ILookupPath, MappedTransform, + ParseHtmlFunc} from './base-converter'; +import { + BaseConverter, buildParseHtmlFunc, } from './base-converter'; import {CweNistMapping} from './mappings/CweNistMapping'; @@ -15,23 +17,32 @@ import { const CWE_NIST_MAPPING = new CweNistMapping(); -let parseHtml: (input: unknown) => string; - -function filterSite(input: Array, name?: string) { +function filterSite(input: T[], name?: string) { // Choose passed site if provided if (name) { return input.find( (element) => (_.get(element, '@name') as unknown as string) === name ); } - // Otherwise choose the site with the most alerts + // Otherwise choose the site with the most alerts (<= keeps the reduce's + // later-site-wins-ties behavior) else { - return input.reduce((a, b) => - (_.get(a, 'alerts') as unknown as Record[]).length > - (_.get(b, 'alerts') as unknown as Record[]).length - ? a - : b - ); + let siteWithMostAlerts = input[0]; + for (const site of input.slice(1)) { + const currentCount = ( + _.get(siteWithMostAlerts, 'alerts') as unknown as Record< + string, + unknown + >[] + ).length; + const candidateCount = ( + _.get(site, 'alerts') as unknown as Record[] + ).length; + if (currentCount <= candidateCount) { + siteWithMostAlerts = site; + } + } + return siteWithMostAlerts; } } function impactMapping(input: unknown): number { @@ -57,10 +68,11 @@ function nistTag(cweid: string): string[] { ); } function checkText(input: Record): string { - const text = []; - text.push(_.get(input, 'solution')); - text.push(_.get(input, 'otherinfo')); - text.push(_.get(input, 'otherinfo')); + const text = [ + _.get(input, 'solution'), + _.get(input, 'otherinfo'), + _.get(input, 'otherinfo') + ]; return text.join('\n'); } function formatCodeDesc(input: unknown): string { @@ -101,14 +113,20 @@ export class ZapResults { constructor(readonly zapJson: string, readonly name?: string, readonly withRaw = false) {} async toHdf(): Promise { - parseHtml = await buildParseHtmlFunc(); + const parseHtml = await buildParseHtmlFunc(); - return (new ZapMapper(this.zapJson, this.name, this.withRaw)).toHdf(); + return new ZapMapper( + this.zapJson, + parseHtml, + this.name, + this.withRaw + ).toHdf(); } } export class ZapMapper extends BaseConverter { withRaw: boolean; + parseHtml: ParseHtmlFunc; mappings: MappedTransform< ExecJSON.Execution & {passthrough: unknown}, @@ -127,13 +145,13 @@ export class ZapMapper extends BaseConverter { title: { path: 'site.@host', transformer: (input: unknown): string => { - return `OWASP ZAP Scan of Host: ${input}`; + return `OWASP ZAP Scan of Host: ${String(input)}`; } }, summary: { path: 'site.@host', transformer: (input: unknown): string => { - return `OWASP ZAP Scan of Host: ${input}`; + return `OWASP ZAP Scan of Host: ${String(input)}`; } }, supports: [], @@ -160,7 +178,10 @@ export class ZapMapper extends BaseConverter { source_location: {}, title: {path: 'name'}, id: {path: 'pluginid'}, - desc: {path: 'desc', transformer: parseHtml}, + desc: { + path: 'desc', + transformer: (input: unknown) => this.parseHtml(input) + }, descriptions: [ { data: {transformer: checkText}, @@ -199,7 +220,13 @@ export class ZapMapper extends BaseConverter { } } }; - constructor(zapJson: string, name?: string, withRaw = false) { + + constructor( + zapJson: string, + parseHtml: ParseHtmlFunc, + name?: string, + withRaw = false + ) { super( _.set( JSON.parse(zapJson), @@ -208,6 +235,7 @@ export class ZapMapper extends BaseConverter { ), false ); + this.parseHtml = parseHtml; this.withRaw = withRaw; } diff --git a/libs/hdf-converters/test/attestations/attestations.spec.ts b/libs/hdf-converters/test/attestations/attestations.spec.ts index a5fee4c56b..05596c0650 100644 --- a/libs/hdf-converters/test/attestations/attestations.spec.ts +++ b/libs/hdf-converters/test/attestations/attestations.spec.ts @@ -1,17 +1,21 @@ import fs from 'fs'; import {ExecJSON} from 'inspecjs'; -import moment from 'moment'; +import {utc} from 'moment'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import yaml from 'yaml'; +import type { + Attestation} from '../../src/utils/attestations'; import { addAttestationToHDF, advanceDate, - Attestation, createAttestationMessage, parseXLSXAttestations, convertAttestationToSegment } from '../../src/utils/attestations'; +const ATTESTATION_MESSAGE_PREFIX = /^Attestation/; +const EXPIRED_MESSAGE_PREFIX = /^Expired/; + const validPassingAttestation_skippedControl: Attestation[] = [ { control_id: 'SV-230223', @@ -155,33 +159,51 @@ const attestations_for_overlay: Attestation[] = [ describe('advanceDate', () => { it('Should return a date two weeks from now when given "fortnightly" as an input', () => { expect( - advanceDate(moment.utc(1662758942000), 'fortnightly').toISOString(true) + advanceDate(utc(1_662_758_942_000), 'fortnightly').toISOString(true) ).toEqual('2022-09-23T21:29:02.000+00:00'); }); it('Should return correct date when given custom number of days to advance by', () => { expect( - advanceDate(moment.utc(1662758942000), '200d').toISOString(true) + advanceDate(utc(1_662_758_942_000), '200d').toISOString(true) ).toEqual('2023-03-28T21:29:02.000+00:00'); }); it('Should return correct date when given custom number of weeks to advance by', () => { expect( - advanceDate(moment.utc(1662758942000), '12w').toISOString(true) + advanceDate(utc(1_662_758_942_000), '12w').toISOString(true) ).toEqual('2022-12-02T21:29:02.000+00:00'); }); it('Should return correct date when given custom number of months to advance by', () => { expect( - advanceDate(moment.utc(1662758942000), '4m').toISOString(true) + advanceDate(utc(1_662_758_942_000), '4m').toISOString(true) ).toEqual('2023-01-09T21:29:02.000+00:00'); }); it('Should return correct date when given custom number of years to advance by', () => { expect( - advanceDate(moment.utc(1662758942000), '5y').toISOString(true) + advanceDate(utc(1_662_758_942_000), '5y').toISOString(true) ).toEqual('2027-09-09T21:29:02.000+00:00'); }); + + // The separator inside the number must be a LITERAL decimal point. While it + // was written unescaped it matched any character, so '1,5d' parsed its + // number as '1,5' — which moment ignores — and the date silently never + // advanced at all. + it('Should not swallow a non-decimal separator into the number', () => { + const start = utc(1_662_758_942_000).valueOf(); + expect( + advanceDate(utc(1_662_758_942_000), '1,5d').valueOf() + ).toBeGreaterThan(start); + }); + + it('Should still accept a genuine decimal number of days', () => { + const oneDay = advanceDate(utc(1_662_758_942_000), '1d').valueOf(); + expect( + advanceDate(utc(1_662_758_942_000), '1.5d').valueOf() + ).toBeGreaterThan(oneDay); + }); }); // Attestation messages are what is displayed in Heimdall for a given control test @@ -194,7 +216,7 @@ describe('CreateAttestationMessage', () => { ); expect(unexpiredAttestationMessage).toEqual( - expect.stringMatching(/^Attestation/) + expect.stringMatching(ATTESTATION_MESSAGE_PREFIX) ); }); @@ -205,7 +227,7 @@ describe('CreateAttestationMessage', () => { ); expect(expiredAttestationMessage).toEqual( - expect.stringMatching(/^Expired/) + expect.stringMatching(EXPIRED_MESSAGE_PREFIX) ); }); }); @@ -270,7 +292,7 @@ describe.sequential('addAttestationToHDF', () => { inputData = JSON.parse( fs.readFileSync( 'sample_jsons/attestations/rhel8_sample_oneOfEachControlStatus.json', - 'utf-8' + 'utf8' ) ) as ExecJSON.Execution; @@ -422,7 +444,7 @@ describe('addAttestationToHDF - Overlay Empty Results Case', () => { const inputDataWithEmptyResults = JSON.parse( fs.readFileSync( 'sample_jsons/attestations/triple_overlay_profile_sample.json', - 'utf-8' + 'utf8' ) ) as ExecJSON.Execution; @@ -487,7 +509,7 @@ describe('parseXLSXAttestations', () => { }); }); -describe('parseXLSXAttestations', () => { +describe('YAML attestation parsing', () => { const yamlInputFile = fs.readFileSync( 'sample_jsons/attestations/attestations_yamlFormat.yaml', 'utf8' diff --git a/libs/hdf-converters/test/base-converter.spec.ts b/libs/hdf-converters/test/base-converter.spec.ts new file mode 100644 index 0000000000..6b658e765d --- /dev/null +++ b/libs/hdf-converters/test/base-converter.spec.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from 'vitest'; +import {parseCsv} from '../src/base-converter'; + +describe('parseCsv', () => { + it('Reports a malformed row as an Error carrying the parser errors', () => { + let thrown: unknown; + try { + // One row with more fields than the header declares. + parseCsv('a,b\n1,2,3'); + } catch (error) { + thrown = error; + } + + // Throwing the parser's raw error ARRAY gave callers a value with no + // message and no stack; the details now ride along as the cause. + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toContain('Failed to parse CSV'); + expect((thrown as Error).cause).toBeDefined(); + }); + + it('Returns the parsed rows when the input is well formed', () => { + expect(parseCsv('a,b\n1,2')).toEqual([{a: '1', b: '2'}]); + }); +}); diff --git a/libs/hdf-converters/test/mappers/forward/anchore-grype_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/anchore_grype_mapper.spec.ts similarity index 88% rename from libs/hdf-converters/test/mappers/forward/anchore-grype_mapper.spec.ts rename to libs/hdf-converters/test/mappers/forward/anchore_grype_mapper.spec.ts index 84383f9193..369b366dc2 100644 --- a/libs/hdf-converters/test/mappers/forward/anchore-grype_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/anchore_grype_mapper.spec.ts @@ -8,7 +8,7 @@ describe('anchore-grype_mapper', () => { const mapper = new AnchoreGrypeMapper( fs.readFileSync( 'sample_jsons/anchore_grype_mapper/sample_input_report/anchore_grype.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('anchore-grype_mapper', () => { fs.readFileSync( 'sample_jsons/anchore_grype_mapper/anchore-grype-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -37,7 +37,7 @@ describe('anchore-grype_mapper_withraw', () => { const mapper = new AnchoreGrypeMapper( fs.readFileSync( 'sample_jsons/anchore_grype_mapper/sample_input_report/anchore_grype.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -53,7 +53,7 @@ describe('anchore-grype_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/anchore_grype_mapper/anchore-grype-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -62,12 +62,12 @@ describe('anchore-grype_mapper_withraw', () => { }); }); -describe('anchore-grype_mapper', () => { +describe('anchore-grype_mapper_amazon', () => { it('Successfully converts amazon.json targeted at a local/cloned repository data', () => { const mapper = new AnchoreGrypeMapper( fs.readFileSync( 'sample_jsons/anchore_grype_mapper/sample_input_report/amazon.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -82,7 +82,7 @@ describe('anchore-grype_mapper', () => { fs.readFileSync( 'sample_jsons/anchore_grype_mapper/amazon-grype-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -91,12 +91,12 @@ describe('anchore-grype_mapper', () => { }); }); -describe('anchore-grype_mapper_withraw', () => { +describe('anchore-grype_mapper_amazon_withraw', () => { it('Successfully converts withraw flagged amazon.json targeted at a local/cloned repository data', () => { const mapper = new AnchoreGrypeMapper( fs.readFileSync( 'sample_jsons/anchore_grype_mapper/sample_input_report/amazon.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -112,7 +112,7 @@ describe('anchore-grype_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/anchore_grype_mapper/amazon-grype-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -121,12 +121,12 @@ describe('anchore-grype_mapper_withraw', () => { }); }); -describe('anchore-grype_mapper', () => { +describe('anchore-grype_mapper_tensorflow', () => { it('Successfully converts tensorflow.json targeted at a local/cloned repository data', () => { const mapper = new AnchoreGrypeMapper( fs.readFileSync( 'sample_jsons/anchore_grype_mapper/sample_input_report/tensorflow.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -141,7 +141,7 @@ describe('anchore-grype_mapper', () => { fs.readFileSync( 'sample_jsons/anchore_grype_mapper/tensorflow-grype-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -150,12 +150,12 @@ describe('anchore-grype_mapper', () => { }); }); -describe('anchore-grype_mapper_withraw', () => { +describe('anchore-grype_mapper_tensorflow_withraw', () => { it('Successfully converts withraw flagged tensorflow.json targeted at a local/cloned repository data', () => { const mapper = new AnchoreGrypeMapper( fs.readFileSync( 'sample_jsons/anchore_grype_mapper/sample_input_report/tensorflow.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -171,7 +171,7 @@ describe('anchore-grype_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/anchore_grype_mapper/tensorflow-grype-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/asff_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/asff_mapper.spec.ts index 5eb0c38b54..aa01e8e62f 100644 --- a/libs/hdf-converters/test/mappers/forward/asff_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/asff_mapper.spec.ts @@ -9,7 +9,7 @@ describe('ASFF Mapper', () => { const mapper = new Mapper( fs.readFileSync( 'sample_jsons/asff_mapper/sample_input_report/asff_sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -37,7 +37,7 @@ describe('ASFF Mapper', () => { fs.readFileSync( 'sample_jsons/asff_mapper/asff-cis_aws-foundations_benchmark_v1.2.0-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -47,7 +47,7 @@ describe('ASFF Mapper', () => { fs.readFileSync( 'sample_jsons/asff_mapper/asff-aws_foundational_security_best_practices_v1.0.0-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -59,7 +59,7 @@ describe('ASFF Mapper', () => { const mapper = new Mapper( fs.readFileSync( 'sample_jsons/asff_mapper/sample_input_report/prowler_sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -72,7 +72,7 @@ describe('ASFF Mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/asff_mapper/prowler-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -83,7 +83,7 @@ describe('ASFF Mapper', () => { const mapper = new Mapper( fs.readFileSync( 'sample_jsons/asff_mapper/sample_input_report/prowler-sample.asff-json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -96,7 +96,7 @@ describe('ASFF Mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/asff_mapper/prowler-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -107,7 +107,7 @@ describe('ASFF Mapper', () => { const mapper = new Mapper( fs.readFileSync( 'sample_jsons/asff_mapper/sample_input_report/trivy-image_golang-1.12-alpine_sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -121,7 +121,7 @@ describe('ASFF Mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/asff_mapper/trivy-image_golang-1.12-alpine-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -132,7 +132,7 @@ describe('ASFF Mapper', () => { let mapper = new Mapper( fs.readFileSync( 'sample_jsons/asff_mapper/sample_input_report/rhel7_V-71931_asff.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -151,7 +151,7 @@ describe('ASFF Mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/asff_mapper/rhel7_V-71931-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -160,7 +160,7 @@ describe('ASFF Mapper', () => { fs.readFileSync( 'sample_jsons/asff_mapper/sample_input_report/example-3-layer-overlay_asff.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ); @@ -188,7 +188,7 @@ describe('ASFF Mapper', () => { fs.readFileSync( 'sample_jsons/asff_mapper/example-3-layer-overlay_hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/burpsuite_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/burpsuite_mapper.spec.ts index 0611cc5994..ff9d7cb2d5 100644 --- a/libs/hdf-converters/test/mappers/forward/burpsuite_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/burpsuite_mapper.spec.ts @@ -8,7 +8,7 @@ describe('burpsuite_mapper', () => { const mapper = new BurpSuiteResults( fs.readFileSync( 'sample_jsons/burpsuite_mapper/sample_input_report/zero.webappsecurity.com.min', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('burpsuite_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/burpsuite_mapper/burpsuite-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -34,7 +34,7 @@ describe('burpsuite_mapper_withraw', () => { const mapper = new BurpSuiteResults( fs.readFileSync( 'sample_jsons/burpsuite_mapper/sample_input_report/zero.webappsecurity.com.min', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -50,7 +50,7 @@ describe('burpsuite_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/burpsuite_mapper/burpsuite-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/checklist_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/checklist_mapper.spec.ts index e4f9d33178..ca16fa2085 100644 --- a/libs/hdf-converters/test/mappers/forward/checklist_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/checklist_mapper.spec.ts @@ -11,7 +11,7 @@ import {InvalidChecklistMetadataException} from '../../../src/ckl-mapper/checkli // ); const readFile = (path: fs.PathOrFileDescriptor) => - fs.readFileSync(path, {encoding: 'utf-8'}); + fs.readFileSync(path, {encoding: 'utf8'}); const parseJsonFile = (path: fs.PathOrFileDescriptor) => JSON.parse(readFile(path)); diff --git a/libs/hdf-converters/test/mappers/forward/checkov_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/checkov_mapper.spec.ts index a589a9ce43..9bf55f9e48 100644 --- a/libs/hdf-converters/test/mappers/forward/checkov_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/checkov_mapper.spec.ts @@ -9,7 +9,7 @@ describe('checkov_mapper', () => { const mapper = new CheckovMapper( fs.readFileSync( 'sample_jsons/checkov_mapper/sample_input_report/checkov_json.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('checkov_mapper', () => { const expected = JSON.parse( fs.readFileSync( 'sample_jsons/checkov_mapper/checkov_json-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); expect(omitVersions(mapper.toHdf())).toEqual(omitVersions(expected)); @@ -34,7 +34,7 @@ describe('checkov_mapper', () => { const mapper = new CheckovMapper( fs.readFileSync( 'sample_jsons/checkov_mapper/sample_input_report/checkov_json.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -47,7 +47,7 @@ describe('checkov_mapper', () => { const expected = JSON.parse( fs.readFileSync( 'sample_jsons/checkov_mapper/checkov_json-withraw-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); expect(omitVersions(mapper.toHdf())).toEqual(omitVersions(expected)); @@ -60,7 +60,7 @@ describe('checkov_mapper', () => { const mapper = new CheckovMapper( fs.readFileSync( 'sample_jsons/checkov_mapper/sample_input_report/checkov_sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -72,7 +72,7 @@ describe('checkov_mapper', () => { const expected = JSON.parse( fs.readFileSync( 'sample_jsons/checkov_mapper/checkov_sample-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); expect(omitVersions(mapper.toHdf())).toEqual(omitVersions(expected)); @@ -84,7 +84,7 @@ describe('checkov_mapper', () => { const mapper = new CheckovMapper( fs.readFileSync( 'sample_jsons/checkov_mapper/sample_input_report/checkov_with_skips.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -96,7 +96,7 @@ describe('checkov_mapper', () => { const expected = JSON.parse( fs.readFileSync( 'sample_jsons/checkov_mapper/checkov_with_skips-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); expect(omitVersions(mapper.toHdf())).toEqual(omitVersions(expected)); @@ -108,7 +108,7 @@ describe('checkov_mapper', () => { const mapper = new CheckovMapper( fs.readFileSync( 'sample_jsons/checkov_mapper/sample_input_report/checkov_synthetic.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -120,7 +120,7 @@ describe('checkov_mapper', () => { const expected = JSON.parse( fs.readFileSync( 'sample_jsons/checkov_mapper/checkov_synthetic-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); expect(omitVersions(mapper.toHdf())).toEqual(omitVersions(expected)); diff --git a/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts index 0a2f3131bb..6eac3c2b51 100644 --- a/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/conveyor_mapper.spec.ts @@ -7,61 +7,61 @@ describe('conveyor_mapper', () => { const mapper = new ConveyorResults( fs.readFileSync( 'sample_jsons/conveyor_mapper/sample_input_report/sample-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); const mapped = mapper.toHdf(); - //fs.writeFileSync( + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-moldy-hdf.json', // JSON.stringify(mapped['Moldy'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-stigma-hdf.json', // JSON.stringify(mapped['Stigma'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-codequality-hdf.json', // JSON.stringify(mapped['CodeQuality'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-clamav-hdf.json', // JSON.stringify(mapped['Clamav'], null, 2) - //); - //fs.writeFileSync( + // ); + // fs.writeFileSync( // 'sample_jsons/conveyor_mapper/conveyor-hdf.json', // JSON.stringify(mapped, null, 2) - //); - expect(omitVersions(mapped['Moldy'])).toEqual( + // ); + expect(omitVersions(mapped.Moldy)).toEqual( omitVersions( JSON.parse( fs.readFileSync( 'sample_jsons/conveyor_mapper/conveyor-moldy-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) ) ); - expect(omitVersions(mapped['Stigma'])).toEqual( + expect(omitVersions(mapped.Stigma)).toEqual( omitVersions( JSON.parse( fs.readFileSync( 'sample_jsons/conveyor_mapper/conveyor-stigma-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) ) ); - expect(omitVersions(mapped['Clamav'])).toEqual( + expect(omitVersions(mapped.Clamav)).toEqual( omitVersions( JSON.parse( fs.readFileSync( 'sample_jsons/conveyor_mapper/conveyor-clamav-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/cyclonedx_sbom_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/cyclonedx_sbom_mapper.spec.ts index 2731686ef8..f61468b211 100644 --- a/libs/hdf-converters/test/mappers/forward/cyclonedx_sbom_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/cyclonedx_sbom_mapper.spec.ts @@ -8,7 +8,7 @@ describe('sbom_mapper_saf', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/generated-saf-sbom.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('sbom_mapper_saf', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-saf-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -35,7 +35,7 @@ describe('sbom_mapper_saf', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/generated-saf-sbom.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -51,7 +51,7 @@ describe('sbom_mapper_saf', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-saf-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -65,7 +65,7 @@ describe('sbom_mapper_dropwizard_vulns', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/dropwizard-vulns.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -80,7 +80,7 @@ describe('sbom_mapper_dropwizard_vulns', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-dropwizard-vulns-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -92,7 +92,7 @@ describe('sbom_mapper_dropwizard_vulns', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/dropwizard-vulns.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -108,7 +108,7 @@ describe('sbom_mapper_dropwizard_vulns', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-dropwizard-vulns-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -122,7 +122,7 @@ describe('sbom_mapper_dropwizard_no_vulns', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/dropwizard-no-vulns.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -137,7 +137,7 @@ describe('sbom_mapper_dropwizard_no_vulns', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-dropwizard-no-vulns-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -149,7 +149,7 @@ describe('sbom_mapper_dropwizard_no_vulns', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/dropwizard-no-vulns.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -165,7 +165,7 @@ describe('sbom_mapper_dropwizard_no_vulns', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-dropwizard-no-vulns-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -179,7 +179,7 @@ describe('sbom_mapper_dropwizard_vex', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/dropwizard-vex.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -194,7 +194,7 @@ describe('sbom_mapper_dropwizard_vex', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-dropwizard-vex-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -206,7 +206,7 @@ describe('sbom_mapper_dropwizard_vex', () => { const mapper = new CycloneDXSBOMResults( fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/dropwizard-vex.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -222,7 +222,7 @@ describe('sbom_mapper_dropwizard_vex', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-dropwizard-vex-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -237,7 +237,7 @@ describe('sbom_mapper_vex', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/vex.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ); @@ -253,7 +253,7 @@ describe('sbom_mapper_vex', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-vex-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -266,7 +266,7 @@ describe('sbom_mapper_vex', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/vex.json', { - encoding: 'utf-8' + encoding: 'utf8' } ), true @@ -283,7 +283,7 @@ describe('sbom_mapper_vex', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-vex-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -298,7 +298,7 @@ describe('sbom_mapper_syft_alpine_container', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/syft-scan-alpine-container.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ); @@ -314,7 +314,7 @@ describe('sbom_mapper_syft_alpine_container', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-syft-alpine-container-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -327,7 +327,7 @@ describe('sbom_mapper_syft_alpine_container', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/syft-scan-alpine-container.json', { - encoding: 'utf-8' + encoding: 'utf8' } ), true @@ -344,7 +344,7 @@ describe('sbom_mapper_syft_alpine_container', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-syft-alpine-container-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -359,7 +359,7 @@ describe('sbom_mapper_converted_spdx', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/spdx-to-cyclonedx.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ); @@ -375,7 +375,7 @@ describe('sbom_mapper_converted_spdx', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-converted-spdx-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -388,7 +388,7 @@ describe('sbom_mapper_converted_spdx', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/spdx-to-cyclonedx.json', { - encoding: 'utf-8' + encoding: 'utf8' } ), true @@ -405,7 +405,7 @@ describe('sbom_mapper_converted_spdx', () => { fs.readFileSync( 'sample_jsons/cyclonedx_sbom_mapper/sbom-converted-spdx-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -413,3 +413,33 @@ describe('sbom_mapper_converted_spdx', () => { ); }); }); + +describe('sbom_mapper_credits', () => { + // Pins the removal of the template wrap around the credits optional chain. + // The wrap rendered a missing `individuals` as the LITERAL STRING 'undefined' + // rather than leaving the tag unset. No fixture covers this: vex.json's only + // credited vulnerability HAS individuals, so the golden output never exercises + // the missing branch. This test builds that case from the real document so the + // only variable is the absent `individuals` array. + it('leaves credits unset when a vulnerability has credits but no individuals', () => { + const raw = JSON.parse( + fs.readFileSync( + 'sample_jsons/cyclonedx_sbom_mapper/sample_input_report/vex.json', + {encoding: 'utf8'} + ) + ); + const credited = raw.vulnerabilities.find( + (vulnerability: {credits?: unknown}) => vulnerability.credits + ); + expect(credited).toBeDefined(); + // The credits object stays truthy — only the individuals list goes away, + // which is exactly the branch the optional chain guards. + delete credited.credits.individuals; + + const hdf = new CycloneDXSBOMResults(JSON.stringify(raw)).toHdf(); + const tags = hdf.profiles[0].controls[0].tags; + + expect(tags.credits).not.toBe('undefined'); + expect(tags.credits).toBeUndefined(); + }); +}); diff --git a/libs/hdf-converters/test/mappers/forward/dbprotect_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/dbprotect_mapper.spec.ts index e1a5e89d71..7dceb5d824 100644 --- a/libs/hdf-converters/test/mappers/forward/dbprotect_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/dbprotect_mapper.spec.ts @@ -8,7 +8,7 @@ describe('dbprotect_mapper_check', () => { const mapper = new DBProtectMapper( fs.readFileSync( 'sample_jsons/dbprotect_mapper/sample_input_report/DbProtect-Check-Results-Details-XML-Sample.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('dbprotect_mapper_check', () => { fs.readFileSync( 'sample_jsons/dbprotect_mapper/dbprotect-check-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -37,7 +37,7 @@ describe('dbprotect_mapper_findings', () => { const mapper = new DBProtectMapper( fs.readFileSync( 'sample_jsons/dbprotect_mapper/sample_input_report/DbProtect-Findings-Detail-XML-Sample.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -52,7 +52,7 @@ describe('dbprotect_mapper_findings', () => { fs.readFileSync( 'sample_jsons/dbprotect_mapper/dbprotect-findings-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -66,7 +66,7 @@ describe('dbprotect_mapper_check_withraw', () => { const mapper = new DBProtectMapper( fs.readFileSync( 'sample_jsons/dbprotect_mapper/sample_input_report/DbProtect-Check-Results-Details-XML-Sample.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -82,7 +82,7 @@ describe('dbprotect_mapper_check_withraw', () => { fs.readFileSync( 'sample_jsons/dbprotect_mapper/dbprotect-check-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -96,7 +96,7 @@ describe('dbprotect_mapper_findings_withraw', () => { const mapper = new DBProtectMapper( fs.readFileSync( 'sample_jsons/dbprotect_mapper/sample_input_report/DbProtect-Findings-Detail-XML-Sample.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -112,7 +112,7 @@ describe('dbprotect_mapper_findings_withraw', () => { fs.readFileSync( 'sample_jsons/dbprotect_mapper/dbprotect-findings-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/dependency_track_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/dependency_track_mapper.spec.ts index d1b8aeabd3..a08c8c34ce 100644 --- a/libs/hdf-converters/test/mappers/forward/dependency_track_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/dependency_track_mapper.spec.ts @@ -8,7 +8,7 @@ describe('dependency_track_mapper', () => { const mapper = new DependencyTrackMapper( fs.readFileSync( 'sample_jsons/dependency_track_mapper/sample_input_report/fpf-default.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('dependency_track_mapper', () => { fs.readFileSync( 'sample_jsons/dependency_track_mapper/hdf-default.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -37,7 +37,7 @@ describe('dependency_track_mapper_withraw', () => { const mapper = new DependencyTrackMapper( fs.readFileSync( 'sample_jsons/dependency_track_mapper/sample_input_report/fpf-default.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -53,7 +53,7 @@ describe('dependency_track_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/dependency_track_mapper/hdf-default-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -67,7 +67,7 @@ describe('dependency_track_mapper_optional_attributes', () => { const mapper = new DependencyTrackMapper( fs.readFileSync( 'sample_jsons/dependency_track_mapper/sample_input_report/fpf-optional-attributes.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -82,7 +82,7 @@ describe('dependency_track_mapper_optional_attributes', () => { fs.readFileSync( 'sample_jsons/dependency_track_mapper/hdf-optional-attributes.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -96,7 +96,7 @@ describe('dependency_track_mapper_no_vulnerabilities', () => { const mapper = new DependencyTrackMapper( fs.readFileSync( 'sample_jsons/dependency_track_mapper/sample_input_report/fpf-no-vulnerabilities.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -111,7 +111,7 @@ describe('dependency_track_mapper_no_vulnerabilities', () => { fs.readFileSync( 'sample_jsons/dependency_track_mapper/hdf-no-vulnerabilities.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -125,7 +125,7 @@ describe('dependency_track_mapper_with_attributions', () => { const mapper = new DependencyTrackMapper( fs.readFileSync( 'sample_jsons/dependency_track_mapper/sample_input_report/fpf-with-attributions.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -140,7 +140,7 @@ describe('dependency_track_mapper_with_attributions', () => { fs.readFileSync( 'sample_jsons/dependency_track_mapper/hdf-with-attributions.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -154,7 +154,7 @@ describe('dependency_track_mapper_info_vulnerability', () => { const mapper = new DependencyTrackMapper( fs.readFileSync( 'sample_jsons/dependency_track_mapper/sample_input_report/fpf-info-vulnerability.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -169,7 +169,7 @@ describe('dependency_track_mapper_info_vulnerability', () => { fs.readFileSync( 'sample_jsons/dependency_track_mapper/hdf-info-vulnerability.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/fortify_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/fortify_mapper.spec.ts index 76a54fc471..a003249a9b 100644 --- a/libs/hdf-converters/test/mappers/forward/fortify_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/fortify_mapper.spec.ts @@ -8,7 +8,7 @@ describe('fortify_mapper', () => { const mapper = new FortifyResults( fs.readFileSync( 'sample_jsons/fortify_mapper/sample_input_report/fortify_webgoat_results.fvdl', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -22,7 +22,7 @@ describe('fortify_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/fortify_mapper/fortify-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -36,7 +36,7 @@ describe('fortify_mapper_withraw', () => { const mapper = new FortifyResults( fs.readFileSync( 'sample_jsons/fortify_mapper/sample_input_report/fortify_webgoat_results.fvdl', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -53,7 +53,7 @@ describe('fortify_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/fortify_mapper/fortify-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/gosec_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/gosec_mapper.spec.ts index b009a62d21..fd247daad8 100644 --- a/libs/hdf-converters/test/mappers/forward/gosec_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/gosec_mapper.spec.ts @@ -8,7 +8,7 @@ describe('gosec_mapper_grype', () => { const mapper = new GosecMapper( fs.readFileSync( 'sample_jsons/gosec_mapper/sample_input_report/Grype_gosec_results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('gosec_mapper_grype', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/gosec_mapper/grype-gosec-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -32,7 +32,7 @@ describe('gosec_mapper_grype', () => { const mapper = new GosecMapper( fs.readFileSync( 'sample_jsons/gosec_mapper/sample_input_report/Grype_gosec_results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -48,7 +48,7 @@ describe('gosec_mapper_grype', () => { fs.readFileSync( 'sample_jsons/gosec_mapper/grype-gosec-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -62,7 +62,7 @@ describe('gosec_mapper_go_ethereum_external_suppressed', () => { const mapper = new GosecMapper( fs.readFileSync( 'sample_jsons/gosec_mapper/sample_input_report/Go_Ethereum_gosec_results_external_suppressed.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -77,7 +77,7 @@ describe('gosec_mapper_go_ethereum_external_suppressed', () => { fs.readFileSync( 'sample_jsons/gosec_mapper/go-ethereum-external-unsuppressed-gosec-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -89,7 +89,7 @@ describe('gosec_mapper_go_ethereum_external_suppressed', () => { const mapper = new GosecMapper( fs.readFileSync( 'sample_jsons/gosec_mapper/sample_input_report/Go_Ethereum_gosec_results_external_suppressed.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -105,7 +105,7 @@ describe('gosec_mapper_go_ethereum_external_suppressed', () => { fs.readFileSync( 'sample_jsons/gosec_mapper/go-ethereum-external-unsuppressed-gosec-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -119,7 +119,7 @@ describe('gosec_mapper_go_ethereum_all_suppressed', () => { const mapper = new GosecMapper( fs.readFileSync( 'sample_jsons/gosec_mapper/sample_input_report/Go_Ethereum_gosec_results_all_suppressed.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -134,7 +134,7 @@ describe('gosec_mapper_go_ethereum_all_suppressed', () => { fs.readFileSync( 'sample_jsons/gosec_mapper/go-ethereum-all-unsuppressed-gosec-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -146,7 +146,7 @@ describe('gosec_mapper_go_ethereum_all_suppressed', () => { const mapper = new GosecMapper( fs.readFileSync( 'sample_jsons/gosec_mapper/sample_input_report/Go_Ethereum_gosec_results_all_suppressed.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -162,7 +162,7 @@ describe('gosec_mapper_go_ethereum_all_suppressed', () => { fs.readFileSync( 'sample_jsons/gosec_mapper/go-ethereum-all-unsuppressed-gosec-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/jfrog_xray_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/jfrog_xray_mapper.spec.ts index 93a17ca2c6..18532a58c1 100644 --- a/libs/hdf-converters/test/mappers/forward/jfrog_xray_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/jfrog_xray_mapper.spec.ts @@ -8,7 +8,7 @@ describe('jfrog_xray_mapper', () => { const mapper = new JfrogXrayMapper( fs.readFileSync( 'sample_jsons/jfrog_xray_mapper/sample_input_report/jfrog_xray_sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('jfrog_xray_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/jfrog_xray_mapper/jfrog-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -34,7 +34,7 @@ describe('jfrog_xray_mapper_withraw', () => { const mapper = new JfrogXrayMapper( fs.readFileSync( 'sample_jsons/jfrog_xray_mapper/sample_input_report/jfrog_xray_sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -50,7 +50,7 @@ describe('jfrog_xray_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/jfrog_xray_mapper/jfrog-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/msft_secure_score_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/msft_secure_score_mapper.spec.ts index 7512cbf02b..92c4a68f69 100644 --- a/libs/hdf-converters/test/mappers/forward/msft_secure_score_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/msft_secure_score_mapper.spec.ts @@ -1,9 +1,11 @@ import fs from 'fs'; import {describe, expect, it} from 'vitest'; -import { - MsftSecureScoreResults, +import type { CombinedResponse } from '../../../src/msft-secure-score-mapper'; +import { + MsftSecureScoreResults +} from '../../../src/msft-secure-score-mapper'; import {omitVersions} from '../../utils'; describe('msft_secure_score_mapper', () => { @@ -11,7 +13,7 @@ describe('msft_secure_score_mapper', () => { const mapper = new MsftSecureScoreResults( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/sample_input_report/combined.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +25,7 @@ describe('msft_secure_score_mapper', () => { const expectedHdfReports = JSON.parse( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/secure_score-hdfs.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -40,7 +42,7 @@ describe('msft_secure_score_mapper_withraw', () => { const mapper = new MsftSecureScoreResults( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/sample_input_report/combined.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -53,7 +55,7 @@ describe('msft_secure_score_mapper_withraw', () => { const expectedHdfReports = JSON.parse( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/secure_score-hdf-withraws.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -71,13 +73,13 @@ describe('msft_secure_score_mapper_multiple_reports', () => { profiles: JSON.parse( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/sample_input_report/profiles.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ), secureScore: JSON.parse( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/sample_input_report/secureScore-multiple.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) }; @@ -92,7 +94,7 @@ describe('msft_secure_score_mapper_multiple_reports', () => { const expectedHdfReports = JSON.parse( fs.readFileSync( 'sample_jsons/msft_secure_score_mapper/secure_score-hdf-multi.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); diff --git a/libs/hdf-converters/test/mappers/forward/nessus_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/nessus_mapper.spec.ts index a8681bfc38..15d58216fb 100644 --- a/libs/hdf-converters/test/mappers/forward/nessus_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/nessus_mapper.spec.ts @@ -1,5 +1,5 @@ import fs from 'fs'; -import {ExecJSON} from 'inspecjs'; +import type {ExecJSON} from 'inspecjs'; import {describe, expect, it} from 'vitest'; import {NessusResults} from '../../../src/nessus-mapper'; import {omitVersions} from '../../utils'; @@ -9,7 +9,7 @@ describe('nessus_mapper', () => { const mapper = new NessusResults( fs.readFileSync( 'sample_jsons/nessus_mapper/sample_input_report/sample.nessus', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -30,33 +30,37 @@ describe('nessus_mapper', () => { ); */ + // Throw rather than assert-and-narrow: an assertion inside the narrowing + // `if` would let a non-array conversion pass this test silently. + if (!Array.isArray(converted)) { + throw new TypeError( + 'Expected the nessus mapper to produce one execution per host' + ); + } + const expectedSet = [ JSON.parse( fs.readFileSync('sample_jsons/nessus_mapper/nessus-hdf-10.0.0.3.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ), JSON.parse( fs.readFileSync('sample_jsons/nessus_mapper/nessus-hdf-10.0.0.2.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ), JSON.parse( fs.readFileSync('sample_jsons/nessus_mapper/nessus-hdf-10.0.0.1.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ]; - expect(Array.isArray(converted)).toBe(true); - - if (Array.isArray(converted)) { - expect(converted.map((resultsSet) => omitVersions(resultsSet))).toEqual( - expectedSet.map((resultsSet: ExecJSON.Execution) => - omitVersions(resultsSet) - ) - ); - } + expect(converted.map((resultsSet) => omitVersions(resultsSet))).toEqual( + expectedSet.map((resultsSet: ExecJSON.Execution) => + omitVersions(resultsSet) + ) + ); }); }); @@ -65,7 +69,7 @@ describe('nessus_mapper_withraw', () => { const mapper = new NessusResults( fs.readFileSync( 'sample_jsons/nessus_mapper/sample_input_report/sample.nessus', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -87,12 +91,20 @@ describe('nessus_mapper_withraw', () => { ); */ + // Throw rather than assert-and-narrow: an assertion inside the narrowing + // `if` would let a non-array conversion pass this test silently. + if (!Array.isArray(converted)) { + throw new TypeError( + 'Expected the nessus mapper to produce one execution per host' + ); + } + const expectedSet = [ JSON.parse( fs.readFileSync( 'sample_jsons/nessus_mapper/nessus-hdf-10.0.0.3-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ), @@ -100,7 +112,7 @@ describe('nessus_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/nessus_mapper/nessus-hdf-10.0.0.2-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ), @@ -108,20 +120,16 @@ describe('nessus_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/nessus_mapper/nessus-hdf-10.0.0.1-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) ]; - expect(Array.isArray(converted)).toBe(true); - - if (Array.isArray(converted)) { - expect(converted.map((resultsSet) => omitVersions(resultsSet))).toEqual( - expectedSet.map((resultsSet: ExecJSON.Execution) => - omitVersions(resultsSet) - ) - ); - } + expect(converted.map((resultsSet) => omitVersions(resultsSet))).toEqual( + expectedSet.map((resultsSet: ExecJSON.Execution) => + omitVersions(resultsSet) + ) + ); }); }); diff --git a/libs/hdf-converters/test/mappers/forward/netsparker_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/netsparker_mapper.spec.ts index 213e8cab85..5e1f4aed83 100644 --- a/libs/hdf-converters/test/mappers/forward/netsparker_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/netsparker_mapper.spec.ts @@ -8,7 +8,7 @@ describe('netsparker_mapper_check', () => { const mapper = new NetsparkerResults( fs.readFileSync( 'sample_jsons/netsparker_mapper/sample_input_report/sample-netsparker-invicti.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('netsparker_mapper_check', () => { fs.readFileSync( 'sample_jsons/netsparker_mapper/netsparker-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -37,7 +37,7 @@ describe('netsparker_mapper_check_withraw', () => { const mapper = new NetsparkerResults( fs.readFileSync( 'sample_jsons/netsparker_mapper/sample_input_report/sample-netsparker-invicti.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -53,7 +53,7 @@ describe('netsparker_mapper_check_withraw', () => { fs.readFileSync( 'sample_jsons/netsparker_mapper/netsparker-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/neuvector_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/neuvector_mapper.spec.ts index a25935b53c..58488ec963 100644 --- a/libs/hdf-converters/test/mappers/forward/neuvector_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/neuvector_mapper.spec.ts @@ -8,7 +8,7 @@ describe('neuvector_mapper', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-caldera.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -22,7 +22,7 @@ describe('neuvector_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-mitre-caldera.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -33,7 +33,7 @@ describe('neuvector_mapper', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-heimdall.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -47,7 +47,7 @@ describe('neuvector_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-mitre-heimdall.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -58,7 +58,7 @@ describe('neuvector_mapper', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-heimdall2.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -72,7 +72,7 @@ describe('neuvector_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-mitre-heimdall2.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -83,7 +83,7 @@ describe('neuvector_mapper', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-vulcan.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -97,7 +97,7 @@ describe('neuvector_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-mitre-vulcan.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -110,7 +110,7 @@ describe('neuvector_mapper_withraw', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-caldera.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -125,7 +125,7 @@ describe('neuvector_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-withraw-mitre-caldera.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -136,7 +136,7 @@ describe('neuvector_mapper_withraw', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-heimdall.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -151,7 +151,7 @@ describe('neuvector_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-withraw-mitre-heimdall.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -162,7 +162,7 @@ describe('neuvector_mapper_withraw', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-heimdall2.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -177,7 +177,7 @@ describe('neuvector_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-withraw-mitre-heimdall2.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -188,7 +188,7 @@ describe('neuvector_mapper_withraw', () => { const mapper = new NeuVectorMapper( fs.readFileSync( 'sample_jsons/neuvector_mapper/sample_input_report/neuvector-mitre-vulcan.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -203,7 +203,7 @@ describe('neuvector_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/neuvector_mapper/neuvector-hdf-withraw-mitre-vulcan.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/nikto_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/nikto_mapper.spec.ts index 2c8c8a0aa1..2ee0210f0c 100644 --- a/libs/hdf-converters/test/mappers/forward/nikto_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/nikto_mapper.spec.ts @@ -8,7 +8,7 @@ describe('nikto_mapper', () => { const mapper = new NiktoMapper( fs.readFileSync( 'sample_jsons/nikto_mapper/sample_input_report/zero.webappsecurity.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('nikto_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/nikto_mapper/nikto-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -34,7 +34,7 @@ describe('nikto_mapper_withraw', () => { const mapper = new NiktoMapper( fs.readFileSync( 'sample_jsons/nikto_mapper/sample_input_report/zero.webappsecurity.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -48,7 +48,7 @@ describe('nikto_mapper_withraw', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/nikto_mapper/nikto-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/prisma_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/prisma_mapper.spec.ts index 862414a83f..02cda17835 100644 --- a/libs/hdf-converters/test/mappers/forward/prisma_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/prisma_mapper.spec.ts @@ -8,7 +8,7 @@ describe('prisma_mapper', () => { const mapper = new PrismaMapper( fs.readFileSync( 'sample_jsons/prisma_mapper/sample_input_report/prismacloud_sample.csv', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); Object.entries(mapper.toHdf()).forEach(([, obj]) => { @@ -20,7 +20,7 @@ describe('prisma_mapper', () => { omitVersions( JSON.parse( fs.readFileSync(fileName, { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/sarif_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/sarif_mapper.spec.ts index 43eed17967..57ba1a54ff 100644 --- a/libs/hdf-converters/test/mappers/forward/sarif_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/sarif_mapper.spec.ts @@ -8,7 +8,7 @@ describe('sarif_mapper', () => { const mapper = new SarifMapper( fs.readFileSync( 'sample_jsons/sarif_mapper/sample_input_report/sarif_input.sarif', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('sarif_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/sarif_mapper/sarif-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -33,7 +33,7 @@ describe('sarif_mapper_withraw', () => { const mapper = new SarifMapper( fs.readFileSync( 'sample_jsons/sarif_mapper/sample_input_report/sarif_input.sarif', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -47,7 +47,7 @@ describe('sarif_mapper_withraw', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/sarif_mapper/sarif-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/scoutsuite_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/scoutsuite_mapper.spec.ts index 631ccb0ff5..56ccdc575e 100644 --- a/libs/hdf-converters/test/mappers/forward/scoutsuite_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/scoutsuite_mapper.spec.ts @@ -8,7 +8,7 @@ describe('scoutsuite_mapper', () => { const mapper = new ScoutsuiteMapper( fs.readFileSync( 'sample_jsons/scoutsuite_mapper/sample_input_report/scoutsuite_sample.js', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -22,7 +22,7 @@ describe('scoutsuite_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/scoutsuite_mapper/scoutsuite-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -35,7 +35,7 @@ describe('scoutsuite_mapper_withraw', () => { const mapper = new ScoutsuiteMapper( fs.readFileSync( 'sample_jsons/scoutsuite_mapper/sample_input_report/scoutsuite_sample.js', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -50,7 +50,7 @@ describe('scoutsuite_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/scoutsuite_mapper/scoutsuite-hdf-withraw.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/snyk_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/snyk_mapper.spec.ts index 70962edf36..d32a68a6f4 100644 --- a/libs/hdf-converters/test/mappers/forward/snyk_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/snyk_mapper.spec.ts @@ -9,7 +9,7 @@ describe('snyk_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/snyk_mapper/sample_input_report/nodejs-goof-local.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ); @@ -25,7 +25,7 @@ describe('snyk_mapper', () => { fs.readFileSync( 'sample_jsons/snyk_mapper/nodejs-goof-local-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -37,7 +37,7 @@ describe('snyk_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/snyk_mapper/sample_input_report/nodejs-goof-remote.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ); @@ -53,7 +53,7 @@ describe('snyk_mapper', () => { fs.readFileSync( 'sample_jsons/snyk_mapper/nodejs-goof-remote-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/sonarqube_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/sonarqube_mapper.spec.ts index 16cdbb895a..acdd1962a3 100644 --- a/libs/hdf-converters/test/mappers/forward/sonarqube_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/sonarqube_mapper.spec.ts @@ -1,5 +1,5 @@ import fs from 'fs'; -import {ExecJSON} from 'inspecjs'; +import type {ExecJSON} from 'inspecjs'; import {describe, expect, it} from 'vitest'; import {SonarqubeResults} from '../../../src/sonarqube-mapper'; import {omitHDFTitle, omitVersions} from '../../utils'; @@ -23,7 +23,7 @@ describe('sonarqube_mapper', () => { fs.readFileSync( 'sample_jsons/sonarqube_mapper/sonarqube-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -52,7 +52,7 @@ describe('sonarqube_mapper', () => { fs.readFileSync( 'sample_jsons/sonarqube_mapper/sonarqube-branch-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -82,7 +82,7 @@ describe('sonarqube_mapper', () => { fs.readFileSync( 'sample_jsons/sonarqube_mapper/sonarqube-pull-request-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/splunk_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/splunk_mapper.spec.ts new file mode 100644 index 0000000000..3ca91abb75 --- /dev/null +++ b/libs/hdf-converters/test/mappers/forward/splunk_mapper.spec.ts @@ -0,0 +1,95 @@ +import {AxiosHeaders, type AxiosResponse} from 'axios'; +import {describe, expect, it, vi} from 'vitest'; +import {SplunkMapper} from '../../../src/splunk-mapper'; + +// queryData awaits trackJob before fetching results, so trackJob must not +// resolve until Splunk reports the search job DONE. Before the fix these +// tests pin, trackJob resolved immediately while a detached setInterval kept +// polling — results could be fetched for an unfinished job, and every error +// thrown inside the timer callbacks was an unhandled rejection instead of a +// failure the caller could see. + +function jobStatus(dispatchState: string, isDone = false): AxiosResponse { + return { + config: {headers: new AxiosHeaders()}, + data: {entry: [{content: {dispatchState, isDone}}]}, + headers: {}, + status: 200, + statusText: 'OK' + }; +} + +function buildMapper() { + const mapper = new SplunkMapper({ + host: 'localhost', + index: 'main', + scheme: 'http' + }); + return {get: vi.spyOn(mapper.axiosInstance, 'get'), mapper}; +} + +describe('SplunkMapper trackJob', () => { + it('resolves only after the search job reports DONE', async () => { + const {get, mapper} = buildMapper(); + get + .mockResolvedValueOnce(jobStatus('RUNNING')) + .mockResolvedValueOnce(jobStatus('RUNNING')) + .mockResolvedValueOnce(jobStatus('DONE', true)); + + await mapper.trackJob('SID123'); + + expect(get).toHaveBeenCalledTimes(3); + expect(get).toHaveBeenLastCalledWith( + 'http://localhost:8089/services/search/jobs/SID123', + expect.objectContaining({timeout: expect.any(Number)}) + ); + }); + + it('rejects when the job reports a failed dispatch state', async () => { + const {get, mapper} = buildMapper(); + get + .mockResolvedValueOnce(jobStatus('RUNNING')) + .mockResolvedValueOnce(jobStatus('FAILED')); + + await expect(mapper.trackJob('SID123')).rejects.toThrow( + 'Failed search job - Detected dispatch state FAILED' + ); + }); + + it('rejects when the status response is malformed', async () => { + const {get, mapper} = buildMapper(); + get.mockResolvedValueOnce({ + config: {headers: new AxiosHeaders()}, + data: {}, + headers: {}, + status: 200, + statusText: 'OK' + }); + + await expect(mapper.trackJob('SID123')).rejects.toThrow( + 'Failed search job - Malformed search job response received' + ); + }); + + it('rejects when the polling request itself fails', async () => { + const {get, mapper} = buildMapper(); + get.mockRejectedValueOnce(new Error('connect ECONNREFUSED')); + + await expect(mapper.trackJob('SID123')).rejects.toThrow( + 'Failed search job - ' + ); + }); + + it('maps a timed-out polling request to the search job timeout error', async () => { + const {get, mapper} = buildMapper(); + get.mockRejectedValueOnce( + Object.assign(new Error('timeout of 120000ms exceeded'), { + code: 'ECONNABORTED' + }) + ); + + await expect(mapper.trackJob('SID123')).rejects.toThrow( + 'Search job timed out - Unable to retrieve query' + ); + }); +}); diff --git a/libs/hdf-converters/test/mappers/forward/trufflehog_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/trufflehog_mapper.spec.ts index f0e6d6005a..f519fc46c4 100644 --- a/libs/hdf-converters/test/mappers/forward/trufflehog_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/trufflehog_mapper.spec.ts @@ -8,7 +8,7 @@ describe('trufflehog_mapper', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('trufflehog_mapper', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -37,7 +37,7 @@ describe('trufflehog_mapper_withraw', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -53,7 +53,7 @@ describe('trufflehog_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -67,7 +67,7 @@ describe('trufflehog_docker_mapper', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog_docker_example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -82,7 +82,7 @@ describe('trufflehog_docker_mapper', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-docker-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -96,7 +96,7 @@ describe('trufflehog_docker_mapper_withraw', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog_docker_example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -112,7 +112,7 @@ describe('trufflehog_docker_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-docker-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -126,7 +126,7 @@ describe('trufflehog_saf_example_mapper', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog_saf_example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -141,7 +141,7 @@ describe('trufflehog_saf_example_mapper', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-saf-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -155,7 +155,7 @@ describe('trufflehog_saf_example_mapper_withraw', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog_saf_example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -171,7 +171,7 @@ describe('trufflehog_saf_example_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-saf-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -181,11 +181,11 @@ describe('trufflehog_saf_example_mapper_withraw', () => { }); describe('trufflehog_example_mapper', () => { - it('Successfully converts withraw flagged trufflehog targeted at a local/cloned repository data', () => { + it('Successfully converts trufflehog targeted at a local/cloned repository data', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog-report-example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), false ); @@ -201,7 +201,7 @@ describe('trufflehog_example_mapper', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-report-example-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -210,12 +210,12 @@ describe('trufflehog_example_mapper', () => { }); }); -describe('trufflehog_example_mapper', () => { +describe('trufflehog_example_mapper_withraw', () => { it('Successfully converts withraw flagged trufflehog targeted at a local/cloned repository data', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog-report-example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -231,7 +231,7 @@ describe('trufflehog_example_mapper', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-report-example-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -245,7 +245,7 @@ describe('trufflehog_dup_ndjson', () => { const mapper = new TrufflehogResults( fs.readFileSync( 'sample_jsons/trufflehog_mapper/sample_input_report/trufflehog_dup.ndjson', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), false ); @@ -261,7 +261,7 @@ describe('trufflehog_dup_ndjson', () => { fs.readFileSync( 'sample_jsons/trufflehog_mapper/trufflehog-ndjson-dup-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/twistlock_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/twistlock_mapper.spec.ts index ec8b5cb477..ec360d281f 100644 --- a/libs/hdf-converters/test/mappers/forward/twistlock_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/twistlock_mapper.spec.ts @@ -8,7 +8,7 @@ describe('twistlock_mapper', () => { const mapper = new TwistlockResults( fs.readFileSync( 'sample_jsons/twistlock_mapper/sample_input_report/twistlock-twistcli-sample-1.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -21,7 +21,7 @@ describe('twistlock_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/twistlock_mapper/twistlock-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -34,7 +34,7 @@ describe('twistlock_mapper_code_repo', () => { const mapper = new TwistlockResults( fs.readFileSync( 'sample_jsons/twistlock_mapper/sample_input_report/twistlock-twistcli-coderepo-scan-sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -49,7 +49,7 @@ describe('twistlock_mapper_code_repo', () => { fs.readFileSync( 'sample_jsons/twistlock_mapper/twistlock-coderepo-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -63,7 +63,7 @@ describe('twistlock_mapper_withraw', () => { const mapper = new TwistlockResults( fs.readFileSync( 'sample_jsons/twistlock_mapper/sample_input_report/twistlock-twistcli-sample-1.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -79,7 +79,7 @@ describe('twistlock_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/twistlock_mapper/twistlock-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -88,12 +88,12 @@ describe('twistlock_mapper_withraw', () => { }); }); -describe('twistlock_mapper_withraw', () => { +describe('twistlock_mapper_code_repo_withraw', () => { it('Successfully converts withRaw flagged Twistlock code repo scan', () => { const mapper = new TwistlockResults( fs.readFileSync( 'sample_jsons/twistlock_mapper/sample_input_report/twistlock-twistcli-coderepo-scan-sample.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -109,7 +109,7 @@ describe('twistlock_mapper_withraw', () => { fs.readFileSync( 'sample_jsons/twistlock_mapper/twistlock-coderepo-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) diff --git a/libs/hdf-converters/test/mappers/forward/veracode_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/veracode_mapper.spec.ts index c9ca0b7af6..f11eeca733 100644 --- a/libs/hdf-converters/test/mappers/forward/veracode_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/veracode_mapper.spec.ts @@ -7,7 +7,7 @@ describe('veracode_mapper', () => { const mapper = new VeracodeMapper( fs.readFileSync( 'sample_jsons/veracode_mapper/sample_input_report/veracode.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -20,7 +20,7 @@ describe('veracode_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/veracode_mapper/veracode-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/xccdf_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/xccdf_mapper.spec.ts index 756fe4a2fc..273ef09352 100644 --- a/libs/hdf-converters/test/mappers/forward/xccdf_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/xccdf_mapper.spec.ts @@ -9,7 +9,7 @@ describe('xccdf_mapper', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-scc-rhel7.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -23,7 +23,7 @@ describe('xccdf_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-scc-rhel7-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -33,7 +33,7 @@ describe('xccdf_mapper', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-scc-rhel8.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -47,7 +47,7 @@ describe('xccdf_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-scc-rhel8-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -59,7 +59,7 @@ describe('xccdf_mapper', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-openscap-ComplianceAsCode-ubuntu1804.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -73,7 +73,7 @@ describe('xccdf_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-openscap-ComplianceAsCode-ubuntu1804-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -83,7 +83,7 @@ describe('xccdf_mapper', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-openscap-rhel7.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -97,7 +97,7 @@ describe('xccdf_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-openscap-rhel7-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -107,7 +107,7 @@ describe('xccdf_mapper', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-openscap-rhel8.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -121,7 +121,7 @@ describe('xccdf_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-openscap-rhel8-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -136,7 +136,7 @@ describe('xccdf_mapper_withraw', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-scc-rhel7.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -151,7 +151,7 @@ describe('xccdf_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-scc-rhel7-hdf-withraw.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -161,7 +161,7 @@ describe('xccdf_mapper_withraw', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-scc-rhel8.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -176,7 +176,7 @@ describe('xccdf_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-scc-rhel8-hdf-withraw.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -188,7 +188,7 @@ describe('xccdf_mapper_withraw', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-openscap-rhel7.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -203,7 +203,7 @@ describe('xccdf_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-openscap-rhel7-hdf-withraw.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -213,7 +213,7 @@ describe('xccdf_mapper_withraw', () => { const mapper = new XCCDFResultsResults( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/sample_input_report/xccdf-results-openscap-rhel8.xml', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), true ); @@ -228,7 +228,7 @@ describe('xccdf_mapper_withraw', () => { JSON.parse( fs.readFileSync( 'sample_jsons/xccdf_results_mapper/xccdf-openscap-rhel8-hdf-withraw.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) diff --git a/libs/hdf-converters/test/mappers/forward/zap_mapper.spec.ts b/libs/hdf-converters/test/mappers/forward/zap_mapper.spec.ts index b6c6c1610b..8690b8807f 100644 --- a/libs/hdf-converters/test/mappers/forward/zap_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/forward/zap_mapper.spec.ts @@ -8,7 +8,7 @@ describe('zap_mapper', () => { const mapper = new ZapResults( fs.readFileSync( 'sample_jsons/zap_mapper/sample_input_report/webgoat.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), 'http://mymac.com:8191' ); @@ -22,7 +22,7 @@ describe('zap_mapper', () => { omitVersions( JSON.parse( fs.readFileSync('sample_jsons/zap_mapper/zap-webgoat-hdf.json', { - encoding: 'utf-8' + encoding: 'utf8' }) ) ) @@ -32,7 +32,7 @@ describe('zap_mapper', () => { const mapper = new ZapResults( fs.readFileSync( 'sample_jsons/zap_mapper/sample_input_report/zero.webappsecurity.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), 'http://zero.webappsecurity.com' ); @@ -47,7 +47,7 @@ describe('zap_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/zap_mapper/zap-webappsecurity-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) @@ -55,12 +55,12 @@ describe('zap_mapper', () => { }); }); -describe('zap_mapper', () => { +describe('zap_mapper_withraw', () => { it('Successfully converts webgoat.json using withRaw flag', async () => { const mapper = new ZapResults( fs.readFileSync( 'sample_jsons/zap_mapper/sample_input_report/webgoat.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), 'http://mymac.com:8191', true @@ -77,7 +77,7 @@ describe('zap_mapper', () => { fs.readFileSync( 'sample_jsons/zap_mapper/zap-webgoat-hdf-withraw.json', { - encoding: 'utf-8' + encoding: 'utf8' } ) ) @@ -88,7 +88,7 @@ describe('zap_mapper', () => { const mapper = new ZapResults( fs.readFileSync( 'sample_jsons/zap_mapper/sample_input_report/zero.webappsecurity.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ), 'http://zero.webappsecurity.com', true @@ -104,7 +104,7 @@ describe('zap_mapper', () => { JSON.parse( fs.readFileSync( 'sample_jsons/zap_mapper/zap-webappsecurity-hdf-withraw.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ) ) diff --git a/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts index 7726453d1d..ba3646de41 100644 --- a/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/asff_reverse_mapper.spec.ts @@ -4,15 +4,41 @@ import {FromHdfToAsffMapper} from '../../../src/converters-from-hdf/asff/reverse import {omitASFFTimes, omitASFFTitle, omitASFFVersions} from '../../utils'; describe('ASFF Reverse Mapper', () => { + it('Leaves the control order of the HDF it was given untouched', () => { + const inputData = JSON.parse( + fs.readFileSync( + 'sample_jsons/asff_reverse_mapper/sample_input_report/rhel7-results.json', + {encoding: 'utf8'} + ) + ); + const idsBefore = inputData.profiles[0].controls.map( + (control: {id: string}) => control.id + ); + + new FromHdfToAsffMapper(inputData, { + input: 'rhel7-results.json', + awsAccountId: '12345678910', + target: 'reverse-proxy', + region: 'us-east-2' + }).toAsff(); + + // The mapper walks the controls in reverse. Doing that with .reverse() + // reordered the caller's own array in place; the fixtures could not see it + // because they only compare the mapper's output. + expect( + inputData.profiles[0].controls.map((control: {id: string}) => control.id) + ).toEqual(idsBefore); + }); + it('Successfully converts a one-layer HDF into ASFF', () => { const inputData = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/sample_input_report/rhel7-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); - //The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool + // The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool const converted = new FromHdfToAsffMapper(inputData, { input: 'rhel7-results.json', awsAccountId: '12345678910', @@ -20,7 +46,7 @@ describe('ASFF Reverse Mapper', () => { region: 'us-east-2' }).toAsff(); - const profileInformation = [converted[converted.length - 1] || {}]; + const profileInformation = [converted.at(-1) || {}]; // fs.writeFileSync( // 'sample_jsons/asff_reverse_mapper/rhel7-results.asff.json', @@ -34,14 +60,14 @@ describe('ASFF Reverse Mapper', () => { const expectedJSON = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/rhel7-results.asff.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); const expectedProfileInfo = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/rhel7-results.asff.json.p0.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -59,11 +85,11 @@ describe('ASFF Reverse Mapper', () => { const inputData = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/sample_input_report/example-3-layer-overlay_03062022.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); - //The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool + // The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool const converted = new FromHdfToAsffMapper(inputData, { input: 'example-3-layer-overlay_03062022.json', awsAccountId: '12345678910', @@ -71,7 +97,7 @@ describe('ASFF Reverse Mapper', () => { region: 'us-east-2' }).toAsff(); - const profileInformation = [converted[converted.length - 1] || {}]; + const profileInformation = [converted.at(-1) || {}]; // fs.writeFileSync( // 'sample_jsons/asff_reverse_mapper/example-3-layer-overlay_03062022.asff.json', @@ -85,14 +111,14 @@ describe('ASFF Reverse Mapper', () => { const expectedJSON = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/example-3-layer-overlay_03062022.asff.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); const expectedProfileInfo = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/example-3-layer-overlay_03062022.asff.json.p0.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -110,7 +136,7 @@ describe('ASFF Reverse Mapper', () => { const inputData = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/sample_input_report/snyk-no-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -129,7 +155,7 @@ describe('ASFF Reverse Mapper', () => { const expectedJSON = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/snyk-no-results.asff.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -142,7 +168,7 @@ describe('ASFF Reverse Mapper', () => { const inputData = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/sample_input_report/restrictions-test-rhel7-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); @@ -161,7 +187,7 @@ describe('ASFF Reverse Mapper', () => { const expectedJSON = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/restrictions-test-results.asff.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); diff --git a/libs/hdf-converters/test/mappers/reverse/caat_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/caat_reverse_mapper.spec.ts index 856ad774af..d009d67ec3 100644 --- a/libs/hdf-converters/test/mappers/reverse/caat_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/caat_reverse_mapper.spec.ts @@ -2,7 +2,8 @@ import * as XLSX from '@e965/xlsx'; import fs from 'fs'; import * as _ from 'lodash'; import {describe, expect, it} from 'vitest'; -import {CAATRow, FromHDFToCAATMapper} from '../../../index'; +import type {CAATRow} from '../../../index'; +import { FromHDFToCAATMapper} from '../../../index'; describe('CAAT Results Reverse Mapper', () => { it('Successfully converts two RHEL HDF and a RHEL triple overlay HDF into CAAT', () => { @@ -10,11 +11,11 @@ describe('CAAT Results Reverse Mapper', () => { const rhelData = fs.readFileSync( 'sample_jsons/caat_reverse_mapper/sample_input_report/red_hat_good.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const tripleData = fs.readFileSync( 'sample_jsons/caat_reverse_mapper/sample_input_report/triple_overlay_profile_example.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const mapper = new FromHDFToCAATMapper([ diff --git a/libs/hdf-converters/test/mappers/reverse/checklist_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/checklist_reverse_mapper.spec.ts index 8213af2a0a..b553b9a16e 100644 --- a/libs/hdf-converters/test/mappers/reverse/checklist_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/checklist_reverse_mapper.spec.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import {describe, expect, it} from 'vitest'; import {ChecklistResults} from '../../../src/ckl-mapper/checklist-mapper'; -import {Stigdata, Checklist} from '../../../src/ckl-mapper/checklistJsonix'; +import type {Stigdata, Checklist} from '../../../src/ckl-mapper/checklistJsonix'; import {replaceCKLVersion} from '../../utils'; import {InvalidChecklistMetadataException} from '../../../src/ckl-mapper/checklist-metadata-utils'; @@ -13,7 +13,7 @@ describe('previously_checklist_converted_hdf_to_checklist', () => { const mapper = new ChecklistResults(hdfData); const expected = fs.readFileSync( 'sample_jsons/checklist_mapper/converted-RHEL8V1R3.ckl', - 'utf-8' + 'utf8' ); const converted = mapper.toCkl(); expect(converted).toEqual(replaceCKLVersion(expected)); @@ -26,7 +26,7 @@ describe('previously_checklist_converted_hdf_to_checklist', () => { const mapper = new ChecklistResults(hdfData); const expected = fs.readFileSync( 'sample_jsons/checklist_mapper/converted-three-stig-checklist.ckl', - 'utf-8' + 'utf8' ); const converted = mapper.toCkl(); expect(converted).toEqual(replaceCKLVersion(expected)); @@ -41,7 +41,7 @@ describe('non_checklist_converted_hdf_to_checklist', () => { const mapper = new ChecklistResults(hdfData); const expected = fs.readFileSync( 'sample_jsons/checklist_mapper/converted-nessus.ckl', - 'utf-8' + 'utf8' ); const converted = mapper.toCkl(); expect(converted).toEqual(replaceCKLVersion(expected)); @@ -56,7 +56,7 @@ describe('Small RHEL8 HDF file', () => { const mapper = new ChecklistResults(hdfData); const expected = fs.readFileSync( 'sample_jsons/checklist_mapper/converted-rhel8_sample_oneOfEachControlStatus.ckl', - 'utf-8' + 'utf8' ); const converted = mapper.toCkl(); expect(converted).toEqual(replaceCKLVersion(expected)); @@ -71,7 +71,7 @@ describe('Small RHEL 7 with severity and severity override tags', () => { const mapper = new ChecklistResults(hdfData); const expected = fs.readFileSync( 'sample_jsons/checklist_mapper/converted-rhel7_overrides.ckl', - 'utf-8' + 'utf8' ); const converted = mapper.toCkl(); expect(converted).toEqual(replaceCKLVersion(expected)); @@ -142,7 +142,7 @@ describe('checklist_mapper_severity_mapping', () => { * @returns Parsed data. */ function loadJsonFile(filePath: string): any { - return JSON.parse(fs.readFileSync(filePath, {encoding: 'utf-8'})); + return JSON.parse(fs.readFileSync(filePath, {encoding: 'utf8'})); } /** * Extract the severity string for a specific control from the mapper. diff --git a/libs/hdf-converters/test/mappers/reverse/html_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/html_reverse_mapper.spec.ts index a2bae32502..f1d787f544 100644 --- a/libs/hdf-converters/test/mappers/reverse/html_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/html_reverse_mapper.spec.ts @@ -7,7 +7,7 @@ describe('HTML Results Reverse Mapper', () => { it('Successfully converts RHEL7 HDF into HTML', async () => { const inputData = fs.readFileSync( 'sample_jsons/html_reverse_mapper/sample_input_report/rhel7-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const mapper = new FromHDFToHTMLMapper( @@ -24,7 +24,7 @@ describe('HTML Results Reverse Mapper', () => { const expected = fs.readFileSync( 'sample_jsons/html_reverse_mapper/rhel7.html', - 'utf-8' + 'utf8' ); expect(omitHTMLStyleTag(converted)).toEqual(omitHTMLStyleTag(expected)); @@ -33,7 +33,7 @@ describe('HTML Results Reverse Mapper', () => { it('Successfully converts SonarQube HDF into HTML', async () => { const inputData = fs.readFileSync( 'sample_jsons/html_reverse_mapper/sample_input_report/sonarqube-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const mapper = new FromHDFToHTMLMapper( @@ -50,7 +50,7 @@ describe('HTML Results Reverse Mapper', () => { const expected = fs.readFileSync( 'sample_jsons/html_reverse_mapper/sonarqube.html', - 'utf-8' + 'utf8' ); expect(omitHTMLStyleTag(converted)).toEqual(omitHTMLStyleTag(expected)); @@ -59,7 +59,7 @@ describe('HTML Results Reverse Mapper', () => { it('Successfully converts SonarQube HDF into HTML with filtered controls', async () => { const inputData = fs.readFileSync( 'sample_jsons/html_reverse_mapper/sample_input_report/sonarqube-hdf.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const mapper = new FromHDFToHTMLMapper( @@ -76,7 +76,7 @@ describe('HTML Results Reverse Mapper', () => { const expected = fs.readFileSync( 'sample_jsons/html_reverse_mapper/sonarqube.html', - 'utf-8' + 'utf8' ); expect(omitHTMLStyleTag(converted)).toEqual(omitHTMLStyleTag(expected)); diff --git a/libs/hdf-converters/test/mappers/reverse/splunk_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/splunk_reverse_mapper.spec.ts index 4c83b7d867..cf9c8708f9 100644 --- a/libs/hdf-converters/test/mappers/reverse/splunk_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/splunk_reverse_mapper.spec.ts @@ -1,23 +1,20 @@ import fs from 'fs'; -import {describe, it} from 'vitest'; +import {describe, expect, it} from 'vitest'; import {FromHDFToSplunkMapper} from '../../../src/converters-from-hdf/splunk/reverse-splunk-mapper'; -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe('Describe Splunk Reverse Mapper', () => { +describe('Splunk Reverse Mapper', () => { it('Successfully converts HDF into Splunk', async () => { // The From Hdf to Asff mapper takes a HDF object and an options argument with the format of the CLI tool const inputData = JSON.parse( fs.readFileSync( 'sample_jsons/asff_reverse_mapper/sample_input_report/rhel7-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ) ); - // Currently tests are to make sure there are no errors during upload to Splunk - await new FromHDFToSplunkMapper(inputData).toSplunk( + // Currently tests are to make sure there are no errors during upload to + // Splunk; toSplunk resolves with the upload's GUID string. + const guid = await new FromHDFToSplunkMapper(inputData).toSplunk( { host: '127.0.0.1', username: 'admin', @@ -27,5 +24,6 @@ describe('Describe Splunk Reverse Mapper', () => { }, 'rhel7-results.json' ); + expect(guid).toBeTypeOf('string'); }); }); diff --git a/libs/hdf-converters/test/mappers/reverse/xccdf_reverse_mapper.spec.ts b/libs/hdf-converters/test/mappers/reverse/xccdf_reverse_mapper.spec.ts index fc257a75bb..e87b30035d 100644 --- a/libs/hdf-converters/test/mappers/reverse/xccdf_reverse_mapper.spec.ts +++ b/libs/hdf-converters/test/mappers/reverse/xccdf_reverse_mapper.spec.ts @@ -7,7 +7,7 @@ describe('XCCDF Results Reverse Mapper', () => { it('Successfully converts RHEL7 HDF into XCCDF-Results', () => { const inputData = fs.readFileSync( 'sample_jsons/xccdf_reverse_mapper/sample_input_report/rhel7-results.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const outputTemplate = fs.readFileSync( 'src/converters-from-hdf/xccdf/hdf2xccdf-results-template.xml' @@ -28,7 +28,7 @@ describe('XCCDF Results Reverse Mapper', () => { const expected = fs.readFileSync( 'sample_jsons/xccdf_reverse_mapper/rhel7-xccdf-results.xml', - 'utf-8' + 'utf8' ); expect(converted).toEqual(replaceXCCDFVersion(expected)); @@ -37,7 +37,7 @@ describe('XCCDF Results Reverse Mapper', () => { it('Successfully converts a 3 layer overlay HDF into XCCDF-Results', () => { const inputData = fs.readFileSync( 'sample_jsons/xccdf_reverse_mapper/sample_input_report/example-3-layer-overlay.json', - {encoding: 'utf-8'} + {encoding: 'utf8'} ); const outputTemplate = fs.readFileSync( 'src/converters-from-hdf/xccdf/hdf2xccdf-results-template.xml' @@ -58,7 +58,7 @@ describe('XCCDF Results Reverse Mapper', () => { const expected = fs.readFileSync( 'sample_jsons/xccdf_reverse_mapper/example-3-layer-overlay-xccdf-results.xml', - 'utf-8' + 'utf8' ); expect(converted).toEqual(replaceXCCDFVersion(expected)); diff --git a/libs/hdf-converters/test/utils.ts b/libs/hdf-converters/test/utils.ts index 16bb730ac2..95e7c08c94 100644 --- a/libs/hdf-converters/test/utils.ts +++ b/libs/hdf-converters/test/utils.ts @@ -1,9 +1,13 @@ -import {ExecJSON} from 'inspecjs'; +import type {ExecJSON} from 'inspecjs'; import _ from 'lodash'; -import {IFindingASFF} from '../src/converters-from-hdf/asff/asff-types'; -import {ExecJSONProfile} from 'inspecjs/src/generated_parsers/v_1_0/exec-json'; +import type {IFindingASFF} from '../src/converters-from-hdf/asff/asff-types'; +import type {ExecJSONProfile} from 'inspecjs/src/generated_parsers/v_1_0/exec-json'; import {version as hdfConvertersVersion} from '../package.json'; +const CKL_VERSION_COMMENT = /(?<=)/; +const XCCDF_VERSION_ELEMENT = /(?<=)\S+(?=<\/version>)/; +const HTML_STYLE_TAG = /(?<=