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
@@ -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)