Skip to content

Work Wingman — Security (technical)

This document separates the security controls that exist in the codebase today from the controls that are planned but not yet built. Everything under What exists today is backed by a file reference you can open and verify. Everything under Roadmap is a stated intention with no implementation behind it yet.

Design center: Work Wingman is a local-first desktop app. The .NET API, the KeePass vault, and the automation browser all run on the user's machine. This shrinks the attack surface — there is no multi-tenant server, no public endpoint, no shared database — but it introduces a specific local threat (a web page in a browser reaching the loopback API) that the controls below address.


What exists today

1. Loopback-only API (not reachable off-machine)

The .NET API binds http://127.0.0.1:5211 by default — the loopback interface, so nothing on the LAN or the internet can open a socket to it. The bind address is read from the WorkWingman:ApiUrl configuration key and only falls back to the loopback default; do not override it to a non-loopback address (e.g. http://0.0.0.0:5211), which would expose the API off-machine and defeat the token gate's assumptions.

Learn more: Binding to 127.0.0.1 vs 0.0.0.0 is the difference between "this machine only" and "every network interface." Background: ASP.NET Core server endpoint configuration.

Caveat: loopback is not a trust boundary against other software on the same machine. Any local process — including a browser rendering a hostile page — can still send HTTP to 127.0.0.1:5211. That is exactly why controls #2 and #3 exist.

2. Secrets never cross the HTTP API

The KeePass vault is exposed through IVaultService / KeePassVaultService, but no endpoint returns a password. The vault controller surfaces metadata only — a VaultEntryInfo carries name, category, username, and URL, never the secret. The automation engine resolves the actual credential in-process, just-in-time, so a secret never becomes an HTTP response body.

  • File: src/WorkWingman.Api/Controllers/VaultController.cs — the three endpoints are GET /api/vault/status, POST /api/vault/unlock, and GET /api/vault/entries (returns IReadOnlyList<VaultEntryInfo>). There is deliberately no GET /entries/{id}/password.
  • The controller's own summary states the invariant: "Secrets are resolved in-process by the automation engine and are deliberately never exposed over HTTP."

Why it matters: even if control #3 were bypassed, the loopback API has no code path that would hand a password to a caller. The dangerous data simply does not travel over the wire.

Learn more: KeePass is the vault format; OWASP Secrets Management Cheat Sheet covers the "resolve just-in-time, never transport" pattern.

3. Token-gated endpoints (install + sensitive data + state-changing actions)

A growing set of endpoints require a per-launch secret: the state-changing installer, every route that reads or writes one of a defined list of sensitive data categories, and every route that performs a state-changing, account-touching, or spending action. As of the Phase 4 security audit, all of them are consolidated onto one mechanism — the reusable RequireLocalTokenAttribute filter — rather than each branch/feature adding its own inline ApiToken.IsTrusted check. (A few call sites — the installer, the account-wall trio on RunsController — still use the inline check directly because they need a custom error message or sit alongside non-auth logic in the same action; both paths call the exact same ApiToken.IsTrusted, so the enforcement is identical either way.)

  • File: src/WorkWingman.Api/Security/ApiToken.cs
  • Generates a 32-byte hex token at startup via Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).
  • Writes it to %LOCALAPPDATA%\WorkWingman\api-token (a per-user profile path, readable only by the user). The file is overwritten each launch, so a stale token cannot be replayed.
  • IsTrusted(HttpRequest) accepts the request if it carries a matching X-WorkWingman-Token header — compared with CryptographicOperations.FixedTimeEquals (constant-time, to prevent a timing side channel) — or originates from the trusted dev origin http://localhost:4200.

3a. The installer (state-changing). The dependency doctor's POST /api/dependencies/install shells out to winget / npm, so it is dangerous if abused.

  • File: src/WorkWingman.Api/Controllers/DependenciesController.cs
  • GET /api/dependencies (detection) stays open — read-only, runs every launch.
  • POST /api/dependencies/install calls apiToken.IsTrusted(Request) and returns 401 otherwise. It also refuses an empty approval list, and only installs the explicit ids the user approved (there is no "install everything" shortcut).

