Skip to content

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:176). 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:287). A separate secret (WP_QR_SECRET) signs the short-lived event-QR tokens, so rotating one never invalidates the other (backend/app/config.py:292).
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:174), limits declared at (backend/app/config.py:537). The pre-auth /api/auth/acs callback is separately throttled per client IP on failed assertions only. The one unauthenticated non-auth endpoint, GET /api/display (PASS-716's event-QR display link), is throttled on two scopes at once — 30/60s per credential and 120/60s per client IP — so one forgotten tablet cannot poll without bound and a stranger cannot guess secrets in volume; both limits are checked before the credential is looked up, so a rejected request costs no database read.
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:46) — 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:708). 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:630).
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:56).
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:520).

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), except the two rehearsal-lifecycle routes (POST /api/rehearsal/exit, POST /api/rehearsal/reset) which carry Depends(require_admin_or_rehearsing) instead — the one narrow admission a rehearsing session needs so it can leave rehearsal at all (PASS-717); every student route carries Depends(require_current_student) (backend/app/auth/deps.py:194-199). 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, including those two rehearsal routes against a non-rehearsing student.
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.
AUTH-4 Are authentication and authorization events (sign-in outcomes, session expiry, sign-out, authorization denials, rate-limit trips) recorded in a structured, queryable log? Yes Closed vocabulary — authz_denied, display_issued, display_rejected, display_revoked, preview_viewed, rate_limited, rehearsal_entered, rehearsal_exited, rehearsal_refused, rehearsal_reset, reports_viewed, session_expired, signin_failure, signin_success, signout, task_archived, task_restored (backend/app/services/security_events.py:18) — every outcome is a security_events row written by emit_security_event() from the sign-in/sign-out routes (backend/app/routers/auth.py), the admin route guard (backend/app/auth/deps.py), the pre-auth rate limiter (backend/app/rate_limit.py), the display-link routes (backend/app/routers/display_links.py), and the rehearsal-mode lifecycle (backend/app/routers/rehearsal.py). The three display_ rows record a display link (PASS-716) being issued, revoked, or refused — a credential that puts a working event code on a screen nobody signed in to, so who handed one out and when it stopped working is exactly what the ledger is for. task_archived/task_restored are the ledger's other NON-auth entries (PASS-719, backend/app/routers/challenges.py): archiving or restoring a week changes what a cohort can see and what counts toward a prize, and it is offered precisely where DELETING that week is refused for destroying student data — so it is an admin action that leaves no destroyed row behind to evidence it, and the ledger is where that evidence belongs. Both carry the acting admin's subject and the week's own route; neither takes a reason. The four rehearsal_ entries (PASS-717) are named for the ADMIN's own subject (claims["sub"]), never the rehearsal student's sso_subject — affiliations/sub are left alone by the rehearsal re-mint on purpose (backend/app/auth/session.py) — and none of the four takes a reason either, including rehearsal_refused: the story asks for both this event and an authz_denied on a refused admin route, but authz_denied's closed reason vocabulary (not_signed_in/insufficient_role) has no word that is true of a signed-in admin refused only for carrying a rehearsal claim, so rehearsal_refused is emitted alone. preview_viewed and reports_viewed (PASS-718) are the two entries that record a staff member READING something rather than changing it, and they exist for one narrow reason: the Getting started checklist completes each step from real state rather than from a click, and previewing a challenge and opening the reports are the only two of its steps that write no row anywhere else. Both carry the acting admin's subject and the route, never a student or a query, and both are emitted at most ONCE per admin (emit_security_event_once, backend/app/services/security_events.py) because the reports dashboard auto-refreshes and an unconditional write would add a row per poll. Neither takes a reason. They are not a general page-view or analytics facility: the vocabulary here stays closed, nothing records which report was read or how often, and no other screen emits a view event. GET /api/security-events.ndjson exports the campus-scoped ledger as newline-delimited JSON, one object per line, for an institutional SIEM to ingest. Covered end to end by test_security_event_audit_log_bdd.py; this is distinct from AUTH-2/VULN-3's static classification of the authorization surface — this row is about what actually happened at runtime.

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:326 — 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 from the cluster's pod CIDR (PASS-103 restricted FORWARDED_ALLOW_IPS off the prior unbounded wildcard to 10.42.0.0/16), and k8s/api-networkpolicy.yaml is the control that actually narrows who can reach the listener at all, restricting it to the ingress controller and the metrics scraper (backend/app/main.py:12-13, docker/api.Dockerfile:113-114, k8s/api-deployment.yaml, k8s/api-networkpolicy.yaml).
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:450-454).
DATA-4 Is a push subscription endpoint (a device identifier) retained only as long as needed, with a defined deletion rule? N/A No push subscription endpoint is ever collected. PASS-629 (US-85 / FR-C7) removed the push_subscriptions table (migration 0014) and the client-side surface that created a subscription — there is no device identifier of this kind for a retention rule to apply to.
DATA-5 Does the security-event log retain the caller's source IP address? No Omitted entirely — no column, no parameter, anywhere in the emission seam (emit_security_event() takes no ip argument at all). Persisting it would contradict this notice's §2.2 "the schema is the control, not merely a policy promise" (docs/privacy-data-handling.md); the rate limiter's own in-memory IP keying is ephemeral, per-process, and already disclosed under SEC-3. test_security_event_audit_log_bdd.py's privacy scenario plants a distinctive IP and asserts it appears in no row and no emitted log line — an added ip column would fail that scenario, not merely contradict this row.
DATA-6 For how long are security events retained? Yes 90 days (security_event_retention_days, backend/app/config.py:640). purge_security_events() (backend/app/services/security_events.py) is the enforcement mechanism — a plain, idempotent DELETE WHERE ts < now - retention_days, gated by a real effect test (test_security_events.py::TestPurgeSecurityEvents seeds rows at the boundary and asserts which survive the DELETE). Scheduling that DELETE to run automatically (a CronJob or equivalent) is out of this story's scope (US-66): the intended production host is App Runner/ECS Fargate (docs/architecture-plan.md:185), not the k8s/ demo cluster this repo's other CronJobs run on, so a CronJob manifest here would be topology this story doesn't own, verifiable only by a source-text grep no test could meaningfully gate. That automated scheduling is not yet tracked as its own backlog story; until one exists, an operator invokes the purge manually or via whatever job runner the eventual production topology provides.

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? N/A Web Push previously routed each notification through the browser vendor's push service (FCM/Mozilla autopush/Apple) — per-notification egress to a third party. PASS-628 retired the transport server-side (nothing dispatched a push after that story); PASS-629 (US-85 / FR-C7) removes what was left — the client-side opt-in surface, pywebpush, and the storage the transport used — so no third-party push service is reachable at all. No subprocessor receives student data. See deployment/pass629_no_push_egress.puml.

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, then pip-audit twice: once against the backend dev environment and once against docker/api.lock.txt, the ==-pinned freeze the api image installs from with --no-deps (Makefile:156-179). The second run is what makes "shipped" literal — the dev environment resolves >= ranges to newest-on-PyPI and is not what the image carries (PASS-701). Gated on every change by 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.

