Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion SW.Bitween.Api/Services/AggregationJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,22 @@ public class AggregationJob(
BitweenDbContext dbContext,
RunFlagUpdater runFlagUpdater,
XchangeService xchangeService,
ILogger<AggregationJob> logger) : IScheduledJob<AggregationJobParams>
ILogger<AggregationJob> logger,
BitweenOptions options = null) : IScheduledJob<AggregationJobParams>
{
public async Task Execute(AggregationJobParams jobParams)
{
var aggSub = await dbContext.Subscriptions()
.FirstOrDefaultAsync(s => s.Id == jobParams.SubscriptionId && !s.Inactive);

if (aggSub == null) return;
if (Services.Mirror.MirrorTraffic.HoldsScheduledRun(options, aggSub, jobParams.CronExpression))
{
// Held, but its next run still moves on, so its page doesn't show one in the past.
try { aggSub.SetSchedules(); await dbContext.SaveChangesAsync(); }
catch (BitweenException) { }
return;
}

// Quartz runs each fire once across the cluster, but each schedule of a subscription and each
// "aggregate now" is a job of its own, so two runs of one aggregation can still overlap, on one
Expand Down
34 changes: 31 additions & 3 deletions SW.Bitween.Api/Services/Mirror/MirrorFaker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,16 @@ public sealed class MirrorFaker(byte[] key, IEnumerable<string> keep = null)
/// <summary>
/// What a setting or a value of prod's is that staging never holds, so a copy leaves it empty for
/// staging's own: an address (a URL, a host, an email, a connection string), a secret (one prod
/// masks, or one named like a secret) or a login (one named like a user name). Null for anything
/// masks, one named like a secret, or one that reads as a credential whatever its name) or a login
/// (one named like a user name). Null for anything
/// else. A host built from a {{token}} isn't one yet, nor is a secret or a login that names one: the
/// token is what is copied. Hosts every system names alike, such as an XML namespace's www.w3.org,
/// are nobody's.
/// </summary>
public static string BlankKind(string key, string value)
{
if (string.IsNullOrEmpty(value)) return null;
if (value == Masked || IsSecretLike(key) && !TokenPattern.IsMatch(value)) return "secret";
if (value == Masked || IsSecretLike(key) && !TokenPattern.IsMatch(value) || LooksLikeCredential(value)) return "secret";
if (IsLoginKey(key) && !TokenPattern.IsMatch(value)) return "login";
if (IsConnectionStringKey(key) || LooksLikeConnectionString(value) || HostsIn(key, value).Any(h => !Untouched(h))) return "address";
return null;
Expand Down Expand Up @@ -99,6 +100,28 @@ public static bool IsSecretLike(string key)
return k is "pass" or "key" or "pin" || SecretWords.Any(k.Contains);
}

/// <summary>
/// A value that reads as a credential whatever its name: a JWT, a bearer or basic header, or a long run
/// of letters and digits with no word in it, such as a license or access key. Prod shows a secret as
/// it is when its adapter doesn't say it is one, and a name like TraxisJwt or LicenseKey says nothing.
/// A GUID or a path isn't one.
/// </summary>
public static bool LooksLikeCredential(string value)
{
var v = (value ?? "").Trim();
if (JwtPattern.IsMatch(v) || AuthHeaderPattern.IsMatch(v)) return true;
if (v.Length < 20 || v.StartsWith('/') || System.Guid.TryParse(v, out _) || !KeyPattern.IsMatch(v)) return false;
// A long run of hex, of digits and letters both, is a key or a hash, which reads as low as words do. One of
// 32 is a GUID's shape, and isn't.
if (v.Length is >= 40 and <= 512 && HexPattern.IsMatch(v) && v.Any(char.IsDigit) && v.Any(char.IsLetter)) return true;
var kinds = (v.Any(char.IsUpper) ? 1 : 0) + (v.Any(char.IsLower) ? 1 : 0) + (v.Any(char.IsDigit) ? 1 : 0);
return kinds >= 2 && BitsPerChar(v) >= 4.0;
Comment thread
hamzahalq marked this conversation as resolved.
}

/// <summary>How unpredictable a value's characters are: about 4.3 for a random key of 20, under 4 for words and paths.</summary>
static double BitsPerChar(string v) =>
-v.GroupBy(c => c).Sum(g => (double)g.Count() / v.Length * Math.Log2((double)g.Count() / v.Length));

/// <summary>
/// A name a login goes by - a user name, a client id - which prod's never is on
/// staging: the team's test login is. SftpUsername and ftpUser are; UserAgent isn't.
Expand Down Expand Up @@ -546,13 +569,18 @@ static byte[] Block(HMACSHA256 hmac, int block, byte[] data)
static readonly HashSet<string> KeptWords = new(StringComparer.OrdinalIgnoreCase) { "true", "false", "null", "none" };

static readonly string[] SecretWords =
["password", "passwd", "pwd", "secret", "token", "credential", "apikey", "accesskey", "privatekey", "passphrase", "certificate", "signingkey"];
["password", "passwd", "pwd", "secret", "token", "credential", "apikey", "accesskey", "privatekey", "passphrase", "certificate", "signingkey",
"jwt", "bearer", "licensekey"];

static readonly string[] LoginEndings = ["user", "username", "userid", "login", "loginname", "loginid", "clientid"];

static readonly string[] AddressWords = ["url", "uri", "host", "server", "endpoint", "address", "domain", "webhook", "smtp", "imap", "ftp"];

static readonly Regex TokenPattern = new(@"\{\{[^}]*\}\}", RegexOptions.Compiled);
static readonly Regex JwtPattern = new(@"^eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]*$", RegexOptions.Compiled);
static readonly Regex AuthHeaderPattern = new(@"^(Bearer|Basic)\s+[A-Za-z0-9+/=_\-.]{4,}$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
static readonly Regex HexPattern = new(@"^[0-9A-Fa-f]+$", RegexOptions.Compiled);
static readonly Regex KeyPattern = new(@"^[A-Za-z0-9+/=_\-]+$", RegexOptions.Compiled);
// Scheme and local part are bounded, as real ones are: unbounded, a long value with neither :// nor @,
// such as a file's encoded attachment, took a search from every position to its end.
static readonly Regex UrlPattern = new(@"(?<scheme>[A-Za-z][A-Za-z0-9+.\-]{0,31})://(?<auth>[^\s/?#""'<>\\]+)(?<rest>[^\s""'<>\\]*)", RegexOptions.Compiled);
Expand Down
13 changes: 13 additions & 0 deletions SW.Bitween.Api/Services/Mirror/MirrorService.Bring.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ async Task<Bringing> PlanBringingAsync(Comparison comparison, Kind kind, IReadOn
var needsYou = AdapterNotes(adapters);
needsYou.AddRange(plan.Problems.Distinct().Select(p => new MirrorNote($"problem:{p}", "needsYou", $"On staging: {p}")));
needsYou.AddRange(FillIns(comparison, needs.Keys));
needsYou.AddRange(AsIsNotes(comparison.Copy, pieces.Where(p => p.State == "new").Select(p => (p.Section, p.Key))));
if (FakedNote(index, needs.Keys) is { } faked) needsYou.Add(faked);
return new Bringing(pieces, needsYou, plan, needs, index, adapters);
}
Expand Down Expand Up @@ -361,6 +362,18 @@ public async Task<int> FilledInAsync(string section, string key)
internal static int CloseStandIns(HashSet<string> standIns, string section, string key) =>
standIns.RemoveWhere(id => JsonSerializer.Deserialize<string[]>(id) is [var s, var k, ..] && s == section && k == key);

/// <summary>
/// The adapters' settings of the items a bring makes that come as prod has them, each with its value, so a
/// person sees them before they come: what decides a secret is its name and its value, and a name can say
/// nothing. Each whole: the page shortens a long one until it is asked for.
/// </summary>
internal static IEnumerable<MirrorNote> AsIsNotes(MirrorSetupCopy copy, IEnumerable<(string Section, string Key)> items)
{
var making = items.ToHashSet();
foreach (var s in (copy.AsIs ?? []).Where(s => making.Contains((s.Section, s.Key))))
yield return new MirrorNote($"asIs:{s.Section}:{s.Key}:{s.Field}", "asIs", s.Value, s.Section, s.Key, Field: s.Field, Noun: Kinds.Of(s.Section).Noun);
}

/// <summary>Kinds that only send alerts: an exchange runs the same whether they are filled in or not.</summary>
internal static bool OnlyAlerts(string section) => section is "notificationChannels" or "notifiers";

Expand Down
65 changes: 56 additions & 9 deletions SW.Bitween.Api/Services/Mirror/MirrorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,40 @@ JsonValue v when v.TryGetValue<string>(out var s) && Version.TryParse(s, out var
};

/// <summary>
/// Every kind prod has, each read on its own: a kind prod's release doesn't have, or the login
/// can't read, is left out and says why, rather than stopping the rest.
/// What prod's API last answered for each kind, by its address, with the kinds it didn't and why. Its
/// setup is a request an item - nearly 200 on Traxis's - and the picker, its preview, the bring and each
/// test's take all want it within minutes, against the 600 a minute prod allows staging's login.
/// </summary>
static async Task<Dictionary<string, string>> ReadAsync(Context ctx, List<string> renamed)
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (DateTimeOffset At, Lazy<Task<ProdSetup>> Read)> ProdSetups =
new(StringComparer.Ordinal);

sealed record ProdSetup(Dictionary<string, List<(string Id, JsonObject Api)>> Raw, Dictionary<string, string> Skipped);

/// <summary>
/// Every kind prod has, each read on its own, read again once five minutes old: a kind prod's release
/// doesn't have, or the login can't read, is left out and says why, rather than stopping the rest. One
/// read at a time per prod - whoever asks while it runs waits on it - and a copy each, since a comparison
/// renames in it. A read that fails isn't kept.
/// </summary>
static async Task<(Dictionary<string, List<(string Id, JsonObject Api)>> Raw, Dictionary<string, string> Skipped)> AnsweredAsync(Context ctx)
{
var url = ctx.Api.Url;
var entry = ProdSetups.AddOrUpdate(url,
_ => (DateTimeOffset.UtcNow, new Lazy<Task<ProdSetup>>(() => ReadSetupAsync(ctx))),
(_, old) => DateTimeOffset.UtcNow - old.At < TimeSpan.FromMinutes(5) ? old
: (DateTimeOffset.UtcNow, new Lazy<Task<ProdSetup>>(() => ReadSetupAsync(ctx))));
ProdSetup read;
try { read = await entry.Read.Value; }
catch
{
ProdSetups.TryRemove(new KeyValuePair<string, (DateTimeOffset, Lazy<Task<ProdSetup>>)>(url, entry));
throw;
}
return (read.Raw.ToDictionary(k => k.Key, k => k.Value.Select(x => (x.Id, (JsonObject)x.Api.DeepClone())).ToList(), StringComparer.Ordinal),
new Dictionary<string, string>(read.Skipped, StringComparer.Ordinal));
}

static async Task<ProdSetup> ReadSetupAsync(Context ctx)
{
var skipped = new Dictionary<string, string>(StringComparer.Ordinal);
var raw = new Dictionary<string, List<(string Id, JsonObject Api)>>(StringComparer.Ordinal);
Expand All @@ -291,14 +321,25 @@ static async Task<Dictionary<string, string>> ReadAsync(Context ctx, List<string
{
skipped[kind.Section] = NotOnProdsRelease;
raw[kind.Section] = [];
continue;
}
catch (BitweenApiException ex) when (ex.Status == HttpStatusCode.Forbidden)
{
skipped[kind.Section] = "Staging's login on prod can't read these.";
raw[kind.Section] = [];
continue;
}
}
return new ProdSetup(raw, skipped);
}

/// <summary>
/// Every kind prod has, as the copy reads it (see <see cref="AnsweredAsync"/>): several of one name are
/// told apart, and a kind in a form this release doesn't read is left out and says why.
/// </summary>
static async Task<Dictionary<string, string>> ReadAsync(Context ctx, List<string> renamed)
{
var (raw, skipped) = await AnsweredAsync(ctx);
foreach (var kind in Kinds.All.Where(k => !skipped.ContainsKey(k.Section)))
{
renamed.AddRange(Distinguish(ctx, kind, raw[kind.Section]));
foreach (var (id, api) in raw[kind.Section]) ctx.Know(kind.Section, kind.KeyOfApi(ctx, api), id);
}
Expand Down Expand Up @@ -373,13 +414,19 @@ static async Task<Dictionary<string, AdapterInfo>> AdaptersAsync(BitweenApi api,
else
{
listed = new Dictionary<string, AdapterInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var prefix in AdapterPrefixes)
async Task<JsonNode> ListOf(string prefix)
{
JsonNode answer;
// Without their startup properties, which nothing here reads: describing a published
// adapter starts it. A release from before the option ignores it and describes them.
try { answer = await api.GetAsync($"api/adapters/catalog?prefix={prefix}&startupValues=false"); }
catch (BitweenApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { continue; }
try { return await api.GetAsync($"api/adapters/catalog?prefix={prefix}&startupValues=false"); }
catch (BitweenApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { return null; }
}
// Staging's kinds side by side, each a read of its storage; prod's one after another, to go easy on it.
var answers = new List<JsonNode>();
if (isProd) foreach (var prefix in AdapterPrefixes) answers.Add(await ListOf(prefix));
else answers.AddRange(await Task.WhenAll(AdapterPrefixes.Select(ListOf)));
foreach (var answer in answers)
{
foreach (var row in (answer as JsonArray ?? []).OfType<JsonObject>())
{
var id = (string)row["key"];
Expand Down
Loading
Loading