diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3ba2d5e..8ce5d1f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -179,6 +179,83 @@ jobs: run: | echo '- `${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.build_tag }}`' >> "$GITHUB_STEP_SUMMARY" + # ─── Image CVE scan ──────────────────────────────────────────────────────── + # Scans the manifest that was just pushed — the only place where base-image + # (nginx:1.27-alpine) and OS-package CVEs are visible. The repo-level pass + # (trivy.yml) sees the lock file and Dockerfile, never the shipped layers. + # + # Report-only for now (`--exit-code 0`): findings go to "Security -> Code + # scanning" and the job log, and promotion proceeds. To bar a vulnerable + # image from promotion — the policy target — set `--exit-code 1` here and add + # `trivy-image` to `dispatch-umbrella`'s `needs`. + # + # Scoped to main: the promotion path this gate is meant to guard. Branch + # dispatches now push a tagged `.` image too (insight#1994) but never + # reach the umbrella, so they are left unscanned to keep dispatch cheap — + # drop the `github.ref` half of the condition below to cover them as well. + trivy-image: + name: Trivy image scan (report-only) + needs: merge + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # checkout (.trivyignore waivers) + packages: read # pull the image under scan from GHCR + security-events: write # upload SARIF to Code Scanning + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Trivy image scan (CRITICAL/HIGH) + # Trivy pulls straight from the registry (TRIVY_USERNAME/TRIVY_PASSWORD) rather than + # via a mounted docker socket, so no daemon and no local `docker pull` is needed. + # `--ignore-unfixed` keeps the output to what a base-image bump can actually fix. + # A multi-arch manifest resolves to the runner's platform (linux/amd64); the arm64 + # leg shares the same base image and package set, so one pass is representative. + env: + TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f + IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.merge.outputs.build_tag }} + TRIVY_USERNAME: ${{ github.actor }} + TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "Scanning ${IMAGE_REF}" + # Two passes over the same warm DB cache: a readable table in the log, SARIF for + # Code Scanning. + docker run --rm \ + -v /tmp/trivy-cache:/root/.cache \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + "$TRIVY_IMAGE" image \ + --severity CRITICAL,HIGH \ + --pkg-types os,library \ + --ignore-unfixed \ + --exit-code 0 \ + --no-progress \ + --format table \ + "$IMAGE_REF" + docker run --rm \ + -v "${{ github.workspace }}:/work" \ + -v /tmp/trivy-cache:/root/.cache \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + "$TRIVY_IMAGE" image \ + --severity CRITICAL,HIGH \ + --pkg-types os,library \ + --ignore-unfixed \ + --exit-code 0 \ + --no-progress \ + --format sarif --output /work/trivy-image.sarif \ + "$IMAGE_REF" + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 + with: + sarif_file: trivy-image.sarif + category: trivy-image + # ─── Umbrella chart dispatch ─────────────────────────────────────────────── # After a successful main-branch image build, trigger the umbrella chart # workflow in constructorfabric/insight so it bumps the frontend subchart diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..06ca330 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,113 @@ +name: Semgrep SAST + +# Report-only SAST gate (constructorfabric/insight#1478), mirroring +# constructorfabric/insight's semgrep.yml so findings stay comparable across the two +# repos. +# +# Findings (Semgrep `--config auto` — per-language auto-detection via the registry, +# matching the org GitLab semgrep-scan component; no `p/rust`/`p/csharp` here since this +# repo is TypeScript + a little Python in scripts/ci) surface in the GitHub +# "Security -> Code scanning" tab (SARIF upload) AND as a count in the job summary, but +# they DO NOT block: `semgrep scan` runs WITHOUT `--error`. Flip to blocking once the +# baseline is triaged and clean (zero un-waived findings) by adding `--error` here and +# marking `sast` a required status check. +# +# Secret detection is intentionally excluded (`--exclude-rule generic.secrets...`); the +# separate TruffleHog gate (trufflehog.yml) owns secret scanning. + +on: + pull_request: + branches: [main] + schedule: + # Nightly full-tree baseline (independent of what any PR touched), 03:27 UTC. + - cron: "27 3 * * *" + workflow_dispatch: + +# A new push obsoletes any run still in flight for the same ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sast: + name: sast + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # checkout + security-events: write # upload SARIF to Code Scanning + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Semgrep scan (report-only — ratchet to blocking with --error once baseline is clean) + # Runs the digest-pinned Semgrep image via `docker run` (not a `container:` job) so the + # checkout and SARIF-upload JS actions keep the host runner's Node — the Semgrep image + # does not ship Node. No `--error`: findings are reported, never block. Secrets excluded + # (TruffleHog owns them). `.semgrepignore` in the repo root prunes build/dep artifacts + # and the vendored cypilot kit. + # NB: `--config auto` requires metrics ON (it sends language/rule/finding counts — not + # source — to semgrep.dev to select rules), so `--metrics off` is intentionally omitted. + run: | + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -w /src \ + semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d \ + semgrep scan \ + --config auto \ + --exclude-rule generic.secrets.security.detected-generic-secret \ + --sarif --output semgrep.sarif + + - name: Summarize findings in the job summary + if: always() + # Null-guarded append (repo convention, cf. ci.yml). Renders a severity + per-rule + # breakdown from the SARIF using the stdlib (no jq dependency). + run: | + [ -f semgrep.sarif ] || exit 0 + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 + # Link the summary to Code scanning. On PRs the analysis is attributed to the PR + # (merge ref) — NOT the branch ref — so filter by pr:; on push/schedule/dispatch + # filter by branch. GITHUB_REF_NAME is "/merge" on pull_request events. + CS_BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/security/code-scanning" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + CS_URL="${CS_BASE}?query=is%3Aopen+pr%3A${GITHUB_REF_NAME%/merge}" + else + CS_URL="${CS_BASE}?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}" + fi + python3 - semgrep.sarif "$GITHUB_STEP_SUMMARY" "$CS_URL" <<'PY' + import json, sys, collections + d = json.load(open(sys.argv[1], encoding="utf-8")) + out = open(sys.argv[2], "a", encoding="utf-8") + cs_url = sys.argv[3] + res = [x for r in d.get("runs", []) for x in r.get("results", [])] + total = len(res) + sev = collections.Counter((x.get("level") or "warning") for x in res) + rules = collections.Counter(x.get("ruleId", "?").split(".")[-1] for x in res) + w = out.write + w("## Semgrep SAST (report-only)\n\n") + w(f"**{total}** finding(s) from `auto` (secrets excluded — TruffleHog owns those). " + f"This check does **not** block; full details in [Security -> Code scanning]({cs_url}).\n\n") + if total: + w("| Severity | Count |\n|---|---:|\n") + for s in ("error", "warning", "note"): + if sev.get(s): + w(f"| {s} | {sev[s]} |\n") + w("\n
Findings by rule\n\n| Count | Rule |\n|---:|---|\n") + for rid, n in rules.most_common(): + w(f"| {n} | `{rid}` |\n") + w("\n
\n") + w("\n_Report-only: becomes blocking when `--error` is added and the baseline is clean._\n") + PY + + - name: Upload SARIF to GitHub Code Scanning + # Skip on fork PRs: they receive a read-only token and cannot upload to Code Scanning, + # which would otherwise red-X this report-only job. + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 + with: + sarif_file: semgrep.sarif + category: semgrep diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 0000000..5e42337 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,169 @@ +name: Trivy SCA + +# Software-composition analysis over the source tree (constructorfabric/insight#1478). +# Two passes, matching the org's GitLab trivy-scan component: +# +# gate — CRITICAL only, blocks. Stop-the-line: a CRITICAL CVE or an embedded secret in a +# dependency manifest must be fixed or waived in `.trivyignore` before merge. +# Baseline today: 0 CRITICAL, so the gate starts green. +# report — HIGH/MEDIUM/LOW, never blocks. Findings land in "Security -> Code scanning" +# (SARIF) plus a job-summary table. Baseline today: 10 HIGH / 16 MEDIUM / 3 LOW, +# almost all from build-time tooling declared under `dependencies`. +# +# Scope: this scans the repository (`trivy fs`) — lock file, Dockerfile, configs. It does NOT +# see the image that ships: base-image and OS-package CVEs are covered by the `trivy-image` +# job in docker.yml, which runs against the pushed manifest. +# +# The report pass deliberately drops the secret scanner (`--scanners vuln,misconfig`): Code +# Scanning alerts on a public repository are world-readable, and Trivy's secret findings quote +# the surrounding match. Secrets stay in the blocking gate, where TruffleHog is the primary +# owner anyway (trufflehog.yml). +# +# Dev dependencies are excluded by Trivy's pnpm default — they are not in the shipped bundle. +# Add `--include-dev-deps` if we ever want build-time supply-chain coverage here too. + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + # Nightly, 03:41 UTC — after the TruffleHog sweep (03:13) and Semgrep baseline (03:27), + # so a bad night shows up as three separate red checks rather than one pile. + - cron: "41 3 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Digest-pinned Trivy, same convention as semgrep.yml / trufflehog.yml. + TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f + +jobs: + gate: + name: gate (CRITICAL) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Trivy fs scan — CRITICAL only (blocking) + # `--ignore-unfixed` keeps the gate actionable: a CRITICAL with no released fix cannot + # be resolved by the PR author, so it belongs in the report pass, not in a merge block. + # Waivers go in `.trivyignore` at the repo root, date-scoped: + # CVE-2026-12345 exp:2026-12-31 # tracked in , fix queued + run: | + docker run --rm \ + -v "${{ github.workspace }}:/src:ro" \ + "$TRIVY_IMAGE" fs \ + --scanners vuln,secret,misconfig \ + --severity CRITICAL \ + --ignore-unfixed \ + --exit-code 1 \ + --no-progress \ + /src + + report: + name: report (HIGH/MEDIUM/LOW) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # checkout + security-events: write # upload SARIF to Code Scanning + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Trivy fs scan — HIGH/MEDIUM/LOW (never blocks) + # Two output formats from two runs over the same cached DB: SARIF for Code Scanning, + # JSON for the summary table below. `--exit-code 0` on both — this pass only reports. + run: | + set -euo pipefail + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -v /tmp/trivy-cache:/root/.cache \ + "$TRIVY_IMAGE" fs \ + --scanners vuln,misconfig \ + --severity HIGH,MEDIUM,LOW \ + --exit-code 0 \ + --no-progress \ + --format sarif --output /src/trivy-fs.sarif \ + /src + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -v /tmp/trivy-cache:/root/.cache \ + "$TRIVY_IMAGE" fs \ + --scanners vuln,misconfig \ + --severity HIGH,MEDIUM,LOW \ + --exit-code 0 \ + --no-progress \ + --format json --output /src/trivy-fs.json \ + /src + + - name: Summarize findings in the job summary + if: always() + # Null-guarded append (repo convention, cf. ci.yml). Groups by package so the summary + # reflects the unit of remediation ("bump X") rather than the CVE count. + run: | + [ -f trivy-fs.json ] || exit 0 + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 + CS_BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/security/code-scanning" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + CS_URL="${CS_BASE}?query=is%3Aopen+pr%3A${GITHUB_REF_NAME%/merge}" + else + CS_URL="${CS_BASE}?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}" + fi + python3 - trivy-fs.json "$GITHUB_STEP_SUMMARY" "$CS_URL" <<'PY' + import json, sys, collections + d = json.load(open(sys.argv[1], encoding="utf-8")) + out = open(sys.argv[2], "a", encoding="utf-8") + cs_url = sys.argv[3] + w = out.write + vulns, miscfg = [], [] + for r in d.get("Results") or []: + vulns += r.get("Vulnerabilities") or [] + miscfg += [(r.get("Target"), m) for m in (r.get("Misconfigurations") or [])] + w("## Trivy SCA (report-only)\n\n") + w(f"**{len(vulns)}** vulnerabilit(ies) + **{len(miscfg)}** misconfiguration(s) at " + f"HIGH/MEDIUM/LOW. CRITICAL is handled by the blocking `gate` job. " + f"Full details in [Security -> Code scanning]({cs_url}).\n\n") + if vulns: + sev = collections.Counter(v["Severity"] for v in vulns) + w("| Severity | Count |\n|---|---:|\n") + for s in ("HIGH", "MEDIUM", "LOW"): + if sev.get(s): + w(f"| {s} | {sev[s]} |\n") + # One row per (package, version): that is what an upgrade actually fixes. + bundles = collections.defaultdict(list) + for v in vulns: + bundles[(v["PkgName"], v.get("InstalledVersion", "?"))].append(v) + rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + w("\n
By package\n\n| Package | Max | CVEs | Fixed in |\n|---|---|---:|---|\n") + for (pkg, ver), items in sorted( + bundles.items(), key=lambda kv: (min(rank.get(i["Severity"], 3) for i in kv[1]), kv[0])): + worst = min(items, key=lambda i: rank.get(i["Severity"], 3))["Severity"] + fixes = sorted({i.get("FixedVersion") or "-" for i in items}) + w(f"| `{pkg}@{ver}` | {worst} | {len(items)} | {', '.join(fixes)} |\n") + w("\n
\n") + if miscfg: + w("\n
Misconfigurations\n\n| Severity | ID | Target | Title |\n|---|---|---|---|\n") + for target, m in miscfg: + w(f"| {m['Severity']} | `{m['ID']}` | `{target}` | {m.get('Title', '')} |\n") + w("\n
\n") + PY + + - name: Upload SARIF to GitHub Code Scanning + # Skip on fork PRs: read-only token cannot upload, which would red-X this report-only job. + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 + with: + sarif_file: trivy-fs.sarif + category: trivy-fs diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml new file mode 100644 index 0000000..54e87f8 --- /dev/null +++ b/.github/workflows/trufflehog.yml @@ -0,0 +1,126 @@ +name: TruffleHog Secrets + +# Blocking secret-detection gate (constructorfabric/insight#1478). +# +# Unlike the Semgrep and Trivy gates this one is BLOCKING from day one: the baseline is +# provably empty (full-history scan over every ref: 0 findings, 0 detector hits), so there +# is nothing to triage and no reason to run in observation mode. Zero-leak policy — a +# committed credential must be revoked and rotated, and rewriting history is not enough. +# +# `--results` is the load-bearing flag. TruffleHog's default (`verified,unknown`) HIDES +# findings whose live verification failed, so an already-revoked or inactive key in the +# history produces a green run — for a public repository that is still a leak. All four +# result kinds are requested so the gate sees what the default would drop. +# +# Raw secret values are never printed to the log or the job summary, and no report artifact +# is uploaded: Actions artifacts and logs on a public repository are world-readable, so +# echoing a hit would publish the very value the gate exists to protect. The summary carries +# detector name, commit and path — enough to locate and rotate. +# +# Scope note: this scans commits reachable from refs. Objects left unreachable by a force +# push or a deleted branch still live on the remote and are NOT covered here; auditing those +# needs a GitHub-source scan (`trufflehog github --repo`) against the API. + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + # Nightly sweep over every branch, 03:13 UTC (ahead of the Semgrep baseline at 03:27). + - cron: "13 3 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + secrets: + name: secrets + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + # Full history, not the tip: a secret deleted in a later commit is still a leak. + fetch-depth: 0 + persist-credentials: false + + - name: Fetch every branch (nightly / manual sweep) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + # PR and push runs scan the history behind the ref under test — that is the merge + # gate. The sweep additionally pulls every other live branch so a secret parked on a + # long-lived feature branch is caught before it reaches main. Explicit authenticated + # URL because `persist-credentials: false` leaves no credential on `origin`; the + # token is masked in logs by Actions. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git fetch --no-tags --prune \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + '+refs/heads/*:refs/remotes/origin/*' + + - name: TruffleHog scan (git history) + # Digest-pinned image via `docker run`, matching semgrep.yml. stdout (JSON findings, + # one object per line, containing raw secret values) is redirected to a file that + # stays on the runner; stderr (progress + the scan summary) is the only thing that + # reaches the log. `--fail` is deliberately NOT used: the summary step below decides + # the exit code, so a failing run still renders a readable report first. + run: | + set -euo pipefail + docker run --rm \ + -v "${{ github.workspace }}:/src:ro" \ + trufflesecurity/trufflehog:3.96.0@sha256:aa821cf4ace8861c7d096d83818cdf7bb9719028a52d37a52eaad44086a52577 \ + git file:///src \ + --json \ + --no-update \ + --results=verified,unknown,unverified,filtered_unverified \ + > trufflehog-findings.jsonl + + - name: Summarize (redacted) and fail on any finding + if: always() + # Null-guarded append (repo convention, cf. ci.yml). Prints detector / commit / path + # only — never `Raw`, `RawV2` or the surrounding line. + run: | + [ -f trufflehog-findings.jsonl ] || exit 0 + python3 - trufflehog-findings.jsonl "${GITHUB_STEP_SUMMARY:-/dev/null}" <<'PY' + import json, sys, collections + rows = [] + with open(sys.argv[1], encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + d = json.loads(line) + git = (d.get("SourceMetadata") or {}).get("Data", {}).get("Git", {}) + rows.append({ + "detector": d.get("DetectorName", "?"), + "verified": bool(d.get("Verified")), + "commit": (git.get("commit") or "")[:8], + "file": git.get("file") or "?", + "line": git.get("line") or "", + }) + out = open(sys.argv[2], "a", encoding="utf-8") + w = out.write + w("## TruffleHog secret scan\n\n") + if not rows: + w("No secrets detected in the scanned history (all result kinds enabled).\n") + sys.exit(0) + ver = sum(1 for r in rows if r["verified"]) + w(f"**{len(rows)}** finding(s) — {ver} live-verified, {len(rows) - ver} unverified. " + "Values are redacted here on purpose; read them from the source commit.\n\n") + w("| Detector | Verified | Commit | Path |\n|---|---|---|---|\n") + for r in sorted(rows, key=lambda r: (not r["verified"], r["detector"])): + w(f"| `{r['detector']}` | {'yes' if r['verified'] else 'no'} | `{r['commit']}` | `{r['file']}`{':' + str(r['line']) if r['line'] else ''} |\n") + by_det = collections.Counter(r["detector"] for r in rows) + w(f"\nBy detector: {', '.join(f'{k} x{v}' for k, v in by_det.most_common())}\n") + w("\n**Revoke and rotate every credential listed above.** Removing the commit is not " + "remediation — assume the value is compromised the moment it was pushed.\n") + out.close() + sys.exit(1) + PY diff --git a/.gitignore b/.gitignore index 794c542..697805f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,14 @@ lerna-debug.log* .eslintcache coverage/ +# Security scanner output (CI writes these into the workspace; trufflehog's +# report holds raw secret values, so it must never be committed) +semgrep.sarif +trivy-fs.json +trivy-fs.sarif +trivy-image.sarif +trufflehog-findings.jsonl + # Editor .vscode/* !.vscode/extensions.json diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..f38025e --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,28 @@ +# Semgrep scan exclusions — build outputs and vendored dependencies only. +# Application AND test source stay in scope (constructorfabric/insight#1478). +# Syntax is .gitignore-style. Point/path/rule-class waivers live elsewhere: +# - per-finding: inline `// nosemgrep: ` / `# nosemgrep: ` + issue link +# - per-rule: `--exclude-rule` in .github/workflows/semgrep.yml (e.g. the TruffleHog boundary) + +# --- Node / frontend build & deps --- +node_modules/ +.pnpm-store/ +dist/ +dist-ssr/ +storybook-static/ +coverage/ +.vite/ +.tanstack/ + +# --- Vendored agent tooling (upstream cypilot kit, not ours to patch) --- +cypilot/ + +# --- Generated vendor code (MSW writes this file, `msw.workerDirectory`) --- +public/mockServiceWorker.js + +# --- Python caches (scripts/ci) --- +__pycache__/ +*.pyc + +# --- VCS / editor noise --- +.git/