using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Text; using System.Text.Json; namespace StellaOps.Scanner.Surface.Secrets; public sealed record RegistryAccessSecret( IReadOnlyList Entries, string? DefaultRegistry); public sealed record RegistryCredential( string Registry, string? Username, string? Password, string? IdentityToken, string? RegistryToken, string? RefreshToken, DateTimeOffset? ExpiresAt, IReadOnlyCollection Scopes, bool? AllowInsecureTls, IReadOnlyDictionary Headers, string? Email); public static partial class SurfaceSecretParser { public static RegistryAccessSecret ParseRegistryAccessSecret(SurfaceSecretHandle handle) { ArgumentNullException.ThrowIfNull(handle); var entries = new List(); string? defaultRegistry = null; var payload = handle.AsBytes(); if (!payload.IsEmpty) { var jsonText = DecodeUtf8(payload); using var document = JsonDocument.Parse(jsonText); var root = document.RootElement; defaultRegistry = GetString(root, "defaultRegistry") ?? GetMetadataValue(handle.Metadata, "defaultRegistry"); if (TryParseRegistryEntries(root, handle.Metadata, entries) || TryParseAuthsObject(root, handle.Metadata, entries)) { // entries already populated } else if (root.ValueKind == JsonValueKind.Object && root.GetRawText().Length > 2) // not empty object { entries.Add(ParseRegistryEntry(root, handle.Metadata, fallbackRegistry: null)); } } if (entries.Count == 0 && TryCreateRegistryEntryFromMetadata(handle.Metadata, out var metadataEntry)) { entries.Add(metadataEntry); } if (entries.Count == 0) { throw new InvalidOperationException("Registry secret payload does not contain credentials."); } defaultRegistry ??= GetMetadataValue(handle.Metadata, "defaultRegistry") ?? entries[0].Registry; return new RegistryAccessSecret( new ReadOnlyCollection(entries), string.IsNullOrWhiteSpace(defaultRegistry) ? entries[0].Registry : defaultRegistry.Trim()); } private static bool TryParseRegistryEntries( JsonElement root, IReadOnlyDictionary metadata, ICollection entries) { if (!TryGetPropertyIgnoreCase(root, "entries", out var entriesElement) || entriesElement.ValueKind != JsonValueKind.Array) { return false; } foreach (var entryElement in entriesElement.EnumerateArray()) { if (entryElement.ValueKind != JsonValueKind.Object) { continue; } entries.Add(ParseRegistryEntry(entryElement, metadata, fallbackRegistry: null)); } return entries.Count > 0; } private static bool TryParseAuthsObject( JsonElement root, IReadOnlyDictionary metadata, ICollection entries) { if (!TryGetPropertyIgnoreCase(root, "auths", out var authsElement) || authsElement.ValueKind != JsonValueKind.Object) { return false; } foreach (var property in authsElement.EnumerateObject()) { if (property.Value.ValueKind != JsonValueKind.Object) { continue; } entries.Add(ParseRegistryEntry(property.Value, metadata, property.Name)); } return entries.Count > 0; } private static RegistryCredential ParseRegistryEntry( JsonElement element, IReadOnlyDictionary metadata, string? fallbackRegistry) { var registry = GetString(element, "registry") ?? GetString(element, "server") ?? fallbackRegistry ?? GetMetadataValue(metadata, "registry") ?? throw new InvalidOperationException("Registry credential is missing a registry identifier."); registry = registry.Trim(); var username = GetString(element, "username") ?? GetString(element, "user"); var password = GetString(element, "password") ?? GetString(element, "pass"); var token = GetString(element, "token") ?? GetString(element, "registryToken"); var identityToken = GetString(element, "identityToken") ?? GetString(element, "identitytoken"); var refreshToken = GetString(element, "refreshToken"); var email = GetString(element, "email"); var allowInsecure = GetBoolean(element, "allowInsecureTls"); var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); PopulateHeaders(element, headers); PopulateMetadataHeaders(metadata, headers); var scopes = new List(); PopulateScopes(element, scopes); PopulateMetadataScopes(metadata, scopes); var expiresAt = ParseDateTime(element, "expiresAt"); var auth = GetString(element, "auth"); if (!string.IsNullOrWhiteSpace(auth)) { TryApplyBasicAuth(auth, ref username, ref password); } username ??= GetMetadataValue(metadata, "username"); password ??= GetMetadataValue(metadata, "password"); token ??= GetMetadataValue(metadata, "token") ?? GetMetadataValue(metadata, "registryToken"); identityToken ??= GetMetadataValue(metadata, "identityToken"); refreshToken ??= GetMetadataValue(metadata, "refreshToken"); email ??= GetMetadataValue(metadata, "email"); return new RegistryCredential( registry, username?.Trim(), password, identityToken, token, refreshToken, expiresAt, scopes.Count == 0 ? Array.Empty() : new ReadOnlyCollection(scopes), allowInsecure, new ReadOnlyDictionary(headers), email); } private static bool TryCreateRegistryEntryFromMetadata( IReadOnlyDictionary metadata, out RegistryCredential entry) { var registry = GetMetadataValue(metadata, "registry"); var username = GetMetadataValue(metadata, "username"); var password = GetMetadataValue(metadata, "password"); var identityToken = GetMetadataValue(metadata, "identityToken"); var token = GetMetadataValue(metadata, "token") ?? GetMetadataValue(metadata, "registryToken"); if (string.IsNullOrWhiteSpace(registry) && string.IsNullOrWhiteSpace(username) && string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(identityToken) && string.IsNullOrWhiteSpace(token)) { entry = null!; return false; } var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); PopulateMetadataHeaders(metadata, headers); var scopes = new List(); PopulateMetadataScopes(metadata, scopes); entry = new RegistryCredential( registry?.Trim() ?? "registry.local", username?.Trim(), password, identityToken, token, GetMetadataValue(metadata, "refreshToken"), ParseDateTime(metadata, "expiresAt"), scopes.Count == 0 ? Array.Empty() : new ReadOnlyCollection(scopes), ParseBoolean(metadata, "allowInsecureTls"), new ReadOnlyDictionary(headers), GetMetadataValue(metadata, "email")); return true; } private static void PopulateScopes(JsonElement element, ICollection scopes) { if (!TryGetPropertyIgnoreCase(element, "scopes", out var scopesElement)) { return; } switch (scopesElement.ValueKind) { case JsonValueKind.Array: foreach (var scope in scopesElement.EnumerateArray()) { if (scope.ValueKind == JsonValueKind.String) { var value = scope.GetString(); if (!string.IsNullOrWhiteSpace(value)) { scopes.Add(value.Trim()); } } } break; case JsonValueKind.String: var text = scopesElement.GetString(); if (!string.IsNullOrWhiteSpace(text)) { foreach (var part in text.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { scopes.Add(part.Trim()); } } break; } } private static void PopulateMetadataScopes(IReadOnlyDictionary metadata, ICollection scopes) { foreach (var (key, value) in metadata) { if (!key.StartsWith("scope", StringComparison.OrdinalIgnoreCase)) { continue; } if (string.IsNullOrWhiteSpace(value)) { continue; } scopes.Add(value.Trim()); } } private static void TryApplyBasicAuth(string auth, ref string? username, ref string? password) { try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(auth)); var separator = decoded.IndexOf(':'); if (separator >= 0) { username ??= decoded[..separator]; password ??= decoded[(separator + 1)..]; } } catch (FormatException) { // ignore malformed auth; caller may still have explicit username/password fields } } private static DateTimeOffset? ParseDateTime(JsonElement element, string propertyName) { if (!TryGetPropertyIgnoreCase(element, propertyName, out var value) || value.ValueKind != JsonValueKind.String) { return null; } var text = value.GetString(); if (string.IsNullOrWhiteSpace(text)) { return null; } if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) { return parsed; } return null; } private static DateTimeOffset? ParseDateTime(IReadOnlyDictionary metadata, string key) { var value = GetMetadataValue(metadata, key); if (string.IsNullOrWhiteSpace(value)) { return null; } if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) { return parsed; } return null; } private static bool? ParseBoolean(IReadOnlyDictionary metadata, string key) { var value = GetMetadataValue(metadata, key); if (string.IsNullOrWhiteSpace(value)) { return null; } if (bool.TryParse(value, out var parsed)) { return parsed; } return null; } }