Skip to content

chore(security): dependency-review, invisible-char detection, and least-privilege workflow permissions #782

Description

@edelauna

Summary

Add the remaining in-repo supply-chain hardening identified in the security posture audit: a dependency-review gate on PRs, invisible/homoglyph-character detection in CI (+ light ESLint), and least-privilege permissions: defaults across workflows. Complements #781 (publish provenance) — this issue does not touch publish workflows.

Background

A posture audit confirmed the foundational controls are in place (branch protection, push protection, secret scanning, Renovate, --frozen-lockfile everywhere, all third-party actions pinned by SHA, pnpm onlyBuiltDependencies allowlist). Three lower-severity in-repo gaps remain:

  • No dependency-review-action — PRs that bump to a known-vulnerable or typosquatted package are not blocked before merge (Renovate opens the PR, but nothing validates the diff's dependency changes).
  • No invisible-character / homoglyph detection — zero-width (U+200B–200D), bidi-override (U+202A–202E, "Trojan Source"), and BOM (U+FEFF) characters are invisible in GitHub's diff UI and in most editors. They compile fine, which is exactly the risk (identifier-splitting / string-literal injection). Neither ESLint nor CI currently flags them.
  • 6 workflows lack a top-level permissions: block (cli-release, code-qa, codeql, e2e, marketplace-publish, release-validation) — these inherit the broad default GITHUB_TOKEN, violating least privilege.

Changes required

1. Dependency review on PRs (code-qa.yml)

Add a new job:

dependency-review:
  runs-on: ubuntu-latest
  if: github.event_name == 'pull_request'
  steps:
    - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
    - uses: actions/dependency-review-action@c74b580d73376b7750d3d2a50bfb8adc2c937507 # v4

2. Invisible / homoglyph character detection

Two layers, with deliberate division of labor:

  • ESLint no-irregular-whitespace (error, skipStrings: true) in the per-workspace eslint.config.mjs files — fast local feedback in the pre-commit hook; catches identifier/token splitting. skipStrings: true keeps it quiet in the non-English i18n locale files. The string-literal variant is intentionally left to the CI grep (below), which is the stronger control.
  • .vscode/settings.json:
    { "editor.unicodeHighlight.invisibleCharacters": true,
      "editor.unicodeHighlight.ambiguousCharacters": true }
  • CI grep (authoritative defense, incl. whitespace in strings) — scans raw file bytes, so it catches zero-width chars in strings, identifiers, comments, and between tokens, plus the full Trojan-Source bidi set (U+202A–202E) and soft hyphens (U+00AD) that ESLint skips. Add a job to code-qa.yml:
    invisible-chars:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        - name: Reject invisible / homoglyph Unicode
          run: |
            ! grep -rnP '[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}\x{FEFF}\x{00AD}]' \
                --include='*.ts' --include='*.tsx' --include='*.js' --include='*.mjs' \
                src webview-ui packages apps
    (May need a small allowlist if any locale/test file legitimately contains these chars.)

Rationale: ESLint's skipStrings: false would only scan AST string literals and miss template/dynamic strings + .json/.md. The raw-byte grep gives broader coverage with less i18n-noise risk, so it's the belt-and-suspenders the string case actually needs.

3. Least-privilege permissions: defaults

Add permissions: contents: read (escalating only where needed) to the 6 workflows that currently lack a top-level block: cli-release.yml, code-qa.yml, codeql.yml, e2e.yml, marketplace-publish.yml, release-validation.yml. Leave the publish jobs' existing job-level permissions intact.

Out of scope

Acceptance criteria

  • dependency-review job runs on PRs and fails on a known-vulnerable/typosquatted bump
  • no-irregular-whitespace enabled (skipStrings: true); pnpm lint is clean
  • CI invisible-char grep fails when a zero-width char is planted in a .ts file (incl. inside a string literal)
  • All 6 workflows have an explicit top-level permissions: block; CI still green

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions