Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Rules can now be data: a JSON file with one read-only SELECT where every
row is a finding, with {column} templates, server or per-database scope,
minimum version, sensitive columns and references. Community rules live
in rules/community (reviewed via pull request), ship with every build
and with the signed rule pack; local rules are read from the agent's
data folder every five minutes. Queries are checked for anything that
could write, run with the least-privilege login, and a missing
permission makes the rule not measurable rather than failing. The
rule engine picks up catalog changes without a restart. Authors get
'rules validate' and 'rules test --instance'. Four example community
rules (sa active, orphaned database owners, default trace off,
untrusted foreign keys).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
204 lines
8.4 KiB
C#
204 lines
8.4 KiB
C#
// Vendor tool: generate a signing key pair, issue licenses, build rule packs and publish update feeds.
|
|
// Runs on the vendor's machine only; the private key never leaves it.
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using SQLInsight.Licensing;
|
|
using SQLInsight.Rules.Lifecycle;
|
|
using SQLInsight.Updates;
|
|
|
|
return args.FirstOrDefault() switch
|
|
{
|
|
"keygen" => KeyGen(Option("--out") ?? "."),
|
|
"issue" => Issue(),
|
|
"verify" => Verify(),
|
|
"rulepack" => BuildRulePack(),
|
|
"publish" => Publish(),
|
|
_ => Usage(),
|
|
};
|
|
|
|
int Usage()
|
|
{
|
|
Console.Error.WriteLine("""
|
|
SQLInsight.LicenseTool
|
|
keygen --out <mappe> Opret nøglepar (privat PEM + offentlig nøgle)
|
|
issue --key <privat.pem> --kid <id> --customer <navn> --plan <Professional|Enterprise>
|
|
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
|
|
verify --public <base64> --kid <id> <licens> Verificér licens
|
|
rulepack --version <yyyy.m.d[.n]> --out <pack.json>
|
|
[--catalog build-catalog.json] [--lifecycle support-lifecycle.json] [--community rules/community]
|
|
Byg regelpakke af katalogfilerne
|
|
publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90]
|
|
[--rulepack <pack.json>]
|
|
[--release <version> --asset win-x64=<zip> [--asset linux-x64=<zip>] [--notes <url>] [--security]]
|
|
Signér kanalens manifest; det, der ikke angives,
|
|
overtages fra det eksisterende manifest
|
|
""");
|
|
return 1;
|
|
}
|
|
|
|
int KeyGen(string folder)
|
|
{
|
|
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var kid = "k" + DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
|
Directory.CreateDirectory(folder);
|
|
var privatePath = Path.Combine(folder, $"sqlinsight-{kid}.private.pem");
|
|
File.WriteAllText(privatePath, key.ExportPkcs8PrivateKeyPem());
|
|
var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo());
|
|
Console.WriteLine($"Privat nøgle: {privatePath} (opbevar sikkert, aldrig i git)");
|
|
Console.WriteLine("Tilføj til src/SQLInsight.Licensing/trusted-keys.json:");
|
|
Console.WriteLine($$"""{ "id": "{{kid}}", "publicKey": "{{publicKey}}" }""");
|
|
return 0;
|
|
}
|
|
|
|
int Issue()
|
|
{
|
|
using var key = ECDsa.Create();
|
|
key.ImportFromPem(File.ReadAllText(Required("--key")));
|
|
var license = new License
|
|
{
|
|
LicenseId = Guid.NewGuid().ToString("N"),
|
|
Customer = Required("--customer"),
|
|
Plan = Enum.Parse<LicensePlan>(Required("--plan"), ignoreCase: true),
|
|
IssuedAt = DateTimeOffset.UtcNow,
|
|
ExpiresAt = DateTimeOffset.Parse(Required("--expires") + "T23:59:59Z", CultureInfo.InvariantCulture),
|
|
MaxInstances = int.Parse(Option("--instances") ?? "0", CultureInfo.InvariantCulture),
|
|
KeyId = Required("--kid"),
|
|
};
|
|
Console.WriteLine(SignedDocument.Sign(license, key));
|
|
return 0;
|
|
}
|
|
|
|
int Verify()
|
|
{
|
|
var keys = new TrustedKeys([KeyValuePair.Create(Required("--kid"), Required("--public"))]);
|
|
var status = new LicenseVerifier(keys, TimeProvider.System).Check(args[^1]);
|
|
Console.WriteLine($"{status.State}: {status.Message}");
|
|
return status.State is LicenseState.Valid ? 0 : 2;
|
|
}
|
|
|
|
int BuildRulePack()
|
|
{
|
|
var root = FindRepositoryRoot();
|
|
var catalog = Option("--catalog") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json");
|
|
var lifecycle = Option("--lifecycle") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json");
|
|
var communityFolder = Option("--community") ?? Path.Combine(root, "rules", "community");
|
|
var community = SQLInsight.Core.CustomRules.CustomRuleLoader.LoadDirectory(communityFolder, "community");
|
|
if (community.Errors.Count > 0)
|
|
{
|
|
community.Errors.ToList().ForEach(e => Console.Error.WriteLine(e));
|
|
return 2;
|
|
}
|
|
|
|
var communityJson = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(community.Rules, SQLInsight.Core.CustomRules.CustomRuleDefinition.Json);
|
|
var pack = RulePack.Create(Version.Parse(Required("--version")), File.ReadAllBytes(catalog), File.ReadAllBytes(lifecycle), communityJson);
|
|
Console.WriteLine($"{community.Rules.Count} community-regler fra {communityFolder}");
|
|
File.WriteAllBytes(Required("--out"), pack);
|
|
Console.WriteLine($"Regelpakke {Required("--version")} skrevet til {Required("--out")}");
|
|
return 0;
|
|
}
|
|
|
|
int Publish()
|
|
{
|
|
using var key = ECDsa.Create();
|
|
key.ImportFromPem(File.ReadAllText(Required("--key")));
|
|
var kid = Required("--kid");
|
|
var feed = Required("--feed");
|
|
var channel = Required("--channel");
|
|
if (channel is not (UpdateManifest.StableChannel or UpdateManifest.PreviewChannel))
|
|
{
|
|
Console.Error.WriteLine("--channel skal være stable eller preview.");
|
|
return 1;
|
|
}
|
|
|
|
var manifestPath = Path.Combine(feed, channel, UpdateFeed.ManifestFile);
|
|
var keys = new TrustedKeys([KeyValuePair.Create(kid, Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()))]);
|
|
var previous = File.Exists(manifestPath) ? SignedDocument.Verify<UpdateManifest>(File.ReadAllText(manifestPath), keys, m => m.KeyId) : null;
|
|
|
|
var rulePack = previous?.RulePack;
|
|
if (Option("--rulepack") is { } packPath)
|
|
{
|
|
var bytes = File.ReadAllBytes(packPath);
|
|
var pack = RulePack.Parse(bytes, packPath);
|
|
rulePack = new RulePackRef(pack.Version.ToString(), AddFile(feed, "rulepacks", $"rulepack-{pack.Version}.json", bytes));
|
|
}
|
|
|
|
var release = previous?.Release;
|
|
if (Option("--release") is { } version)
|
|
{
|
|
AppVersion.Parse(version);
|
|
var assets = new List<ReleaseAsset>();
|
|
for (var i = 0; i < args.Length - 1; i++)
|
|
{
|
|
if (args[i] == "--asset" && args[i + 1].Split('=', 2) is [var platform, var file])
|
|
{
|
|
assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform}.zip", File.ReadAllBytes(file))));
|
|
}
|
|
}
|
|
|
|
if (assets.Count == 0)
|
|
{
|
|
Console.Error.WriteLine("--release kræver mindst én --asset <platform>=<zip>.");
|
|
return 1;
|
|
}
|
|
|
|
release = new AppRelease
|
|
{
|
|
Version = version,
|
|
Released = DateOnly.FromDateTime(DateTime.UtcNow),
|
|
Security = args.Contains("--security"),
|
|
NotesUrl = Option("--notes"),
|
|
Assets = assets,
|
|
};
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var manifest = new UpdateManifest
|
|
{
|
|
KeyId = kid,
|
|
Channel = channel,
|
|
PublishedAt = now,
|
|
ExpiresAt = now.AddDays(int.Parse(Option("--days") ?? "90", CultureInfo.InvariantCulture)),
|
|
RulePack = rulePack,
|
|
Release = release,
|
|
};
|
|
Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
|
|
File.WriteAllText(manifestPath, SignedDocument.Sign(manifest, key));
|
|
Console.WriteLine($"{manifestPath}: regelpakke {rulePack?.Version ?? "-"}, version {release?.Version ?? "-"}, udløber {manifest.ExpiresAt:yyyy-MM-dd}");
|
|
return 0;
|
|
}
|
|
|
|
static FileRef AddFile(string feed, string folder, string name, byte[] bytes)
|
|
{
|
|
var relative = $"{folder}/{name}";
|
|
var path = Path.Combine(feed, folder, name);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
if (File.Exists(path) && !File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes))
|
|
{
|
|
throw new InvalidOperationException($"{relative} findes allerede med andet indhold. Udgivne filer ændres aldrig; brug et nyt versionsnummer.");
|
|
}
|
|
|
|
File.WriteAllBytes(path, bytes);
|
|
return new FileRef(relative, Convert.ToHexString(SHA256.HashData(bytes)), bytes.LongLength);
|
|
}
|
|
|
|
static string FindRepositoryRoot()
|
|
{
|
|
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir is not null; dir = dir.Parent)
|
|
{
|
|
if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
|
|
{
|
|
return dir.FullName;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException("Kør fra repositoriet, eller angiv --catalog og --lifecycle.");
|
|
}
|
|
|
|
string? Option(string name)
|
|
{
|
|
var i = Array.IndexOf(args, name);
|
|
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
|
}
|
|
|
|
string Required(string name) => Option(name) ?? throw new ArgumentException($"{name} mangler.");
|