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.
- File:
src/WorkWingman.Api/Program.csline 11builder.WebHost.UseUrls(builder.Configuration["WorkWingman:ApiUrl"] ?? "http://127.0.0.1:5211"); - The Electron shell and the Angular client both point at the same loopback URL
(
electron/preload.jsline 20,frontend/src/app/core/api.service.tsline 17).
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 areGET /api/vault/status,POST /api/vault/unlock, andGET /api/vault/entries(returnsIReadOnlyList<VaultEntryInfo>). There is deliberately noGET /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 matchingX-WorkWingman-Tokenheader — compared withCryptographicOperations.FixedTimeEquals(constant-time, to prevent a timing side channel) — or originates from the trusted dev originhttp://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/installcallsapiToken.IsTrusted(Request)and returns401otherwise. 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— anIAsyncActionFilterattribute that resolvesApiTokenfrom DI and calls the sameIsTrustedcheck, returning401when 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-Tokento 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.csline 34public 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 tomain/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, thendotnet 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'stopLevelPackagesandtransitivePackagesand 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 cithennpm 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 --outdatedandnpm outdatedare 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/), andgithub-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— excludesbin/,obj/,*.kdbx(the vault),.env,node_modules/, andappsettings.*.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-originPOSTto 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
RequireLocalTokenfilter, 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. TheRequireLocalTokenfilter 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
securityjob gates every push/PR —dotnet list package --vulnerable --include-transitivefails the build on any High/Critical backend CVE (control #8), andnpm audit --audit-level=highdoes the same for the frontend. Dependabot opens weekly PRs so fixes land before an advisory goes stale..gitignorekeepsnode_modulesout 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):
AlwaysRequireReviewBeforeSubmitis computedtrue(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, andnpm 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 ciwith 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 auditgate 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¶
- gitleaks — scans commits/history for leaked credentials.
- Learn more: github.com/gitleaks/gitleaks.
- GitHub secret scanning + push protection — blocks known secret formats at push time.
- Learn more: docs.github.com — secret scanning and push protection.
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:5211in 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.
Related docs¶
- docs/plain/security.md — the same topic in everyday language.
- docs/technical/data-backup.md — local-Excel AES-256-GCM encryption and the sensitive-domain local-only-by-default gate for Drive/Sheets sync.
- docs/ARCHITECTURE.md — process model, loopback binding, vault placement.
src/WorkWingman.Api/Security/ApiToken.cs— the token gate.src/WorkWingman.Api/Controllers/VaultController.cs— metadata-only vault surface.- OWASP Top 10 · OWASP ASVS