Security & Compliance

Application Manager includes built-in security features and a capability declaration inventory with behavioural probing, powered by the Riptide Platform SDK 1.4.3.

Security Features

Authentication Security

  • BCrypt password hashing — passwords are never stored in plain text
  • Password history — prevents reuse of recent passwords
  • Account lockout — configurable maximum login attempts (default: 5) with automatic lockout duration (default: 15 minutes)
  • Password reset tokens — time-limited tokens sent via email, expire after a configurable period (default: 60 minutes)
  • Session management — sliding expiration with configurable timeout (default: 60 minutes)

HTTP Security Headers

The SDK security middleware (UseRiptideSecurity()) applies the following headers automatically:

Header Default
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy Restricts to self origins
Server Removed

Configure in appsettings.json under Riptide:Security:Headers. See Configuration Reference for all options.

Rate Limiting

Built-in rate limiting protects against abuse:

Limit Default
Global requests per minute 100
Email resend per hour 3
Bulk operation concurrency 3
API requests per minute 60

API Authentication

The REST API uses API keys passed in the X-Api-Key header. Keys are issued and managed through the Application Manager Web UI, with each key individually scoped and revocable. See the API Reference for details.

Role-Based Access Control

Application Manager enforces two authorization levels:

  • Admin — standard administrative access to the web UI (user management, configuration, etc.)
  • SecurityAdmin — required for access to the security dashboard and compliance features

The SecurityAdmin role is assigned via the Roles administration UI by adding the SecurityAdmin claim to a user.

Capability Declaration Inventory

Overview

Application Manager records the SDK security capabilities each registered application self-declares at startup, and shows how those declarations map to industry framework expectations:

  • SOC 2 — Service Organization Control
  • HIPAA — Health Insurance Portability and Accountability Act
  • FedRAMP — Federal Risk and Authorization Management Program
  • StateRAMP — State Risk and Authorization Management Program

What this is not. A score of 100 means the application declared every capability the framework template asks for. It does not mean the capability is correctly configured, that the underlying implementation is working, or that the application would pass an external audit. A vendor that publishes a manifest claiming all capabilities exist will score 100 regardless of actual security posture. Use these scores as inventory and a coverage starting point — not as audit evidence.

Inventory Dashboard

The dashboard at /security/dashboard (requires SecurityAdmin role) shows a coverage matrix with:

  • All registered applications along one axis
  • Compliance frameworks along the other
  • Self-attested coverage scores at each intersection (0–100 = the percentage of framework controls whose required SDK capability the application declared)
  • Visual indicators for high, mixed, and low coverage thresholds

Declaration Reports

Click any application to view its detailed report at /security/report/{applicationId}:

  • Control-by-control coverage — each framework control and whether the application declared the SDK capability that maps to it
  • Coverage trends — how the application's declared-coverage score has changed over time
  • Run history — timestamped records of every declaration evaluation

On-Demand Evaluation

Trigger a declaration evaluation for any application directly from the inventory dashboard. The evaluation runs in the background and results appear when complete. Real-time progress updates are shown in the interface.

Scheduled Evaluation

Enable automatic periodic re-evaluation in appsettings.json:

{
  "SecurityAudit": {
    "ScheduledAudit": {
      "Enabled": true,
      "IntervalHours": 24,
      "Frameworks": ["SOC2", "HIPAA", "FedRAMP", "StateRAMP"]
    }
  }
}

When enabled, the background service automatically re-evaluates all active applications at the configured interval. (Configuration key names are unchanged for backwards compatibility; future releases will rename them alongside the schema migration.)

Custom Framework Templates

Add custom framework templates without restarting the application:

  1. Create a compliance-templates.json file following the template format
  2. Put the resulting file into the Application Manager's configuration file tree in the Config Manager at /security/compliance-templates.json
  3. The custom framework will appear in the inventory dashboard alongside the built-in frameworks

Evidence schema

Underlying the inventory feature is a three-table aggregate that the audit roadmap will fill out across subsequent phases:

Table Purpose
Assessments One row per (application × framework × time) run. Carries the headline score, the tier (Compliant / Mixed / NonCompliant / NotAssessable), and the run metadata (source, triggering user, assessor version).
ControlVerdicts One row per control evaluated within an assessment. Verdict is one of Pass, Fail, Inconclusive, NotApplicable, or CompensatingControl, and carries the control's severity copied from the framework template at evaluation time.
EvidenceRecords One or more rows per verdict, capturing what the assessor saw and where it came from. Source values today are limited to self-attestation; later phases will introduce http-probe, signed-attestation, audit-log-sample, cloud-config, and others. Carries content, content hash, and (in later phases) a signature and a hash-chain link to the previous record.

Each per-control verdict carries an EvidenceRecord row with Source = "self-attestation" (or "self-attestation-absent" for the failure case) capturing the declaration that justifies the verdict, plus a SHA-256 content hash used for tamper detection. Scoring is severity-weighted with Critical/High failure caps. The audit tables are append-only; every verdict and evidence row is signed at write time, and an integrity-check endpoint surfaces tampering after the fact.

Behavioural probing

Self-attestation is one source of evidence. Probes are the second: code that reaches into the application's running surface (HTTP, TLS) and observes whether it actually behaves the way a control requires.

The probe framework:

  • IControlProbe — a unit of behavioural evidence collection. Carries a stable ProbeId, a description, a per-probe timeout, and a ProbeAsync(ctx, ct) method.
  • IProbeRegistry — maps (framework, controlId) → list of probes. Multiple probes per control are allowed.
  • IProbeOrchestrator — runs the registered probes for an assessment in parallel, attaches a signed EvidenceRecord row per probe result, and reconciles the verdict.

Verdict combination rule:

Probe + self-attestation outcomes Final verdict
All Pass / CompensatingControl Pass (preserve)
Any Fail Fail
Mixed Pass + Inconclusive (no Fails) Inconclusive

The orchestrator never overstates the verdict: mixed evidence downgrades to Inconclusive rather than averaging to Pass. A failing probe always wins over passing self-attestation — the application's claim doesn't beat observed behaviour.

Probes today cover TLS posture, HTTP security headers, anonymous access, information disclosure, CORS configuration, account lockout, rate limiting, and session management — each bound to the relevant control IDs across the SOC 2, HIPAA, FedRAMP, and StateRAMP framework templates.

TLS posture probe

TlsPostureProbe inspects the application's TLS handshake on its BaseUrl and the HTTP response headers it returns. It runs against StateRAMP SC-8 (Transmission Confidentiality and Integrity) when an audit's framework is StateRAMP.

Inspected Pass criteria
TLS protocol version ≥ 1.2
Cipher algorithm Not in deprecated list (Rc4, Des, TripleDes, Null)
Server certificate chain Trusted (no SslPolicyErrors)
Certificate expiry Valid now and not within 30 days of expiry
Strict-Transport-Security header Present, with max-age ≥ 31 536 000 (1 year)
Verdict Trigger
Pass All criteria above satisfied
Fail Any hard fail (TLS < 1.2, weak cipher, expired cert, untrusted chain, missing HSTS)
Inconclusive Probe cannot run (BaseUrl missing or not HTTPS, network/DNS error), or only soft warnings (cert expiring within 30 days, HSTS max-age below 1 year)

The captured evidence JSON includes the negotiated TLS version, cipher and hash algorithms, certificate subject/issuer/thumbprint and validity window, chain status, HSTS presence and max-age, and a structured list of fails and warns so an operator can reproduce the verdict.

Future PRs bind the same probe instance to the SOC2 / HIPAA / FedRAMP transmission-encryption controls when those framework templates land in this codebase.

HTTP security headers probe

HttpSecurityHeadersProbe issues a baseline GET against the application's BaseUrl and inspects the response headers for the OWASP-aligned defences a modern web application is expected to set. Bound to StateRAMP SI-3 (Malicious Code Protection).