Hosting account & infrastructure security

Scope note. These rows describe the AWS account that will host the production deployment described in docs/architecture-plan.md and ADR 0001 — its administrative access model and audit logging — as configured on 2026-08-17. They do not describe a running production system: the pilot topology today is the demo cluster referenced under DATA-2, and no production workload has been deployed into this account yet. Unlike every other row in this table, these state cloud-provider account state rather than repository configuration, so they carry no file:line citation and no automated live-value comparison — see the note in backend/tests/vendor_security_conventions.py's ROWS_STATING_NO_LIVE_VALUE. Operational detail lives in aws-identity-runbook.org at the repository root.

Question ID Question Answer Explanation / Reference
INFRA-1 Is administrative access to the hosting cloud account federated through SSO with multifactor authentication, rather than static credentials? Yes Administrative access is federated through AWS IAM Identity Center. The account holds zero IAM users and zero long-lived access keys; the sole human identity is an Identity Center user granted AdministratorAccess through an Administrators group assignment, with MFA registered. CLI access is a short-lived role session obtained via aws sso login, not a stored credential — the resulting principal is an AWSReservedSSO_AdministratorAccess assumed role. This is the control HECVAT DCTR-14 asks about.
INFRA-2 Is the cloud provider's root/superuser account restricted to break-glass use? Yes Root is reserved for the small set of tasks AWS permits no other identity to perform — account settings, account closure, certain billing actions. MFA is enabled on root, root holds no access keys, and the workstation's default CLI profile resolves to the federated Identity Center role rather than to root, so no routine command runs with root authority.
INFRA-3 Are administrative API actions in the hosting account recorded in a tamper-evident audit log? Yes A multi-region AWS CloudTrail organization trail records management API calls across every enabled region and every account in the organization, capturing caller identity, timestamp, source address, parameters and outcome. Log file validation is enabled, so the delivered archive carries digest files that detect post-hoc modification.
INFRA-4 For how long are infrastructure audit logs retained? Yes Indefinitely in the trail's S3 archive, which has no expiry lifecycle rule; the CloudWatch Logs mirror that drives alerting is trimmed at 90 days. This is the infrastructure audit log and is separate from the application-level security-event ledger and its 90-day retention described under DATA-6 — the two answer different questions and are enforced by different mechanisms.
INFRA-5 Is use of the privileged root account detected and alerted on? Yes A CloudWatch metric filter matches any CloudTrail record whose identity type is Root, excluding AWS service-initiated events, and drives an alarm that publishes to the security contact address. The notification path was verified end to end by forcing the alarm into state and confirming provider-side delivery, not merely by inspecting configuration.
INFRA-6 Is privileged access granted and revoked through a documented procedure? Yes Granting and revoking a privileged identity is documented step by step, including offboarding order, in aws-identity-runbook.org at the repository root. Account access is conferred by group membership rather than per-user assignment, so revocation is a single membership deletion and cannot leave a stranded direct grant behind.
INFRA-7 Is there a documented, scheduled review of the privileged-access list? Yes A quarterly review of the privileged-access list is documented step by step in aws-identity-runbook.org at the repository root, covering permission sets, account assignments, group membership, and the expected absence of IAM users and long-lived access keys. The runbook carries a dated review log rather than only a procedure, because HECVAT PPPR-13 asks for a review that is documented and currently followed; the first review was performed 2026-08-28 and recorded there. It found one privileged identity in one group, every account assignment conferred by group membership rather than direct grant, and no IAM users or long-lived access keys across either account in the organization.
INFRA-8 Is there a designated security contact for provider notices and vulnerability reports? Yes A dedicated security@ role address is registered as the cloud provider's security alternate contact, with separate billing and operations contacts, so abuse reports, leaked-credential notices and provider security bulletins route to a monitored role address rather than to an individual's mailbox or to the account's root recovery address.

