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:
@@ -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>\\<this machine>\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)
|
||||
|
||||
Reference in New Issue
Block a user