From 956775025bc159eb18dfe08156bd02485c6eb14e Mon Sep 17 00:00:00 2001 From: Sepaseh Date: Fri, 31 Jul 2026 19:20:19 +0330 Subject: [PATCH 1/3] chore: streamline quality and release tooling --- .agents/rules/styling.md | 24 +- .agents/rules/testing.md | 7 +- .github/CODEOWNERS | 13 +- .github/workflows/ci.yml | 48 +-- .github/workflows/dast.yml | 17 +- .github/workflows/deployment-smoke.yml | 18 +- .github/workflows/mutation.yml | 13 +- .github/workflows/release.yml | 4 +- .github/workflows/staging.yml | 19 +- CONTRIBUTING.md | 8 +- README.md | 2 + SECURITY.md | 2 +- docs/branch-protection.md | 22 +- docs/contract-testing.md | 17 +- docs/deployment.md | 44 +-- docs/development.md | 3 +- docs/quality-gate.md | 40 -- docs/release-operations.md | 23 +- docs/releasing.md | 20 +- docs/security/reviews.md | 66 ++-- docs/security/threat-model.md | 11 +- docs/sonarqube.md | 19 + docs/staging.md | 30 +- docs/testing.md | 55 ++- eslint.config.ts | 483 +++++++++---------------- package-lock.json | 14 +- package.json | 6 +- scripts/run-lighthouse.mjs | 23 +- sonar-project.properties | 1 - src/features/dashboard/Dashboard.tsx | 2 +- src/features/roles/Roles.tsx | 2 +- src/features/users/Users.tsx | 2 +- src/layouts/auth/Auth.tsx | 12 +- src/layouts/default/Default.tsx | 10 +- tooling/paths.ts | 3 + tsconfig.node.json | 1 + vite.config.ts | 5 +- vitest.config.ts | 5 +- vitest.contract.config.ts | 5 +- 39 files changed, 427 insertions(+), 672 deletions(-) delete mode 100644 docs/quality-gate.md create mode 100644 docs/sonarqube.md create mode 100644 tooling/paths.ts diff --git a/.agents/rules/styling.md b/.agents/rules/styling.md index 7992b13..44891c8 100644 --- a/.agents/rules/styling.md +++ b/.agents/rules/styling.md @@ -17,17 +17,13 @@ Ant Design is the source of truth for theming. ## CSS property order -Object style props (`style` and Ant Design `styles`) follow recess order: - -1. `content` -2. Positioning: `position`, `top`, `right`, `bottom`, `left`, `zIndex` -3. Box model: `display`, `overflow`, `width`, `height`, `padding`, `margin` -4. Flex/Grid: `flex`, `flexDirection`, `alignItems`, `justifyContent`, `gap` -5. Border: `border*`, `borderRadius`, `boxShadow`, `outline` -6. Background and color: `background*`, `color`, `opacity` -7. Typography: `font*`, `lineHeight`, `textAlign`, `textOverflow`, `whiteSpace` -8. UI: `cursor`, `pointerEvents`, `userSelect` -9. SVG: `fill`, `stroke` -10. Transform and animation: `transform`, `transition`, `animation` - -The local ESLint rule `local/style-props-recess-order` enforces and can auto-fix this ordering. +Keep object style props (`style` and Ant Design `styles`) in alphabetical order. +The local ESLint rule `local/style-props-alphabetical-order` warns and can +auto-fix the ordering. Objects containing spread or computed properties are +left unchanged because reordering them could change behavior. + +Do not mix a CSS shorthand with one of its longhands in the same object, such +as `margin` with `marginTop`. The local rule +`local/style-props-no-shorthand-conflicts` reports these ambiguous overrides as +errors. Duplicate object keys are errors through ESLint's standard +`no-dupe-keys` rule. diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index c2baabb..ece2cb9 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -12,14 +12,15 @@ Use the configured project checks first: npm run typecheck npm run lint npm run test:coverage -npm run test:e2e -npm run build +npm run test:contract +npm run test:e2e -- --project=chromium +npm run performance npm run knip ``` ## Choosing a test layer -If the project gains a test setup, use this decision tree: +Use this decision tree when choosing the existing test setup: - Pure utility or mapper: unit test. - Hook that composes state or side effects: integration test. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cc2e19c..5c18be1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,22 +5,25 @@ /.github/ @sepaseh /package.json @sepaseh /package-lock.json @sepaseh +/sonar-project.properties @sepaseh +/docs/sonarqube.md @sepaseh -# Release policy, version state, and generated release notes. +# Optional manual release and environment tooling. /.release-please-manifest.json @sepaseh /CHANGELOG.md @sepaseh /docs/release-operations.md @sepaseh /docs/releasing.md @sepaseh /release-please-config.json @sepaseh - -# Production-like staging gate. /.github/workflows/staging.yml @sepaseh /docs/staging.md @sepaseh /staging.config.ts @sepaseh /staging/ @sepaseh - -# Security model, review evidence, and active scanning. +/.github/workflows/deployment-smoke.yml @sepaseh +/smoke.config.ts @sepaseh +/smoke/ @sepaseh /.github/workflows/dast.yml @sepaseh + +# Security model and review evidence. /docs/security/ @sepaseh /SECURITY.md @sepaseh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8da2fb..b35a279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,6 @@ concurrency: permissions: contents: read -env: - NODE_VERSION: 24.18.0 - jobs: check: runs-on: ubuntu-latest @@ -31,7 +28,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc cache: npm - run: npm ci @@ -55,40 +52,29 @@ jobs: path: pacts retention-days: 14 - - name: Validate SonarQube configuration - continue-on-error: true - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository + - name: Detect SonarQube configuration + id: sonar-config env: SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | - missing=() - - [[ -z "$SONAR_HOST_URL" ]] && missing+=("SONAR_HOST_URL") - [[ -z "$SONAR_PROJECT_KEY" ]] && missing+=("SONAR_PROJECT_KEY") - [[ -z "$SONAR_TOKEN" ]] && missing+=("SONAR_TOKEN") - - if (( ${#missing[@]} > 0 )); then - missing_list=$(IFS=,; echo "${missing[*]}") - echo "::error title=Missing SonarQube configuration::Configure these repository settings: $missing_list" - exit 1 + if [[ -n "$SONAR_HOST_URL" && -n "$SONAR_PROJECT_KEY" && -n "$SONAR_TOKEN" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice title=SonarQube skipped::Repository settings are not available for this run." fi - - name: SonarQube scan + - name: SonarQube advisory scan + if: steps.sonar-config.outputs.enabled == 'true' continue-on-error: true - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 env: SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} with: - args: -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} + args: -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - run: npm run knip @@ -107,13 +93,17 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc cache: npm - run: npm ci - run: npm run performance + - name: Lighthouse advisory audit + continue-on-error: true + run: npm run lighthouse + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist-${{ github.sha }} @@ -141,14 +131,14 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc cache: npm - run: npm ci - - run: npx playwright install --with-deps chromium firefox webkit + - run: npx playwright install --with-deps chromium - - run: npm run test:e2e + - run: npm run test:e2e -- --project=chromium - if: ${{ !cancelled() }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index a16532c..31b8c1c 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -1,10 +1,16 @@ name: DAST on: - schedule: - - cron: "0 3 1 * *" workflow_dispatch: - + inputs: + target_url: + description: Authorized staging application URL + required: true + type: string + allowed_host: + description: Exact hostname permitted for the scan + required: true + type: string concurrency: cancel-in-progress: false group: staging-dast @@ -14,13 +20,12 @@ permissions: jobs: zap: - environment: staging runs-on: ubuntu-latest timeout-minutes: 60 env: - DAST_ALLOWED_HOST: ${{ vars.STAGING_ALLOWED_HOST }} + DAST_ALLOWED_HOST: ${{ inputs.allowed_host }} DAST_PRODUCTION_HOST: ${{ vars.PRODUCTION_HOST }} - DAST_TARGET: ${{ vars.STAGING_BASE_URL }} + DAST_TARGET: ${{ inputs.target_url }} steps: - name: Validate authorized target diff --git a/.github/workflows/deployment-smoke.yml b/.github/workflows/deployment-smoke.yml index c46aa80..207e8d0 100644 --- a/.github/workflows/deployment-smoke.yml +++ b/.github/workflows/deployment-smoke.yml @@ -1,7 +1,6 @@ name: Deployment smoke tests on: - deployment_status: workflow_dispatch: inputs: application_url: @@ -15,26 +14,17 @@ on: concurrency: cancel-in-progress: true - group: deployment-smoke-${{ github.event.deployment.environment || inputs.application_url }} + group: deployment-smoke-${{ inputs.application_url }} permissions: contents: read jobs: smoke: - if: >- - github.event_name == 'workflow_dispatch' || - github.event.deployment_status.state == 'success' runs-on: ubuntu-latest env: - NODE_VERSION: 24.18.0 - SMOKE_API_HEALTH_URL: >- - ${{ github.event_name == 'workflow_dispatch' && - inputs.api_health_url || vars.SMOKE_API_HEALTH_URL }} - SMOKE_BASE_URL: >- - ${{ github.event_name == 'workflow_dispatch' && - inputs.application_url || - github.event.deployment_status.environment_url }} + SMOKE_API_HEALTH_URL: ${{ inputs.api_health_url }} + SMOKE_BASE_URL: ${{ inputs.application_url }} steps: - name: Validate smoke-test targets @@ -49,7 +39,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: npm - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - run: npm ci diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index f5c3a7a..4c41450 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -4,16 +4,6 @@ on: schedule: - cron: "0 4 * * 1" workflow_dispatch: - pull_request: - branches: - - main - paths: - - "src/shared/api/token.ts" - - "src/shared/api/token.test.ts" - - "src/shared/storage/**" - - "src/shared/lib/**" - - "stryker.config.json" - concurrency: cancel-in-progress: true group: mutation-${{ github.ref }} @@ -26,7 +16,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 env: - NODE_VERSION: 24.18.0 VITE_API_BASE_URL: http://localhost steps: @@ -35,7 +24,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: npm - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - run: npm ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fec6310..8be380a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ name: Release on: - push: - branches: - - main workflow_dispatch: permissions: @@ -12,6 +9,7 @@ permissions: jobs: release: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 268ab10..6bf03aa 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -1,7 +1,6 @@ name: Staging validation on: - deployment_status: workflow_dispatch: inputs: application_url: @@ -15,27 +14,17 @@ on: concurrency: cancel-in-progress: true - group: staging-${{ github.event.deployment.id || inputs.application_url }} + group: staging-${{ inputs.application_url }} permissions: contents: read jobs: validate: - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.deployment_status.state == 'success' && - github.event.deployment.environment == 'staging') runs-on: ubuntu-latest env: - NODE_VERSION: 24.18.0 - STAGING_API_HEALTH_URL: >- - ${{ github.event_name == 'workflow_dispatch' && - inputs.api_health_url || vars.STAGING_API_HEALTH_URL }} - STAGING_BASE_URL: >- - ${{ github.event_name == 'workflow_dispatch' && - inputs.application_url || - github.event.deployment_status.environment_url }} + STAGING_API_HEALTH_URL: ${{ inputs.api_health_url }} + STAGING_BASE_URL: ${{ inputs.application_url }} steps: - name: Validate staging targets @@ -52,7 +41,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: npm - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - run: npm ci diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f26c374..1434a04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ review. ## Development setup Kernel uses Node.js 24.15.0 or newer in the Node.js 24 LTS line. If you use -`nvm`, select the repository version before installing dependencies: +`nvm`, select the exact repository version from `.nvmrc` before installing +dependencies. CI reads the same file: ```bash nvm use @@ -43,9 +44,10 @@ npm run audit npm run lint npm run format:check npm run test:coverage +npm run test:contract npm run knip -npm run build -npm run test:e2e +npm run performance +npm run test:e2e -- --project=chromium ``` Use `npm run format` and `npm run lint:fix` to apply safe automatic fixes. diff --git a/README.md b/README.md index d077d8d..f56bd9b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Install dependencies: npm install ``` +The exact Node.js version used by CI is recorded in `.nvmrc`. + Create a local environment file: ```bash diff --git a/SECURITY.md b/SECURITY.md index a3b2277..070ba67 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,7 +42,7 @@ The maintained security program includes: - The [application threat model](docs/security/threat-model.md). - The [authentication and session review](docs/security/authentication-review.md). - The [security testing and review schedule](docs/security/reviews.md). -- Monthly active DAST against an explicitly authorized staging environment. +- Optional manual DAST against an explicitly authorized non-production target. Security scan artifacts and unresolved vulnerability details are private. Automated results require human triage and do not replace manual review. diff --git a/docs/branch-protection.md b/docs/branch-protection.md index 767a113..53bf93c 100644 --- a/docs/branch-protection.md +++ b/docs/branch-protection.md @@ -5,11 +5,15 @@ request after the branch is current and these checks pass: | Required check | Coverage | | -------------------------------------------- | ------------------------------------------------------- | -| `CI / check` | Audit, types, lint, format, tests, SonarQube, dead code | +| `CI / check` | Audit, types, lint, format, tests, contracts, dead code | | `CI / build` | Validated production build | | `CI / e2e` | Critical browser journeys | | `CodeQL / Analyze JavaScript and TypeScript` | CodeQL security analysis | +SonarQube also runs on pull requests as an advisory signal. It is intentionally +not a required check; findings should be reviewed without making service +availability a condition for merging. + Protection also: - Applies to repository administrators. @@ -18,16 +22,6 @@ Protection also: - Prevents deletion of `main`. An approving review is not required for ordinary changes while the repository -has only one active maintainer. Release pull requests follow the explicit -approval process in the -[release operations runbook](release-operations.md). Enable at least one -approval and Code Owner review when another maintainer can review pull requests -without blocking all development. - -Configure the GitHub `production` environment separately with required -reviewers. Production deployment jobs must reference that environment so its -approval gate applies after merge and before deployment. - -The SonarQube scan is part of the required `check` job. Configure the repository -settings described in [Testing](testing.md#sonarqube) before treating the -quality gate as active. +has only one active maintainer. Enable at least one approval and Code Owner +review when another maintainer can review pull requests without blocking all +development. diff --git a/docs/contract-testing.md b/docs/contract-testing.md index fbb818a..8ed852a 100644 --- a/docs/contract-testing.md +++ b/docs/contract-testing.md @@ -12,8 +12,8 @@ practical. Expand it as dedicated unit coverage grows. Do not exclude a mutant only to raise the score; add an assertion or document why the mutation is equivalent to the original behavior. -Mutation testing runs weekly and when its selected source or tests change. -Reports are retained as private workflow artifacts. +Mutation testing runs weekly or on manual request, outside the pull-request +feedback loop. Reports are retained as private workflow artifacts. ## Consumer contracts @@ -22,13 +22,14 @@ mock provider. It verifies the request method, path, JSON body, wire-format field names, response shape, and the frontend's snake-case to camel-case transformation. -The generated `pacts/kernel-web-kernel-api.json` contract is a CI artifact. The -API repository must verify that contract against the candidate API before -frontend or backend deployment. Publish it to the team's Pact Broker when one -is configured, using the frontend commit SHA as the consumer version and the -branch or release environment for deployment metadata. +The generated `pacts/kernel-web-kernel-api.json` contract is a CI artifact. +Kernel does not currently assume a Pact Broker or provider-verification +pipeline. A downstream project should verify the contract against its candidate +API before treating Pact as a deployment gate. When a broker is configured, +publish it using the frontend commit SHA as the consumer version and the branch +or release environment for deployment metadata. -Changes are compatible only when: +Once provider verification is adopted, changes are compatible only when: - Consumer contract generation passes. - The provider verifies every interaction. diff --git a/docs/deployment.md b/docs/deployment.md index e5abe9e..5e280f7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,8 +4,11 @@ This repository includes an nginx configuration for serving the built frontend with client-side routing, long-lived asset caching, and required security headers. -Release candidates must pass the production-like -[staging environment](staging.md) before production promotion. +Kernel is a project template and does not have an active deployment environment +of its own. The staging validation, deployment smoke, and DAST workflows are +reusable templates that run only when started manually. A downstream project +may configure and automate them after it has real, authorized deployment +targets. ## Production Build @@ -81,35 +84,10 @@ HSTS is honored by browsers only over HTTPS. When TLS terminates at a CDN or load balancer, configure that edge to preserve these response headers. Run the Playwright suite against the production build to verify the complete policy. -## Post-deployment smoke tests +## Optional deployment checks -The deployment smoke workflow runs after a successful GitHub deployment status -or can be started manually. It checks that: - -- The deployed HTML and application root load. -- Same-origin scripts, stylesheets, and fonts resolve successfully. -- Direct navigation to `/auth/register` reaches the client-side route. -- The configured public API health endpoint returns a successful status. - -Set the repository variable `SMOKE_API_HEALTH_URL` to the public HTTPS health -endpoint used by automatic deployment runs. The deployment provider must -include its application URL in the successful deployment status. For a manual -run, provide both URLs as workflow inputs. - -To run the same checks locally against a deployed environment: - -```bash -SMOKE_BASE_URL=https://app.example.com/ \ -SMOKE_API_HEALTH_URL=https://api.example.com/health \ -npm run test:smoke -``` - -## Release control and rollback - -Production deployments must use the immutable artifact associated with the -approved GitHub Release. Keep at least the current and previous production -artifacts available so recovery does not require rebuilding old source. - -Follow the [release operations runbook](release-operations.md) for approval -evidence, observation windows, rollback triggers, recovery steps, and incident -follow-up. +When a real environment exists, the `Staging validation`, `Deployment smoke +tests`, and `DAST` workflows can be started manually with explicitly supplied +targets. They do not run on pull requests, schedules, or deployment events. +See [Staging](staging.md) and [Release operations](release-operations.md) before +using them against an authorized environment. diff --git a/docs/development.md b/docs/development.md index 7d3e4e3..278d6bb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,7 +4,8 @@ This project is a Vite React application written in TypeScript. ## Prerequisites -- Node.js 24.15.0 or newer in the Node.js 24 LTS line +- Node.js 24.15.0 or newer in the Node.js 24 LTS line; `.nvmrc` contains the + exact version used by CI - npm - Access to the backend API diff --git a/docs/quality-gate.md b/docs/quality-gate.md deleted file mode 100644 index c6b068e..0000000 --- a/docs/quality-gate.md +++ /dev/null @@ -1,40 +0,0 @@ -# SonarQube Analysis - -Kernel uses SonarQube as an advisory code-quality signal. Analysis findings are -visible in SonarQube and pull requests, but the repository CI does not fail when -the scanner is unavailable or the quality gate is not satisfied. - -The recommended project quality gate uses these new-code targets: - -| New-code condition | Recommended value | -| -------------------------- | ----------------- | -| Issues | `0` | -| Security Hotspots reviewed | `100%` | -| Coverage | `>= 80%` | -| Duplicated lines | `<= 3%` | - -These targets apply to new code only. They guide incremental improvement without -blocking structural refactors or small maintenance changes. - -## SonarQube setup - -An administrator must: - -1. Create a custom quality gate based on `Sonar way`. -2. Use the recommended targets above as project guidance. -3. Keep security findings visible and assign critical findings for remediation. -4. Assign the gate to the Kernel project. -5. Define new code using the previous-version or reference-branch strategy. - -The repository keeps SonarQube's small-change exception enabled and does not wait -for the quality-gate result. The CI scan is non-blocking, so analysis remains -available without making SonarQube an availability dependency for pull requests. - -The remote gate cannot be configured from this repository. It requires -SonarQube project-administrator access and all three GitHub Actions settings: -the `SONAR_TOKEN` repository secret plus the `SONAR_PROJECT_KEY` and -`SONAR_HOST_URL` repository variables. CI reports missing configuration without -blocking the remaining checks. See [Testing](testing.md#sonarqube) for -configuration details. If a separate SonarQube or SonarCloud status check is -required by branch protection, that check must also be made optional in the -GitHub repository settings. diff --git a/docs/release-operations.md b/docs/release-operations.md index 3904242..8418cca 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -1,9 +1,13 @@ # Release approval and rollback -This runbook governs production releases of Kernel. It complements the +This template describes a possible production release process for downstream +projects. It complements the [versioning policy](releasing.md) and the [deployment guide](deployment.md). +This is a readiness template, not evidence of an active deployment process. +Use it only after the project has real staging and production environments. + ## Ownership | Role | Owner | Responsibility | @@ -18,8 +22,9 @@ request and production deployment should be approved by someone other than the change author. Repository ownership is enforced for release configuration and version files -through `CODEOWNERS`. Configure the GitHub `production` environment with required -reviewers and prevent administrators from bypassing its protection rules. +through `CODEOWNERS`. A downstream project adopting this process should +configure a protected GitHub `production` environment with appropriate +reviewers. ## Release approval @@ -29,16 +34,16 @@ the release manager must verify: - The version matches the intended SemVer impact. - The changelog is complete, understandable, and contains no sensitive data. - CI `check`, `build`, and `e2e` jobs pass for the exact candidate commit. -- CodeQL passes and blocking SonarQube security findings are reviewed. +- CodeQL passes and advisory SonarQube findings are reviewed. - Dependency audit, performance budgets, and production security-header tests pass. -- Required DAST and manual security reviews have no unresolved release-blocking - findings. +- If DAST or manual security review is adopted, it has no unresolved + release-blocking findings. - Required configuration or data migrations have a tested rollback path. - The previous production artifact and its configuration remain available. - A deployment operator and incident lead are available for the release window. -- The exact candidate artifact passes the - [staging validation gate](staging.md). +- If staging is adopted as a release gate, the exact candidate artifact passes + [staging validation](staging.md). Approval is recorded by approving and merging the release pull request. Never publish a tag or deploy from an unreviewed commit. The generated GitHub Release, @@ -50,7 +55,7 @@ After deployment: 1. Record the version, commit SHA, artifact identifier, operator, and start time in the deployment record. -2. Run the automated deployment smoke tests. +2. Manually run the deployment smoke workflow. 3. Verify login, protected routing, and one read-only authenticated journey. 4. Confirm security headers and API health checks succeed. 5. Compare error rate, failed requests, latency, largest contentful paint, diff --git a/docs/releasing.md b/docs/releasing.md index 630d0b1..8b4f1fa 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -25,22 +25,26 @@ Scopes are optional, for example `feat(auth): add passkey login`. Pull requests should be squash-merged with a compliant title so each merged change has one clear release-note entry. -## Automated release flow +## Optional manual release flow -After releasable commits reach `main`, the Release workflow creates or updates a -release pull request. That pull request updates: +The repository does not publish releases automatically. When a release is +needed, manually run the `Release` workflow on `main`; Release Please then +creates or updates a release pull request. The release job is restricted to +`main`; manual runs started from another branch are skipped. The pull request +updates: - `CHANGELOG.md` - `package.json` and `package-lock.json` - `.release-please-manifest.json` Review and merge the release pull request only after its required checks pass. -The next workflow run then creates the `vMAJOR.MINOR.PATCH` tag and GitHub -Release with generated notes. This application is private and is not published -to npm. +Run the workflow manually again to create the `vMAJOR.MINOR.PATCH` tag and +GitHub Release with generated notes. This application is private and is not +published to npm. -Every production release must follow the approval, verification, rollback, and -follow-up steps in the [release operations runbook](release-operations.md). +A downstream project adopting production releases should tailor and follow the +approval, verification, rollback, and follow-up template in the +[release operations runbook](release-operations.md). The workflow uses the repository `GITHUB_TOKEN` by default. If repository policy requires release pull requests to trigger other workflows, configure a diff --git a/docs/security/reviews.md b/docs/security/reviews.md index 36bd4a1..71ea117 100644 --- a/docs/security/reviews.md +++ b/docs/security/reviews.md @@ -3,55 +3,31 @@ Security checks are defense in depth and do not replace review by a qualified person. -| Activity | Frequency | Owner | Evidence | -| --------------------------------------------- | ------------------------------------------- | ------------------------------ | -------------------------------------- | -| Dependency audit, CodeQL, and secret scanning | Every change | Maintainer | Required CI checks | -| Staging DAST full scan | Monthly and before major releases | Security reviewer | Private workflow artifact | -| Threat-model review | Quarterly and on trust-boundary changes | Security reviewer | Reviewed document change | -| Authentication/session review | Quarterly and on auth changes | Frontend and API owners | Completed checklist | -| Manual application security review | At least annually and before major releases | Independent qualified reviewer | Private report and tracked remediation | -| Rollback exercise | At least twice yearly | Release manager | Exercise record | - -## DAST rules - -The `DAST` workflow runs an active OWASP ZAP full scan against the repository -variable `STAGING_BASE_URL`. The target must be an explicitly authorized, -disposable staging environment. Active scans may submit forms, create data, -trigger email or OTP delivery, and place load on the API. - -Configure a protected GitHub environment named `staging`. Store -`STAGING_BASE_URL`, `STAGING_ALLOWED_HOST`, and `PRODUCTION_HOST` as -environment-scoped variables so repository-level values cannot redirect the -scan. Require approval from a staging security owner before the job can start. - -Before enabling the schedule: - -1. Obtain written authorization from the application, API, hosting, CDN, and - security owners. -2. Seed non-sensitive test data and disable real notifications or downstream - side effects. -3. Confirm backups, rate limits, monitoring, and a cleanup procedure. -4. Set `STAGING_BASE_URL` to the exact HTTPS application origin, - `STAGING_ALLOWED_HOST` to that origin's hostname, and `PRODUCTION_HOST` to the - production hostname. The workflow requires an exact staging-host match and - rejects the production host. -5. Run the workflow manually and review the private artifact. - -The job fails on reported alerts. Do not suppress a rule merely to make the -scan pass. Record a suppression only after review, with the rule ID, affected -URL, evidence, owner, expiry date, and justification. - -Do not run active DAST against production. Authenticated scanning requires a -separate least-privileged test account, explicit scope approval, and secure -injection of short-lived credentials; never commit scan credentials. +| Activity | Frequency | Owner | Evidence | +| --------------------------------------------- | ---------------------------- | ------------------------------ | -------------------------------------- | +| Dependency audit, CodeQL, and secret scanning | Every change | Maintainer | Required CI checks | +| Authorized DAST scan | Manually when staging exists | Security reviewer | Private workflow artifact | +| Threat-model review | On trust-boundary changes | Security reviewer | Reviewed document change | +| Authentication/session review | On authentication changes | Frontend and API owners | Completed checklist | +| Manual application security review | Before a production launch | Independent qualified reviewer | Private report and tracked remediation | ## Finding handling Triage every finding for exploitability, affected versions, data exposure, and release impact. Handle sensitive details through GitHub private vulnerability reporting. Assign an owner and due date, add regression coverage, and rerun the -relevant scan after remediation. +relevant check after remediation. + +Critical or actively exploited findings block deployment. High-severity +findings block the affected release unless the security owner documents a +time-limited exception with compensating controls. + +Kernel keeps the DAST workflow only as a reusable template for downstream +projects; it is not part of Kernel's active quality gate. The workflow has no +schedule and can only be started manually. -Critical or actively exploited findings block deployment and trigger the -incident process. High-severity findings block the affected release unless the -security owner documents a time-limited exception with compensating controls. +Before using the template, configure the repository variable `PRODUCTION_HOST` +with the production hostname only, without a protocol or path. Each manual run +still requires an explicitly authorized target and its exact allowed hostname. +The fixed repository variable prevents the run from redefining production and +must never match the scan target. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 87a1460..f368b9e 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -28,8 +28,7 @@ infrastructure, or trust-boundary changes and at least quarterly. refresh cookie and its lifecycle. 5. Language and theme preferences cross the browser-storage boundary but must never contain credentials or sensitive account data. -6. CI builds artifacts and security reports using repository variables and - secrets. Staging and production are separate deployment boundaries. +6. CI builds artifacts and security reports without deployment credentials. 7. Sanitized browser errors and performance events may cross into the configured observability service. @@ -43,7 +42,7 @@ infrastructure, or trust-boundary changes and at least quarterly. | Broken access control | Unauthorized user or role administration | Client route/action filtering | Enforce every permission on the API; client checks are not security boundaries | | Sensitive data exposure | Privacy or credential compromise | HTTPS validation, redacted observability, memory-only access token | Inspect logs, errors, caches, source maps, and browser storage | | Malicious or vulnerable build input | Compromised production artifact | Pinned Actions, audit, CodeQL, SBOM/license checks, protected branch | Review workflow permissions and artifact provenance | -| Unsafe deployment or rollback | Extended outage or vulnerable release | Staging gate, immutable releases, smoke tests, rollback runbook | Exercise rollback and compare release, artifact, and deployed SHA | +| Unsafe deployment or rollback | Extended outage or vulnerable release | Reproducible production build and immutable commit history | Define deployment and rollback controls when hosting is selected | | Denial of service or automated abuse | Unavailable authentication/API | Client request cancellation where applicable | Verify API rate limits, quotas, timeouts, and alerting | | Clickjacking or content-type confusion | Deceptive UI or script execution | Frame denial, MIME sniffing protection, CSP | Validate headers on CDN and direct routes | | Observability leakage | Secrets in monitoring systems | Client-side redaction and optional endpoint | Test server-side scrubbing and access/retention policy | @@ -68,10 +67,8 @@ infrastructure, or trust-boundary changes and at least quarterly. - Refresh cookies are inaccessible to JavaScript and narrowly scoped. - Logout and terminal refresh failure clear client and server session state. - Error reports, logs, build artifacts, and test evidence contain no secrets. -- Released tags are immutable and production can return to a known-good - artifact. -- Active security testing targets only explicitly authorized, disposable - staging systems. +- Production can return to a known-good build once deployment automation is + introduced. ## Residual risks diff --git a/docs/sonarqube.md b/docs/sonarqube.md new file mode 100644 index 0000000..c9813f2 --- /dev/null +++ b/docs/sonarqube.md @@ -0,0 +1,19 @@ +# SonarQube + +CI runs SonarQube as an advisory check after Vitest produces the LCOV coverage +report. Scanner failures do not block the remaining checks or merging. Runs +without the required repository settings are skipped with a notice. + +Configure `SONAR_HOST_URL` and `SONAR_PROJECT_KEY` as repository variables and +`SONAR_TOKEN` as a repository secret. + +To keep pull-request feedback focused, configure SonarQube in its project UI: + +- Apply the quality gate to new code rather than existing project debt. +- Keep bugs, vulnerabilities, and security hotspots visible. +- Disable stylistic TypeScript rules already enforced by ESLint or the compiler. +- Add rule or file exclusions only for a reviewed, documented false positive. + +Analysis scope and LCOV import stay versioned in `sonar-project.properties`. +Quality Profile and issue exclusions belong in the SonarQube UI so maintainers +can review them centrally. diff --git a/docs/staging.md b/docs/staging.md index 57eba97..d576d8c 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -1,12 +1,15 @@ # Staging environment -Staging is the production-like release gate for Kernel. It must use the same +This is an optional, manually invoked capability. The repository does not +currently assume that a staging environment exists. + +When provisioned, staging is the production-like release gate for Kernel. It must use the same build artifact, web-server configuration, routing rules, security headers, and API contract intended for production. ## Environment requirements -Provision a GitHub environment named exactly `staging` and configure: +Provide a staging deployment with: - A stable HTTPS application URL. - Non-production API, identity, and data stores with production-compatible @@ -18,20 +21,15 @@ Provision a GitHub environment named exactly `staging` and configure: - Access controls that still allow GitHub-hosted test runners to reach the application and health endpoint. -Set the repository Actions variable `STAGING_API_HEALTH_URL` to the public HTTPS -health endpoint. The validation job intentionally does not attach itself to a -GitHub environment, because doing so would create another deployment status and -could retrigger the deployment-status workflow. - Keep staging configuration separate from production secrets. Build once with an immutable release identifier and promote that artifact between environments; do not rebuild source specifically for production. -## Automated validation +## Manual validation -The `Staging validation` workflow runs when GitHub receives a successful -deployment status for the `staging` environment. It can also be started -manually with application and API health URLs. +Start the `Staging validation` workflow manually and supply the application and +API health URLs. It never runs on pull requests, schedules, or deployment +events. The live suite verifies: @@ -56,7 +54,8 @@ npm run test:staging ## Promotion -A release is eligible for production only when: +When staging is adopted as a release gate, a release is eligible for production +only when: 1. The ordinary CI and CodeQL checks pass, and SonarQube findings are reviewed. 2. The exact candidate artifact is deployed to staging. @@ -68,6 +67,7 @@ A release is eligible for production only when: Failed staging checks block promotion. Fix the issue through a reviewed change, create a new candidate artifact, and repeat the complete staging gate. -The separate active DAST workflow also targets staging on the -[security review schedule](security/reviews.md). Its authorization, isolation, -and side-effect controls must be satisfied before enabling scheduled scans. +The separate DAST workflow is a reusable template for downstream projects, not +an active Kernel check. A project adopting it must configure `PRODUCTION_HOST` +and satisfy its authorization, isolation, and side-effect controls before every +manual scan. diff --git a/docs/testing.md b/docs/testing.md index b7faab5..81739b8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -47,22 +47,24 @@ separate because they run against the built application. - `npm test` runs Vitest once. - `npm run test:watch` starts Vitest in watch mode. - `npm run test:coverage` writes text, HTML, JSON summary, and LCOV reports. +- `npm run test:contract` generates Pact consumer contracts. - `npm run test:e2e` builds the application and runs Playwright. - `npm run test:e2e:ui` opens Playwright's interactive UI. - `npm run test:e2e:report` opens the most recent HTML report. - `npm run test:e2e:update-snapshots` reviews and updates visual baselines. -- `npm run performance` builds the app, checks bundle budgets, and runs - Lighthouse. +- `npm run performance` builds the app and checks bundle-size budgets. +- `npm run lighthouse` runs the local advisory Lighthouse audit after a build. +- `npm run test:mutation`, `test:smoke`, and `test:staging` run specialized + checks that are scheduled or manually invoked outside the pull-request loop. Vitest loads the tracked `.env.test` file, which provides deterministic local API and application base URLs for unit, component, and API integration tests. These values take precedence over developer-specific `.env.local` settings, so tests never depend on or contact a configured development backend. -Local Chromium and mobile E2E runs use the installed stable Chrome channel. -Firefox and WebKit use Playwright's pinned browser builds. GitHub CI installs -all three pinned engines for reproducibility. Install the required local -browsers once with: +Pull requests run the critical browser suite in Chromium. Firefox, WebKit, +mobile, and visual projects remain available for focused local checks when a +change warrants broader browser coverage. Install those browsers as needed: ```sh npx playwright install firefox webkit @@ -81,12 +83,16 @@ The production build enforces two JavaScript size limits: - No individual JavaScript chunk may exceed 450 KB. - All emitted JavaScript chunks combined may not exceed 1.6 MB. -Lighthouse audits the production login page and requires a performance score of -at least 80, first contentful paint within 3.5 seconds, largest contentful paint -within 4 seconds, time to interactive within 4 seconds, total blocking time -below 300 milliseconds, and cumulative layout shift no greater than 0.1. -Reports are written to `.lighthouseci/` and uploaded by CI for 14 days, -including when the performance job fails. +These limits catch unexpectedly large bundles without pretending that a CI +preview server represents real production performance. + +CI also runs a non-blocking Lighthouse audit against the built login page and +uploads its HTML and JSON reports for 14 days. Broad advisory thresholds flag +only substantial regressions: a performance score below 65, FCP above 5s, LCP +above 6s, TTI above 7s, TBT above 600ms, or CLS above 0.2. Runner variance may +still affect results, so bundle-size budgets remain the blocking performance +gate. Replace the preview target with a representative deployed URL when one +exists. ## Conventions @@ -122,9 +128,6 @@ Coverage uses Vitest's V8 provider and includes untested source files. Reports are written to `coverage/`, which is ignored by Git. Global thresholds preserve the established baseline across statements, branches, functions, and lines. Raise thresholds as coverage grows; never lower them to make a change pass. -SonarQube reports a recommended target of at least 80% coverage on new code. Its -quality gate is advisory and does not block CI. - Coverage is a guardrail, not a quality score. Critical authentication, token refresh, authorization, and account-management branches should receive direct behavioral tests even when the global threshold is already satisfied. @@ -138,21 +141,7 @@ commands, CI behavior, thresholds, and provider verification requirements. ## SonarQube -CI sends the existing LCOV report to SonarQube and requires all of these GitHub -repository settings: - -| Setting | GitHub type | Value | -| ------------------- | ------------------- | ------------------------------------------- | -| `SONAR_HOST_URL` | Repository variable | Base URL of the SonarQube service | -| `SONAR_PROJECT_KEY` | Repository variable | Key of the Kernel project in SonarQube | -| `SONAR_TOKEN` | Repository secret | Project analysis token created in SonarQube | - -Configure them under **Settings → Secrets and variables → Actions**. Store the -token as a secret, never as a variable or committed file. CI reports the names -of missing settings before analysis starts and continues the remaining checks; -it never prints their values. - -The scan publishes findings without waiting for the configured quality gate. -Scanner or gate failures are reported as errors but do not block CI. See -[SonarQube Analysis](quality-gate.md) for the recommended new-code targets and -project setup. +SonarQube consumes the existing Vitest LCOV report and adds advisory analysis +for new bugs, vulnerabilities, security hotspots, and maintainability issues. +It does not replace ESLint, TypeScript, Vitest, or CodeQL and does not block CI. +See [SonarQube](sonarqube.md) for repository settings and noise controls. diff --git a/eslint.config.ts b/eslint.config.ts index 866538b..35b49f3 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,252 +12,72 @@ import { fileURLToPath } from "url"; const srcRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "src"); -// Recess order: positioning → box model → flex/grid → border → background/color → typography → ui → transform/animation -// https://github.com/stormwarning/stylelint-config-recess-order -const RECESS_ORDER = [ - // Generated content - "content", - // Positioning - "position", - "top", - "right", - "bottom", - "left", - "inset", - "insetBlock", - "insetBlockStart", - "insetBlockEnd", - "insetInline", - "insetInlineStart", - "insetInlineEnd", - "zIndex", - // Box model - "display", - "visibility", - "float", - "clear", - "overflow", - "overflowX", - "overflowY", - "overflowBlock", - "overflowInline", - "clipPath", - "boxSizing", - "width", - "minWidth", - "maxWidth", - "height", - "minHeight", - "maxHeight", - "inlineSize", - "minInlineSize", - "maxInlineSize", - "blockSize", - "minBlockSize", - "maxBlockSize", - "aspectRatio", - "padding", - "paddingTop", - "paddingRight", - "paddingBottom", - "paddingLeft", - "paddingBlock", - "paddingBlockStart", - "paddingBlockEnd", - "paddingInline", - "paddingInlineStart", - "paddingInlineEnd", - "margin", - "marginTop", - "marginRight", - "marginBottom", - "marginLeft", - "marginBlock", - "marginBlockStart", - "marginBlockEnd", - "marginInline", - "marginInlineStart", - "marginInlineEnd", - // Flex - "flex", - "flexDirection", - "flexWrap", - "flexFlow", - "flexGrow", - "flexShrink", - "flexBasis", - "alignContent", - "alignItems", - "alignSelf", - "justifyContent", - "justifyItems", - "justifySelf", - "placeContent", - "placeItems", - "placeSelf", - "gap", - "rowGap", - "columnGap", - "order", - // Grid - "grid", - "gridTemplate", - "gridTemplateColumns", - "gridTemplateRows", - "gridTemplateAreas", - "gridAutoColumns", - "gridAutoRows", - "gridAutoFlow", - "gridArea", - "gridColumn", - "gridColumnStart", - "gridColumnEnd", - "gridRow", - "gridRowStart", - "gridRowEnd", - // Border - "border", - "borderTop", - "borderRight", - "borderBottom", - "borderLeft", - "borderBlock", - "borderBlockStart", - "borderBlockEnd", - "borderInline", - "borderInlineStart", - "borderInlineEnd", - "borderWidth", - "borderTopWidth", - "borderRightWidth", - "borderBottomWidth", - "borderLeftWidth", - "borderStyle", - "borderTopStyle", - "borderRightStyle", - "borderBottomStyle", - "borderLeftStyle", - "borderColor", - "borderTopColor", - "borderRightColor", - "borderBottomColor", - "borderLeftColor", - "borderRadius", - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomRightRadius", - "borderBottomLeftRadius", - "borderStartStartRadius", - "borderStartEndRadius", - "borderEndStartRadius", - "borderEndEndRadius", - "borderSpacing", - "borderCollapse", - "outline", - "outlineWidth", - "outlineStyle", - "outlineColor", - "outlineOffset", - "boxShadow", - // Background & color - "background", - "backgroundColor", - "backgroundImage", - "backgroundPosition", - "backgroundPositionX", - "backgroundPositionY", - "backgroundSize", - "backgroundRepeat", - "backgroundAttachment", - "backgroundClip", - "backgroundOrigin", - "color", - "opacity", - "filter", - "backdropFilter", - "mixBlendMode", - "isolation", - // Typography - "font", - "fontFamily", - "fontSize", - "fontWeight", - "fontStyle", - "fontVariant", - "fontStretch", - "lineHeight", - "letterSpacing", - "wordSpacing", - "textAlign", - "textDecoration", - "textDecorationColor", - "textDecorationLine", - "textDecorationStyle", - "textTransform", - "textOverflow", - "textShadow", - "textIndent", - "whiteSpace", - "wordBreak", - "wordWrap", - "overflowWrap", - "hyphens", - "verticalAlign", - "direction", - // List & table - "listStyle", - "listStyleType", - "listStylePosition", - "listStyleImage", - "tableLayout", - "captionSide", - "emptyCells", - // UI - "cursor", - "pointerEvents", - "resize", - "userSelect", - "appearance", - "caretColor", - "scrollBehavior", - "scrollSnapType", - "scrollSnapAlign", - "touchAction", - "willChange", - // SVG - "fill", - "fillOpacity", - "fillRule", - "stroke", - "strokeWidth", - "strokeDasharray", - "strokeDashoffset", - "strokeLinecap", - "strokeLinejoin", - "strokeMiterlimit", - "strokeOpacity", - // Transform & animation - "transform", - "transformOrigin", - "transformStyle", - "perspective", - "perspectiveOrigin", - "backfaceVisibility", - "transition", - "transitionProperty", - "transitionDuration", - "transitionTimingFunction", - "transitionDelay", - "animation", - "animationName", - "animationDuration", - "animationTimingFunction", - "animationDelay", - "animationIterationCount", - "animationDirection", - "animationFillMode", - "animationPlayState", -]; +const SHORTHAND_LONGHANDS: Record = { + animation: [ + "animationDelay", + "animationDirection", + "animationDuration", + "animationFillMode", + "animationIterationCount", + "animationName", + "animationPlayState", + "animationTimingFunction", + ], + background: [ + "backgroundAttachment", + "backgroundClip", + "backgroundColor", + "backgroundImage", + "backgroundOrigin", + "backgroundPosition", + "backgroundRepeat", + "backgroundSize", + ], + border: [ + "borderBlock", + "borderBottom", + "borderColor", + "borderInline", + "borderLeft", + "borderRight", + "borderStyle", + "borderTop", + "borderWidth", + ], + font: [ + "fontFamily", + "fontSize", + "fontStretch", + "fontStyle", + "fontVariant", + "fontWeight", + "lineHeight", + ], + inset: ["bottom", "insetBlock", "insetInline", "left", "right", "top"], + margin: [ + "marginBlock", + "marginBottom", + "marginInline", + "marginLeft", + "marginRight", + "marginTop", + ], + outline: ["outlineColor", "outlineOffset", "outlineStyle", "outlineWidth"], + padding: [ + "paddingBlock", + "paddingBottom", + "paddingInline", + "paddingLeft", + "paddingRight", + "paddingTop", + ], + transition: [ + "transitionDelay", + "transitionDuration", + "transitionProperty", + "transitionTimingFunction", + ], +}; const STYLE_PROP_LIST = ["style", "styles"] as const; @@ -277,48 +97,102 @@ function getPropertyName(key: TSESTree.Property["key"]): string { return ""; } -function getRecessIndex(key: string) { - const i = RECESS_ORDER.indexOf(key); - return i === -1 ? Infinity : i; +function getStyleObjects(node: TSESTree.JSXAttribute) { + if ( + node.name.type !== AST_NODE_TYPES.JSXIdentifier || + !node.value || + node.value.type !== AST_NODE_TYPES.JSXExpressionContainer || + node.value.expression.type !== AST_NODE_TYPES.ObjectExpression + ) { + return []; + } + + const name = node.name.name as StylePropName; + + if (!STYLE_PROPS.has(name)) return []; + + const expression = node.value.expression; + + if (name === "style") return [expression]; + + return expression.properties.flatMap((slot) => + slot.type === AST_NODE_TYPES.Property && + slot.value.type === AST_NODE_TYPES.ObjectExpression + ? [slot.value] + : [], + ); +} + +function getSortableProperties(objNode: TSESTree.ObjectExpression) { + const properties = objNode.properties; + + if ( + properties.some( + (property) => + property.type !== AST_NODE_TYPES.Property || + property.computed || + (property.key.type !== AST_NODE_TYPES.Identifier && + property.key.type !== AST_NODE_TYPES.Literal), + ) + ) { + return []; + } + + return properties as TSESTree.Property[]; } -function checkAndFixStyleObject( +function checkAndFixAlphabeticalStyleOrder( context: Rule.RuleContext, objNode: TSESTree.ObjectExpression, ) { - const props = objNode.properties.filter( - (p): p is TSESTree.Property => - p.type === AST_NODE_TYPES.Property && - (p.key.type === AST_NODE_TYPES.Identifier || - p.key.type === AST_NODE_TYPES.Literal), + const properties = getSortableProperties(objNode); + if (properties.length < 2) return; + + const sorted = [...properties].sort((a, b) => + getPropertyName(a.key).localeCompare(getPropertyName(b.key), "en"), + ); + const firstMismatch = properties.findIndex( + (property, index) => property !== sorted[index], ); - const sorted = [...props].sort((a, b) => { - const keyA = getPropertyName(a.key); - const keyB = getPropertyName(b.key); - return getRecessIndex(keyA) - getRecessIndex(keyB); - }); + if (firstMismatch === -1) return; - const sourceCode = context.sourceCode; - - props.forEach((prop, i) => { - if (prop !== sorted[i]) { - const key = getPropertyName(prop.key); - const expectedKey = getPropertyName(sorted[i].key); - - context.report({ - node: prop.key, - message: `CSS property "${key}" is out of recess order (expected "${expectedKey}" here)`, - fix(fixer) { - return props.map((original, j) => - fixer.replaceText(original, sourceCode.getText(sorted[j] as any)), - ); - }, - }); - } + context.report({ + node: properties[firstMismatch].key, + message: "Style properties should be in alphabetical order.", + fix(fixer) { + return properties.map((property, index) => + fixer.replaceText(property, context.sourceCode.getText(sorted[index])), + ); + }, }); } +function checkShorthandConflicts( + context: Rule.RuleContext, + objNode: TSESTree.ObjectExpression, +) { + const properties = getSortableProperties(objNode); + const propertyByName = new Map( + properties.map((property) => [getPropertyName(property.key), property]), + ); + + for (const [shorthand, longhands] of Object.entries(SHORTHAND_LONGHANDS)) { + const shorthandProperty = propertyByName.get(shorthand); + if (!shorthandProperty) continue; + + const conflicts = longhands.filter((longhand) => + propertyByName.has(longhand), + ); + if (conflicts.length === 0) continue; + + context.report({ + node: shorthandProperty.key, + message: `Do not mix CSS shorthand "${shorthand}" with ${conflicts.join(", ")}.`, + }); + } +} + const localRules = { rules: { "architecture-boundaries": { @@ -423,46 +297,25 @@ const localRules = { }; }, }, - "style-props-recess-order": { + "style-props-alphabetical-order": { meta: { fixable: "code" }, create(context: Rule.RuleContext) { return { JSXAttribute(node: TSESTree.JSXAttribute) { - if (node.name.type !== AST_NODE_TYPES.JSXIdentifier) return; - - const name = node.name?.name as StylePropName; - - if (!STYLE_PROPS.has(name)) { - return; - } - - if ( - !node.value || - node.value.type !== AST_NODE_TYPES.JSXExpressionContainer - ) { - return; - } - - const expr = node.value.expression; - - if (expr.type !== AST_NODE_TYPES.ObjectExpression) { - return; - } - - if (name === "styles") { - expr.properties.forEach((slot) => { - if ( - slot.type === AST_NODE_TYPES.Property && - slot.value?.type === AST_NODE_TYPES.ObjectExpression - ) { - checkAndFixStyleObject(context, slot.value); - } - }); - - return; - } - - checkAndFixStyleObject(context, expr); + getStyleObjects(node).forEach((styleObject) => + checkAndFixAlphabeticalStyleOrder(context, styleObject), + ); + }, + }; + }, + }, + "style-props-no-shorthand-conflicts": { + create(context: Rule.RuleContext) { + return { + JSXAttribute(node: TSESTree.JSXAttribute) { + getStyleObjects(node).forEach((styleObject) => + checkShorthandConflicts(context, styleObject), + ); }, }; }, @@ -493,12 +346,14 @@ export default [ { allowConstantExport: true }, ], "@typescript-eslint/no-explicit-any": "off", + "no-dupe-keys": "error", "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", "local/architecture-boundaries": "error", "local/no-parent-relative-imports": "error", "local/no-alias-for-same-dir": "error", - "local/style-props-recess-order": "error", + "local/style-props-alphabetical-order": "warn", + "local/style-props-no-shorthand-conflicts": "error", }, }, ]; diff --git a/package-lock.json b/package-lock.json index 47ca970..32a647b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10284,20 +10284,20 @@ } }, "node_modules/tldts-core": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", - "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "dev": true, "license": "MIT" }, "node_modules/tldts-icann": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.4.9.tgz", - "integrity": "sha512-q6gyxCkcFPu5OZd2hi2quysxCG3ejIi53EACesbCEZHhH7FO3HnliqwO5zUs0TV6dA54SxhCrTGAc4ugl7rTiw==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.4.10.tgz", + "integrity": "sha512-JrzJnTNDURpnyPCf/b0bq/mAiQhPcNwkwpfO67M0nECzVzSIuCFN+EYjqnEjnmO60nPRabS3zIFChbwPp/Q+Lg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.9" + "tldts-core": "^7.4.10" } }, "node_modules/toidentifier": { diff --git a/package.json b/package.json index 58fd117..9da7130 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint:fix": "eslint . --ext .ts,.tsx --fix", "lighthouse": "node scripts/run-lighthouse.mjs", "knip": "knip", - "performance": "npm run build && npm run bundle:size && npm run lighthouse", + "performance": "npm run build && npm run bundle:size", "preview": "vite preview", "test": "vitest run", "test:contract": "vitest run --config=vitest.contract.config.ts", @@ -50,11 +50,11 @@ }, "devDependencies": { "@axe-core/playwright": "4.12.1", + "@eslint/js": "^10.0.1", "@pact-foundation/pact": "^17.0.1", + "@playwright/test": "^1.62.0", "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", - "@eslint/js": "^10.0.1", - "@playwright/test": "^1.62.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", diff --git a/scripts/run-lighthouse.mjs b/scripts/run-lighthouse.mjs index eebe1b7..3cc969f 100644 --- a/scripts/run-lighthouse.mjs +++ b/scripts/run-lighthouse.mjs @@ -17,12 +17,15 @@ const url = new URL( `/${[appBasePath, "auth"].filter(Boolean).join("/")}`, "https://127.0.0.1:4174", ).href; + +// These intentionally broad limits catch major regressions without treating a +// shared CI runner as a production performance environment. const thresholds = { - "cumulative-layout-shift": 0.1, - "first-contentful-paint": 4_000, - interactive: 4_500, - "largest-contentful-paint": 4_000, - "total-blocking-time": 300, + "cumulative-layout-shift": 0.2, + "first-contentful-paint": 5_000, + interactive: 7_000, + "largest-contentful-paint": 6_000, + "total-blocking-time": 600, }; const server = await preview({ @@ -59,9 +62,9 @@ try { const failures = []; const performanceScore = result.lhr.categories.performance.score ?? 0; - if (performanceScore < 0.75) { + if (performanceScore < 0.65) { failures.push( - `performance score ${Math.round(performanceScore * 100)} (minimum 75)`, + `performance score ${Math.round(performanceScore * 100)} (minimum 65)`, ); } @@ -76,11 +79,13 @@ try { } if (failures.length > 0) { - throw new Error(`Lighthouse budget exceeded:\n${failures.join("\n")}`); + throw new Error( + `Lighthouse advisory thresholds exceeded:\n${failures.join("\n")}`, + ); } console.log( - `Lighthouse budgets passed with a performance score of ${Math.round(performanceScore * 100)}.`, + `Lighthouse advisory passed with a performance score of ${Math.round(performanceScore * 100)}.`, ); } finally { try { diff --git a/sonar-project.properties b/sonar-project.properties index c0522ff..8722d8f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,5 +5,4 @@ sonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx,e2e/**/*.spec.ts sonar.exclusions=src/**/*.d.ts,src/**/*.test.ts,src/**/*.test.tsx,src/assets/fonts/**,src/test/** sonar.coverage.exclusions=src/**/*.d.ts,src/**/*.test.ts,src/**/*.test.tsx,src/**/index.ts,src/main.tsx,src/test/**,src/features/**/types.ts,src/shared/api/types.ts sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.qualitygate.ignoreSmallChanges=true sonar.qualitygate.wait=false diff --git a/src/features/dashboard/Dashboard.tsx b/src/features/dashboard/Dashboard.tsx index fbb6813..c24220c 100644 --- a/src/features/dashboard/Dashboard.tsx +++ b/src/features/dashboard/Dashboard.tsx @@ -6,9 +6,9 @@ export const DashboardPage = () => { return (
); diff --git a/src/features/roles/Roles.tsx b/src/features/roles/Roles.tsx index 8e60c68..a259fc9 100644 --- a/src/features/roles/Roles.tsx +++ b/src/features/roles/Roles.tsx @@ -162,10 +162,10 @@ export const RolesPage = () => { <>
diff --git a/src/features/users/Users.tsx b/src/features/users/Users.tsx index ad832fd..33433d0 100644 --- a/src/features/users/Users.tsx +++ b/src/features/users/Users.tsx @@ -344,10 +344,10 @@ export const UsersPage = () => { <>
diff --git a/src/layouts/auth/Auth.tsx b/src/layouts/auth/Auth.tsx index 6121418..028e0ae 100644 --- a/src/layouts/auth/Auth.tsx +++ b/src/layouts/auth/Auth.tsx @@ -17,21 +17,21 @@ export const AuthLayout = () => { return (
diff --git a/src/layouts/default/Default.tsx b/src/layouts/default/Default.tsx index 71a5c3f..6110c60 100644 --- a/src/layouts/default/Default.tsx +++ b/src/layouts/default/Default.tsx @@ -86,9 +86,9 @@ export const DefaultLayout = () => { gap={16} justify="space-between" style={{ + backgroundColor: token.colorBgContainer, height: 64, paddingInline: token.paddingSM, - backgroundColor: token.colorBgContainer, }} > @@ -143,16 +143,16 @@ export const DefaultLayout = () => { <> {`${user.firstName} ${user.lastName}`.trim()} @@ -172,7 +172,7 @@ export const DefaultLayout = () => {
diff --git a/tooling/paths.ts b/tooling/paths.ts new file mode 100644 index 0000000..b05d3db --- /dev/null +++ b/tooling/paths.ts @@ -0,0 +1,3 @@ +import { fileURLToPath } from "node:url"; + +export const srcPath = fileURLToPath(new URL("../src", import.meta.url)); diff --git a/tsconfig.node.json b/tsconfig.node.json index ff54c4f..3d7b2b8 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -28,6 +28,7 @@ "smoke.config.ts", "staging/**/*.ts", "staging.config.ts", + "tooling/**/*.ts", "vite.config.ts", "vitest.contract.config.ts", "vitest.config.ts" diff --git a/vite.config.ts b/vite.config.ts index 378ba5e..e9d8acc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,9 @@ import basicSsl from "@vitejs/plugin-basic-ssl"; import react from "@vitejs/plugin-react"; -import path from "path"; import { defineConfig, loadEnv } from "vite"; +import { srcPath } from "./tooling/paths.ts"; + const securityHeaders = { "Content-Security-Policy": "default-src 'self'; base-uri 'self'; connect-src 'self' https:; font-src 'self' data:; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests", @@ -53,7 +54,7 @@ export default defineConfig(({ mode }) => { }, resolve: { alias: { - "@": path.resolve(__dirname, "src"), + "@": srcPath, }, }, server: { diff --git a/vitest.config.ts b/vitest.config.ts index 3b09398..53ce12c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ -import path from "path"; import { defineConfig } from "vitest/config"; +import { srcPath } from "./tooling/paths.ts"; + export default defineConfig({ resolve: { alias: { - "@": path.resolve(__dirname, "src"), + "@": srcPath, }, }, test: { diff --git a/vitest.contract.config.ts b/vitest.contract.config.ts index b739cbc..f3c8f4d 100644 --- a/vitest.contract.config.ts +++ b/vitest.contract.config.ts @@ -1,10 +1,11 @@ -import path from "path"; import { defineConfig } from "vitest/config"; +import { srcPath } from "./tooling/paths.ts"; + export default defineConfig({ resolve: { alias: { - "@": path.resolve(__dirname, "src"), + "@": srcPath, }, }, test: { From 98a94a158625687a665197125c83e0374947f161 Mon Sep 17 00:00:00 2001 From: Sepaseh Date: Fri, 31 Jul 2026 21:20:48 +0330 Subject: [PATCH 2/3] fix: address workflow review feedback --- .github/workflows/dast.yml | 34 ++++++++---- .github/workflows/deployment-smoke.yml | 71 ++++++++++++++++++++++++-- .github/workflows/staging.yml | 55 ++++++++++++++++++-- docs/deployment.md | 4 ++ docs/release-operations.md | 6 ++- docs/security/reviews.md | 11 ++-- docs/staging.md | 8 +-- eslint.config.ts | 12 ++++- 8 files changed, 174 insertions(+), 27 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 31b8c1c..9389a42 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -7,10 +7,6 @@ on: description: Authorized staging application URL required: true type: string - allowed_host: - description: Exact hostname permitted for the scan - required: true - type: string concurrency: cancel-in-progress: false group: staging-dast @@ -23,7 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: - DAST_ALLOWED_HOST: ${{ inputs.allowed_host }} + DAST_ALLOWED_HOST: ${{ vars.STAGING_ALLOWED_APP_HOST }} DAST_PRODUCTION_HOST: ${{ vars.PRODUCTION_HOST }} DAST_TARGET: ${{ inputs.target_url }} @@ -33,11 +29,31 @@ jobs: run: | node -e ' const value = process.env.DAST_TARGET; - const allowedHost = process.env.DAST_ALLOWED_HOST?.toLowerCase(); - const productionHost = process.env.DAST_PRODUCTION_HOST?.toLowerCase(); + const parseConfiguredHostname = (name, rawValue) => { + if (!rawValue || rawValue !== rawValue.trim()) { + throw new Error(`${name} must be a hostname without whitespace`); + } + const normalized = rawValue.toLowerCase(); + const hostnamePattern = + /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + if (!hostnamePattern.test(normalized)) { + throw new Error(`${name} must contain a hostname only`); + } + const parsed = new URL(`https://${rawValue}`); + if (parsed.hostname !== normalized) { + throw new Error(`${name} must be a canonical hostname only`); + } + return parsed.hostname; + }; if (!value) throw new Error("STAGING_BASE_URL is required"); - if (!allowedHost) throw new Error("STAGING_ALLOWED_HOST is required"); - if (!productionHost) throw new Error("PRODUCTION_HOST is required"); + const allowedHost = parseConfiguredHostname( + "STAGING_ALLOWED_APP_HOST", + process.env.DAST_ALLOWED_HOST, + ); + const productionHost = parseConfiguredHostname( + "PRODUCTION_HOST", + process.env.DAST_PRODUCTION_HOST, + ); const url = new URL(value); if (url.protocol !== "https:") { throw new Error("STAGING_BASE_URL must use HTTPS"); diff --git a/.github/workflows/deployment-smoke.yml b/.github/workflows/deployment-smoke.yml index 207e8d0..086790a 100644 --- a/.github/workflows/deployment-smoke.yml +++ b/.github/workflows/deployment-smoke.yml @@ -11,6 +11,10 @@ on: description: Public API health endpoint required: true type: string + deployment_id: + description: Immutable identifier of the deployed release + required: true + type: string concurrency: cancel-in-progress: true @@ -23,16 +27,75 @@ jobs: smoke: runs-on: ubuntu-latest env: + SMOKE_ALLOWED_API_HOST: ${{ vars.SMOKE_ALLOWED_API_HOST }} + SMOKE_ALLOWED_APP_HOST: ${{ vars.SMOKE_ALLOWED_APP_HOST }} SMOKE_API_HEALTH_URL: ${{ inputs.api_health_url }} SMOKE_BASE_URL: ${{ inputs.application_url }} + SMOKE_DEPLOYMENT_ID: ${{ inputs.deployment_id }} + SMOKE_EXPECTED_DEPLOYMENT_ID: ${{ vars.SMOKE_EXPECTED_DEPLOYMENT_ID }} steps: - name: Validate smoke-test targets + shell: bash run: | - if [[ -z "$SMOKE_BASE_URL" || -z "$SMOKE_API_HEALTH_URL" ]]; then - echo "SMOKE_BASE_URL and SMOKE_API_HEALTH_URL are required." - exit 1 - fi + node -e ' + const parseConfiguredHostname = (name, rawValue) => { + if (!rawValue || rawValue !== rawValue.trim()) { + throw new Error(`${name} must be a hostname without whitespace`); + } + const normalized = rawValue.toLowerCase(); + const hostnamePattern = + /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + if (!hostnamePattern.test(normalized)) { + throw new Error(`${name} must contain a hostname only`); + } + const parsed = new URL(`https://${rawValue}`); + if (parsed.hostname !== normalized) { + throw new Error(`${name} must be a canonical hostname only`); + } + return parsed.hostname; + }; + const parseTarget = (name, rawValue) => { + if (!rawValue) throw new Error(`${name} is required`); + const url = new URL(rawValue); + if (url.protocol !== "https:") { + throw new Error(`${name} must use HTTPS`); + } + if (url.username || url.password) { + throw new Error(`${name} must not contain credentials`); + } + return url; + }; + const appUrl = parseTarget("SMOKE_BASE_URL", process.env.SMOKE_BASE_URL); + const apiUrl = parseTarget( + "SMOKE_API_HEALTH_URL", + process.env.SMOKE_API_HEALTH_URL, + ); + const allowedAppHost = parseConfiguredHostname( + "SMOKE_ALLOWED_APP_HOST", + process.env.SMOKE_ALLOWED_APP_HOST, + ); + const allowedApiHost = parseConfiguredHostname( + "SMOKE_ALLOWED_API_HOST", + process.env.SMOKE_ALLOWED_API_HOST, + ); + if (appUrl.hostname !== allowedAppHost) { + throw new Error("SMOKE_BASE_URL host is not explicitly allowed"); + } + if (apiUrl.hostname !== allowedApiHost) { + throw new Error("SMOKE_API_HEALTH_URL host is not explicitly allowed"); + } + const deploymentId = process.env.SMOKE_DEPLOYMENT_ID; + const expectedDeploymentId = process.env.SMOKE_EXPECTED_DEPLOYMENT_ID; + if (!deploymentId || !expectedDeploymentId) { + throw new Error( + "SMOKE_DEPLOYMENT_ID and SMOKE_EXPECTED_DEPLOYMENT_ID are required", + ); + } + if (deploymentId !== expectedDeploymentId) { + throw new Error("The smoke targets do not match the expected deployment"); + } + ' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 6bf03aa..b6187d3 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -23,16 +23,63 @@ jobs: validate: runs-on: ubuntu-latest env: + STAGING_ALLOWED_API_HOST: ${{ vars.STAGING_ALLOWED_API_HOST }} + STAGING_ALLOWED_APP_HOST: ${{ vars.STAGING_ALLOWED_APP_HOST }} STAGING_API_HEALTH_URL: ${{ inputs.api_health_url }} STAGING_BASE_URL: ${{ inputs.application_url }} steps: - name: Validate staging targets + shell: bash run: | - if [[ -z "$STAGING_BASE_URL" || -z "$STAGING_API_HEALTH_URL" ]]; then - echo "STAGING_BASE_URL and STAGING_API_HEALTH_URL are required." - exit 1 - fi + node -e ' + const parseConfiguredHostname = (name, rawValue) => { + if (!rawValue || rawValue !== rawValue.trim()) { + throw new Error(`${name} must be a hostname without whitespace`); + } + const normalized = rawValue.toLowerCase(); + const hostnamePattern = + /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + if (!hostnamePattern.test(normalized)) { + throw new Error(`${name} must contain a hostname only`); + } + const parsed = new URL(`https://${rawValue}`); + if (parsed.hostname !== normalized) { + throw new Error(`${name} must be a canonical hostname only`); + } + return parsed.hostname; + }; + const parseTarget = (name, rawValue) => { + if (!rawValue) throw new Error(`${name} is required`); + const url = new URL(rawValue); + if (url.protocol !== "https:") { + throw new Error(`${name} must use HTTPS`); + } + if (url.username || url.password) { + throw new Error(`${name} must not contain credentials`); + } + return url; + }; + const appUrl = parseTarget("STAGING_BASE_URL", process.env.STAGING_BASE_URL); + const apiUrl = parseTarget( + "STAGING_API_HEALTH_URL", + process.env.STAGING_API_HEALTH_URL, + ); + const allowedAppHost = parseConfiguredHostname( + "STAGING_ALLOWED_APP_HOST", + process.env.STAGING_ALLOWED_APP_HOST, + ); + const allowedApiHost = parseConfiguredHostname( + "STAGING_ALLOWED_API_HOST", + process.env.STAGING_ALLOWED_API_HOST, + ); + if (appUrl.hostname !== allowedAppHost) { + throw new Error("STAGING_BASE_URL host is not explicitly allowed"); + } + if (apiUrl.hostname !== allowedApiHost) { + throw new Error("STAGING_API_HEALTH_URL host is not explicitly allowed"); + } + ' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: diff --git a/docs/deployment.md b/docs/deployment.md index 5e280f7..9fe69e3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -89,5 +89,9 @@ Playwright suite against the production build to verify the complete policy. When a real environment exists, the `Staging validation`, `Deployment smoke tests`, and `DAST` workflows can be started manually with explicitly supplied targets. They do not run on pull requests, schedules, or deployment events. +Before enabling them, configure their protected allowed-host variables. For +deployment smoke, set `SMOKE_ALLOWED_APP_HOST`, `SMOKE_ALLOWED_API_HOST`, and +`SMOKE_EXPECTED_DEPLOYMENT_ID`; the expected identifier must match the +immutable `deployment_id` supplied for the run. See [Staging](staging.md) and [Release operations](release-operations.md) before using them against an authorized environment. diff --git a/docs/release-operations.md b/docs/release-operations.md index 8418cca..b828f58 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -55,7 +55,11 @@ After deployment: 1. Record the version, commit SHA, artifact identifier, operator, and start time in the deployment record. -2. Manually run the deployment smoke workflow. +2. Verify that the application and API health targets match the protected + authorized hosts, then manually run the deployment smoke workflow with its + required `application_url`, `api_health_url`, and immutable `deployment_id` + inputs. Add the workflow run and uploaded artifact identifiers to the + deployment record. 3. Verify login, protected routing, and one read-only authenticated journey. 4. Confirm security headers and API health checks succeed. 5. Compare error rate, failed requests, latency, largest contentful paint, diff --git a/docs/security/reviews.md b/docs/security/reviews.md index 71ea117..09b999a 100644 --- a/docs/security/reviews.md +++ b/docs/security/reviews.md @@ -26,8 +26,9 @@ Kernel keeps the DAST workflow only as a reusable template for downstream projects; it is not part of Kernel's active quality gate. The workflow has no schedule and can only be started manually. -Before using the template, configure the repository variable `PRODUCTION_HOST` -with the production hostname only, without a protocol or path. Each manual run -still requires an explicitly authorized target and its exact allowed hostname. -The fixed repository variable prevents the run from redefining production and -must never match the scan target. +Before using the template, configure protected repository or environment +variables `PRODUCTION_HOST` and `STAGING_ALLOWED_APP_HOST` with exact hostnames +only, without a protocol, port, path, or whitespace. `STAGING_ALLOWED_APP_HOST` +is the trusted authorization source for each manual DAST target; workflow +dispatch callers cannot redefine it. `PRODUCTION_HOST` prevents scanning +production and must never match the authorized staging host. diff --git a/docs/staging.md b/docs/staging.md index d576d8c..97961a2 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -27,9 +27,11 @@ do not rebuild source specifically for production. ## Manual validation -Start the `Staging validation` workflow manually and supply the application and -API health URLs. It never runs on pull requests, schedules, or deployment -events. +Configure protected `STAGING_ALLOWED_APP_HOST` and +`STAGING_ALLOWED_API_HOST` repository or environment variables with exact +hostnames only. Start the `Staging validation` workflow manually and supply the +application and API health URLs. It never runs on pull requests, schedules, or +deployment events. The live suite verifies: diff --git a/eslint.config.ts b/eslint.config.ts index 35b49f3..cecb7a5 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -141,6 +141,16 @@ function getSortableProperties(objNode: TSESTree.ObjectExpression) { return properties as TSESTree.Property[]; } +function getNamedProperties(objNode: TSESTree.ObjectExpression) { + return objNode.properties.filter( + (property): property is TSESTree.Property => + property.type === AST_NODE_TYPES.Property && + !property.computed && + (property.key.type === AST_NODE_TYPES.Identifier || + property.key.type === AST_NODE_TYPES.Literal), + ); +} + function checkAndFixAlphabeticalStyleOrder( context: Rule.RuleContext, objNode: TSESTree.ObjectExpression, @@ -172,7 +182,7 @@ function checkShorthandConflicts( context: Rule.RuleContext, objNode: TSESTree.ObjectExpression, ) { - const properties = getSortableProperties(objNode); + const properties = getNamedProperties(objNode); const propertyByName = new Map( properties.map((property) => [getPropertyName(property.key), property]), ); From ca192e22a9454827aba15f9361d5b8e2a51f3e1d Mon Sep 17 00:00:00 2001 From: Sepaseh Date: Fri, 31 Jul 2026 21:25:48 +0330 Subject: [PATCH 3/3] fix: name DAST input in validation errors --- .github/workflows/dast.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 9389a42..a52e9da 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -45,7 +45,7 @@ jobs: } return parsed.hostname; }; - if (!value) throw new Error("STAGING_BASE_URL is required"); + if (!value) throw new Error("target_url is required"); const allowedHost = parseConfiguredHostname( "STAGING_ALLOWED_APP_HOST", process.env.DAST_ALLOWED_HOST, @@ -56,13 +56,13 @@ jobs: ); const url = new URL(value); if (url.protocol !== "https:") { - throw new Error("STAGING_BASE_URL must use HTTPS"); + throw new Error("target_url must use HTTPS"); } if (url.username || url.password) { - throw new Error("STAGING_BASE_URL must not contain credentials"); + throw new Error("target_url must not contain credentials"); } if (url.hostname.toLowerCase() !== allowedHost) { - throw new Error("STAGING_BASE_URL host is not explicitly allowed"); + throw new Error("target_url host is not explicitly allowed"); } if (url.hostname.toLowerCase() === productionHost) { throw new Error("DAST must not target the production host");