diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
new file mode 100644
index 000000000..84d09ab79
--- /dev/null
+++ b/.github/workflows/semgrep.yml
@@ -0,0 +1,100 @@
+name: Semgrep SAST
+
+# Report-only SAST gate (constructorfabric/insight#1797, split from #1463; umbrella #1478).
+#
+# Findings (p/default + the p/rust, p/csharp, p/python language packs) 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 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ 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.
+ run: |
+ docker run --rm \
+ -v "${{ github.workspace }}:/src" \
+ -w /src \
+ semgrep/semgrep:1.131.0@sha256:6bd07d7b166b097e1384f41b94a62d8c8a26a4fff8713992c296e053310da01f \
+ semgrep scan \
+ --config p/default \
+ --config p/rust \
+ --config p/csharp \
+ --config p/python \
+ --exclude-rule generic.secrets.security.detected-generic-secret \
+ --sarif --output semgrep.sarif \
+ --metrics off
+
+ - name: Summarize findings in the job summary
+ if: always()
+ # Null-guarded append (repo convention, cf. e2e-bronze-to-api.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
+ python3 - semgrep.sarif "$GITHUB_STEP_SUMMARY" <<'PY'
+ import json, sys, collections
+ d = json.load(open(sys.argv[1]))
+ out = open(sys.argv[2], "a")
+ 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 `p/default` + `p/rust`/`p/csharp`/`p/python` (secrets excluded — TruffleHog owns those). "
+ "This check does **not** block; full details in **Security -> Code scanning**.\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("\nFindings 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 (#1797)._\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/.semgrepignore b/.semgrepignore
new file mode 100644
index 000000000..140d90866
--- /dev/null
+++ b/.semgrepignore
@@ -0,0 +1,26 @@
+# Semgrep scan exclusions — build outputs and vendored dependencies only.
+# Application AND test source stay in scope (constructorfabric/insight#1797).
+# 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)
+
+# --- Rust build output ---
+target/
+
+# --- Python build/dep/caches ---
+.venv/
+venv/
+__pycache__/
+*.pyc
+*.egg-info/
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+
+# --- Node / frontend build & deps ---
+node_modules/
+dist/
+build/
+
+# --- VCS / editor noise ---
+.git/