Business continuity & disaster recovery

Question ID Question Answer Explanation / Reference
DOCU-01 Does the vendor maintain a documented business continuity plan? Compensating Control A named plan exists — docs/continuity-and-recovery-plan.md — stating an accountable owner, the pilot's dependencies (AWS, image registry, compute platform, database), and loss scenarios mapped to the real, existing recovery procedures that handle them. It is a Compensating Control rather than a plain Yes because the plan's first full-recovery exercise against the deployed production topology (PASS-382/US-86) has not yet been performed — recorded PENDING in the plan and tracked in docs/tier-deferrals/PASS-525-production-recovery-exercise.md. The recurring restore test the plan's test cadence relies on runs today and is recorded in docs/runbook.md's Drill log.
DOCU-02 Does the vendor maintain a documented disaster recovery plan with stated recovery objectives? Compensating Control The same document (docs/continuity-and-recovery-plan.md) states RPO 24h / RTO 60min (backend/tests/backup_restore_conventions.py:76-77), pending SHS's ratification, written against the deployed single-AZ database configuration (infra/bin/infra.ts's WpData). Compensating Control for the same reason as DOCU-01: the objectives are backed by an automated, verified restore drill, but the one full-recovery exercise against the production topology is still owed.

Residual gaps

  1. No staging tier exists. The DAST baseline (VULN-2) is run against the local docker-compose.yml stack rather than a separate staging environment — see docs/security-dast-baseline.md's own caveat on what that stack does and does not represent relative to a production deploy.
  2. pip-audit's severity floor is stricter than npm's. pip-audit fails the build on any reported advisory by default; npm audit only 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.
  3. Infrastructure rows state provider state, not repository state. The INFRA rows above are the only rows in this table with no file:line citation and no CONFIG_FACTS live-value comparison, because the facts they assert live in an AWS account rather than in this repository. They are declared in ROWS_STATING_NO_LIVE_VALUE with that reason. The anti-drift guarantee that covers every other row therefore does not cover these: if the account's configuration changes, this table will not red until a human notices.