HECVAT Lite — Vendor Security Assessment
Product: SHS Digital Wellness Passport (student + admin web app) Report date: 2026-07-20 Prepared for: CSUB ITS Security vendor review (US-44 / PR-F2)
This report follows the structure of REN-ISAC/EDUCAUSE's HECVAT Lite — one row per question, an answer, and an explanation citing the code or config that backs it — without reproducing HECVAT's copyrighted question set verbatim. Answer is one of:
- Yes — the control is in place, with evidence cited in Explanation.
- No — the control is not in place.
- N/A — the question's subject matter does not apply to this product (e.g. the product provisions no physical media, so a media-sanitization control has no subject).
- Compensating Control — the named control is not implemented as asked, but an equivalent mitigation is, described in Explanation.
Every row that states a configuration fact (cookie flags, token algorithm/TTL,
rate limits, headers, thresholds) states the live value and cites the
file:line it is read from. backend/tests/vendor_security_conventions.py's
CONFIG_FACTS reads those same values from the running code at test time and
test_vendor_security_package_bdd.py's second HECVAT scenario compares them
against what this table says — if the code changes and this table doesn't,
the suite reds. That is the anti-drift mechanism PASS-54's runbook
declared_targets check established for load-test targets, applied here to
security posture.
The file:line references are checked too, not just the values (PASS-218).
rows_with_a_drifted_citation opens each cited line and asserts it contains
the symbol or directive that row is about — declared in CITATION_ANCHORS,
one anchor per citation. A citation that still names a real file but points a
few lines off, at the comment about the setting rather than the setting, is
a failure: it sends a reviewer to the wrong place, which is the whole cost a
citation exists to avoid.
config_fact_rows_without_a_citation closes the other half (PASS-235): every
row CONFIG_FACTS asserts a live value for must cite something, so a brand
new configuration-fact row can no longer ship with no citation at all —
rows_with_a_drifted_citation alone only starts checking a row once it
already cites a line, which caught a citation being deleted but not one that
was never added. Its remaining gap was that the must-cite set was still the
question ids an author remembered to declare in CONFIG_FACTS — a new row
could state a configuration value, add no CONFIG_FACTS entry for it, cite
nothing, and still ship green.
PASS-236 closes that by total classification instead of prose inference:
every row in this table must be a CONFIG_FACTS row, cite a file:line, or
be declared in ROWS_STATING_NO_LIVE_VALUE with a human-reviewed reason it
states no live value. rows_unaccounted_for fails a row in none of the
three; stale_no_live_value_declarations fails a declared row that later
gains a citation or a CONFIG_FACTS entry, so a declaration can't quietly go
stale once the row it covers actually starts stating something live. This is
the same shape PUBLIC_ALLOWLIST / unclassified_routes already use for
routes: rather than guess "does this row assert a configuration fact" from
its wording (an ALL_CAPS identifier or a number-with-unit shows up in rows
that assert nothing live — see AUTH-3 and VULN-3 below), a human classifies
every row once and the check holds that classification honest going forward.
Application security
| Question ID | Question | Answer | Explanation / Reference |
|---|---|---|---|
| SEC-1 | Is the session cookie HttpOnly, SameSite-restricted, and Secure? | Yes | HttpOnly=True, SameSite=Lax, Secure=True (production only) — set on wp_session at sign-in (backend/app/routers/auth.py:87). Secure is gated on production because dev/demo signs in over plain HTTP on localhost, where a Secure cookie is silently dropped. |
| SEC-2 | What algorithm and lifetime does the session token use? | Yes | HS256, 3600s TTL — algorithm at (backend/app/auth/session.py:9), TTL default at (backend/app/config.py:67). A separate secret (WP_QR_SECRET) signs the short-lived event-QR tokens, so rotating one never invalidates the other (backend/app/config.py:72). |
| SEC-3 | Are check-in and conversational-guide endpoints rate limited per user? | Yes | check-in 10/60s, guide 20/60s — sliding-window-log limiter keyed on student_id, never client IP, so a venue's shared Wi-Fi/NAT never throttles a whole crowd off one address (backend/app/rate_limit.py:127), limits declared at (backend/app/config.py:219). The pre-auth /auth/acs callback is separately throttled per client IP on failed assertions only. |
| SEC-4 | What HTTP security headers does every response carry? | Yes | CSP: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; HSTS: max-age=63072000; includeSubDomains (production only). Every response also carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Permissions-Policy, Cross-Origin-Opener-Policy/Cross-Origin-Resource-Policy: same-origin, and Cross-Origin-Embedder-Policy: require-corp (backend/app/middleware.py:16). The frontend's static-file server sets its own equivalent CSP and header set (docker/frontend.nginx.conf:40) — since PASS-221 self-hosted the Roboto/Oswald/Cinzel webfonts, that CSP names no outside origin at all. |
| SEC-5 | Does the application refuse to start in production with an insecure configuration? | Yes | mock IdP, known/weak secrets — a production boot with any of these fails loudly at startup rather than serving traffic insecurely (backend/app/config.py:278). No Anthropic key requirement here since PASS-378 retired reflection auto-scoring, the one production-facing path that needed one; PASS-379 then retired challenge import, the app's only remaining Anthropic caller, so there is no Anthropic caller left at all. Covered end to end by test_secrets_management_bdd.py. |
| SEC-6 | Are aggregate reports privacy-aware (no small-group re-identification)? | Yes | 5 — any per-bucket count in [1, 5) is suppressed rather than shown exactly, since a count that small can name the student(s) behind it (backend/app/config.py:271). |
| SEC-7 | Do containers run as a non-root user? | Yes | 10001 — the api image creates and runs as a dedicated non-root appuser (uid 10001), not the image default root (docker/api.Dockerfile:24). |
| SEC-8 | Is Cross-Origin Resource Sharing restricted to an explicit allow-list? | Yes | empty (same-origin) — no CORS header is added at all until a deploy actually splits the SPA and API across origins; the demo topology serves both behind one ingress origin. When set, it is always an explicit origin list — CORSMiddleware refuses "*" together with allow_credentials=True, which every frontend fetch sends (backend/app/config.py:202). |
Authentication & access control
| Question ID | Question | Answer | Explanation / Reference |
|---|---|---|---|
| AUTH-1 | Does the product support SSO via SAML? | Yes | The real campus IdP path is a pluggable AuthProvider (backend/app/auth/provider.py); a mock provider stands in for local dev/demo and is refused at production boot (SEC-5 above). |
| AUTH-2 | Is role/authorization enforced server-side, not only in the client UI? | Yes | Every admin route carries Depends(require_admin); every student route carries Depends(require_current_student) (backend/app/auth/deps.py:49-93). backend/tests/test_vendor_security_package_bdd.py's Scenario 4 derives the full admin route set from each route's dependency tree — not a maintained prefix list — and asserts a signed-in student is rejected on every one of them. |
| AUTH-3 | Is the staff/admin role assigned from an explicit, reviewed mapping rather than substring matching on IdP claims? | Yes | Exact, case-insensitive match against WP_STAFF_ROLE_MAP; no substring grant (backend/app/auth/roles.py). Covered by test_role_based_access_control.py's administrative-aide regression case. |
Data protection & privacy
| Question ID | Question | Answer | Explanation / Reference |
|---|---|---|---|
| DATA-1 | Is student data (FERPA-scoped) ever included in an AI model prompt or aggregate export? | No | No student-authored text reaches a third-party model — the application makes no call to any AI model provider at all. Reflection auto-scoring was retired (PASS-378 / FR-E6): a reflection is stored, never scored (backend/app/services/assessments.py:265 — store_reflection). The wellness guide (currently flag-disabled for the pilot) answers from a static corpus and never reads what the student typed (backend/app/services/guide.py:92 — GroundedWellnessGuide.reply ignores message). Challenge import, the only other model integration, was retired by PASS-379: the deployed application holds no AI vendor SDK. |
| DATA-2 | Is data encrypted in transit? | Yes | Production terminates TLS at the ingress and redirects any plain-HTTP request (HTTPSRedirectMiddleware); the api trusts X-Forwarded-Proto only behind that ingress (backend/app/main.py:74-75, docker/api.Dockerfile:62-63). |
| DATA-3 | Is production seeded with synthetic/demo data by default? | No | WP_SEED_DEMO is off by default; a real deployment's environment never sets it (backend/app/config.py:128-132). |
| DATA-4 | Is a push subscription endpoint (a device identifier) retained only as long as needed, with a defined deletion rule? | Yes | A push subscription endpoint is a device identifier held in its own push_subscriptions table, never on the student record. It is deleted on three triggers: when the student unsubscribes the device, when the push service returns a 410 (gone) on a delivery attempt, and by ON DELETE CASCADE when the student record itself is deleted. No endpoint is retained past any of those, so a stale device leaves no residue. |
Third-party subprocessors
| Question ID | Question | Answer | Explanation / Reference |
|---|---|---|---|
| SUB-1 | Are third-party subprocessors that receive data disclosed, with the scope of what each can observe? | Yes | Web Push routes each notification through the browser vendor's push service — FCM for Chrome, Mozilla autopush for Firefox, Apple for Safari — which is per-notification egress to a third party. The payload is encrypted end to end (RFC 8291, aes128gcm), so the push service cannot read the body; it does see the subscription endpoint and the delivery timing. To keep that observable surface harmless, an automatically-proposed notification carries no student name, campus, challenge name, or task detail (US-73 / FR-E6) — so even endpoint correlation or a decryption failure leaks nothing FERPA-scoped. No other subprocessor receives student data. |
Vulnerability management
| Question ID | Question | Answer | Explanation / Reference |
|---|---|---|---|
| VULN-1 | Are shipped dependencies scanned for known vulnerabilities on every change? | Yes | make security-deps runs npm audit --omit=dev --audit-level=high and pip-audit against the backend's shipped dependencies, gating both CI systems (Makefile:130-142, .github/workflows/ci.yml, bitbucket-pipelines.yml). See docs/runbook.md's Security operations section for cadence and the accepted-risk register. |
| VULN-2 | Is the application scanned for runtime (DAST) vulnerabilities? | Yes | An OWASP ZAP baseline scan; findings are triaged in docs/security-dast-baseline.md, every row Resolved or Accepted with a stated rationale. |
| VULN-3 | Is the authorization surface swept for accidentally unguarded routes? | Yes | test_vendor_security_package_bdd.py classifies every mounted route as admin-guarded, student-guarded, or explicitly public; an unclassified route fails the suite rather than shipping silently unguarded — the gap this closed was /api/verify's two staff-check-in routes, previously outside the FR-A4 sweep's hardcoded prefix list. |
Residual gaps
- No staging tier exists. The DAST baseline (VULN-2) is run against the
local
docker-compose.ymlstack rather than a separate staging environment — seedocs/security-dast-baseline.md's own caveat on what that stack does and does not represent relative to a production deploy. - pip-audit's severity floor is stricter than npm's.
pip-auditfails the build on any reported advisory by default;npm auditonly fails at--audit-level=high(Makefile:82-94, matching the existing npm rationale for excluding dev/build-tooling advisories). Both satisfy the story's "known-critical vulnerabilities fail the build," but the two audits are not tuned to the identical floor.