3b. Sensitive-data and state-changing routes (Phase 4 audit — the full list). Every endpoint that reads/writes one of the categories below, or performs a state-changing/account-touching/ spending action, is gated. RequireLocalTokenAttribute is the one reusable mechanism:

  • File: src/WorkWingman.Api/Security/RequireLocalTokenAttribute.cs — an IAsyncActionFilter attribute that resolves ApiToken from DI and calls the same IsTrusted check, returning 401 when it fails. Applicable at class level (gates every action) or method level (gates one action in an otherwise-open controller).

Sensitive-data categories (financial, salary, demographics, health/benefits, equity, assessments/ personality, interview retros, integration key status, vault):

Endpoint(s) Category Gate
api/interview-retro/* (whole controller) candid personal self-report on interview performance [RequireLocalToken] on the class
GET/PUT api/profile/intake demographics (Demographics.Gender/RaceEthnicity/VeteranStatus/DisabilityStatus) [RequireLocalToken] per action
GET api/profile/demographic-resources demographics-derived resource matches [RequireLocalToken] per action
GET/PUT api/profile/equity, GET api/equity/valuation equity-grant details/valuation [RequireLocalToken] (class on EquityController, per action on ProfileController)
GET/PUT api/profile/financial, POST api/finances/tax-estimate, POST api/finances/compare current salary, gross-salary submissions [RequireLocalToken] per action
GET/POST/PUT api/assessment* (profile, Big Five scoring, MBTI/work/communication/learning-style saves) personality/psychometric self-report [RequireLocalToken] per action
POST api/assessment/export/google-forms assessment data + leaves the machine [RequireLocalToken] on the class (AssessmentFormsController)
GET api/integration-keys, PUT/DELETE api/integration-keys/{id} integration key status (last-4 fragment is secret-derived) inline ApiToken.IsTrusted (pre-Phase-4; same enforcement)
GET api/vault/status, POST api/vault/unlock, GET api/vault/entries vault metadata (account/tenant enumeration) [RequireLocalToken] on the class (VaultController)

State-changing / account-touching / spending actions:

Endpoint(s) Why it's dangerous cross-origin Gate
POST api/dependencies/install shells winget/npm inline ApiToken.IsTrusted
POST api/applications/sync-interviews scans Gmail, writes real Google/Apple calendar events [RequireLocalToken] per action
POST api/data-backup/export can return a workbook containing intake/financial/equity directly in the response body — a direct exfiltration path if ungated [RequireLocalToken] per action
POST api/data-backup/import overwrites local domain data from an uploaded file [RequireLocalToken] per action
POST api/data-backup/{domainKey}/sync syncs a domain to the user's own Google Drive/Sheets [RequireLocalToken] per action
POST api/study-media/playlist writes a real playlist into the user's own YouTube account inline ApiToken.IsTrusted
POST api/study/{jobId}/notebooklm/publish-mcp the confirm() dialog in the UI is not itself a security boundary inline ApiToken.IsTrusted
POST/DELETE api/study-visuals/* (generate/regenerate/enhance/delete, video generate/delete) spends the user's own paid Gemini/OpenAI/Magnific quota, or deletes a local file inline ApiToken.IsTrusted per action
POST api/runs/{id}/account/check|acknowledge, GET api/runs/{id}/account/credential provisions/reads vault state, or returns a raw vault password inline ApiToken.IsTrusted per action

Deliberately left open (not in scope for this audit): RunsController's non-account-wall actions (GetActive/Start/Pause/Resume/Stop/Resolve) — starting/driving a run is state-changing in a sense, but the automation engine's own AlwaysRequireReviewBeforeSubmit invariant (control #5) and the sub-90%-confidence pause already prevent it from taking an irreversible action unattended; gating the whole live-run UX behind the token is tracked as a residual gap, not an oversight (see T1 below). FamilyController (household/school-cost comparisons) is not one of the task's enumerated sensitive categories and stays open, consistent with ApplicationsController's other list/detail reads.

Shared plumbing.

  • File: electron/preload.js
  • The Electron preload reads the token file and exposes it to the renderer via contextBridge.exposeInMainWorld('workWingmanApiToken', token) — with Node integration off.
  • File: frontend/src/app/core/api-token.interceptor.ts
  • Attaches X-WorkWingman-Token to every /api/ call when the token is present; gated endpoints enforce it, the rest ignore it.

Threat neutralized: a hostile web page loaded in a browser can send HTTP to 127.0.0.1:5211, but it cannot read %LOCALAPPDATA%\WorkWingman\api-token (no filesystem access from a sandboxed page), so it cannot forge the header. It also cannot forge another site's Origin (the browser sets that itself). Result: its install request, any read/write of api/interview-retro/*, and every other endpoint in the two tables above (financial/salary/demographics/equity/assessment/vault reads, and every account-touching/spending/exfiltration-risk write) is rejected with 401.

Interceptor policy: the frontend attaches the token to every /api/ call rather than an allow-list (see the interceptor's own doc comment for the full endpoint catalog it keeps in sync with the server-side gate). This is safe because the token can only ever be read by the genuine Electron renderer or the trusted dev origin — a non-gated endpoint receiving it harmlessly ignores the header — and it means adding [RequireLocalToken] to a newly-sensitive controller later needs zero frontend changes.

Learn more: RandomNumberGenerator (CSPRNG), CryptographicOperations.FixedTimeEquals (constant-time comparison, avoids timing attacks), Electron contextBridge and Electron context isolation.

4. Codex review gate (process control)

Every commit is reviewed by Codex — checking for bugs, OWASP Top 10 issues, performance, and code quality — before it is pushed. This is a human-in-the-loop process control, not a runtime control, but it is the gate that catches security regressions before they land.

5. Trust model enforced in code (never auto-submit)

The automation engine never submits an application on the user's behalf without an explicit review.

  • File: src/WorkWingman.Core/Models/Connection.cs line 34
    public bool AlwaysRequireReviewBeforeSubmit => true;   // computed, always true
    
  • The engine pauses when fill confidence drops below 90%, handing control back to the user rather than guessing. See the README's trust-model note and the live-run pause/resolve endpoints (POST /api/runs/{id}/pause|resume|stop|resolve).

6. CI builds and tests both stacks

  • File: .github/workflows/ci.yml — on every push/PR to main/master, builds and tests the .NET backend (Windows) and the Angular frontend (Ubuntu).

8. Dependency & CVE scanning gate (security job)

CI now includes a dedicated Security (dependencies) job (.github/workflows/ci.yml) that fails the build on known-vulnerable packages, on the same push/PR triggers as every other job.

  • Backend: dotnet restore WorkWingman.slnx, then dotnet list WorkWingman.slnx package --vulnerable --include-transitive --format json. The JSON is parsed by .github/scripts/parse-vulnerable-packages.sh, which walks every project/framework's topLevelPackages and transitivePackages and emits ::error:: (failing the job) for any High/Critical severity advisory, or ::warning:: for Moderate/Low. An advisory is downgraded from error to warning only if its URL is listed in .github/dependency-allowlist.txt — a file that requires a written justification per entry (currently empty; the backend scan is clean).
  • Frontend: npm ci then npm audit --audit-level=high — its own exit code fails the step on any high/critical advisory. Moderate/low are reported but do not fail the build.
  • Advisory-only, never fails the job: dotnet list package --outdated and npm outdated are printed for visibility so outdated-but-not-yet-vulnerable packages stay on the radar.
  • Dependabot (.github/dependabot.yml) opens weekly, grouped minor/patch update PRs (capped at 5 open PRs each) for three ecosystems: nuget (backend, scanned from the repo root), npm (frontend/), and github-actions (workflow action versions).

As of the last scan: dotnet list package --vulnerable --include-transitive is clean across every project in WorkWingman.slnx. The frontend had five transitive advisories (@babel/core, esbuild, qs — pulled in via @angular/build, vite, and Stryker's typed-rest-client respectively); all were resolved by pinning the patched versions with npm overrides in frontend/package.json (@babel/core@^7.29.7, esbuild@^0.28.1, qs@^6.15.3) — no allowlist entries were needed.

Learn more: dotnet list package docs (the --vulnerable flag cross-references the GitHub Advisory Database; --include-transitive catches indirect dependencies), npm audit docs, npm overrides field (forces a specific version for a transitive dependency without waiting on the parent package to bump it), Dependabot version updates.

Honest limitation: the gate gives us dependency/CVE coverage, but CI still does not run SAST, secret scanning, or DAST. Those remain in the Roadmap.

7. git hygiene

  • File: .gitignore — excludes bin/, obj/, *.kdbx (the vault), .env, node_modules/, and appsettings.*.local.json.
  • History was rewritten to purge accidentally-committed build output (bin/obj), so those artifacts are gone from the repo, not merely ignored going forward.

Learn more: gitignore reference, GitHub: removing sensitive data from a repository.


Threat model

Framing follows the app's local-first shape. For each threat we state the surface, the mitigation that exists today, and the residual gap (usually pointing at the roadmap).

Learning references for this section: OWASP Top 10 (the canonical web-risk list) and OWASP ASVS (the Application Security Verification Standard — a checklist of what "secure" concretely means).

T1 — Malicious web page reaches the loopback API

  • Surface: the API listens on 127.0.0.1:5211. Any local process, including a browser rendering an attacker's page, can send it HTTP. A page could try a cross-origin POST to trigger installs or read data. (Maps to OWASP A01 Broken Access Control / CSRF-style abuse.)
  • Mitigation (exists):
  • The dangerous, state-changing endpoint (/api/dependencies/install) is token-gated (control #3a). The page cannot read the token file and cannot forge the trusted origin → 401.
  • Every sensitive-data category (financial/salary, demographics, equity, assessments/personality, interview retros, integration-key status, vault) and every state-changing/account-touching/ spending action is token-gated the same way (control #3b, via the reusable RequireLocalToken filter, consolidated in the Phase 4 audit — see the two tables above for the full endpoint list). A hostile page can neither read nor seed/overwrite any of that data → 401.
  • Secrets never cross the HTTP API (control #2), so even a fully readable response body never contains a password.
  • Residual gap: the app's remaining open read endpoints (job queue, application/interview list, study catalogs, connections settings, family cost comparisons) are still reachable by any local process. This is resume-grade or non-enumerated data the user already stores locally — the residual exposure is read access to it via loopback rather than exfiltration off the machine, a materially milder concern than the categories now gated. RunsController's live-run start/pause/resume/stop/resolve actions are also still open (see § 3b "Deliberately left open"); the automation engine's own review-before-submit and confidence-pause invariants (control #5) bound the damage even so. The RequireLocalToken filter is reusable, so extending the gate further later (a deliberate sensitivity-tiering choice, not a plumbing task) is one attribute each; that, plus adding DAST against the loopback surface, is the next step here.

T2 — Supply-chain / vulnerable dependencies

  • Surface: NuGet packages (Flurl, Polly, Playwright, KeePassLib, etc.) and npm packages (Angular, Electron). A vulnerable or malicious transitive dependency is the classic modern attack vector. (Maps to OWASP A06 Vulnerable and Outdated Components.)
  • Mitigation (exists): the CI security job gates every push/PR — dotnet list package --vulnerable --include-transitive fails the build on any High/Critical backend CVE (control #8), and npm audit --audit-level=high does the same for the frontend. Dependabot opens weekly PRs so fixes land before an advisory goes stale. .gitignore keeps node_modules out of the repo on top of that.
  • Residual gap: the gate gives us CVE coverage but not deeper supply-chain analysis (typosquatting, malicious-package detection, SBOM). OWASP Dependency-Check / Trivy remain on the Roadmap for that broader pass.

T3 — Credential theft

  • Surface: the user's stored logins (LinkedIn, Workday, Google, Apple). If these leaked via an API response, a log line, or a committed file, an attacker gains the user's accounts. (Maps to OWASP A02 Cryptographic Failures / A09 Logging Failures.)
  • Mitigation (exists):
  • Secrets live in the KeePass vault and are never returned over HTTP (control #2).
  • The vault file (*.kdbx) is git-ignored (control #7); it never enters the repo.
  • Passwords are resolved in-process, just-in-time, and are never logged.
  • Residual gap: add automated secrets scanning (gitleaks + GitHub push protection) so an accidental hard-coded credential is caught before it is committed. See Secrets scanning.

T4 — Automation safety (unwanted actions on the user's behalf)

  • Surface: the automation engine drives a real browser and can fill and submit real job applications. An over-eager or wrong automation could submit something the user did not intend.
  • Mitigation (exists): AlwaysRequireReviewBeforeSubmit is computed true (control #5) — the engine never auto-submits — and it pauses below 90% fill confidence, returning control to the user. The install endpoint likewise acts only on explicitly approved ids.
  • Residual gap: none structural; this is a deliberate, code-enforced human-in-the-loop guarantee.

Roadmap (planned, not yet implemented)

Everything in this section is planned per the project mandate and NOT built. No code, workflow, or config for these exists in the repo today. (The dependency/CVE gate — Dependabot, dotnet list package --vulnerable, and npm audit — used to live here; it now ships, see control #8.) The reference repos from the USPTO work used commercial Fortify for SAST; the tools below are the free / open-source replacements chosen for Work Wingman.

The intended end state: CI runs the scanners below on every push/PR and publishes SARIF to the GitHub Security tab so findings are triaged in one place.

SAST (static application security testing)

The Fortify replacement — analyze source for vulnerabilities without running it.

  • GitHub CodeQL — semantic code analysis for C#/.NET and JS/TS.
  • Learn more: codeql.github.com — CodeQL treats code as a queryable database; GitHub maintains security query packs that map to real CVE classes.
  • Semgrep — run semgrep ci with the OWASP and security-audit rulesets.
  • Learn more: semgrep.dev and github.com/semgrep/semgrep — fast, rule-based pattern scanning; the p/owasp-top-ten and p/security-audit rulesets are the relevant starting packs.
  • Security Code Scan — a .NET-specific Roslyn analyzer that flags injection, XSS, weak crypto, and similar at build time.
  • Learn more: security-code-scan.github.io.

Broader supply-chain scanning

The .NET vulnerable-package gate, npm audit gate, and Dependabot are done — see control #8. What's left is a deeper pass beyond known-CVE matching:

  • OWASP Dependency-Check — maps dependencies to known CVEs (NVD-backed); overlaps with the dotnet list --vulnerable/npm audit gate but cross-references a second database.
  • Learn more: owasp.org/www-project-dependency-check.
  • Trivy — broad scanner (dependencies, containers, misconfig, secrets) and SBOM generation.
  • Learn more: github.com/aquasecurity/trivy.

Trivy and Dependency-Check overlap with each other; pick one (or both) as a deeper second pass rather than assuming both run today — neither does yet.

Secrets scanning

DAST

Dynamic testing — probe the running loopback API for issues static analysis can't see.

  • OWASP ZAP — run the baseline scan against http://127.0.0.1:5211 in CI.
  • Learn more: zaproxy.org — the baseline scan is a fast, passive-plus- light-active pass suitable for a CI gate.

Publish SARIF to the GitHub Security tab

All of the above emit SARIF (Static Analysis Results Interchange Format). Uploading it via github/codeql-action/upload-sarif surfaces every scanner's findings in one Security → Code scanning view.

Security MCP in the repo

Add a security MCP server so Claude can run and triage findings interactively from inside the repo.

  • Semgrep MCP server (primary choice): github.com/semgrep/mcp — exposes Semgrep scanning as MCP tools, so an agent can scan, read findings, and propose fixes in one loop.
  • Alternative — Snyk MCP: Snyk offers an MCP integration if the project prefers Snyk's advisory database.
  • Setup note (when built): register the MCP server in the project's MCP config, point it at the repo root, and give it read access to scan results so triage stays in-editor.