code-review: 2026-05-28 baseline re-review of all 23 modules at 1eb6e97

Re-applies the full 10-category checklist to every src/ project — including
first-time reviews of the four newer components (AuditLog, NotificationOutbox,
SiteCallAudit, Transport) — so the code-reviews/ index reflects today's
codebase rather than the 2026-05-16 baseline. 172 new Open findings (0
Critical, 18 High, 62 Medium, 92 Low); 481 findings total across 23 modules.

regen-readme.py now derives each module's Last reviewed + Commit from its
findings.md header instead of hard-coding 2026-05-16 / 9c60592, so future
single-module re-reviews show their own date in the Module Status table.
This commit is contained in:
Joseph Doherty
2026-05-28 02:55:47 -04:00
parent 1eb6e972b0
commit f93b7b99bb
25 changed files with 8793 additions and 115 deletions
+327 -3
View File
@@ -5,10 +5,10 @@
| Module | `src/ScadaLink.CentralUI` |
| Design doc | `docs/requirements/Component-CentralUI.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-17 |
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 0 |
| Commit reviewed | `1eb6e97` |
| Open findings | 8 |
## Summary
@@ -73,6 +73,55 @@ cross-thread `Dictionary`; CentralUI-022 unguarded `InvokeAsync`), category 4
claims), category 9 (CentralUI-025 untested `SessionExpiry` poll). Categories
1, 2, 5, 6, 7, 10 produced no new findings.
_Re-review (2026-05-28, `1eb6e97`):_
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | CentralUI-026 (AuditFilterBar UTC), CentralUI-027 (3 other pages with same UTC bug). |
| 2 | Akka.NET conventions | ☑ | No new findings — module is presentation; `DebugStreamService` actor usage unchanged. |
| 3 | Concurrency & thread safety | ☑ | CentralUI-030 (StringWriter capture buffer not thread-safe under intra-script `Task.WhenAll`). |
| 4 | Error handling & resilience | ☑ | No new findings — the prior CentralUI-018/023 patterns hold. |
| 5 | Security | ☑ | CentralUI-028 (NotificationReport + SiteCallsReport not site-scoped — CentralUI-002 regression on new pages). |
| 6 | Performance & resource management | ☑ | CentralUI-031 (TransportImport buffers full bundle bytes in component state). |
| 7 | Design-document adherence | ☑ | CentralUI-032 (AuditResultsGrid forward-only paging diverges from "keyset paginated" implied bi-directional). |
| 8 | Code organization & conventions | ☑ | CentralUI-029 (`JS.InvokeAsync<int>("eval", ...)` in ConfigurationAuditLog vs the `_content/.../BrowserTime` module pattern). |
| 9 | Testing coverage | ☑ | CentralUI-033 (TransportImport / SiteCallsReport query-string drill-in code paths untested). |
| 10 | Documentation & comments | ☑ | No new findings — code comments accurately describe intent. |
#### Re-review 2026-05-28 (commit `1eb6e97`)
All 25 prior findings remain closed. This re-review re-examined the full
module against the 10-category checklist with attention to the
recently-added Transport export/import wizards (`TransportExport`,
`TransportImport`) and the operational Audit Log page (Bundle B..G). The
most consequential pattern in this pass is that the **CentralUI-008
local-input-treated-as-UTC** bug, fixed for the legacy
`AuditLog.razor` via the `BrowserTime.LocalInputToUtc` helper, has been
silently recreated on every other page that exposes a
`<input type="datetime-local">` filter — `AuditFilterBar` (the new
operational Audit Log filter, CentralUI-026), `SiteCallsReport`,
`NotificationReport`, and `EventLogs` (CentralUI-027). The Audit Log
page CSV export URL therefore mis-shifts the From/To filter window by
the operator's UTC offset, and the same offset bug silently corrupts
audit-style queries on Site Calls / Notification Report / Event Logs.
Second-most consequential is **CentralUI-028**: the new `NotificationReport`
and `SiteCallsReport` pages (both `[Authorize(RequireDeployment)]`) do
NOT filter their site dropdown or row data through `SiteScopeService`,
and the relay actions (`RetryNotification`/`DiscardNotification`,
`RetrySiteCall`/`DiscardSiteCall`) issue no server-side site-scope
re-check before relaying to the owning site — so a site-scoped Deployment
user can read and act on notifications and cached calls for sites
outside their grant, replicating the original CentralUI-002 defect on
the two pages added after the CentralUI-002 fix landed. The remaining
new findings (CentralUI-029..CentralUI-033) cover a residual `JS.InvokeAsync<int>("eval", ...)`
in `ConfigurationAuditLog`, a single-thread `StringWriter` capture buffer
in the Test Run sandbox (a sandboxed script that uses `Task.WhenAll` can
write concurrently), a `using var` `MemoryStream` followed by `ms.ToArray()`
buffering the full bundle in memory in `TransportImport`, the
`AuditResultsGrid` having no Previous-page control (forward-only navigation,
a UX/design adherence gap), and the un-tested `TransportImport` /
`SiteCallsReport` query-string drill-in code paths.
## Findings
### CentralUI-001 — Test Run sandbox executes arbitrary C# with no trust-model enforcement
@@ -1216,3 +1265,278 @@ also forces the CentralUI-020 fix.
**Resolution**
2026-05-17 — added `SessionExpiryComponentTests` (bUnit): an expired ping (401) redirects to `/login`, a live ping (200) and a transient failure (status 0) do not, and on the `/login` route the component neither pings nor redirects; also added `AuthPingEndpointTests` covering the `/auth/ping` endpoint contract.
### CentralUI-026 — `AuditFilterBar` From/To filters treat browser-local datetimes as UTC
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor:97-104`; `src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs:56-58,150-178,203-213` |
**Description**
The new operational Audit Log filter bar binds two `<input type="datetime-local">` controls
straight to `AuditQueryModel.CustomFromUtc` / `CustomToUtc` (`DateTime?`), and `ToFilter`
emits those values as `AuditLogQueryFilter.FromUtc` / `ToUtc` without converting from
the browser's local time zone. A `datetime-local` input yields the user's *browser-local*
wall-clock value, so for any non-UTC user the audit query window is shifted by their UTC
offset — returning the wrong rows from the central `AuditLog` table and producing a
mis-shifted CSV export through `AuditLogPage.BuildExportUrl`, which round-trips the
filter's `FromUtc`/`ToUtc` straight into `?from=`/`?to=` query params. This is the same
defect CentralUI-008 fixed for the legacy `Components/Pages/Monitoring/AuditLog.razor`
via the `BrowserTime.LocalInputToUtc(value, _browserUtcOffsetMinutes)` helper — but the
new Audit Log v2 filter bar does not use that helper, so a Bundle B/C/D/E/F regression
re-introduced the bug for the page-replacement target. The CLAUDE.md "all timestamps are
UTC throughout" decision is satisfied at the wire level but violated at the input
boundary, exactly as the original finding called out.
**Recommendation**
Fetch the browser offset once via JS interop (mirroring `ConfigurationAuditLog.OnAfterRenderAsync`
and `AuditLog.razor`'s implementation), pipe both `CustomFromUtc` and `CustomToUtc` through
`BrowserTime.LocalInputToUtc(value, offsetMinutes)` inside `AuditQueryModel.ToFilter`
(or in the filter-bar Apply path before calling `ToFilter`), and add a regression test
that pins the non-UTC behaviour (mirroring `BrowserTimeTests.LocalInputToUtc_NonUtcBrowser_DoesNotEqualNaiveRelabelling`).
The label "Custom From / To" should also be clarified ("UTC" vs "local") in the UI.
### CentralUI-027 — Same UTC misinterpretation in `SiteCallsReport`, `NotificationReport`, and `EventLogs`
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:74-80`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:421-425`; `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor:75-81,639-640`; `src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor:62-73,261-262` |
**Description**
The same `datetime-local`-treated-as-UTC bug from CentralUI-008 and CentralUI-026 is
present on three other pages:
- `SiteCallsReport.ToUtc` stamps `DateTimeKind.Utc` on the local-input value
(`DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)`).
- `NotificationReport.ToUtc` does the same — `new DateTimeOffset(DateTime.SpecifyKind(local.Value, DateTimeKind.Utc))`.
- `EventLogs.FetchPage` emits `new DateTimeOffset(_filterFrom.Value, TimeSpan.Zero)`,
which labels the browser-local wall-clock value as UTC (the exact pre-fix shape of
CentralUI-008).
For any non-UTC operator, every Site-Calls / Notification / Event-Log query is silently
shifted by their UTC offset. The bug is mass-recreated on every page added after
CentralUI-008 landed — the `BrowserTime` helper exists but is only used by the legacy
Audit Log page and `ConfigurationAuditLog`.
**Recommendation**
Plumb the browser offset (via `eval` interop or a dedicated JS module, mirroring
`ConfigurationAuditLog`/`AuditLog.razor`) into each of these pages and route every
local-input value through `BrowserTime.LocalInputToUtc(value, offsetMinutes)` before
constructing the wire filter. Add regression tests pinning the non-UTC behaviour for
at least one representative page so the helper's continued use is enforced.
### CentralUI-028 — `NotificationReport` and `SiteCallsReport` bypass `SiteScopeService` — Deployment role site-scoping defeated on the two new central-mirror pages
| | |
|--|--|
| Severity | High |
| Category | Security |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor:2,434,472,502`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:2,52-59`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:97-110,201,250-251,278-279` |
**Description**
Both pages are `[Authorize(Policy = RequireDeployment)]` and, per CLAUDE.md "Security &
Auth", the Deployment role must be site-scoped. CentralUI-002 fixed this for every
Deployment/Monitoring page that existed at the time by introducing `SiteScopeService`
and threading `FilterSitesAsync` / `IsSiteAllowedAsync` through the site dropdowns and
mutating calls. The two new central-mirror pages — Notification Report (Notification
Outbox queryable list) and Site Calls Report (Site Call Audit queryable list) — do NOT
inject `SiteScopeService`, do NOT filter their Source-Site `<select>` lists (they
enumerate `await SiteRepository.GetAllSitesAsync()` straight to the dropdown), do NOT
narrow the query results by permitted site, and do NOT re-check the user's grant
before relaying Retry/Discard to the owning site. `NotificationReport.RetryNotificationAsync`,
`NotificationReport.DiscardNotificationAsync`, `SiteCallsReport.RetrySiteCallAsync`,
and `SiteCallsReport.DiscardSiteCallAsync` all dispatch with the row's `SourceSiteId` /
`SourceSite` unchecked. A scoped Deployment user can therefore (a) browse every row in
the central `Notifications` / `SiteCalls` table including those for sites outside their
grant, (b) submit Retry/Discard URLs hand-crafted from the row metadata, and (c) the
site relay completes successfully because the CommunicationService only sees the
row's source-site identifier, not the user's grant. This is a direct regression of the
CentralUI-002 contract on the two pages that landed after CentralUI-002 was closed.
**Recommendation**
Inject `SiteScopeService` into both pages; filter the source-site dropdown through
`FilterSitesAsync`; default the filter to the permitted-site set so a scoped user sees
only their own rows (or push the predicate into the central query — preferred, so the
filter cannot be bypassed by URL manipulation); and re-check `IsSiteAllowedAsync` in
`RetryNotificationAsync`/`DiscardNotificationAsync`/`RetrySiteCallAsync`/`DiscardSiteCallAsync`
before the CommunicationService call, surfacing a "not permitted for this site" toast
on failure (mirroring `ParkedMessages.razor`'s `SelectedSiteIsPermitted` guard).
Add `Site_ScopedDeploymentUser_OnlySeesPermittedRows` and
`Site_ScopedDeploymentUser_CannotRetryRowOnNonPermittedSite` regression tests modelled
on `TopologyPageTests.SiteScoping_*`.
### CentralUI-029 — `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module
| | |
|--|--|
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor:248-263` |
**Description**
`OnAfterRenderAsync` fetches the browser's UTC offset with
`JS.InvokeAsync<int>("eval", "new Date().getTimezoneOffset()")`. Calling `eval` over
JS interop is a code-smell: it widens the JS-interop attack surface (any future
attacker who can influence the second argument runs arbitrary JS), it is brittle
under stricter Content-Security-Policy headers (CSP `script-src` directives commonly
forbid `unsafe-eval`), and it bypasses the existing module-import pattern the rest
of the module follows (`session-expiry.js`, `audit-grid.js`, `nav-state.js`,
`transport.js` are all loaded as `IJSObjectReference` modules). The legacy
`AuditLog.razor` (CentralUI-008 fix) and the planned helper exist precisely to avoid
this. Today the eval text is a static string so there is no live bug; the issue is
that the pattern invites a future caller to compose the argument from page state.
**Recommendation**
Move the offset lookup into a small wwwroot JS module (e.g.
`wwwroot/js/browser-time.js` exporting `getTimezoneOffsetMinutes()`) and `import` it
via `IJSObjectReference` like the other helpers. Replace the `eval` call with
`module.InvokeAsync<int>("getTimezoneOffsetMinutes")`. The fix is local and removes
a residual eval surface; the same module can host the rest of the `BrowserTime`
plumbing CentralUI-027 will need.
### CentralUI-030 — `SandboxConsoleCapture`'s per-call `StringWriter` is not thread-safe under intra-script concurrency
| | |
|--|--|
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/SandboxConsoleCapture.cs:31-118`; `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:401-404` |
**Description**
CentralUI-003 correctly routed console capture through an `AsyncLocal<StringWriter?>`
so concurrent Test Runs cannot cross-contaminate. `BeginCapture` flows the capture
buffer through the call-tree, and `Target` reads it on every `Write`. But a single
script execution can still write to its captured `StringWriter` from multiple threads
within one call-tree: the script trust model allows `System.Threading.Tasks`, so a
user script can `await Task.WhenAll(t1, t2, t3)` where each task is `Task.Run(() => Console.WriteLine(...))`,
and `_current.Value` flows into each `Task.Run`. The capture buffer is a plain
`StringWriter` (`captured = new StringWriter()` in `RunInSandboxAsync`), which is
**not** thread-safe — concurrent `WriteLine` calls can throw or interleave
character-level. The Akka/gRPC-thread race fixed by CentralUI-003 is gone, but the
intra-script-concurrency race is a residual hazard for any script that exercises
parallel tasks (a realistic shape for a Test Run that calls multiple `External.Call`s
concurrently). Severity is Low because the symptom is a corrupted ConsoleOutput
string, not a security/data-loss issue, and the script must opt into Task-based
concurrency to trigger it.
**Recommendation**
Wrap the capture buffer with `TextWriter.Synchronized(new StringWriter())` (the
BCL's purpose-built thread-safe wrapper), or hold a lock inside `SandboxConsoleCapture.Write*`
on the current scope's `StringWriter`. Add a focused test that runs `await Task.WhenAll(...)`
with `Console.WriteLine` in each task and asserts the resulting `ConsoleOutput` has
the expected line count regardless of thread interleaving.
### CentralUI-031 — `TransportImport` buffers the full bundle bytes in component state
| | |
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:72,104-142,160-161` |
**Description**
`OnFileSelectedAsync` reads the uploaded `.scadabundle` into a `MemoryStream`,
calls `ms.ToArray()`, and stores the byte array on the component as
`private byte[]? _bundleBytes`. The bytes live on the Blazor circuit for the
lifetime of the wizard — through the passphrase step, the diff step (which can
take an arbitrary amount of operator time on a large bundle), the confirm step,
and the apply step — and are only cleared in `ResetSessionState` (Done /
re-upload). For an operator who walks away from the diff step mid-review, the
configured `MaxBundleSizeMb` (default not enforced here; only the file-size
check on read) worth of bytes stays pinned on the central node's heap per
open circuit. The page has no `IDisposable` to clear the bytes on tear-down
either. Severity is Low because the cap is checked at upload time and Import
is Admin-only (limited concurrent users), but the lifetime is longer than the
strictly-needed retention.
**Recommendation**
Stream the bundle to a temp file (or to the `IBundleImporter`'s session store)
rather than caching it on the component. Failing that, implement `IDisposable`
on `TransportImport` and clear `_bundleBytes` (`Array.Clear` for sensitivity)
on dispose; also clear the cached passphrase string. Tighten `MaxBundleSizeMb`
docs to call out the in-memory cost per concurrent import session.
### CentralUI-032 — `AuditResultsGrid` paging is forward-only, no Previous button
| | |
|--|--|
| Severity | Low |
| Category | Design-document adherence |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor:76-82`; `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs:65,196-197,219-220` |
**Description**
The Audit Log results grid (Bundle B / M7-T3) renders a single "Next page" button
and a `Page N · M rows` label, with no Previous control. The design doc says
"Keyset pagination ordered by `(OccurredAtUtc desc, EventId desc)`. Default page
size 100." — keyset paging is naturally forward-only, but a usable audit-triage
workflow needs to step back to the previous page (the `SiteCallsReport` keyset
implementation correctly maintains a `Stack<(...)> _cursorStack` for exactly this).
An operator who clicks Next once and misses a row on the first page cannot return
without re-applying the filter to start a fresh first page. The current shape
also makes the "Page N" label slightly misleading — there is no in-grid affordance
to use it as a navigation target.
**Recommendation**
Mirror the `SiteCallsReport.razor.cs` keyset-paging shape: maintain a
`Stack<(DateTime?, Guid?)> _cursorStack` of previous-page cursors, add a Previous
button gated on `_cursorStack.Count > 0`, push the current cursor on Next and pop
on Previous. Either implement this or update the design doc to acknowledge
forward-only paging on the Audit Log grid.
### CentralUI-033 — Drill-in / query-string code paths for the new Transport + SiteCalls pages are untested
| | |
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:97-238,267-319`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:107-148`; `tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs`; `tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs` |
**Description**
The CentralUI-025 lesson — "a critical drill-in/redirect path was untested, so the
CentralUI-020 defect was not caught" — applies again to the two newest pages.
`SiteCallsReport.ApplyQueryStringFilters` parses `?status=` and `?stuck=true` to
seed the filters from a Health-dashboard KPI tile drill-in; there is no test that
pins this seeding (an unrecognised status, a missing param, the case-insensitive
match). `TransportImport` has a 5-step state machine and a 3-strike passphrase
lockout, both with intricate transition logic
(`GoFromUploadAsync` re-trying `LoadAsync`, the `_failedUnlockAttempts` reset on
success, the audit-row write on failure) — none of the step-machine transition
paths or the lockout reset / lockout-trip behaviours are pinned by tests. The
existing `TransportImportPageTests` exercise rendering shapes, not the lifecycle.
**Recommendation**
Add bUnit tests for `SiteCallsReport.ApplyQueryStringFilters` covering valid /
invalid / case-mismatched `?status=` values and the `?stuck=true` toggle, and
add `TransportImport` lifecycle tests covering: an encrypted-bundle upload
advances to Step 2 without opening a session; a wrong passphrase increments the
counter and writes the `BundleImportUnlockFailed` audit row; the lockout resets
the wizard to Step 1 once `MaxUnlockAttemptsPerSession` is reached; a successful
unlock resets the counter and advances to Step 3.