fix(security): close Theme 7 — 8 secrets / redaction / append-only findings

Security-sensitive batch, handled main-thread for careful judgment on
secret-leak and pepper-bypass paths.

Secret leak / pepper bypass:
- CD-016 (pepper bypass): InboundApiRepository's GetApiKeyByValueAsync no
  longer hashes the candidate with the unpeppered ApiKeyHasher.Default —
  ctor takes a lazy Func<IApiKeyHasher> accessor (lazy so test composition
  roots without a pepper still bring up the repository), and the DI
  registration wires sp.GetService<IApiKeyHasher>() so the production
  peppered hasher matches the stored KeyHash. Regression test asserts
  positive (peppered roundtrip) AND negative (Default hasher misses the
  same key — proving the lookup uses the injected hasher).
- MgmtSvc-020 (SMTP credential leak): UpdateSmtpConfig/ListSmtpConfigs
  now project through SmtpConfigPublicShape so the response payload and
  audit-row afterState never carry the Credentials field — only a
  HasCredentials bool. The SMTP password / OAuth2 client secret no
  longer leaves the Admin-only UpdateSmtpConfig boundary the caller
  already supplied it to.

Redaction:
- AuditLog-008 (test-fixture under-redact): new
  SafeDefaultAuditPayloadFilter (stateless singleton) does HTTP header
  redaction for the always-sensitive defaults (Authorization, X-Api-Key,
  Cookie, Set-Cookie). FallbackAuditWriter, CentralAuditWriter, and
  AuditLogIngestActor (both ingest paths) default to it instead of null
  — composition roots that bypass AddAuditLog can no longer write
  unredacted auth headers to the audit store.
- NotifService-025 (over-mask): CredentialRedactor.Scrub now only masks
  the last colon-separated component (password / clientSecret) AND only
  if it's >= 12 chars (typical password heuristic). Short user names
  like "root" no longer become global redaction tokens that eat unrelated
  diagnostic text. The full packed string is always masked regardless of
  length. 3 new negative tests pin the no-over-mask contract.

Audit-row correctness / fail-loud:
- InboundAPI-025: Program.cs UseWhen predicate now excludes /api/audit,
  /api/management, /api/centralui, /api/script-analysis AND requires POST
  — the AuditWriteMiddleware no longer emits spurious ApiInbound rows
  for audit-log query/export endpoints (write-on-read recursion broken).
- ESG-021: ApplyAuth now logs Warning (not silent) on empty
  AuthConfiguration for apikey/basic, unknown AuthType, and malformed
  Basic config. AuthConfiguration value NEVER logged. AuthType=none
  remains silent (documented unauthenticated sentinel).
- Security-021: AddSecurity now logs a startup Warning when
  RequireHttpsCookie=false — an HTTP-only deployment that previously
  transmitted the cookie-embedded JWT silently in cleartext is now
  audible in the log.

Defensive:
- CD-021: SwitchOutPartitionAsync's monthBoundary format string now
  yyyy-MM-dd HH:mm:ss.fffffff (datetime2(7) precision) so a future
  sub-second / non-midnight boundary doesn't silently round to the
  wrong partition.

Plus reconciled stale per-module Open-findings counters that had drifted
from earlier sessions (AuditLog, CD, ESG, IAPI, MgmtSvc, NotifService,
Security).

