fix(secrets): audit malformed ${secret:} tokens, cap SecretName at 450, resolve local UNC shares (scadaproj#52)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:38:36 -05:00
parent 4a3c4e9b7e
commit ebc493361e
8 changed files with 236 additions and 6 deletions
+18
View File
@@ -10,6 +10,24 @@ one of them.
Fixes the scadaproj#15 code-review findings (`code-reviews/Secrets/findings.md`): six P2s (scadaproj#30–#35),
the P3 batch (scadaproj#45: Secrets-004–009, 011, 012, 014–016, 018, 019) and the scadaproj#49 follow-ups.
### scadaproj#52 follow-ups
- **The CLI reference audit reports malformed `${secret:}` tokens.** New `ReferenceStatus.Malformed`:
an empty/blank name, an unterminated `${secret:name` or a nested marker — exactly the shapes
`SecretReferenceExpander` now refuses at startup — yields one finding (`${secret:...} (malformed)`)
listing every config path, and the audit flow tells the operator to fix it. Not seedable.
- **`SecretName` is capped at 450 characters** (`SecretName.MaxLength`, measured after trim/lowercase),
matching the hub's `NVARCHAR(450)` name column. Enforced in the constructor, so every store/API
entry point rejects an over-long name up front instead of the hub refusing the row later.
Behaviour change: an already-stored name longer than 450 can no longer be constructed.
- **Secrets-005 follow-up: an ordinary local SMB share.** On Windows, `\\localhost\share\...` /
`\\<this machine>\share\...` for a non-administrative share is mapped to the directory the share
publishes (`NetShareGetInfo` level 2), so a store reached through a share that points back at the
content root is refused. Limits (documented in `SecretsStorePathRules`): the API needs
Administrators / Server Operators rights (denied => compared as written); a share on another machine
is never resolved; on non-Windows an NFS/SMB mount cannot be resolved to its export, so that case is
not caught. Test is Windows-only and skips when it cannot create a share.
### Fixed
- **SQL-Server and Akka registration no longer depends on call order** (scadaproj#30, Secrets-001).
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.Secrets.Abstractions;
/// </summary>
public readonly record struct SecretName
{
/// <summary>The longest permitted normalized name; matches the hub's <c>NVARCHAR(450)</c> name column.</summary>
public const int MaxLength = 450;
private const string AllowedChars = "abcdefghijklmnopqrstuvwxyz0123456789._/-";
/// <summary>
@@ -17,7 +20,7 @@ public readonly record struct SecretName
/// <exception cref="ArgumentException">
/// The value is null, empty, or whitespace; contains the path-traversal sequence
/// <c>..</c>; is rooted (starts or ends with <c>/</c>) or contains an empty path
/// segment (<c>//</c>); or contains any character outside <c>[a-z0-9._/-]</c> (after
/// segment (<c>//</c>); or is longer than <see cref="MaxLength"/> (450) characters; or contains any character outside <c>[a-z0-9._/-]</c> (after
/// trim and lowercasing).
/// </exception>
public SecretName(string value)
@@ -31,6 +34,13 @@ public readonly record struct SecretName
nameof(value));
}
if (normalized.Length > MaxLength)
{
throw new ArgumentException(
$"Secret name must be at most {MaxLength} characters (was {normalized.Length}).",
nameof(value));
}
if (normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException(
@@ -57,6 +57,13 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow
continue;
}
if (finding.Status == ReferenceStatus.Malformed)
{
console.MarkupLineInterpolated(
$"[red]Malformed ${{secret:}} token[/] at {string.Join(", ", finding.ConfigPaths)} — the app refuses to start with it; give it a name and a closing '}}'.");
continue;
}
if (!IsSeedable(finding.Status))
{
continue; // Ok (nothing to do) or PresentUnverified (unreachable on a KEK-capable session).
@@ -132,7 +139,7 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow
{
ReferenceStatus.Ok => "green",
ReferenceStatus.PresentUnverified => "yellow",
_ => "red", // Missing / Tombstoned / Undecryptable / InvalidName
_ => "red", // Missing / Tombstoned / Undecryptable / InvalidName / Malformed
};
// A deterministic "N Status" count-by-status roll-up, ordered by the status enum.
@@ -30,6 +30,13 @@ public enum ReferenceStatus
/// unfilled placeholder like <c>REPLACE ME</c>, and so on), so it cannot be looked up at all.
/// </summary>
InvalidName,
/// <summary>
/// A <c>${secret:</c> marker that does not start a complete, named reference (<c>${secret:}</c>,
/// <c>${secret: }</c>, an unterminated <c>${secret:name</c>, or a marker swallowed inside another
/// match). <c>SecretReferenceExpander</c> refuses to start with such a value, so the audit surfaces it.
/// </summary>
Malformed,
}
/// <summary>
@@ -49,6 +56,12 @@ public sealed class ReferenceAuditor
// the references the expander would resolve at startup. Do NOT diverge from the expander's regex.
private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);
// Pinned to SecretReferenceExpander.TokenMarker.
private const string TokenMarker = "${secret:";
/// <summary>The <see cref="ReferenceFinding.SecretName"/> used for a malformed-token finding.</summary>
public const string MalformedFindingName = "${secret:...} (malformed)";
/// <summary>
/// Walks every configuration value, collects each distinct <c>${secret:NAME}</c> reference with the
/// config key paths that use it, and classifies each name against the session's store. References are
@@ -79,8 +92,29 @@ public sealed class ReferenceAuditor
continue;
}
foreach (Match match in TokenPattern.Matches(value))
MatchCollection matches = TokenPattern.Matches(value);
// Mirror the expander's fail-closed shape check: every marker must start one complete,
// non-blank reference, otherwise the app refuses to start.
if (CountMarkers(value) != matches.Count
|| matches.Any(m => string.IsNullOrWhiteSpace(m.Groups[1].Value)))
{
if (!groups.TryGetValue(MalformedFindingName, out ReferenceGroup? bad))
{
bad = new ReferenceGroup(null) { Malformed = true };
groups[MalformedFindingName] = bad;
}
bad.Paths.Add(entry.Key);
}
foreach (Match match in matches)
{
if (string.IsNullOrWhiteSpace(match.Groups[1].Value))
{
continue;
}
string raw = match.Groups[1].Value.Trim();
ReferenceGroup group = ResolveGroup(groups, raw);
group.Paths.Add(entry.Key);
@@ -90,7 +124,9 @@ public sealed class ReferenceAuditor
var findings = new List<ReferenceFinding>(groups.Count);
foreach ((string key, ReferenceGroup group) in groups)
{
ReferenceStatus status = group.Name is { } name
ReferenceStatus status = group.Malformed
? ReferenceStatus.Malformed
: group.Name is { } name
? await ClassifyAsync(session, name, ct).ConfigureAwait(false)
: ReferenceStatus.InvalidName;
findings.Add(new ReferenceFinding(key, status, [.. group.Paths]));
@@ -131,6 +167,19 @@ public sealed class ReferenceAuditor
// A configuration key is a comment/documentation key when its leaf segment (after the last ':') starts
// with '_'. Duplicated from SecretReferenceExpander.IsCommentKey — keep the two in lockstep.
private static int CountMarkers(string value)
{
int count = 0;
for (int at = value.IndexOf(TokenMarker, StringComparison.Ordinal);
at >= 0;
at = value.IndexOf(TokenMarker, at + TokenMarker.Length, StringComparison.Ordinal))
{
count++;
}
return count;
}
private static bool IsCommentKey(string key)
{
int lastSeparator = key.LastIndexOf(':');
@@ -177,5 +226,7 @@ public sealed class ReferenceAuditor
public SortedSet<string> Paths { get; } = new(StringComparer.Ordinal);
public SecretName? Name { get; } = name;
public bool Malformed { get; init; }
}
}
@@ -46,8 +46,13 @@ namespace ZB.MOM.WW.Secrets.Configuration;
/// leads elsewhere — the next deploy's tree has no such link), and on Windows the <c>\\?\</c> / <c>\\.\</c> device
/// prefixes are removed, a loopback administrative share (<c>\\localhost\C$\…</c>,
/// <c>\\127.0.0.1\C$\…</c>, <c>\\&lt;this machine&gt;\C$\…</c>) is mapped back to its drive, and 8.3
/// short names (<c>PROGRA~1</c>) are expanded. A UNC path through any other share is compared as
/// written — what a share points at is server configuration this rule cannot see.
/// short names (<c>PROGRA~1</c>) are expanded. An ordinary (non-administrative) share on this
/// machine is mapped to the directory it publishes via <c>NetShareGetInfo</c> (Windows only; that API
/// needs Administrators / Server Operators rights, and when it is denied the path is compared as
/// written). A UNC path to <em>another</em> machine's share is always compared as written — what a
/// remote share points at is server configuration this rule cannot see. On non-Windows there are no
/// UNC shares and an NFS/SMB mount point cannot be resolved to its export, so a store on a mount whose
/// export is the content root is not caught (documented limit).
/// </para>
/// </remarks>
public static class SecretsStorePathRules
@@ -241,11 +246,68 @@ public static class SecretsStorePathRules
{
p = segments[1][0] + @":\" + string.Join('\\', segments[2..]);
}
else if (segments.Length >= 2 && IsLoopbackHost(segments[0]) && TryGetLocalSharePath(segments[1]) is { } sharePath)
{
// An ordinary (non-administrative) share on THIS machine: map it to the directory it
// publishes so a store reached through a share that points back at the content root
// is compared by location.
p = sharePath.TrimEnd('\\') + (segments.Length > 2 ? @"\" + string.Join('\\', segments[2..]) : string.Empty);
}
}
return p;
}
/// <summary>
/// Returns the local directory published by share <paramref name="shareName"/> on this machine, or
/// <see langword="null"/> when it cannot be determined (no such share, or the caller lacks the
/// rights <c>NetShareGetInfo</c> level 2 demands — Administrators / Server Operators). A share on
/// ANOTHER machine is never resolved: what it points at is that server's configuration.
/// </summary>
private static string? TryGetLocalSharePath(string shareName)
{
try
{
if (NetShareGetInfo(null, shareName, 2, out IntPtr buffer) != 0)
{
return null;
}
try
{
SHARE_INFO_2 info = Marshal.PtrToStructure<SHARE_INFO_2>(buffer);
return string.IsNullOrWhiteSpace(info.shi2_path) ? null : info.shi2_path;
}
finally
{
_ = NetApiBufferFree(buffer);
}
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or SEHException)
{
return null;
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHARE_INFO_2
{
public string shi2_netname;
public uint shi2_type;
public string shi2_remark;
public uint shi2_permissions;
public uint shi2_max_uses;
public uint shi2_current_uses;
public string shi2_path;
public string shi2_passwd;
}
[DllImport("netapi32.dll", CharSet = CharSet.Unicode)]
private static extern uint NetShareGetInfo(string? serverName, string netName, uint level, out IntPtr bufptr);
[DllImport("netapi32.dll")]
private static extern uint NetApiBufferFree(IntPtr buffer);
private static bool IsLoopbackHost(string host) =>
host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.Equals("127.0.0.1", StringComparison.Ordinal)
@@ -173,6 +173,34 @@ public sealed class ReferenceAuditorTests : IDisposable
Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "api/key").Status);
}
[Theory]
[InlineData("${secret:}")]
[InlineData("${secret: }")]
[InlineData("prefix ${secret:unterminated")]
[InlineData("${secret:a${secret:b}")]
public async Task Malformed_token_yields_Malformed_finding_matching_the_expander(string value)
{
SecretsSession session = await Open(EnvKey(), Config(("Bad:Ref", value), ("Good:Ref", "${secret:api/key}")));
IReadOnlyList<ReferenceFinding> findings =
await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
ReferenceFinding bad = findings.Single(f => f.Status == ReferenceStatus.Malformed);
Assert.Equal(["Bad:Ref"], bad.ConfigPaths);
Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "api/key").Status);
}
[Fact]
public async Task Well_formed_tokens_produce_no_Malformed_finding()
{
SecretsSession session = await Open(EnvKey(), Config(("A", "x ${secret:a/b} y ${secret:c}")));
IReadOnlyList<ReferenceFinding> findings =
await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
Assert.DoesNotContain(findings, f => f.Status == ReferenceStatus.Malformed);
}
[Fact]
public async Task Comment_keys_are_skipped_like_the_expander()
{
@@ -139,6 +139,50 @@ public sealed class SecretsStorePathSpellingTests : IDisposable
Assert.True(Inside(Path.Combine(longDir, "secrets.db"), shortDir!));
}
[SkippableFact]
public void An_ordinary_local_share_pointing_at_the_content_root_is_inside_on_windows()
{
Skip.IfNot(OperatingSystem.IsWindows(), "Windows path semantics");
string root = Path.Combine(_base, "app");
string share = "zbtest" + Guid.NewGuid().ToString("N")[..8];
Skip.IfNot(CreateShare(share, root), "cannot create an SMB share here (needs Administrators)");
try
{
Assert.True(Inside($@"\\localhost\{share}\data\secrets.db", root));
Assert.True(Inside($@"\\{Environment.MachineName}\{share}\secrets.db", root));
Assert.False(Inside($@"\\localhost\{share}\secrets.db", Path.Combine(_base, "outside")));
}
finally
{
_ = NetShareDel(null, share, 0);
}
}
private static bool CreateShare(string name, string path)
{
var info = new ShareInfo2 { shi2_netname = name, shi2_type = 0, shi2_max_uses = uint.MaxValue, shi2_path = path };
return NetShareAdd(null, 2, ref info, out _) == 0;
}
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private struct ShareInfo2
{
public string shi2_netname;
public uint shi2_type;
public string? shi2_remark;
public uint shi2_permissions;
public uint shi2_max_uses;
public uint shi2_current_uses;
public string shi2_path;
public string? shi2_passwd;
}
[System.Runtime.InteropServices.DllImport("netapi32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern uint NetShareAdd(string? server, uint level, ref ShareInfo2 buf, out uint parmErr);
[System.Runtime.InteropServices.DllImport("netapi32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern uint NetShareDel(string? server, string netName, uint reserved);
private static string? ShortName(string path)
{
char[] buffer = new char[1024];
@@ -27,6 +27,16 @@ public class SecretNameTests
public void RejectsRootedOrEmptySegment(string input)
=> Assert.Throws<ArgumentException>(() => new SecretName(input));
[Fact]
public void AcceptsExactlyMaxLength_AndRejectsOneOver()
{
Assert.Equal(450, SecretName.MaxLength);
Assert.Equal(450, new SecretName(new string('a', 450)).Value.Length);
Assert.Throws<ArgumentException>(() => new SecretName(new string('a', 451)));
// Length is measured after trim/lowercase normalization.
Assert.Equal(450, new SecretName(" " + new string('A', 450) + " ").Value.Length);
}
[Fact]
public void DefaultInstance_ThrowsOnValue()
=> Assert.Throws<InvalidOperationException>(() => _ = default(SecretName).Value);