Inspected header Pass criteria
Content-Security-Policy Present, and not relying solely on 'unsafe-inline'/'unsafe-eval' without nonces, hashes, or 'strict-dynamic'
X-Frame-Options or CSP frame-ancestors X-Frame-Options: DENY / SAMEORIGIN, or any frame-ancestors directive in CSP
X-Content-Type-Options nosniff
Referrer-Policy One of: no-referrer, no-referrer-when-downgrade, same-origin, strict-origin, strict-origin-when-cross-origin, origin-when-cross-origin, origin
Permissions-Policy Present (any value) — soft signal
Verdict Trigger
Pass All hard-required headers present and restrictive
Fail Any hard-required header missing or permissive
Inconclusive Probe cannot run (BaseUrl missing or invalid; HTTP fetch error), or the only finding is a missing Permissions-Policy

Strict-Transport-Security is intentionally not rechecked — the TLS posture probe already covers it. Splitting responsibility keeps each probe's diagnostic output focused.

Anonymous access probe

AnonymousAccessProbe requests conventional protected paths without credentials and observes whether the application correctly rejects them. Bound to StateRAMP IA-2 (Identification and Authentication).

Path Expected without auth
/admin 401 or 403
Verdict Trigger
Pass At least one path returned 401/403; no path returned a successful 2xx with content
Fail Any path returned 2xx with non-empty body — anonymous access succeeded
Inconclusive Every path returned 404 / redirect / 5xx / network error — auth-gate posture cannot be determined

