diff --git a/.github/DEVELOPMENT.md b/.github/DEVELOPMENT.md index ed44e34..6beee97 100644 --- a/.github/DEVELOPMENT.md +++ b/.github/DEVELOPMENT.md @@ -22,6 +22,18 @@ This page contains the steps to build and run the Syncfusion Toolkit for Blazor dotnet build ./Syncfusion.Blazor.Toolkit.slnx ``` +### Release sanity check (local) + +If you want to mimic what `.github/workflows/nuget-publish.yml` does on a release runner, pass `-p:ContinuousIntegrationBuild=true` so SourceLink and the package hash match what CI produces: + +```dotnetcli +dotnet restore src/Syncfusion.Blazor.Toolkit.csproj -p:ContinuousIntegrationBuild=true +dotnet build src/Syncfusion.Blazor.Toolkit.csproj -c Release --no-restore -p:ContinuousIntegrationBuild=true +dotnet pack src/Syncfusion.Blazor.Toolkit.csproj -c Release --no-build -o nupkg -p:ContinuousIntegrationBuild=true +``` + +> **Note**: `dotnet pack` triggers a `BeforeBuild` target that runs `npm install` and `gulp blazor-toolkit-themes` if `src/wwwroot/styles/fluent.min.css` is absent. Make sure Node.js (LTS) is on `PATH`. The release workflow installs Node 22 explicitly to handle this. + ## Running Samples - Open the `samples/Blazor.Toolkit.Samples.slnx` file in Visual Studio. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 8ef95be..a9426e2 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -58,4 +58,4 @@ On a **monthly cadence** (targeting the second Wednesday of each month), the mai This project maintains a current security reference in the repository's [THREAT-MODEL.md](../THREAT-MODEL.md) document. The project team has reviewed the current architecture, package surface, and release flow and has documented the principal risks and mitigations in good faith. -This attestation reflects the project’s current understanding as of 2026-08-12 and is intended to be updated as the toolkit evolves. +This attestation reflects the project’s current understanding as of 2026-08-21 and is intended to be updated as the toolkit evolves. diff --git a/.github/THREAT-MODEL.md b/.github/THREAT-MODEL.md index d6138ab..e3c8aad 100644 --- a/.github/THREAT-MODEL.md +++ b/.github/THREAT-MODEL.md @@ -129,6 +129,10 @@ This threat model should be reviewed when: ## Self-attestation -This threat model was prepared as a current security reference for the Syncfusion Blazor Toolkit project and reflects the maintainers’ understanding of the project as of 2026-08-12. The project team intends to review and update this document as changes to the component library, assets, or build pipeline occur. +This threat model was prepared as a current security reference for the Syncfusion Blazor Toolkit project and reflects the maintainers’ understanding of the project as of 2026-08-21. The project team intends to review and update this document as changes to the component library, assets, or build pipeline occur. The maintainers attest that the information provided here is a good-faith assessment of the project’s current security risks and mitigations based on the repository structure and package design at the time of publication. + +### Change since last review + +- **2026-08-21 — Hardened CD pipeline for nuget-publish.** Added SLSA build provenance attestation (`actions/attest-build-provenance`), deterministic builds via `ContinuousIntegrationBuild=true`, exit-code-driven vulnerability scan with downloadable `vuln-report` artifact, and concurrency guard for re-tagged same-version pushes. Accepted-risks entries AR-1 and AR-2 were reviewed and remain applicable; no new accepted risk was introduced. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1f64e5b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,59 @@ +version: 2 +updates: + # NuGet packages (.NET) + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "nuget" + commit-message: + prefix: "deps(nuget)" + rebase-strategy: "auto" + groups: + microsoft-aspnetcore: + patterns: + - "Microsoft.AspNetCore.*" + - "Microsoft.Extensions.*" + syncfusion: + patterns: + - "Syncfusion.*" + + # npm (gulp, Playwright, ESLint, sass, etc.) + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "npm" + commit-message: + prefix: "deps(npm)" + rebase-strategy: "auto" + groups: + eslint: + patterns: + - "eslint*" + - "@eslint/*" + - "typescript-eslint*" + playwright: + patterns: + - "@playwright/*" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "deps(actions)" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9144b4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,394 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + # ========================================================= + # Job 1: bUnit (matrix) + # ========================================================= + bunit: + name: bUnit (.NET ${{ matrix.dotnet-version }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + + env: + TZ: UTC + LANG: en_US.UTF-8 + + strategy: + fail-fast: false + matrix: + dotnet-version: ['8.0.x', '9.0.x', '10.0.x'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ matrix.dotnet-version }}-${{ hashFiles('**/*.*proj') }} + restore-keys: | + nuget-${{ runner.os }}-${{ matrix.dotnet-version }}- + nuget-${{ runner.os }}- + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('**/package.json') }} + restore-keys: npm-${{ runner.os }}- + + - name: Install npm dependencies + run: | + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install --no-fund --no-audit + fi + + - name: Restore + run: dotnet restore src/Syncfusion.Blazor.Toolkit.csproj + + - name: Build + run: dotnet build src/Syncfusion.Blazor.Toolkit.csproj -c Release --no-restore + + - name: Set timezone and locale for deterministic tests + run: | + sudo ln -fs /usr/share/zoneinfo/UTC /etc/localtime + sudo apt-get update -y + sudo apt-get install -y locales + sudo locale-gen en_US.UTF-8 + export LANG=en_US.UTF-8 + export TZ=UTC + shell: bash + + - name: Run bUnit tests + run: | + mkdir -p TestResults + dotnet test tests/Syncfusion.Blazor.Toolkit.BUnitTest/ \ + -c Release \ + -f net8.0 \ + --logger "trx;LogFileName=bunit-${{ matrix.dotnet-version }}.trx" \ + --logger "html;LogFileName=bunit-${{ matrix.dotnet-version }}.html" \ + --results-directory TestResults \ + --collect:"XPlat Code Coverage" \ + --verbosity normal + + - name: Upload bUnit results + if: always() + uses: actions/upload-artifact@v4 + with: + name: bunit-results-${{ matrix.dotnet-version }} + path: | + TestResults/ + **/coverage.cobertura.xml + retention-days: 14 + if-no-files-found: ignore + + - name: Publish bUnit test results + if: always() + uses: dorny/test-reporter@v1 + continue-on-error: true + with: + name: bUnit (.NET ${{ matrix.dotnet-version }}) + path: 'TestResults/**/*.trx' + reporter: dotnet-trx + fail-on-error: false + fail-on-empty: false + + # ========================================================= + # Job 2: Playwright + # ========================================================= + playwright: + name: Playwright + runs-on: ubuntu-latest + timeout-minutes: 35 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 9.x + 10.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.*proj') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('**/package.json') }} + restore-keys: npm-${{ runner.os }}- + + - name: Install npm dependencies + run: | + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install --no-fund --no-audit + fi + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/package.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Restore + run: dotnet restore src/Syncfusion.Blazor.Toolkit.csproj + + - name: Build + run: dotnet build src/Syncfusion.Blazor.Toolkit.csproj -c Release --no-restore + + - name: Run Playwright tests + run: npx playwright test --reporter=html,line + env: + CI: true + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 14 + if-no-files-found: ignore + + # ========================================================= + # Job 3: NuGet vulnerability scan + # ========================================================= + vulnerability-scan: + name: NuGet vulnerability scan + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 9.x + 10.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.*proj') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore (NuGetAudit is enabled in the csproj) + run: dotnet restore src/Syncfusion.Blazor.Toolkit.csproj + + - name: Check for vulnerable packages + run: | + set -o pipefail + dotnet list src/Syncfusion.Blazor.Toolkit.csproj package --vulnerable --include-transitive 2>&1 | tee vuln-report.txt + if grep -qiE "has the following vulnerable packages|^.*vulnerable packages" vuln-report.txt; then + echo "::error::Vulnerable NuGet packages detected (see artifact nuget-vuln-report)" + exit 1 + fi + echo "No vulnerable packages found" + + - name: Upload NuGet vulnerability report + if: always() + uses: actions/upload-artifact@v4 + with: + name: nuget-vuln-report + path: vuln-report.txt + if-no-files-found: ignore + retention-days: 14 + + # ========================================================= + # Job 4: ESLint security + # ========================================================= + eslint-security: + name: ESLint security + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: | + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install --no-fund --no-audit + fi + + - name: Run ESLint security scan + run: npm run lint:security + + # ========================================================= + # Job 5: XSS / unsafe markup scan (gulp) + # ========================================================= + xss-scan: + name: XSS / unsafe markup scan + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: | + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install --no-fund --no-audit + fi + + - name: Run XSS / unsafe markup scan + # The gulp task already writes xss-scan-report.txt on findings and + # exits non-zero, so we don't need any extra glue here. + run: npx gulp security-xss-scan + + - name: Upload XSS scan report + if: always() + uses: actions/upload-artifact@v4 + with: + name: xss-scan-report + path: xss-scan-report.txt + if-no-files-found: ignore + retention-days: 14 + + # ========================================================= + # Job 6: Summary + # ========================================================= + summary: + name: CI Summary + runs-on: ubuntu-latest + needs: [bunit, playwright, vulnerability-scan, eslint-security, xss-scan] + if: always() + permissions: + contents: read + pull-requests: write + + steps: + - name: Check results and fail if needed + run: | + echo "bUnit result: ${{ needs.bunit.result }}" + echo "Playwright result: ${{ needs.playwright.result }}" + echo "NuGet vuln scan result: ${{ needs.vulnerability-scan.result }}" + echo "ESLint security result: ${{ needs.eslint-security.result }}" + echo "XSS scan result: ${{ needs.xss-scan.result }}" + + if [[ "${{ needs.bunit.result }}" != "success" \ + || "${{ needs.playwright.result }}" != "success" \ + || "${{ needs.vulnerability-scan.result }}" != "success" \ + || "${{ needs.eslint-security.result }}" != "success" \ + || "${{ needs.xss-scan.result }}" != "success" ]]; then + echo "One or more jobs failed → failing the workflow" + exit 1 + fi + + echo "All jobs succeeded" + + - name: Post summary comment (PRs only) + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const bunitOk = '${{ needs.bunit.result }}' === 'success'; + const pwOk = '${{ needs.playwright.result }}' === 'success'; + const vulnOk = '${{ needs.vulnerability-scan.result }}' === 'success'; + const eslintOk = '${{ needs.eslint-security.result }}' === 'success'; + const xssOk = '${{ needs.xss-scan.result }}' === 'success'; + const overall = (bunitOk && pwOk && vulnOk && eslintOk && xssOk) + ? '✅ All checks passed' + : '❌ Some checks failed'; + + const body = `### CI Summary + + | Job | Status | + |-----|--------| + | **bUnit** (.NET 8 / 9 / 10) | ${bunitOk ? '✅ Passed' : '❌ Failed'} | + | **Playwright** | ${pwOk ? '✅ Passed' : '❌ Failed'} | + | **NuGet vulnerability scan** | ${vulnOk ? '✅ Passed' : '❌ Failed'} | + | **ESLint security** | ${eslintOk ? '✅ Passed' : '❌ Failed'} | + | **XSS / unsafe markup scan** | ${xssOk ? '✅ Passed' : '❌ Failed'} | + + **Overall:** ${overall} + `; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9657b4b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,63 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '0 6 * * 1' # Every Monday 06:00 UTC + workflow_dispatch: + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 360 + + permissions: + security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: csharp + build-mode: autobuild # Best for .NET + - language: javascript-typescript + build-mode: none # For gulp / package.json / Playwright + - language: actions + build-mode: none # For .github/workflows/*.yml + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # Uncomment if you want extra queries later: + # queries: security-extended,security-and-quality + + # Only needed when build-mode is "manual" + - name: Manual build (csharp) + if: matrix.build-mode == 'manual' + run: | + dotnet restore ./Syncfusion.Blazor.Toolkit.slnx + dotnet build ./Syncfusion.Blazor.Toolkit.slnx -c Release --no-restore + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" \ No newline at end of file diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml deleted file mode 100644 index 16ced6f..0000000 --- a/.github/workflows/nuget-publish.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Publish NuGet - -# Triggered by version tags: v1.0.0, v1.2.3-preview, etc. -on: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+-*' - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write # required for both NuGet OIDC trusted publishing and Azure Workload Identity - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 # full history required for SourceLink commit SHA - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.x - 9.x - 10.x - - # Decode the strong-name key stored as a base64 GitHub Secret. - # To create the secret: certutil -encode sf.snk sf.snk.b64 (or base64 sf.snk) - - name: Write strong-name key - run: echo "${{ secrets.STRONG_NAME_KEY_BASE64 }}" | base64 --decode > src/sf.snk - - - name: Restore - run: dotnet restore src/Syncfusion.Blazor.Toolkit.csproj - - # Fail the release if any direct or transitive dependency has a known CVE. - - name: Dependency vulnerability scan - run: | - dotnet list src/Syncfusion.Blazor.Toolkit.csproj package --vulnerable --include-transitive 2>&1 | tee vuln-report.txt - if grep -q "has the following vulnerable packages" vuln-report.txt; then - echo "::error::Vulnerable packages detected — release blocked. See vuln-report.txt for details." - exit 1 - fi - - - name: Build - run: dotnet build src/Syncfusion.Blazor.Toolkit.csproj -c Release --no-restore - - - name: Pack - run: dotnet pack src/Syncfusion.Blazor.Toolkit.csproj -c Release --no-build -o nupkg - - - name: Remove strong-name key - if: always() - run: rm -f src/sf.snk - - - name: Install NuGetKeyVaultSignTool - run: dotnet tool install --global NuGetKeyVaultSignTool - - # Log in to Azure using Workload Identity Federation — no client secret needed. - # Prerequisite: add a federated credential for this repo+workflow on the service principal in Azure. - # Non-secret config values (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_KEY_VAULT_URL, - # AZURE_KEY_VAULT_CERT_NAME) are stored as GitHub Actions repository *variables* (vars.*), not secrets. - - name: Azure login (OIDC) - uses: azure/login@v2 - with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - allow-no-subscriptions: true - - # DefaultAzureCredential picks up the ambient Workload Identity token set by azure/login above. - - name: Sign NuGet packages - run: | - NuGetKeyVaultSignTool sign nupkg/*.nupkg \ - --file-digest sha256 \ - --timestamp-rfc3161 http://timestamp.digicert.com \ - --timestamp-digest sha256 \ - --azure-key-vault-url "${{ vars.AZURE_KEY_VAULT_URL }}" \ - --azure-key-vault-certificate "${{ vars.AZURE_KEY_VAULT_CERT_NAME }}" - - # Exchange the GitHub OIDC token for a short-lived NuGet.org API token (trusted publishing). - # Prerequisite: configure a trusted publisher on nuget.org for this repo + workflow file. - - name: Push to NuGet.org - run: | - NUGET_TOKEN=$(curl -sS \ - -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=nuget.org" | jq -r '.value') - echo "::add-mask::$NUGET_TOKEN" - dotnet nuget push nupkg/*.nupkg \ - --api-key "$NUGET_TOKEN" \ - --source https://api.nuget.org/v3/index.json \ - --skip-duplicate diff --git a/gulpfile.js b/gulpfile.js index 66266f9..4600f61 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -103,3 +103,135 @@ gulp.task('scss-to-css', function (done) { gulp.task('blazor-toolkit-themes', gulp.series('combined-scss', 'scss-to-css')); +/* + * security-xss-scan + * + * Scans C#, Razor, JS, and TS source files for patterns that frequently + * indicate unsanitised HTML / JavaScript evaluation. The job that runs this in + * CI (see .github/workflows/ci.yml) fails fast on findings unless the file:line + * is added to XSS_ALLOWLIST below with a short reviewer-issued justification. + * + * IMPORTANT: this is a *defensive heuristic*, not a complete XSS detector. It + * complements CodeQL (semantic) for the C# / Razor surface and ESLint + * (`eslint-plugin-security`) for the JS / TS surface. + */ +const XSS_PATTERNS = [ + { name: 'eval()', regex: /\beval\s*\(/g }, + { name: 'new Function(', regex: /new\s+Function\s*\(/g }, + { name: 'MarkupString', regex: /\bMarkupString\b/g }, + { name: 'HtmlString', regex: /\bHtmlString\b/g }, + { name: 'dangerouslySetInnerHTML-like', regex: /innerHTML\s*=/g }, + { name: 'document.write', regex: /\bdocument\.write\b/g }, + { name: 'setTimeout-string-arg', regex: /setTimeout\s*\(\s*['"`]/g }, + { name: 'setInterval-string-arg', regex: /setInterval\s*\(\s*['"`]/g } +]; + +const XSS_ALLOWLIST = [ + // e.g. 'src/Components/SafeMarkup/Render.cs:42' +]; + +const XSS_SCAN_GLOBS = [ + 'src/Components/**/*.{cs,razor,js,ts,mjs,cjs}', + 'src/Base/**/*.{cs,razor,js,ts,mjs,cjs}', + 'src/Data/**/*.{cs,razor,js,ts,mjs,cjs}' +]; + +function isXSSAllowlisted(file, line) { + return XSS_ALLOWLIST.some(entry => { + const [af, al] = entry.split(':'); + if (af !== file) { + return false; + } + if (!al) { + return true; + } + if (al.includes('-')) { + const [from, to] = al.split('-').map(n => parseInt(n, 10)); + return line >= from && line <= to; + } + return parseInt(al, 10) === line; + }); +} + +gulp.task('security-xss-scan', function (done) { + let allFiles = []; + for (const pattern of XSS_SCAN_GLOBS) { + allFiles = allFiles.concat(glob.sync(pattern, { + nodir: true, + ignore: [ + '**/bin/**', + '**/obj/**', + '**/node_modules/**', + // Never scan the bundled client scripts / sample apps here. + '**/wwwroot/**', + '**/samples/**', + '**/tests/**', + // Compiled themes. + '**/wwwroot/styles/**' + ] + })); + } + // Deduplicate (some files may match multiple globs) + allFiles = Array.from(new Set(allFiles)); + + const findings = []; + for (const file of allFiles) { + let content; + try { + content = fs.readFileSync(file, 'utf8'); + } catch (e) { + continue; + } + const lines = content.split(/\r?\n/); + lines.forEach((line, i) => { + const lineNumber = i + 1; + if (isXSSAllowlisted(file, lineNumber)) { + return; + } + for (const pattern of XSS_PATTERNS) { + if (pattern.regex.test(line)) { + pattern.regex.lastIndex = 0; + findings.push({ + file: file, + line: lineNumber, + pattern: pattern.name, + text: line.trim() + }); + } + pattern.regex.lastIndex = 0; + } + }); + } + + if (findings.length) { + const grouped = {}; + for (const f of findings) { + grouped[f.pattern] = grouped[f.pattern] || []; + grouped[f.pattern].push(f); + } + console.error('========================================================='); + console.error(`XSS / unsafe markup scan: ${findings.length} finding(s)`); + console.error('========================================================='); + for (const pattern of Object.keys(grouped)) { + console.error(`\n[${pattern}] (${grouped[pattern].length})`); + for (const f of grouped[pattern]) { + console.error(` ${f.file}:${f.line} ${f.text}`); + } + } + try { + fs.writeFileSync('xss-scan-report.txt', + findings.map(f => `${f.file}:${f.line} [${f.pattern}] ${f.text}`).join('\n'), + 'utf8'); + } catch (e) { + console.error('Could not write xss-scan-report.txt: ' + e.message); + } + process.exitCode = 1; + return done(new Error(`${findings.length} XSS-related finding(s) - see xss-scan-report.txt`)); + } + + console.log('XSS / unsafe markup scan: no risky patterns found'); + done(); +}); + +gulp.task('security', gulp.series('security-xss-scan')); + diff --git a/package.json b/package.json index 1c51e40..8fb2e78 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,21 @@ "type": "git", "url": "https://github.com/syncfusion/blazor-toolkit.git" }, + "scripts": { + "lint:security": "eslint . --max-warnings 0" + }, "devDependencies": { - "gulp": "^4.0.2", - "shelljs": "^0.8.5", - "@playwright/test": "^1.58.2", + "gulp": "^4.0.2", + "shelljs": "^0.8.5", + "@playwright/test": "^1.58.2", "gulp-sass": "5.1.0", - "sass": "1.51.0", + "sass": "1.51.0", "gulp-clean-css": "^4.3.0", - "gulp-rename": "^2.1.0" + "gulp-rename": "^2.1.0", + "eslint": "^9.13.0", + "@eslint/js": "^9.13.0", + "typescript-eslint": "^8.11.0", + "eslint-plugin-security": "^3.0.1", + "globals": "^15.11.0" } } diff --git a/src/Base/Globalization.cs b/src/Base/Globalization.cs index 392ca5a..797d54a 100644 --- a/src/Base/Globalization.cs +++ b/src/Base/Globalization.cs @@ -79,6 +79,7 @@ internal static string GetDateFormat(T date, string? format = null) { return string.Empty; } + dateCulture = dateCulture.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; } diff --git a/src/Components/Calendars/DateTimePicker/SfDateTimePicker.razor.cs b/src/Components/Calendars/DateTimePicker/SfDateTimePicker.razor.cs index 523e039..f9f0e75 100644 --- a/src/Components/Calendars/DateTimePicker/SfDateTimePicker.razor.cs +++ b/src/Components/Calendars/DateTimePicker/SfDateTimePicker.razor.cs @@ -734,9 +734,10 @@ protected override async Task ClientPopupRenderAsync() else if (ShowPopupList && IsListRendered) { IsListRendered = false; + IsListRender = true; DatePickerClientProps options = GetClientProperties(); + StateHasChanged(); await InvokeVoidAsync(_datePickerJsModule, _datePickerJsInProcessModule, "renderPopup", [DataId, PopupElement, PopupHolderEle, PopupEventArgs, options, Step, ScrollTo ?? default]).ConfigureAwait(true); - IsListRender = true; } } @@ -1073,15 +1074,14 @@ internal override async Task UpdateCalendarPropertyAsync(string key, object? dat TimeSpan offset = ((DateTimeOffset)dateTimeValue).Offset; int hour = ((DateTimeOffset)dateTimeValue).Hour; int minute = ((DateTimeOffset)dateTimeValue).Minute; - int second = ((DateTimeOffset)dateTimeValue).Second; - int milliSecond = ((DateTimeOffset)dateTimeValue).Millisecond; - dateValue = new DateTimeOffset(year, month, day, hour, minute, second, milliSecond, offset); + dateValue = new DateTimeOffset(year, month, day, hour, minute, 0, 0, offset); } else { if (dateTimeValue is not null) { - dateValue = new DateTime(((DateTime)dateTimeValue).Ticks, DateTimeKind.Local); + DateTime dt = (DateTime)dateTimeValue; + dateValue = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0, DateTimeKind.Local); } } await UpdateValueAsync(dateValue).ConfigureAwait(false); diff --git a/src/Components/Calendars/TimePicker/SfTimePicker.razor.cs b/src/Components/Calendars/TimePicker/SfTimePicker.razor.cs index a272df6..8fd3f75 100644 --- a/src/Components/Calendars/TimePicker/SfTimePicker.razor.cs +++ b/src/Components/Calendars/TimePicker/SfTimePicker.razor.cs @@ -914,6 +914,28 @@ private async Task GenerateListAsync() { formatString = string.IsNullOrEmpty(Format) ? "HH:mm:ss" : formatString.Replace("hh", "HH", StringComparison.Ordinal); } + if (DatePart == default) + { + if (Value is not null && !IsTimeSpanType()) + { + try + { + DateTime source = ConvertDate(Value); + if (source != default) + { + DatePart = source.Date; + } + } + catch + { + // Fall through to today's date + } + } + if (DatePart == default) + { + DatePart = DateTime.Today; + } + } while (end >= start) { DateTime listDateTime = new(DatePart.Year, DatePart.Month, DatePart.Day, start.Hours, start.Minutes, start.Seconds, start.Milliseconds, DatePart.Kind); diff --git a/src/Syncfusion.Blazor.Toolkit.csproj b/src/Syncfusion.Blazor.Toolkit.csproj index 46631cd..9bdc1bd 100644 --- a/src/Syncfusion.Blazor.Toolkit.csproj +++ b/src/Syncfusion.Blazor.Toolkit.csproj @@ -9,7 +9,8 @@ true true net8.0;net9.0;net10.0 - Copyright 2001 - 2026 Syncfusion® Inc. + + Copyright 2001 - $([System.DateTime]::UtcNow.Year) Syncfusion® Inc. Syncfusion Blazor Toolkit Components The Syncfusion® Toolkit for Blazor is a high-performance, open-source collection of lightweight UI components designed to accelerate Blazor application development (Server and WebAssembly). These controls help developers build modern, responsive, and feature-rich web applications faster, with clean code and excellent performance. syncfusion_logo.png @@ -38,6 +39,31 @@ enable latest-All true + All + true + moderate + all + + CA2100;CA2102;CA2103;CA2108;CA2115;CA2116;CA2117;CA2118;CA2134;CA2136;CA2143;CA2144;CA2146;CA2147;CA2149;CA2150;CA2151;CA2152;CA2153;CA2154;CA2155;CA2156;CA2157;CA2158;CA2159;CA2300;CA2301;CA2302;CA2303;CA2304;CA2305;CA2306;CA2307;CA2308;CA2309;CA2310;CA2311;CA2312;CA2313;CA2314;CA2315;CA2316;CA2317;CA2318;CA2319;CA2320;CA2321;CA2322;CA2323;CA2324;CA2325;CA2326;CA2327;CA2328;CA2329;CA2330;CA2331;CA2332;CA2333;CA2334;CA2335;CA2336;CA2337;CA2338;CA2339;CA2340;CA2341;CA2342;CA2343;CA2344;CA2345;CA2346;CA2347;CA2348;CA2349;CA2350;CA2351;CA2352;CA2353;CA2354;CA2355;CA2356;CA2357;CA2358;CA2359;CA2360;CA2361;CA2362;CA2363;CA2364;CA2365;CA2366;CA2367;CA2368;CA2369;CA2370;CA2371;CA2372;CA2373;CA2374;CA2375;CA2376;CA2377;CA2378;CA2379;CA2380;CA2381;CA2382;CA2383;CA2384;CA2385;CA2386;CA2387;CA2388;CA2389;CA2390;CA2391;CA2392;CA2393;CA2394;CA2395;CA2396;CA2397;CA2398;CA2399;CA3075;CA3076;CA3077;CA5350;CA5351;CA5352;CA5353;CA5354;CA5355;CA5356;CA5357;CA5358;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;CA5404;CA5405 diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Base/BunitTestContext.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Base/BunitTestContext.cs index 9ed9e2d..c825a0c 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Base/BunitTestContext.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Base/BunitTestContext.cs @@ -6,16 +6,26 @@ namespace Syncfusion.Blazor.Toolkit.Tests { public class BunitTestContext : TestContext { - public BunitTestContext() + private static readonly CultureInfo TestCulture = BuildTestCulture(); + private static CultureInfo BuildTestCulture() { // Create a new culture based on en-US and set the ShortDatePattern var cultureInfo = new CultureInfo("en-US"); cultureInfo.DateTimeFormat.ShortDatePattern = "M/d/yyyy"; cultureInfo.DateTimeFormat.ShortestDayNames = new[] { "S", "M", "T", "W", "T", "F", "S" }; + return cultureInfo; + } - // Apply this culture globally - CultureInfo.DefaultThreadCurrentCulture = cultureInfo; - CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; + static BunitTestContext() + { + CultureInfo.DefaultThreadCurrentCulture = TestCulture; + CultureInfo.DefaultThreadCurrentUICulture = TestCulture; + } + + public BunitTestContext() + { + Thread.CurrentThread.CurrentCulture = TestCulture; + Thread.CurrentThread.CurrentUICulture = TestCulture; this.BeforeEachRun(); } diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DatePicker/DateFormat.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DatePicker/DateFormat.cs index 60c4a7e..d9f138b 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DatePicker/DateFormat.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DatePicker/DateFormat.cs @@ -31,8 +31,9 @@ private string GetDateFormat(T date, string? format = null, string? culture = var currentCulture = CultureInfo.CurrentCulture; IFormattable? dateValue = date as IFormattable; var dateCulture = dateValue?.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture!, currentCulture.NumberFormat.NativeDigits); - return dateCulture; + return dateCulture!; } catch (Exception e) { diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeFormat.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeFormat.cs index 5def04f..4d32d4a 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeFormat.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeFormat.cs @@ -34,6 +34,7 @@ private string GetDateFormat(T date, string format = null, string culture = n var currentCulture = CultureInfo.CurrentCulture; IFormattable dateValue = date as IFormattable; var dateCulture = dateValue.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; } diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeNavigation.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeNavigation.cs index 3b46e6c..173b9df 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeNavigation.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeNavigation.cs @@ -31,6 +31,7 @@ private string GetDateFormat(T date, string format = null, string culture = n var currentCulture = CultureInfo.CurrentCulture; IFormattable dateValue = date as IFormattable; var dateCulture = dateValue.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; } diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePicker.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePicker.cs index 51569e8..1c10c28 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePicker.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePicker.cs @@ -246,6 +246,7 @@ private string GetDateFormat(T date, string format = null, string culture = n var currentCulture = CultureInfo.CurrentCulture; IFormattable dateValue = date as IFormattable; var dateCulture = dateValue.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; } @@ -1116,11 +1117,16 @@ public async Task ShowHideDateTime() await dateInstance.Instance.HidePopupAsync(); await dateInstance.Instance.ClosePopupAsync(); await dateInstance.Instance.ShowTimePopupAsync(); - popupEle = dateInstance.Find(".e-popup"); + popupEle = dateInstance.WaitForElements(".e-popup", TimeSpan.FromSeconds(5))[0]; var liCollec = popupEle.QuerySelectorAll("li"); + Assert.NotEmpty(liCollec); liCollec[1].Click(); var inputEle = dateInstance.Find("input"); - Assert.Contains("12:30", inputEle.GetAttribute("value").Replace('\u202F', ' ').Trim()); + dateInstance.WaitForAssertion(() => + { + Assert.Contains("12:30", inputEle.GetAttribute("value").Replace('\u202F', ' ').Trim()); + Assert.NotNull(dateInstance.Instance.Value); + }); Assert.Equal(new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, 0, 30, 0), dateInstance.Instance.Value); } [Fact(Timeout = 10000)] @@ -1157,13 +1163,22 @@ public async Task ValueChangeOnDynamically() buttonElem.Click(); await Task.Delay(100); await dateInstance.Instance.ShowTimePopupAsync(); - var popupEle = dateInstance.Find(".e-popup"); + var popupEle = dateInstance.WaitForElements(".e-popup", TimeSpan.FromSeconds(5))[0]; var liCollec = popupEle.QuerySelectorAll("li"); - liCollec[1].Click(); - inputEle = dateInstance.Find("input"); - Assert.Contains("12:30 AM", inputEle.GetAttribute("value").Replace('\u202F', ' ').Trim()); - var selectedVaue = popupEle.QuerySelector("li.e-active").GetAttribute("data-value"); - Assert.Equal("12:30 AM", selectedVaue.Replace('\u202F', ' ').Trim()); + Assert.NotEmpty(liCollec); + // liCollec[0] is 12:00 AM, liCollec[1] is 12:30 AM for Step=30 default. + var expectedTimeLi = liCollec[1]; + var expectedTimeValueText = expectedTimeLi.GetAttribute("data-value")?.Replace('\u202F', ' ').Trim(); + Assert.Equal("12:30 AM", expectedTimeValueText); + expectedTimeLi.Click(); + await Task.Delay(50); + Assert.Equal(new TimeSpan(0, 30, 0), dateInstance.Instance.Value.TimeOfDay); + var inputEle2 = dateInstance.Find("input"); + dateInstance.WaitForAssertion(() => + { + var text = inputEle2.GetAttribute("value") ?? string.Empty; + Assert.Contains("12:30", text.Replace('\u202F', ' ').Trim()); + }, TimeSpan.FromSeconds(2)); } [Fact(Timeout = 10000, DisplayName = "show method with input focus related test case")] public async Task CheckInputFocus() diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerEvents.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerEvents.cs index 0ffc76e..58cf7ba 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerEvents.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerEvents.cs @@ -33,6 +33,7 @@ private string GetDateFormat(T date, string format = null, string culture = n var currentCulture = CultureInfo.CurrentCulture; IFormattable dateValue = date as IFormattable; var dateCulture = dateValue.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; } diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerMask.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerMask.cs index e41c7d1..d5f9684 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerMask.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimePickerMask.cs @@ -50,6 +50,8 @@ public async Task ClearButton() var dateInstance = RenderComponent>(param => param.Add(p => p.EnableMask, true)); var containerEle = dateInstance.Find("input").ParentElement; dateInstance.SetParametersAndRender(("ShowClearButton", true)); + dateInstance.WaitForState(() => containerEle.Children.Length >= 3); + // Re-read the container now that the third child exists. containerEle = dateInstance.Find("input").ParentElement; var clearEle = containerEle.Children[1]; Assert.Contains("e-clear-icon", clearEle.ClassName); @@ -81,16 +83,14 @@ public async Task ClearButton() Assert.Equal(6, tRows.Length); var tCell = tRows[1].QuerySelectorAll("td"); tCell[3].Click(); - var inputEle = dateInstance.Find("input"); await Task.Delay(100); - Assert.NotNull(inputEle.GetAttribute("value")); + Assert.NotNull(dateInstance.Find("input").GetAttribute("value")); Assert.NotNull(dateInstance.Instance.Value); containerEle = dateInstance.Find("input").ParentElement; clearEle = containerEle.Children[1]; clearEle.MouseDown(); await Task.Delay(200); - inputEle = dateInstance.Find("input"); - Assert.Null(inputEle.GetAttribute("value")); + Assert.Null(dateInstance.Find("input").GetAttribute("value")); Assert.Null(dateInstance.Instance.Value); } private string GetNativeDigits(string formatValue, string[] nativeDigits) @@ -113,6 +113,7 @@ private string GetDateFormat(T date, string format = null, string culture = n var currentCulture = CultureInfo.CurrentCulture; IFormattable dateValue = date as IFormattable; var dateCulture = dateValue.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; } diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeStrictMode.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeStrictMode.cs index 5a29fa4..c9470e2 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeStrictMode.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Calendars/DateTimePicker/DateTimeStrictMode.cs @@ -33,6 +33,7 @@ private string GetDateFormat(T date, string format = null, string culture = n var currentCulture = CultureInfo.CurrentCulture; IFormattable dateValue = date as IFormattable; var dateCulture = dateValue.ToString(format, currentCulture); + dateCulture = dateCulture?.Replace('\u202F', ' '); dateCulture = GetNativeDigits(dateCulture, currentCulture.NumberFormat.NativeDigits); return dateCulture; }