Skip to content

Security scanning & posture

Work Wingman is a local-first desktop app: the .NET API listens on loopback only (http://127.0.0.1:5211), secrets live in a local KeePass vault, and no user data leaves the machine except through connections the user explicitly configures (BYO credentials). The scanning stack below defends the codebase itself — its dependencies, its history, and the one network surface the app has (the loopback API).

What runs, where

Layer Tool Where Gate
SAST (code) Semgrep — p/csharp p/typescript p/javascript p/security-audit p/secrets CI sast job + local Docker Hard fail on any finding
Secrets (history) Gitleaks over the full git history, .gitleaks.toml allowlist CI secrets job + local Docker Hard fail on any leak
Dependencies (CVE) dotnet list package --vulnerable (+ allowlist script), npm audit --audit-level=high CI security job Hard fail on High/Critical
Dependency updates Dependabot — nuget, /frontend npm, /electron npm, github-actions GitHub, weekly PRs, not a gate
DAST (runtime) OWASP ZAP baseline vs the real API booted on loopback CI dast job + local Docker Hard fail on FAILs; WARNs reported
Interactive triage Semgrep MCP server (.mcp.json) Claude Code sessions Advisory (gating stays in CI)
Review gates Codex + Gemini (+ local council) review every commit, incl. an explicit OWASP ask Per-commit, pre-push Convergence required

Running the scans locally

All scanners run through Docker so results match CI exactly (Semgrep and ZAP have no native Windows CLI). From the repo root, Git Bash needs MSYS_NO_PATHCONV=1 so /repo-style container paths survive:

# SAST — identical invocation to the CI `sast` job
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd):/src" -w /src semgrep/semgrep \
  semgrep scan --metrics=off --error \
  --config p/csharp --config p/typescript --config p/javascript \
  --config p/security-audit --config p/secrets \
  --exclude node_modules --exclude dist src frontend electron tools

# Secrets — full history, same config as CI
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd):/repo" zricethezav/gitleaks:latest \
  git --no-banner -c /repo/.gitleaks.toml /repo

# DAST — boot the API first (dotnet run --project src/WorkWingman.Api --no-launch-profile),
# then baseline-scan it (host.docker.internal reaches the host's loopback from the container)
MSYS_NO_PATHCONV=1 docker run --rm zaproxy/zap-stable \
  zap-baseline.py -t http://host.docker.internal:5211 -I

Suppression policy

A finding is either fixed or suppressed with a reviewed justification — never silently ignored:

  • Semgrep: inline // nosemgrep: <rule-id> -- <reason> at the flagged line. The reason is part of the suppression; a bare nosemgrep should be rejected in review.
  • Gitleaks: an entry in .gitleaks.toml with a comment explaining why it is a false positive. Never allowlist a real credential — rotate it instead.
  • CVEs: .github/dependency-allowlist.txt (advisory id + reason), consumed by .github/scripts/parse-vulnerable-packages.sh.
  • ZAP: WARNs are tolerated by the gate (-I) but should be fixed when cheap — e.g. the API now sends Cache-Control: no-store on every response because its data is personal and fully dynamic.

Interactive scanning in Claude Code (Semgrep MCP)

.mcp.json registers the Semgrep MCP server — the CURRENT form, semgrep mcp, built into the Semgrep binary (the old uvx semgrep-mcp package and ghcr.io/semgrep/mcp image are deprecated; the image now only serves a deprecation notice tool). It runs inside the official semgrep/semgrep container because the Semgrep CLI has no native Windows build. The repo is mounted read-only at /workspace so the path-based tools (semgrep_scan, semgrep_scan_with_custom_rule, get_abstract_syntax_tree, …) can see the code. The mount path in .mcp.json is absolute — clones at a different path must adjust it. The MCP is for interactive triage — deterministic gating stays in CI.

As of 2026-07 there is no official OWASP-Foundation scanner MCP (the "OWASP MCP Top 10" is a framework about securing MCP servers themselves, not a scanning tool); Semgrep's OWASP-mapped rulesets are the chosen equivalent. Re-check occasionally.

Deliberately not used (and why)

  • CodeQL — the CodeQL CLI/action license covers public repos and GitHub Advanced Security customers; this repo is private without GHAS, so running it would violate the license. Semgrep is the SAST of record. Revisit if the repo goes public or gets GHAS.
  • SARIF upload / code-scanning alerts tab — same GHAS gating on private repos. The hard CI gates make the signal impossible to miss without the dashboard.
  • gitleaks-action — the wrapper wants a license key for org accounts; the MIT-licensed CLI in Docker does the same job with no key.
  • SonarCloud — free tier is public-repos-only.

App-level guardrails (context for reviewers)

These are properties reviews and scans must never regress:

  • The automation engines locate but never click Submit; applying is always the user's act.
  • The app never types stored passwords into pages ("offer, don't type" — see docs/technical/ats-accounts.md); account walls pause the run for the user.
  • Sensitive endpoints require the per-launch loopback token (RequireLocalToken); tokens seen in docs or history are dead by design (regenerated every launch, loopback-only).
  • BYO credentials only: the app never creates accounts or mints keys for the user; user keys live in the local KeePass vault.
  • Apply-URL extraction validates hosts against the detected ATS family (SSRF guard in AtsDetector).