The probe does not follow redirects: a 302 to /login is treated as Inconclusive for now (it's a positive signal but ambiguous). Future revisions may add a configurable ProtectedProbeEndpoint field on RegisteredApplication so operators can point the probe at known-protected URLs in apps that don't follow conventional path layouts.

Information disclosure probe

InformationDisclosureProbe hunts for two classes of leak: sensitive files served at well-known paths, and identifying response headers that reveal the application's software stack. Bound to StateRAMP AC-3 (Access Enforcement).

Sensitive path Pass criteria
/.git/HEAD Not 2xx with content
/.env Not 2xx with content
/web.config Not 2xx with content
/appsettings.json Not 2xx with content
/appsettings.Production.json Not 2xx with content
Header Pass criteria
Server Absent, or present without a version-like substring (X.Y digits)
X-Powered-By Absent
Verdict Trigger
Pass No sensitive path returned content; no fingerprinting headers
Fail Any sensitive path returned a 2xx with non-empty body — secrets / source / framework config exposed
Inconclusive No exposure but identifying headers present (Server with version, X-Powered-By at all), or probe could not reach the base URL

The captured evidence JSON includes per-path status codes, a 128-char content excerpt for any exposure, and the observed header values, so an operator can verify the verdict and remediate.

CORS configuration probe

CorsConfigurationProbe sends an OPTIONS preflight from a hostile origin (https://riptide-cors-probe.invalid) and inspects the application's CORS response. Catches the common misconfiguration where an app reflects any Origin header back into Access-Control-Allow-Origin, often combined with Access-Control-Allow-Credentials: true. Bound to StateRAMP AC-3 alongside the information disclosure probe.

Observed Verdict
Reflected hostile origin + Allow-Credentials: true Fail (severe — any malicious site can issue authenticated cross-origin requests)
Reflected hostile origin (no credentials) Fail
Wildcard * with Allow-Credentials: true Fail (browsers reject, but config intent is permissive)
Wildcard * without credentials Inconclusive (common for public APIs, worth confirming)
OPTIONS returned 404 / 405 (no preflight handler) Inconclusive
Specific safe origin returned, doesn't match hostile probe Pass
No Access-Control-Allow-Origin header at all Pass (CORS not enabled)

The probe records the full preflight response (status code, all CORS headers, Vary: Origin presence) in evidence so an operator can reproduce the verdict.

Rate limiting probe

RateLimitingProbe issues a modest sequential burst (15 GETs against BaseUrl with a 50ms inter-request delay) and looks for evidence of an active rate-limiter — either an HTTP 429 Too Many Requests response, or any response carrying a Retry-After header. Bound to StateRAMP SC-5 (Denial of Service Protection), and the RateLimiting SDK capability is added to the platform self-registration set so Application Manager scores itself against this control.

Observed Verdict
Any response is 429, or any response carries Retry-After Pass
Every request failed at transport (DNS / connection refused / TLS) Inconclusive
All responses 2xx/3xx/non-429 4xx — no limit hit Inconclusive (the configured limit may simply be higher than the probe's burst size)
BaseUrl missing or invalid Inconclusive

The probe never returns Fail. A single short burst can't disprove rate limiting — the limit may simply be set higher than the burst size. The self-attestation pass covers the "rate limiting is configured" assertion; the probe's job is to provide positive evidence when it can. Burst size is deliberately small (BurstSize = 15) so that an operator watching their own dashboards sees a brief uptick rather than something resembling abuse. If the configured limit ever needs to be probed at a higher rate, that becomes a future configurable.

The captured evidence JSON includes per-request status codes, Retry-After values, transport-error type names, request durations, and aggregate counts (rateLimited, retryAfter, transportError, success), so an operator can reproduce the verdict and tune the configured limit.

SOC 2 framework template

Soc2Template ships an engineering baseline mapping the AICPA Trust Services Criteria (TSC 2017, with 2022 points of focus) to the same SDK security capabilities the StateRAMP template uses. The same probes are reused — the Common Criteria overlap heavily with NIST 800-53 in spirit, just under different identifiers.

SOC 2 control Probe(s) StateRAMP analogue
CC6.2 Authentication of Users and Devices AnonymousAccessProbe IA-2
CC6.6 Boundary Protection InformationDisclosureProbe, CorsConfigurationProbe AC-3
CC6.7 Transmission of Sensitive Information TlsPostureProbe SC-8
CC6.8 Prevention of Malicious Software HttpSecurityHeadersProbe SI-3
A1.1 Capacity and Performance RateLimitingProbe SC-5

Controls without a behavioural probe (e.g. CC8.1 Change Management, C1.1 Confidentiality at Rest) are evaluated against the application's declared SDK capabilities only — same hybrid Pass / Fail / Inconclusive contract as every other control. A SOC 2 audit is performed by a licensed CPA; this template aligns engineering posture with the criteria a CPA would test, but is not itself an audit report.

HIPAA framework template

HipaaTemplate ships an engineering baseline mapping the HIPAA Security Rule (45 CFR Part 164 Subpart C) to SDK security capabilities. Covers the Administrative Safeguards (§164.308) and Technical Safeguards (§164.312); Physical Safeguards (§164.310) are facility-scoped and out of scope for an application-level audit.

HIPAA control Probe(s) Cross-framework analogue
164.312(a)(1) Access Control InformationDisclosureProbe, CorsConfigurationProbe StateRAMP AC-3 / SOC 2 CC6.6
164.312(a)(2)(i) Unique User Identification AnonymousAccessProbe StateRAMP IA-2
164.312(d) Person or Entity Authentication AnonymousAccessProbe StateRAMP IA-2
164.312(e)(1) Transmission Security HttpSecurityHeadersProbe StateRAMP SI-3
164.312(e)(2)(ii) Encryption in Transit TlsPostureProbe StateRAMP SC-8

Administrative-safeguards controls (§164.308) don't have a behavioural analogue — they're policy / procedure attestations and remain capability-only. Technical-safeguards controls without a probe (e.g. 164.312(b) Audit Controls, 164.312(a)(2)(iv) Encryption of ePHI at rest) likewise fall back to declared capabilities.

HIPAA compliance is a legal posture, not a software certification. This template aligns engineering controls with the Security Rule but does not constitute a Privacy / Security Officer's attestation.

FedRAMP framework template

FedRampTemplate ships an engineering baseline mapping a representative subset of NIST SP 800-53 Rev 5 controls required for FedRAMP Moderate authorisation. Because both StateRAMP and FedRAMP derive from NIST 800-53, the control IDs match StateRAMP exactly — AC-3, IA-2, SC-8, etc. — and the same probes apply unchanged.

FedRAMP control Probe(s)
AC-3 Access Enforcement InformationDisclosureProbe, CorsConfigurationProbe
IA-2 Identification and Authentication AnonymousAccessProbe
SC-5 Denial of Service Protection RateLimitingProbe
SC-8 Transmission Confidentiality and Integrity TlsPostureProbe
SI-3 Malicious Code Protection HttpSecurityHeadersProbe

Controls without a probe — AU-2/3/9 Audit and Accountability, IA-5 Authenticator Management, SC-13 Cryptographic Protection, SC-28 Protection at Rest, RA-5 Vulnerability Monitoring, etc. — fall back to declared SDK capabilities. The duplication of bindings between StateRAMP and FedRAMP (rather than aliasing one to the other) is deliberate: the two programs can diverge in future revisions, and the assessor looks bindings up by exact (framework, controlId) pair.

FedRAMP authorisation is granted by a Joint Authorization Board after a 3PAO assessment that produces a System Security Plan, Security Assessment Report, and (eventually) a P-ATO. This template aligns engineering posture with the controls a 3PAO would test; it does not constitute any of those artefacts.

Compensating-control overrides

Operators frequently mitigate a failing or inconclusive control with evidence the assessor cannot observe directly: a third-party penetration-test attestation, a manual configuration check, an external audit ticket. The workflow that captures that evidence persists across audit cycles, expires on a configurable date, and can be revoked by an admin.

Compensating-control overrides are first-class entities (CompensatingControlOverride) that the assessor consults on every run, so an approved override survives subsequent scheduled cycles rather than being reverted by a fresh assessment.

From the per-application Report page, every Fail and Inconclusive row gains an Add compensating control button. The form asks for:

  • Justification (required free-text) — what external control mitigates the gap
  • External reference (optional URL or ticket id) — pen-test report, audit identifier
  • Valid until (optional date) — after this date the override stops applying. Leave blank for an open-ended override that an admin must revoke manually. Pen-test reports typically have a one-year shelf life; setting ValidUntil = report-issued-date + 1y is the standard pattern.
  • Attachment (optional) — either a file upload (≤ 2 MB, base64-encoded into the override blob) or pasted text (≤ 64 KB, stored verbatim)

Submitting the form persists a CompensatingControlOverride row keyed by (ApplicationId, Framework, ControlId) in PendingApproval state. Independent reviewer approval is required: the assessor does not consult the row until a second admin (someone other than the submitter) flips it to Approved. The submitter is redirected to the per-application overrides list where the row appears with a Pending badge.

Assessor integration. ApplicationComplianceAssessor queries the override store on every run via ICompensatingControlOverrideRepository.GetActiveForAssessmentAsync(appId, framework, asOf) and flips Fail or Inconclusive verdicts to CompensatingControl when an active row matches. Pass and existing CompensatingControl verdicts are left alone — overrides only mitigate gaps, they do not weaken positive evidence. Each flip attaches a new EvidenceRecord with Source = "compensating-control" capturing the override id, justification, reference, attachment metadata, and operator identity, so the substitute evidence is signed and chain-linked alongside whatever probe/self-attestation evidence the verdict already carried.

Score and tier are recomputed afterwards. Mitigating a Critical Fail un-caps the tier (the Critical-failure rule keys on Verdict == Fail, not severity alone), so a single override can move an application from NonCompliant to Mixed or Compliant on every cycle the override is active.

Override lifecycle. Each application has a dedicated overrides view at /security/compensating-controls?applicationId={id} — linked from the per-application Report page — listing every override (active, pending, rejected, expired, revoked) newest-first with the framework/control, justification, reference, attachment indicator, expiry, creator, reviewer, and a Revoke action for active rows. Revocation is irreversible (subsequent calls are no-ops); the row stays in history with the revocation timestamp, revoker, and optional reason. Expired rows likewise stay in history and stop applying automatically without operator intervention.

Reviewer queue. A cross-application Pending approvals queue at /security/compensating-controls/pending — linked from the security dashboard — lists every override currently in PendingApproval state across all applications. Each row shows the application, framework, control, full justification, reference, attachment indicator, expiry, and submitter, with Approve / Reject buttons for any row not submitted by the current viewer. Self-review attempts surface a "Submitted by you" badge in place of the action buttons; the repository enforces the same constraint server-side — ApproveAsync and RejectAsync return SelfReviewBlocked when reviewedBy matches CreatedBy (case-insensitive). Approval triggers a targeted audit cycle for the (application, framework) so the operator-facing report reflects the override on the next page load. Rejected rows stay in history with the reviewer's notes and never apply.

Guardrails on creation:

  • Cannot mitigate a verdict that is currently Pass, CompensatingControl, or NotApplicable — only Fail and Inconclusive are valid targets.
  • Cannot mitigate when no prior assessment exists — there is no failing verdict to flip until an assessor has run.
  • Cannot mitigate when the latest assessment is not assessable (NoCapabilitiesRegistered, FrameworkUnknown, etc.).
  • ValidUntil must be in the future when set; past dates are rejected at form submission.

When two operators race to mitigate the same (application, framework, controlId) triple, the most recently created active row wins; the older row remains in the store as history.

Audit job system

An operational metadata layer makes scheduled and on-demand audit cycles observable. Each cycle creates a job row with a status (Pending, Running, Completed, Failed, Cancelled) and per-task records, so an operator can answer "did this morning's run finish?" or "which app/framework pair failed?" without grepping container logs:

Table Purpose
AuditJobs One row per cycle. Carries headline status (Running / Succeeded / PartiallyFailed / Failed / Cancelled), trigger source, intended task count, per-outcome counters, and a job-level error message for cases where the job aborts before fanning out.
AuditJobTasks One row per (application × framework) attempt within a job. Per-task lifecycle (Pending / Running / Succeeded / NotAssessable / Failed / Skipped), attempt counter, attempt + completion timestamps, duration, soft FK to the produced Assessment, and a per-task error message. Snapshots ApplicationName so historical rows render sensibly even if an app is renamed or deleted.

Distinct from the audit substrate (Assessments / ControlVerdicts / EvidenceRecords): the substrate is append-only and signed; job tables are operational metadata that mutate in place as tasks transition. Job rows are not signed or chained.

Background service flow. Each cycle:

  1. Creates an AuditJob row with Source = Scheduled, populates one Pending task per (app × framework).
  2. Walks the task list. For each task: marks Running, persists, runs the assessor, persists the produced Assessment via AssessmentRepository.AddAsync (signed + chain-linked as before), marks the task terminal (Succeeded / NotAssessable / Failed), increments the parent job's counters, persists.
  3. At end of cycle: maps counters → terminal job status via AuditJobStatusResolver.Resolve (Cancelled if the host shut down with skipped tasks; Failed if every task failed; PartiallyFailed if mixed; Succeeded otherwise — NotAssessable does not count as a failure).

Operator surface.

  • GET /security/jobs — recent jobs newest-first, with status badge, progress bar, per-outcome counts, duration.
  • GET /security/jobs/{id} — per-task breakdown with status, attempt, duration, link to the produced report, and per-task error messages.

The job runs in flight: counters and per-task rows are persisted as each task completes, so an operator hitting /security/jobs/{id} mid-cycle sees the work that has already finished while the rest is still Pending or Running.

Manual on-demand trigger

The cycle execution code path moved out of SecurityAuditBackgroundService into AuditCycleRunner so the same pipeline can be invoked from the operator UI without waiting for the scheduled cadence. The Jobs page (/security/jobs) gains a Run audit now button that POSTs to /security/jobs/run; the controller fires the cycle in the background and immediately redirects to the new job's progress page so the operator can watch task-by-task progress fill in.

A process-wide SemaphoreSlim (AuditCycleRunner.CycleLock) serialises cycles. Two cycles cannot safely run concurrently against the SQLite-backed audit substrate — both the chain-link write and the per-task job updates would race — so a manual trigger that arrives during an in-flight scheduled cycle returns a friendly busy message rather than queueing. Manual cycles are flagged with AuditSource.Manual and TriggeredBy = <operator username> so the audit trail distinguishes operator-driven runs from the scheduler.

Cancel an in-flight cycle. A running job's detail page shows a Cancel cycle button that POSTs to /security/jobs/{id}/cancel. The runner tracks in-flight cycles in a concurrent (jobId → CancellationTokenSource) dictionary; TryCancel signals the CTS, the per-task loop checks the token between attempts, the current task finishes its in-progress attempt, the remaining tasks are marked Skipped, and the job's terminal status resolves to Cancelled. Returns 404-equivalent ("nothing to cancel") when the job has already finished.

Re-run a job's exact scope. Each completed job's detail page (/security/jobs/{id}) gains a Re-run this scope button that POSTs to /security/jobs/{id}/rerun. The controller reads the source job's task list, extracts the distinct (applicationIds, frameworks), and starts a new manual cycle restricted to that exact pair-set — useful when a transient infra issue caused several tasks to fail in the prior run and the operator wants to retry just those without doing the full scheduled cycle. Like the full-scope manual trigger, the cycle runs in the background and redirects to the new job's progress page. The button is hidden while the source job is still Running (avoids double-trigger UX confusion) and on jobs that have no tasks.

Stale-job sweep on startup

The audit cycle lock is per-process, so a host crash mid-cycle leaves the row in Running with no live worker — without intervention the jobs dashboard would show perpetual "Running" rows from an instance that no longer exists. SecurityAuditStartupService now runs IAuditJobRepository.CancelStaleRunningJobsAsync on every host start: any AuditJob row still in Running is flipped to Cancelled with LastErrorMessage = "Host restarted before completion.", and any of its Pending / Running child tasks transition to Skipped (with SkippedTaskCount incremented to match). Tasks that already reached a terminal state (Succeeded / NotAssessable / Failed) are preserved exactly. The sweep is idempotent — a clean restart finds zero stale jobs and is a no-op — and is wrapped in try/catch so a sweep failure doesn't block host bootstrap.

Transient retry

A single TLS handshake timeout, a single connection reset, or a transient HTTP 503 from a probed application should not promote a clean app to Failed on the dashboard. The per-task assessor invocation is wrapped with a retry policy:

Setting Default Notes
Enabled true Set to false to make the first failure terminal (no retry).
MaxAttempts 3 Total attempts including the first.
InitialDelaySeconds 2 Pause before the second attempt.
BackoffMultiplier 2.0 delay = InitialDelay × Multiplier^(attempt − 2).
MaxDelaySeconds 60 Hard cap so a degraded app can't stall the whole cycle.

Configure under SecurityAudit:ScheduledAudit:Retry in appsettings.json. Defaults give the schedule: attempt 1 → fail → wait 2 s → attempt 2 → fail → wait 4 s → attempt 3 → terminal.

What's classified as transient. Network and IO faults the assessor's probes can hit on a flaky run: HttpRequestException, SocketException, IOException, TimeoutException, and probe-level TaskCanceledException (host-shutdown cancellation arrives via OperationCanceledException matched against the cycle's token and is handled separately). The classifier walks one level of inner-exception wrapping and short-circuits on AggregateException only when every inner is itself transient. Anything else defaults to terminalInvalidOperationException (used by guard rails like the compensating-control appender), ArgumentException, and unrecognised exotic faults fail immediately so deterministic bugs don't loop forever.

What an operator sees. The per-task Attempt counter on the jobs detail view shows how many shots the scheduler took. The error message on a budget-exhausted task is prefixed Failed after N transient retries: so the distinction between "first try failed" and "tried hard then gave up" is visible without log archaeology. The task stays in Running between attempts (with the attempt counter incrementing) so the dashboard reflects "still trying" rather than flickering between Failed and Running.

Tamper resistance

Every ControlVerdict and EvidenceRecord row is signed at insert time by IEvidenceSigner (default impl: DataProtectionEvidenceSigner). The signature is sealed by ASP.NET Core Data Protection — symmetric authenticated encryption tied to the deployment's data-protection key chain. An attacker with database write access cannot forge or modify rows without also possessing the data-protection keys. This is not an asymmetric signature — an external auditor cannot verify rows with only a public key; asymmetric attestation is on the roadmap for a future release.

The Assessments, ControlVerdicts, and EvidenceRecords tables are append-only: ConfigurationDbContext.SaveChangesAsync throws on any attempt to update or delete rows of these types. Corrections must be written as new rows on a new Assessment.

Hash-chained evidence. Every EvidenceRecord carries a PreviousRecordHash field that links it to the ContentHash of the previous row in the global chronological order. The link is part of the signed canonical form, so changing it breaks the row's signature; deleting an intermediate row breaks the chain at its successor; reordering rows breaks the chain at every reordered position. The chain is one defence-in-depth layer beyond per-row signatures: an attacker who somehow forges a single row's signature still has to also forge every subsequent row's signature to keep the chain consistent.

Two integrity APIs:

  • GET /security/verify/{assessmentId} — re-canonicalises every verdict and evidence row in one assessment and recomputes signatures against the active key. Per-row breakdown.
  • GET /security/verify-chain — walks the global chain end-to-end and reports any breaks (OrphanedGenesis / MissingPredecessor / HashMismatch). Returns the head hash, useful as an external "checkpoint" pin.

The dashboard runs the chain check on page load and surfaces a red banner if the chain is broken. The Report page's "Verify integrity" dialog combines the per-row signature check with the chain check.

Rows signed by a now-rotated key fail signature verification — verification today checks only against the active key; rotation-aware verification across historical keys is a planned follow-up.

Production deployment notes

The data-protection key chain backs the signer. Default deployment uses the framework's filesystem-backed key store, which is acceptable for single-instance demos but should be configured for production:

// In Program.cs, before AddSingleton<IEvidenceSigner>:
builder.Services.AddDataProtection()
    .PersistKeysToAzureBlobStorage(...)
    .ProtectKeysWithAzureKeyVault(...);
// or AWS / HashiCorp / file-system equivalents

Without this configuration, key rotation across container restarts can produce signatures that the next pod can't verify — historical assessments become unverifiable after rotation. Document this prominently in deployment runbooks before any state-agency production deployment.

Legacy table

The legacy ApplicationSecurityAssessment table still exists for archival reference but is no longer written or read by the application. Its repository interface and EF Core implementation are marked [Obsolete]. A future cleanup PR will export remaining historical rows into the new aggregate (lossy: Source = "legacy-self-attestation") and drop the table.

Audit Trail

All security-related actions are logged in the audit trail:

  • Capability declaration evaluation runs and results
  • Security configuration changes
  • Role and permission modifications
  • Login attempts (successful and failed)
  • Session creation and termination

The audit trail is retained for a configurable period (default: 2,555 days / ~7 years) to support compliance record-keeping requirements.

Security Configuration

All security settings are managed in appsettings.json. Key sections:

{
  "Security": {
    "SessionTimeoutMinutes": 60,
    "PasswordResetTokenExpirationMinutes": 60,
    "MaxLoginAttempts": 5,
    "LockoutDurationMinutes": 15
  },
  "Riptide": {
    "Security": {
      "Headers": { },
      "Audit": {
        "Enabled": true,
        "RetentionDays": 2555
      },
      "Compliance": {
        "Enabled": true,
        "EnabledTemplates": ["SOC2", "HIPAA", "FedRAMP"]
      }
    }
  }
}

See Configuration Reference for complete details on every security-related setting.