Build clean; all affected test projects green (Host 208, ConfigDB 242,
ESG 69, IAPI 151, MgmtSvc 100, NotifService 55, Security 85, AuditLog
247/248 — 1 pre-existing date-sensitive integration test flake on
PartitionPurgeTests, unrelated). README regenerated: 46 open (was 54).
This commit is contained in:
Joseph Doherty
2026-05-28 08:04:10 -04:00
parent 55f46e7c92
commit 46cb6965ac
22 changed files with 500 additions and 77 deletions
+15 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 2 | | Open findings | 1 |
## Summary ## Summary
@@ -411,9 +411,22 @@ resolution instead.
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs:51-77`, `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs:77-104`, `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:125,155` | | Location | `src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs:51-77`, `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs:77-104`, `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:125,155` |
**Resolution (2026-05-28):** New `SafeDefaultAuditPayloadFilter` in
`src/ScadaLink.AuditLog/Payload/` — a stateless singleton that performs HTTP
header redaction for the hard-coded sensitive defaults (Authorization,
X-Api-Key, Cookie, Set-Cookie). The three writer-chain sites
(`FallbackAuditWriter`, `CentralAuditWriter`, `AuditLogIngestActor`
both the audit-only and cached-telemetry paths) now default to
`SafeDefaultAuditPayloadFilter.Instance` instead of null when no filter is
injected, so a test fixture (or any composition root that bypasses
`AddAuditLog`) never persists those headers verbatim. The real
`DefaultAuditPayloadFilter` (truncation + body / SQL-param redaction +
per-target overrides) is wired by `AddAuditLog` and takes precedence in
production.
**Description** **Description**
`FallbackAuditWriter`, `CentralAuditWriter`, and `AuditLogIngestActor` all accept an `FallbackAuditWriter`, `CentralAuditWriter`, and `AuditLogIngestActor` all accept an
+27 -3
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 3 | | Open findings | 1 |
## Summary ## Summary
@@ -946,9 +946,22 @@ throws and exactly one row lands.
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:35-39` | | Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:35-39` |
**Resolution (2026-05-28):** Took option (a) — `InboundApiRepository` ctor now
accepts `Func<IApiKeyHasher>? hasherAccessor = null` (deferred resolution to
sidestep startup-time pepper-validation in test composition roots that don't
configure one). `GetApiKeyByValueAsync` calls `_hasherAccessor()` so the
hash matches the production peppered digest. `AddConfigurationDatabase`
registers the repository with a factory wiring
`() => sp.GetService<IApiKeyHasher>() ?? ApiKeyHasher.Default` — the
peppered hasher is used when available, falling back to Default only when
none is registered (legacy / test composition). Regression test
`CD016_GetApiKeyByValue_UsesInjectedPepperedHasher_NotDefault` asserts
positively (peppered roundtrip) and negatively (Default hasher misses the
key entirely, proving the lookup uses the injected hasher).
**Description** **Description**
`GetApiKeyByValueAsync` resolves an API key by its presented plaintext value by hashing `GetApiKeyByValueAsync` resolves an API key by its presented plaintext value by hashing
@@ -1191,9 +1204,20 @@ converter on the column) so the defence at the read site is no longer required.
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:192-338` | | Location | `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:192-338` |
**Resolution (2026-05-28):** Took the targeted (1) part of the recommendation —
the `monthBoundary` format string is now `"yyyy-MM-dd HH:mm:ss.fffffff"`
matching `datetime2(7)` precision, so a future sub-second or non-midnight
boundary won't silently round to the wrong partition. The staging table name
is still interpolated (T-SQL DDL identifiers can't be parameterised) but
remains internally constructed as `$"AuditLog_Staging_{Guid.NewGuid():N}"`
so SQL injection is structurally impossible. The larger
`sp_executesql`-with-typed-parameter migration was scoped to a future
follow-up since the current shape is fully controlled and the precision-
mismatch hazard was the only practical concern.
**Description** **Description**
`SwitchOutPartitionAsync` builds two large SQL batches via interpolated strings `SwitchOutPartitionAsync` builds two large SQL batches via interpolated strings
+13 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 4 | | Open findings | 1 |
## Summary ## Summary
@@ -1180,9 +1180,20 @@ that happens to encode a number (already correctly returns `string`).
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:385-415` | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:385-415` |
**Resolution (2026-05-28):** `ApplyAuth` is now an instance method that uses
the existing `_logger`. Three previously-silent fail-open paths now emit a
`LogWarning` so an operator debugging a recurring 401 sees the cause inside
ScadaLink: (1) empty `AuthConfiguration` for `AuthType=apikey`/`basic`,
(2) unknown `AuthType` (anything except `apikey`/`basic`/`none`),
(3) malformed Basic config (no `:` separator). The `AuthConfiguration`
value is NEVER included in the log message. `AuthType="none"` remains
silent — it's the documented sentinel for intentionally-unauthenticated
systems. Behaviour is otherwise unchanged: the request still goes out
(never block on auth-config issues), the failure mode is just now visible.
**Description** **Description**
`ApplyAuth` has three fail-open paths that all result in an HTTP request being sent `ApplyAuth` has three fail-open paths that all result in an HTTP request being sent
+14 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 2 | | Open findings | 1 |
## Summary ## Summary
@@ -1233,9 +1233,21 @@ immediate change required; this is a watch-list item.
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:183-185`; consumers: `src/ScadaLink.ManagementService/AuditEndpoints.cs:93-94`; emitter: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs:175-252` | | Location | `src/ScadaLink.Host/Program.cs:183-185`; consumers: `src/ScadaLink.ManagementService/AuditEndpoints.cs:93-94`; emitter: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs:175-252` |
**Resolution (2026-05-28):** Took the defensive path-exclusion in
`Program.cs` (Option 1 from the recommendation). The `UseWhen` predicate
now excludes the four known `/api/*` sub-trees that belong to other modules
(`/api/audit`, `/api/management`, `/api/centralui`, `/api/script-analysis`)
AND additionally requires `POST` — the inbound API method route is the
only POST under `/api/`, so future GET-y additions under any of those
sub-trees still survive the gate. The endpoint-filter refactor option
(move the audit emission into an `IEndpointFilter` co-located with
`MapInboundAPI`) was rejected for batch scope — touches more test fixtures
and the path-predicate is fragile only against future POST additions on
the excluded prefixes, which would be noisy in code review.
**Description** **Description**
`Program.cs` wires the audit middleware as `Program.cs` wires the audit middleware as
+13 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 3 (1 Deferred — see ManagementService-012) | | Open findings | 1 (1 Deferred — see ManagementService-012) |
## Summary ## Summary
@@ -927,9 +927,20 @@ mixed-set intersected.
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:1136``:1153` | | Location | `src/ScadaLink.ManagementService/ManagementActor.cs:1136``:1153` |
**Resolution (2026-05-28):** Added `SmtpConfigPublicShape` projection that
returns every non-secret field plus a `HasCredentials` bool — never the
`Credentials` field itself. Both `HandleListSmtpConfigs` (broader read
access via OperationalAuditRoles) and `HandleUpdateSmtpConfig` (Admin-only
write) now project through it. The audit-row `afterState` and the response
payload both carry the credential-free shape, so the SMTP password / OAuth2
client secret never leaves the `UpdateSmtpConfig` boundary that the caller
already supplied them to. ManagementService 100/100 tests still pass.
Follow-up to tag `SmtpConfiguration.Credentials` with `[JsonIgnore]` in
Commons remains useful belt-and-braces but is out of scope here.
**Description** **Description**
`HandleUpdateSmtpConfig` reads the existing `SmtpConfiguration` entity, applies the `HandleUpdateSmtpConfig` reads the existing `SmtpConfiguration` entity, applies the
+16 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 5 | | Open findings | 1 |
## Summary ## Summary
@@ -813,9 +813,23 @@ After NS-019 is decided:
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.NotificationService/CredentialRedactor.cs:34-48` | | Location | `src/ScadaLink.NotificationService/CredentialRedactor.cs:34-48` |
**Resolution (2026-05-28):** Tightened the policy per the recommendation —
only the LAST colon-separated component (password in Basic / clientSecret
in OAuth2) is considered a secret, AND only when it's plausibly secret-shaped
(>= `MinSecretLength` = 12 chars). The full packed string is always masked
regardless of length (its exact appearance can only come from the
credential itself). A short user name like `root` (4 chars) or a sender
alias like `smtp` (4 chars) no longer becomes a global redaction token
that eats unrelated diagnostic text. 3 new negative tests added
(`Scrub_ShortUserName_IsNotMaskedOutsidePackedString`,
`Scrub_TenantId_IsNotMaskedOutsidePackedString`,
`Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength`); 1
existing test bumped its inline password length from 10→16 chars to stay
above the new threshold.
**Description** **Description**
```csharp ```csharp
+12 -20
View File
@@ -41,31 +41,31 @@ module file and counted in **Total**.
|----------|---------------| |----------|---------------|
| Critical | 0 | | Critical | 0 |
| High | 0 | | High | 0 |
| Medium | 19 | | Medium | 16 |
| Low | 35 | | Low | 30 |
| **Total** | **54** | | **Total** | **46** |
## Module Status ## Module Status
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total | | Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|--------|---------------|--------|----------------|------|-------| |--------|---------------|--------|----------------|------|-------|
| [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 11 | | [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 11 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 | | [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 33 | | [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 33 |
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 14 | | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 14 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 23 | | [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 23 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 22 | | [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 22 |
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 24 | | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 24 |
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 | | [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 24 | | [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 23 | | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 | | [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/3 | 4 | 22 | | [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/3 | 4 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 25 | | [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 23 | | [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 10 | | [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 25 | | [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 25 |
| [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 21 | | [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 21 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 6 | | [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 | | [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 26 | | [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 26 |
@@ -88,16 +88,13 @@ _None open._
_None open._ _None open._
### Medium (19) ### Medium (16)
| ID | Module | Title | | ID | Module | Title |
|----|--------|-------| |----|--------|-------|
| AuditLog-001 | [AuditLog](AuditLog/findings.md) | Combined-telemetry transport is plumbed end-to-end but never invoked in production | | AuditLog-001 | [AuditLog](AuditLog/findings.md) | Combined-telemetry transport is plumbed end-to-end but never invoked in production |
| ConfigurationDatabase-016 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `InboundApiRepository.GetApiKeyByValueAsync` hashes the candidate with the unpeppered `ApiKeyHasher.Default` |
| ExternalSystemGateway-020 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `JsonElementToParameterValue` silently downcasts non-Int64 JSON numbers to `double`, losing precision for `decimal` SQL parameters on retry | | ExternalSystemGateway-020 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `JsonElementToParameterValue` silently downcasts non-Int64 JSON numbers to `double`, losing precision for `decimal` SQL parameters on retry |
| Host-016 | [Host](Host/findings.md) | Site `CentralContactPoints` second entry targets the site's own remoting port | | Host-016 | [Host](Host/findings.md) | Site `CentralContactPoints` second entry targets the site's own remoting port |
| InboundAPI-025 | [InboundAPI](InboundAPI/findings.md) | `AuditWriteMiddleware` runs against the entire `/api/*` branch — emits spurious `ApiInbound` audit rows for `/api/audit/query` and `/api/audit/export` |
| ManagementService-020 | [ManagementService](ManagementService/findings.md) | UpdateSmtpConfig returns and audits the SMTP Credentials field verbatim |
| ManagementService-021 | [ManagementService](ManagementService/findings.md) | Transport bundle handlers have zero test coverage | | ManagementService-021 | [ManagementService](ManagementService/findings.md) | Transport bundle handlers have zero test coverage |
| NotificationService-024 | [NotificationService](NotificationService/findings.md) | No test affirms the central-only invariant; the orphaned-path tests give a false coverage signal | | NotificationService-024 | [NotificationService](NotificationService/findings.md) | No test affirms the central-only invariant; the orphaned-path tests give a false coverage signal |
| SiteCallAudit-001 | [SiteCallAudit](SiteCallAudit/findings.md) | SupervisorStrategy override is dead code; XML claims Resume that is not enforced | | SiteCallAudit-001 | [SiteCallAudit](SiteCallAudit/findings.md) | SupervisorStrategy override is dead code; XML claims Resume that is not enforced |
@@ -112,11 +109,10 @@ _None open._
| TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key | | TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key |
| Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests | | Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests |
### Low (35) ### Low (30)
| ID | Module | Title | | ID | Module | Title |
|----|--------|-------| |----|--------|-------|
| AuditLog-008 | [AuditLog](AuditLog/findings.md) | Test composition roots that omit `IAuditPayloadFilter` silently pass UNREDACTED payloads through the writer chain |
| CLI-020 | [CLI](CLI/findings.md) | `bundle export` success-envelope parse is unguarded | | CLI-020 | [CLI](CLI/findings.md) | `bundle export` success-envelope parse is unguarded |
| CLI-022 | [CLI](CLI/findings.md) | `CommandTreeTests` excludes the two new command groups | | CLI-022 | [CLI](CLI/findings.md) | `CommandTreeTests` excludes the two new command groups |
| CentralUI-029 | [CentralUI](CentralUI/findings.md) | `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module | | CentralUI-029 | [CentralUI](CentralUI/findings.md) | `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module |
@@ -130,12 +126,10 @@ _None open._
| Commons-020 | [Commons](Commons/findings.md) | Transport types and new Audit-message types have no unit tests in `ScadaLink.Commons.Tests` | | Commons-020 | [Commons](Commons/findings.md) | Transport types and new Audit-message types have no unit tests in `ScadaLink.Commons.Tests` |
| Commons-023 | [Commons](Commons/findings.md) | Trailing-optional `SourceNode` on positional records mixes additive evolution patterns | | Commons-023 | [Commons](Commons/findings.md) | Trailing-optional `SourceNode` on positional records mixes additive evolution patterns |
| Communication-020 | [Communication](Communication/findings.md) | `SiteAddressCacheLoaded` carries mutable `Dictionary`/`List` types | | Communication-020 | [Communication](Communication/findings.md) | `SiteAddressCacheLoaded` carries mutable `Dictionary`/`List` types |
| ConfigurationDatabase-021 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `SwitchOutPartitionAsync` interpolates `monthBoundary` / staging table name into raw SQL |
| ConfigurationDatabase-024 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | Missing test coverage for SPLIT-RANGE failure-continuation and production-shape rowversion delete | | ConfigurationDatabase-024 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | Missing test coverage for SPLIT-RANGE failure-continuation and production-shape rowversion delete |
| DeploymentManager-021 | [DeploymentManager](DeploymentManager/findings.md) | `ResolveSiteIdentifierAsync` silently substitutes the DB id when the site row is missing | | DeploymentManager-021 | [DeploymentManager](DeploymentManager/findings.md) | `ResolveSiteIdentifierAsync` silently substitutes the DB id when the site row is missing |
| DeploymentManager-022 | [DeploymentManager](DeploymentManager/findings.md) | `Pending` and `InProgress` are written back-to-back with no intervening work | | DeploymentManager-022 | [DeploymentManager](DeploymentManager/findings.md) | `Pending` and `InProgress` are written back-to-back with no intervening work |
| DeploymentManager-024 | [DeploymentManager](DeploymentManager/findings.md) | Test probe actors hold mutable static state across tests | | DeploymentManager-024 | [DeploymentManager](DeploymentManager/findings.md) | Test probe actors hold mutable static state across tests |
| ExternalSystemGateway-021 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `ApplyAuth` silently sends an unauthenticated request on unknown `AuthType`, empty `AuthConfiguration`, or malformed Basic config |
| HealthMonitoring-021 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralSiteId = "central"` reserved constant silently collides with a real site named "central" | | HealthMonitoring-021 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralSiteId = "central"` reserved constant silently collides with a real site named "central" |
| HealthMonitoring-022 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralHealthReportLoopTests` uses real-time `PeriodicTimer` + `Task.Delay`; flake-prone on slow CI | | HealthMonitoring-022 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralHealthReportLoopTests` uses real-time `PeriodicTimer` + `Task.Delay`; flake-prone on slow CI |
| Host-018 | [Host](Host/findings.md) | Shipped per-role configs omit `NodeOptions.NodeName`, leaving `SourceNode` null | | Host-018 | [Host](Host/findings.md) | Shipped per-role configs omit `NodeOptions.NodeName`, leaving `SourceNode` null |
@@ -143,8 +137,6 @@ _None open._
| Host-021 | [Host](Host/findings.md) | Microsoft `Logging:LogLevel` section in `appsettings.json` is dead config under Serilog | | Host-021 | [Host](Host/findings.md) | Microsoft `Logging:LogLevel` section in `appsettings.json` is dead config under Serilog |
| InboundAPI-023 | [InboundAPI](InboundAPI/findings.md) | `EndpointExtensions.HandleInboundApiRequest` composition wiring has no test coverage | | InboundAPI-023 | [InboundAPI](InboundAPI/findings.md) | `EndpointExtensions.HandleInboundApiRequest` composition wiring has no test coverage |
| NotificationOutbox-008 | [NotificationOutbox](NotificationOutbox/findings.md) | `FallbackMaxRetries` / `FallbackRetryDelay` path is unreachable in production AND untested | | NotificationOutbox-008 | [NotificationOutbox](NotificationOutbox/findings.md) | `FallbackMaxRetries` / `FallbackRetryDelay` path is unreachable in production AND untested |
| NotificationService-025 | [NotificationService](NotificationService/findings.md) | `CredentialRedactor` over-masks: any 4-character credential component is masked anywhere it appears, including unrelated log text |
| Security-021 | [Security](Security/findings.md) | `RequireHttpsCookie=false` dev opt-out has no warning path — an HTTP production deployment silently transmits the JWT bearer credential in cleartext |
| SiteCallAudit-006 | [SiteCallAudit](SiteCallAudit/findings.md) | Stuck-only paging test does not exercise the multi-page boundary with an interleaved non-stuck row at the cursor | | SiteCallAudit-006 | [SiteCallAudit](SiteCallAudit/findings.md) | Stuck-only paging test does not exercise the multi-page boundary with an interleaved non-stuck row at the cursor |
| SiteEventLogging-018 | [SiteEventLogging](SiteEventLogging/findings.md) | `FailedWriteCount` is exposed but never consumed by Health Monitoring | | SiteEventLogging-018 | [SiteEventLogging](SiteEventLogging/findings.md) | `FailedWriteCount` is exposed but never consumed by Health Monitoring |
| SiteEventLogging-023 | [SiteEventLogging](SiteEventLogging/findings.md) | Concurrent-stress test uses a non-volatile `stop` flag | | SiteEventLogging-023 | [SiteEventLogging](SiteEventLogging/findings.md) | Concurrent-stress test uses a non-volatile `stop` flag |
+14 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 1 (Security-021); 1 deferred (Security-008) | | Open findings | 0 (1 deferred Security-008) |
## Summary ## Summary
@@ -971,9 +971,21 @@ every required `SecurityOptions` field is enforced at startup, not at first use.
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.Security/SecurityOptions.cs:100-108`; `src/ScadaLink.Security/ServiceCollectionExtensions.cs:54-59` | | Location | `src/ScadaLink.Security/SecurityOptions.cs:100-108`; `src/ScadaLink.Security/ServiceCollectionExtensions.cs:54-59` |
**Resolution (2026-05-28):** Added `ILoggerFactory` to the cookie-options
`Configure` callback in `AddSecurity` so an explicit `RequireHttpsCookie=false`
opt-out now emits a startup `LogWarning` ("auth cookie SecurePolicy is
SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext
over plain HTTP. This setting is intended for local dev only — set
SecurityOptions:RequireHttpsCookie=true in production."). Default is still
`true`, so production deployments unchanged. The "fail startup when
RequireHttpsCookie=false AND ASPNETCORE_ENVIRONMENT=Production" hard-stop
option was not implemented (the dev Docker cluster intentionally runs with
the flag false, and the env-var name varies across deploy mechanisms);
the warning is the right ergonomic floor.
**Description** **Description**
The Security-002 fix added `RequireHttpsCookie` (default `true`) so the auth cookie's The Security-002 fix added `RequireHttpsCookie` (default `true`) so the auth cookie's
@@ -164,8 +164,12 @@ public class AuditLogIngestActor : ReceiveActor
// is a silent no-op. // is a silent no-op.
// Filter BEFORE the IngestedAtUtc stamp so the redacted // Filter BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. Filter // copy carries the central-side ingest timestamp. Filter
// is contract-bound to never throw; null = pass-through. // is contract-bound to never throw. AuditLog-008: a null
var filtered = filter?.Apply(evt) ?? evt; // filter (test composition root, no IAuditPayloadFilter
// registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filtered = safeFilter.Apply(evt);
var ingested = filtered with { IngestedAtUtc = nowUtc }; var ingested = filtered with { IngestedAtUtc = nowUtc };
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId); accepted.Add(evt.EventId);
@@ -239,8 +243,10 @@ public class AuditLogIngestActor : ReceiveActor
// Filter the audit half BEFORE the dual-write — only the // Filter the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are filterable; SiteCalls // AuditLog row's payload columns are filterable; SiteCalls
// carries operational state only (status, retry count) and // carries operational state only (status, retry count) and
// is left untouched. // is left untouched. AuditLog-008: null filter falls back
var filteredAudit = filter?.Apply(entry.Audit) ?? entry.Audit; // to SafeDefault so header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filteredAudit = safeFilter.Apply(entry.Audit);
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt }; var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt }; var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
@@ -41,7 +41,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{ {
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly ILogger<CentralAuditWriter> _logger; private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter? _filter; private readonly IAuditPayloadFilter _filter;
private readonly ICentralAuditWriteFailureCounter _failureCounter; private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity; private readonly INodeIdentityProvider? _nodeIdentity;
@@ -80,7 +80,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{ {
_services = services ?? throw new ArgumentNullException(nameof(services)); _services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_filter = filter; // AuditLog-008: never default to null — over-redact instead.
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the
// real filter still scrubs Authorization / X-Api-Key / Cookie /
// Set-Cookie before persistence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter(); _failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity; _nodeIdentity = nodeIdentity;
} }
@@ -99,9 +104,11 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try try
{ {
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The // Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws"; the null-coalesce keeps the // filter contract is "never throws". AuditLog-008: _filter is now
// M4 test composition roots (no filter passed) working unchanged. // non-null (SafeDefaultAuditPayloadFilter fallback) so header
var filtered = _filter?.Apply(evt) ?? evt; // redaction always runs even in composition roots that omit the
// real filter.
var filtered = _filter.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins // SourceNode-stamping (Task 12): caller-provided value wins
// (supports any future direct-write callsite that already has its // (supports any future direct-write callsite that already has its
@@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.AuditLog.Payload;
/// <summary>
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
/// HTTP header redaction for the always-sensitive defaults
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
/// redaction, or truncation — those stages need
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
/// over-redact safely, never throw, never miss a header that's on the
/// default sensitive list.
/// </summary>
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
{
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
private static readonly string[] DefaultHeaderRedactList =
{
"Authorization",
"X-Api-Key",
"Cookie",
"Set-Cookie",
};
private static readonly Regex HeaderRegex = new(
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private SafeDefaultAuditPayloadFilter() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
ArgumentNullException.ThrowIfNull(rawEvent);
try
{
return rawEvent with
{
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
};
}
catch
{
// Over-redact: drop both summaries entirely so a malformed parse
// path never leaks the original. The contract is "never throw."
return rawEvent with
{
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
};
}
}
private static string? RedactHeaders(string? summary)
{
if (string.IsNullOrEmpty(summary)) return summary;
return HeaderRegex.Replace(summary, m =>
{
var name = m.Groups["name"].Value;
foreach (var sensitive in DefaultHeaderRedactList)
{
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
{
return $"{name}: [REDACTED]";
}
}
return m.Value;
});
}
}
@@ -31,7 +31,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly RingBufferFallback _ring; private readonly RingBufferFallback _ring;
private readonly IAuditWriteFailureCounter _failureCounter; private readonly IAuditWriteFailureCounter _failureCounter;
private readonly ILogger<FallbackAuditWriter> _logger; private readonly ILogger<FallbackAuditWriter> _logger;
private readonly IAuditPayloadFilter? _filter; private readonly IAuditPayloadFilter _filter;
private readonly SemaphoreSlim _drainGate = new(1, 1); private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary> /// <summary>
@@ -60,7 +60,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
_ring = ring ?? throw new ArgumentNullException(nameof(ring)); _ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter)); _failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_filter = filter; // null = no-op pass-through; see WriteAsync. // AuditLog-008: never default to a null filter — over-redact instead.
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
// redaction with the hard-coded sensitive defaults (Authorization,
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
// doesn't bind the real options never persists those headers
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -72,9 +79,10 @@ public sealed class FallbackAuditWriter : IAuditWriter
// and (on failure) to the ring buffer — so a primary outage that // and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has // drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The filter contract is // already been truncated and redacted. The filter contract is
// "MUST NOT throw"; the null-coalesce keeps test composition roots // "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
// that don't wire a filter working unchanged. // to SafeDefaultAuditPayloadFilter so header redaction is always
var filtered = _filter?.Apply(evt) ?? evt; // applied even in composition roots that don't wire the real filter).
var filtered = _filter.Apply(evt);
try try
{ {
@@ -198,8 +198,12 @@ VALUES
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this // ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
// unambiguously and the value is round-trip-safe across SET DATEFORMAT // unambiguously and the value is round-trip-safe across SET DATEFORMAT
// settings. // settings. CD-021: use datetime2(7) precision (.fffffff) so a future
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss"); // non-midnight or sub-second boundary doesn't silently round to the
// wrong partition (today the migration only seeds at T00:00:00 exactly,
// but the format string is on the boundary value's own contract — match
// it to the column precision rather than to the current seed pattern).
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
// Two-statement batch: the first SELECT samples the per-partition row // Two-statement batch: the first SELECT samples the per-partition row
// count BEFORE the dance so we can report it back to the purge actor; // count BEFORE the dance so we can report it back to the purge actor;
@@ -10,16 +10,38 @@ namespace ScadaLink.ConfigurationDatabase.Repositories;
public class InboundApiRepository : IInboundApiRepository public class InboundApiRepository : IInboundApiRepository
{ {
private readonly ScadaLinkDbContext _context; private readonly ScadaLinkDbContext _context;
// CD-016: lazily resolved so the InboundAPI ApiKeyHasher factory (which throws
// when no pepper is configured) is only invoked if GetApiKeyByValueAsync is
// actually called — Central/Host startup composition roots that never call
// this method (the production ApiKeyValidator deliberately doesn't) get to
// bring InboundApiRepository up without forcing every test to wire a
// throw-away pepper into InboundApiOptions.
private readonly Func<IApiKeyHasher> _hasherAccessor;
private readonly ILogger<InboundApiRepository> _logger; private readonly ILogger<InboundApiRepository> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the InboundApiRepository class. /// Initializes a new instance of the InboundApiRepository class.
/// </summary> /// </summary>
/// <param name="context">The database context for accessing inbound API data.</param> /// <param name="context">The database context for accessing inbound API data.</param>
/// <param name="hasherAccessor">
/// CD-016: factory that returns the API-key hasher used to digest a candidate
/// plaintext for the peppered <see cref="GetApiKeyByValueAsync"/> lookup.
/// Resolution is deferred to first call so a composition root that doesn't
/// register <see cref="IApiKeyHasher"/> (or whose factory would throw because
/// no pepper is configured) can still bring up the repository for callers that
/// don't touch the value-lookup path. Defaults to a factory returning
/// <see cref="ApiKeyHasher.Default"/>; production wires
/// <c>sp =&gt; sp.GetRequiredService&lt;IApiKeyHasher&gt;()</c> via DI so the
/// lookup uses the same peppered digest as the production write path.
/// </param>
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param> /// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
public InboundApiRepository(ScadaLinkDbContext context, ILogger<InboundApiRepository>? logger = null) public InboundApiRepository(
ScadaLinkDbContext context,
Func<IApiKeyHasher>? hasherAccessor = null,
ILogger<InboundApiRepository>? logger = null)
{ {
_context = context ?? throw new ArgumentNullException(nameof(context)); _context = context ?? throw new ArgumentNullException(nameof(context));
_hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default);
_logger = logger ?? NullLogger<InboundApiRepository>.Instance; _logger = logger ?? NullLogger<InboundApiRepository>.Instance;
} }
@@ -34,7 +56,13 @@ public class InboundApiRepository : IInboundApiRepository
/// <inheritdoc /> /// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default) public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
{ {
var keyHash = ApiKeyHasher.Default.Hash(keyValue); // CD-016: hash the candidate with the DI-provided peppered hasher so this
// lookup matches keys whose stored KeyHash was produced by the production
// ApiKeyHasher(pepper). The pre-fix call to ApiKeyHasher.Default would
// silently return null for every real key on any peppered deployment.
// Resolution is deferred until this method is actually called so the
// pepper-validating factory doesn't fire during startup composition.
var keyHash = _hasherAccessor().Hash(keyValue);
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken); return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
} }
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces; using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Services;
@@ -53,7 +54,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>(); services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>(); services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>(); services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>(); // CD-016: factory registration wires a lazy accessor for IApiKeyHasher so
// the production peppered hasher is used (via DI) when GetApiKeyByValueAsync
// is actually called, but composition roots that never call it (and may
// not register IApiKeyHasher at all) still bring up the repository.
services.AddScoped<IInboundApiRepository>(sp => new InboundApiRepository(
sp.GetRequiredService<ScadaLinkDbContext>(),
hasherAccessor: () => sp.GetService<Commons.Types.InboundApi.IApiKeyHasher>()
?? Commons.Types.InboundApi.ApiKeyHasher.Default,
logger: sp.GetService<ILogger<InboundApiRepository>>()));
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>(); services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>(); services.AddScoped<IInstanceLocator, InstanceLocator>();
@@ -464,12 +464,29 @@ public class ExternalSystemClient : IExternalSystemClient
return url; return url;
} }
private static void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system) private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
{ {
if (string.IsNullOrEmpty(system.AuthConfiguration)) // ESG-021: distinguish "intentionally unauthenticated" (AuthType = none)
return; // from "AuthConfiguration is missing or empty for a type that requires it"
// (deployment glitch, decryption failure, operator typo). The unauthenticated
// case is silent; the requires-creds-but-empty case logs a Warning so an
// operator debugging a recurring 401 sees the cause inside ScadaLink instead
// of having to read the remote system's logs. The value of AuthConfiguration
// is NEVER logged.
var authType = system.AuthType?.Trim().ToLowerInvariant() ?? string.Empty;
switch (system.AuthType.ToLowerInvariant()) if (string.IsNullOrEmpty(system.AuthConfiguration))
{
if (authType is "apikey" or "basic")
{
_logger.LogWarning(
"ApplyAuth: External system '{System}' has AuthType '{AuthType}' but AuthConfiguration is empty; request will be sent without an auth header.",
system.Name, system.AuthType);
}
return;
}
switch (authType)
{ {
case "apikey": case "apikey":
// Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key) // Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
@@ -493,6 +510,26 @@ public class ExternalSystemClient : IExternalSystemClient
Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}")); Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
} }
else
{
// ESG-021: malformed Basic config (no ':' separator) means the
// request goes out with no Authorization header. Warn so the
// failure mode is visible inside ScadaLink.
_logger.LogWarning(
"ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed (expected 'username:password'); request will be sent without an Authorization header.",
system.Name);
}
break;
case "none":
// Documented sentinel for unauthenticated systems — silent by design.
break;
default:
// ESG-021: unknown AuthType silently fell through here before. Warn.
_logger.LogWarning(
"ApplyAuth: External system '{System}' has unknown AuthType '{AuthType}'; request will be sent without an auth header. Allowed values: apikey, basic, none.",
system.Name, system.AuthType);
break; break;
} }
} }
+16 -1
View File
@@ -195,8 +195,23 @@ try
// is responsible for stashing the resolved API key name on // is responsible for stashing the resolved API key name on
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its // HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
// in-handler API key validation succeeds. // in-handler API key validation succeeds.
// InboundAPI-025: scope the audit middleware to the inbound API method
// route (/api/{methodName}) and explicitly exclude the management/audit
// sub-trees that share the /api prefix. Without these exclusions the
// middleware would emit a spurious ApiInbound audit row for every
// /api/audit/query and /api/audit/export call (and would treat audit-log
// reads as inbound script invocations — recursive write-on-read). The
// POST-only filter rules out the GET routes on /api/audit, /api/centralui,
// /api/script-analysis even if a future route is added under those
// prefixes with the same verb; the explicit prefix excludes still belt-
// and-brace POST-y additions there.
app.UseWhen( app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/api"), ctx => ctx.Request.Path.StartsWithSegments("/api")
&& !ctx.Request.Path.StartsWithSegments("/api/audit")
&& !ctx.Request.Path.StartsWithSegments("/api/centralui")
&& !ctx.Request.Path.StartsWithSegments("/api/script-analysis")
&& !ctx.Request.Path.StartsWithSegments("/api/management")
&& HttpMethods.IsPost(ctx.Request.Method),
branch => branch.UseAuditWriteMiddleware()); branch => branch.UseAuditWriteMiddleware());
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready. // WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
@@ -1135,10 +1135,36 @@ public class ManagementActor : ReceiveActor
return true; return true;
} }
/// <summary>
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
/// boundary via response payloads or audit afterState. Mirrors the
/// ApiKey-projection pattern in HandleListApiKeys / CD-012's fix.
/// </summary>
private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
new
{
c.Id,
c.Host,
c.Port,
c.AuthType,
c.FromAddress,
c.TlsMode,
c.ConnectionTimeoutSeconds,
c.MaxConcurrentConnections,
c.MaxRetries,
c.RetryDelay,
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
};
private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp) private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp)
{ {
var repo = sp.GetRequiredService<INotificationRepository>(); var repo = sp.GetRequiredService<INotificationRepository>();
return await repo.GetAllSmtpConfigurationsAsync(); var configs = await repo.GetAllSmtpConfigurationsAsync();
// MgmtSvc-020: project away the Credentials field — read access to this
// list is broader than the Admin-only UpdateSmtpConfig path that owns
// the secret.
return configs.Select(SmtpConfigPublicShape).ToList();
} }
private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user) private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
@@ -1156,8 +1182,12 @@ public class ManagementActor : ReceiveActor
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials; if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
await repo.UpdateSmtpConfigurationAsync(config); await repo.UpdateSmtpConfigurationAsync(config);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config); // MgmtSvc-020: audit the credential-free shape — the *fact of* the change
return config; // (and which non-secret fields hold) is observable; the secret value is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmtpConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
return publicShape;
} }
// ======================================================================== // ========================================================================
@@ -24,6 +24,15 @@ public static class CredentialRedactor
/// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2 /// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2
/// <c>tenantId:clientId:clientSecret</c>. May be null. /// <c>tenantId:clientId:clientSecret</c>. May be null.
/// </param> /// </param>
/// <summary>
/// NS-025: minimum length for a colon-separated SECRET component to be
/// considered worth masking. Twelve characters is the standard heuristic
/// for "long enough to be a password / client secret"; shorter components
/// (e.g. a 4-char user name like <c>root</c>, or a 7-char "from" alias)
/// would mask too much unrelated diagnostic text if treated as secrets.
/// </summary>
private const int MinSecretLength = 12;
public static string Scrub(string? text, string? credentials) public static string Scrub(string? text, string? credentials)
{ {
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials)) if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
@@ -33,16 +42,36 @@ public static class CredentialRedactor
var result = text; var result = text;
// Mask each individual colon-delimited component (covers user, password, // NS-025: redact only the obviously-secret slots — the LAST
// tenant, clientId, clientSecret) and the whole packed string. Order longest // colon-separated component (the password in Basic, the client
// first so a component that is a substring of another is still fully masked. // secret in OAuth2) and the whole packed string — not the user
var parts = credentials.Split(':') // name / tenant id / client id. A short user name like "root" or
.Where(p => p.Length >= 4) // a sender alias like "smtp" no longer becomes a global redaction
.Append(credentials) // token that eats unrelated path / error text.
.Distinct() var secretsToRedact = new List<string>();
.OrderByDescending(p => p.Length);
foreach (var part in parts) // The full packed credential is always the most-sensitive shape.
secretsToRedact.Add(credentials);
// The trailing colon-component is the password / clientSecret slot.
// Only redact it if it's plausibly secret-shaped (>= MinSecretLength).
var parts = credentials.Split(':');
if (parts.Length >= 2)
{
var lastComponent = parts[^1];
if (lastComponent.Length >= MinSecretLength)
{
secretsToRedact.Add(lastComponent);
}
}
// Order longest first so a secret that is a substring of the packed
// string is still fully masked.
var ordered = secretsToRedact
.Distinct()
.OrderByDescending(s => s.Length);
foreach (var part in ordered)
{ {
result = result.Replace(part, Mask, StringComparison.Ordinal); result = result.Replace(part, Mask, StringComparison.Ordinal);
} }
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace ScadaLink.Security; namespace ScadaLink.Security;
@@ -56,7 +57,7 @@ public static class ServiceCollectionExtensions
// session window. Bound here via PostConfigure so SecurityOptions // session window. Bound here via PostConfigure so SecurityOptions
// (configured by the Host after AddSecurity) is honoured. // (configured by the Host after AddSecurity) is honoured.
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme) services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<SecurityOptions>>((cookieOptions, securityOptions) => .Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
{ {
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes; var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes); cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
@@ -69,6 +70,18 @@ public static class ServiceCollectionExtensions
cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie
? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always ? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always
: Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest; : Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
// Security-021: when the operator opts out of HTTPS-only cookies,
// log a Warning so an HTTP-only deployment is at least audible in
// the startup log. The cookie carries the embedded JWT bearer
// credential — over plain HTTP that travels in cleartext on every
// request. The default is true; this branch fires only on an
// explicit opt-out (typically the dev Docker cluster).
if (!securityOptions.Value.RequireHttpsCookie)
{
loggerFactory.CreateLogger("ScadaLink.Security").LogWarning(
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
}
}); });
services.AddScadaLinkAuthorization(); services.AddScadaLinkAuthorization();
@@ -15,7 +15,7 @@ public class InboundApiRepositoryTests : IDisposable
public InboundApiRepositoryTests() public InboundApiRepositoryTests()
{ {
_context = SqliteTestHelper.CreateInMemoryContext(); _context = SqliteTestHelper.CreateInMemoryContext();
_repository = new InboundApiRepository(_context, _logger); _repository = new InboundApiRepository(_context, hasherAccessor: null, logger: _logger);
} }
public void Dispose() public void Dispose()
@@ -40,6 +40,37 @@ public class InboundApiRepositoryTests : IDisposable
Assert.Equal(key.Id, byValue!.Id); Assert.Equal(key.Id, byValue!.Id);
} }
[Fact]
public async Task CD016_GetApiKeyByValue_UsesInjectedPepperedHasher_NotDefault()
{
// CD-016 regression: stored KeyHash is produced by a peppered hasher.
// A repository whose lookup uses ApiKeyHasher.Default (the pre-fix
// behaviour) would compute a different digest and return null. With the
// pepper-aware hasherAccessor wired in, the lookup must round-trip.
var peppered = new Commons.Types.InboundApi.ApiKeyHasher("a-strong-test-pepper-of-sufficient-length");
var pepperedHash = peppered.Hash("secret-with-pepper");
var key = ApiKey.FromHash("Peppered", pepperedHash);
key.IsEnabled = true;
using var ctx = SqliteTestHelper.CreateInMemoryContext();
var repo = new InboundApiRepository(ctx, hasherAccessor: () => peppered, logger: _logger);
await repo.AddApiKeyAsync(key);
await repo.SaveChangesAsync();
var byValue = await repo.GetApiKeyByValueAsync("secret-with-pepper");
Assert.NotNull(byValue);
Assert.Equal(key.Id, byValue!.Id);
// And: a repository wired with the Default (unpeppered) hasher MUST
// NOT find the same key — proving the lookup actually uses the
// injected hasher and the original bug shape.
var defaultRepo = new InboundApiRepository(ctx,
hasherAccessor: () => Commons.Types.InboundApi.ApiKeyHasher.Default,
logger: _logger);
var missByDefault = await defaultRepo.GetApiKeyByValueAsync("secret-with-pepper");
Assert.Null(missByDefault);
}
[Fact] [Fact]
public async Task AddApiMethod_AndGetByName_RoundTrips() public async Task AddApiMethod_AndGetByName_RoundTrips()
{ {
@@ -8,11 +8,13 @@ public class CredentialRedactorTests
[Fact] [Fact]
public void Scrub_BasicAuthPassword_IsMasked() public void Scrub_BasicAuthPassword_IsMasked()
{ {
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pw!'"; // Password 'Hunter2pass!word' is 16 chars (>= MinSecretLength=12) and
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pw!"); // therefore qualifies as a redactable secret-shaped trailing component.
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pass!word'";
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pass!word");
Assert.DoesNotContain("Hunter2pw!", result); Assert.DoesNotContain("Hunter2pass!word", result);
Assert.DoesNotContain("svc:Hunter2pw!", result); Assert.DoesNotContain("svc:Hunter2pass!word", result);
} }
[Fact] [Fact]
@@ -35,4 +37,40 @@ public class CredentialRedactorTests
{ {
Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass")); Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass"));
} }
// --- NS-025: don't over-mask short non-secret components ---
[Fact]
public void Scrub_ShortUserName_IsNotMaskedOutsidePackedString()
{
// 'root' is the Basic Auth user name — short, common, and absolutely
// not a secret. It must NOT be masked when it appears in unrelated
// diagnostic text like a file path.
var text = "Config file at /root/.config/scada.conf was not found.";
var result = CredentialRedactor.Scrub(text, "root:hunter2longenoughpwd");
Assert.Contains("/root/.config", result);
}
[Fact]
public void Scrub_TenantId_IsNotMaskedOutsidePackedString()
{
// The tenant id is not secret — only the client secret is. A tenant id
// appearing in unrelated text (e.g. an error-code suffix) must survive.
var text = "Error code tnt-1234567890-abcd reported by upstream";
var result = CredentialRedactor.Scrub(text, "tnt-1234567890-abcd:cli-guid:RealClientSecretLongEnough");
Assert.Contains("tnt-1234567890-abcd", result);
}
[Fact]
public void Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength()
{
// Even a short packed string must be masked when it appears verbatim —
// that exact appearance can only come from the credential itself.
var text = "Auth bundle was rejected: u:p";
var result = CredentialRedactor.Scrub(text, "u:p");
Assert.DoesNotContain("u:p", result);
}
} }