fix(client): Schluessel raus aus argv, Wartezeit einstellbar, Packager sperrt

Fuenf von sechs Befunden einer externen Integration. Der sechste - unsignierte
Lizenzurteile - ist bestaetigt, aber bewusst nicht Teil dieses Commits.

1. Lizenzschluessel stand in der Kommandozeile
   Der Agent nahm --license-key nur als Argument und las keine
   Umgebungsvariablen. "ps" zeigt argv jedem Benutzer der Maschine - exakt die
   Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Damit nahm das
   SDK einen bereits geloesten Sicherheitsbefund zurueck.
   Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und
   DC_DOWNLOAD_PASSWORD, Umgebung vor Argument. LaunchUpdateAgent uebergibt
   den Schluessel nicht mehr als Argument, sondern setzt die Variable auf dem
   eigenen Prozess: das Kind erbt den Umgebungsblock, danach wird sie wieder
   entfernt. Das funktioniert auch mit UseShellExecute=true, wo sich
   ProcessStartInfo.Environment nicht setzen laesst.

2. --wait-timeout war nicht durchgereicht
   Der Agent kannte den Parameter, LaunchUpdateAgent hatte keinen dafuer - es
   galten fest 60 Sekunden. Eine Anwendung, die allein fuer host.StopAsync 30
   Sekunden braucht, kommt damit gefaehrlich nah an die Grenze. Neu:
   waitTimeoutSeconds. Ausserdem ist im Quelltext und in der Doku jetzt
   festgehalten, dass exitCurrentApp:true ueber Environment.Exit(0) laeuft und
   damit finally-Bloecke und IHostApplicationLifetime uebergeht - bei offenem
   Zustand die falsche Wahl.

3. ILicensePrompt war tot
   Der Konstruktor nahm es entgegen, legte es in _prompt ab und benutzte es
   nirgends. Wer darauf eine headless-Story aufbaute, baute auf Sand. Neu:
   EnsureLicensedAsync() - zwischengespeicherten Schluessel nehmen, sonst
   fragen, pruefen, bei Ablehnung erneut fragen. allowPrompt:false lehnt ohne
   Cache ab, statt auf eine Eingabe zu warten, die im Dienst nie kommt. Ein
   voruebergehender Netzfehler fuehrt nicht zur erneuten Abfrage - der
   Schluessel ist ja nicht falsch.

4. Der Packager warnte nur
   Er bricht jetzt ab. Anlass war ein echter API-Schluessel in einem
   oeffentlich abrufbaren Paket - und die Warnung war damals ausgerechnet
   unterdrueckt, weil die Datei auf der preserve-Liste stand.
   Zwei Stufen: Dateiname (appsettings.Local.json, master.key, *.pfx, *.db,
   server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_,
   dc_master_, AKIA, private Schluessel). Die Inhaltspruefung findet auch
   Dateien mit unverdaechtigem Namen.
   Platzhalter loesen bewusst nicht aus: "sk-DEIN-SCHLUESSEL-HIER" haette
   sonst jede ausgelieferte Vorlage blockiert, und --allow-secrets waere nach
   einer Woche Gewohnheit. Beim Erproben zuerst genau in diese Falle gelaufen.

5. BuildInfo.targets war nur per Pfad-Import zu haben
   Die Anleitung empfahl einen <Import> ins Nachbar-Repository - das setzt
   voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben
   Fassung stehen. Das Client-Projekt ist jetzt packbar und legt das Target
   unter build/ ins Paket, wo NuGet es selbst importiert.

Ausserdem: Unauthorized wurde nur im statischen Zweig erkannt, im API-Zweig
kam ein 401 als gewoehnlicher HTTP-Fehler an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-14 13:47:55 +02:00
co-authored by Claude Opus 5
parent e01a608c08
commit 1967b49ad7
7 changed files with 510 additions and 21 deletions
@@ -9,6 +9,7 @@ using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
@@ -266,7 +267,10 @@ namespace Deploymentcenter.Packager
}
}
WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config);
if (!CheckForSecrets(filteredFiles, publishDir, config, HasFlag(args, "--allow-secrets")))
{
return 1;
}
// ---------------------------------------------------------------
// Paket bauen
@@ -798,6 +802,197 @@ namespace Deploymentcenter.Packager
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
/// beim Update die Konfiguration jedes Zielsystems.
/// </summary>
/// <summary>
/// Prueft die zu packenden Dateien auf Zugangsdaten und bricht ab,
/// wenn welche gefunden werden.
///
/// Frueher wurde nur gewarnt. Das hat nicht gereicht: Ein echter
/// OpenRouter-Schluessel lag dadurch in einem oeffentlich abrufbaren
/// Paket. Der Zugangsschutz macht das nicht harmlos - dann ist er eben
/// fuer jeden Lizenznehmer lesbar statt fuer alle.
///
/// Zwei Stufen: der Dateiname (eine appsettings.Local.json gehoert
/// nicht ins Paket) und der Inhalt (ein gefuelltes Password= oder ein
/// Schluessel im Format sk-... verraet sich selbst). Der zweite Teil
/// findet auch Dateien, deren Name unverdaechtig ist.
///
/// Rueckgabe false bedeutet: nicht weitermachen.
/// </summary>
static bool CheckForSecrets(List<string> files, string publishDir, PackagerConfig config, bool allowSecrets)
{
var byName = new List<string>();
var byContent = new List<(string Path, string Reason)>();
string[] suspiciousNames =
{
"appsettings.Local.json", "appsettings.*.Local.json", "*.local.json",
"secrets.json", "connectionstrings.json",
"master.key", "*.pfx", "*.key", "*.pem", "*.p12",
"server_settings.xml", "*.db", "*.sqlite", "*.sqlite3"
};
foreach (var file in files)
{
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
if (GlobMatcher.IsMatch(relPath, suspiciousNames))
{
byName.Add(relPath);
continue;
}
string? reason = FindSecretInContent(file);
if (reason != null)
{
byContent.Add((relPath, reason));
}
}
if (byName.Count == 0 && byContent.Count == 0)
{
return true;
}
Console.ForegroundColor = allowSecrets ? ConsoleColor.Yellow : ConsoleColor.Red;
Console.WriteLine();
Console.WriteLine($"[{(allowSecrets ? "WARNUNG" : "ABBRUCH")}] Im Paket liegen mutmasslich Zugangsdaten:");
foreach (var name in byName)
{
Console.WriteLine($" - {name} (Dateiname)");
}
foreach (var (path, reason) in byContent)
{
Console.WriteLine($" - {path} ({reason})");
}
Console.WriteLine();
Console.WriteLine(" Release-Pakete sind fuer jeden Lizenznehmer lesbar. Was hier steht,");
Console.WriteLine(" ist damit an alle Kunden ausgeliefert.");
Console.WriteLine();
Console.WriteLine(" - Gehoert die Datei gar nicht ins Paket: in excludePatterns aufnehmen");
Console.WriteLine(" und pruefen, warum sie im Publish-Verzeichnis liegt (haeufig eine");
Console.WriteLine(" CopyToOutputDirectory-Regel in der csproj).");
Console.WriteLine(" - Ist es eine Vorlage: die echten Werte durch Platzhalter ersetzen");
Console.WriteLine(" und sie in preservePatterns aufnehmen.");
if (!allowSecrets)
{
Console.WriteLine();
Console.WriteLine(" Bewusst gewollt? --allow-secrets");
}
Console.ResetColor();
Console.WriteLine();
return allowSecrets;
}
/// <summary>
/// Sucht in einer Textdatei nach Werten, die wie Zugangsdaten
/// aussehen. Binaerdateien und grosse Dateien werden uebersprungen -
/// dort waere die Trefferquote schlecht und die Laufzeit hoch.
/// </summary>
static string? FindSecretInContent(string path)
{
try
{
var info = new FileInfo(path);
if (info.Length == 0 || info.Length > 512 * 1024)
return null;
string extension = Path.GetExtension(path).ToLowerInvariant();
string[] textLike = { ".json", ".xml", ".config", ".ini", ".yml", ".yaml", ".env", ".txt", ".toml" };
if (Array.IndexOf(textLike, extension) < 0 && extension.Length != 0)
return null;
string content = File.ReadAllText(path);
// Gefuelltes Passwortfeld - "Password=" allein ist eine leere
// Vorlage und damit in Ordnung.
var password = Regex.Match(content,
@"(?i)\b(password|passwort|pwd)\s*[=:]\s*""?([^""\s;,}]{4,})",
RegexOptions.CultureInvariant);
if (password.Success && !LooksLikePlaceholder(password.Groups[2].Value))
{
return "gefuelltes Passwortfeld";
}
// Verbreitete Schluesselformate.
//
// Auch hier wird der Treffer gegen die Platzhalterliste
// gehalten. Ohne das blockiert "sk-DEIN-SCHLUESSEL-HIER" in
// einer Vorlage jeden Build - und wer das erlebt, setzt
// --allow-secrets kuenftig gewohnheitsmaessig. Dann haette die
// Sperre genau nichts gebracht.
foreach (var (pattern, label) in new[]
{
(@"\bsk-[A-Za-z0-9_\-]{16,}", "API-Schluessel (sk-...)"),
(@"\bghp_[A-Za-z0-9]{20,}", "GitHub-Token"),
(@"\bdc_(master|sub|setup)_[0-9a-f]{16,}", "Deploymentcenter-Token"),
(@"\bAKIA[0-9A-Z]{16}\b", "AWS-Zugriffsschluessel"),
(@"-----BEGIN [A-Z ]*PRIVATE KEY-----", "privater Schluessel")
})
{
foreach (Match match in Regex.Matches(content, pattern, RegexOptions.CultureInvariant))
{
if (!LooksLikePlaceholder(match.Value))
{
return label;
}
}
}
return null;
}
catch
{
// Nicht lesbar heisst nicht verdaechtig.
return null;
}
}
/// <summary>
/// Erkennt Vorlagenwerte, damit eine ausgelieferte Musterdatei nicht
/// jeden Build blockiert.
/// </summary>
static bool LooksLikePlaceholder(string value)
{
string lower = value.Trim().ToLowerInvariant();
if (lower.Length == 0)
return true;
string[] markers =
{
"changeme", "change-me", "todo", "xxx", "platzhalter", "placeholder",
"your-", "dein-", "deine-", "<", "beispiel", "example", "secret-here",
"password", "schluessel-hier", "schlüssel-hier", "hier-einfuegen",
"replace-me", "insert-", "dummy", "sample"
};
foreach (var marker in markers)
{
if (lower.Contains(marker))
return true;
}
// Durchgehend Grossbuchstaben mit Bindestrichen und ohne Ziffern
// ist typisch fuer Platzhalter (SK-MEIN-SCHLUESSEL), aber nicht
// fuer echte Schluessel - die tragen praktisch immer Ziffern.
string body = value.Trim();
if (body.Length > 4 && body == body.ToUpperInvariant() && !body.Any(char.IsDigit))
{
return true;
}
return false;
}
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
{
string[] suspicious =
@@ -952,6 +1147,7 @@ namespace Deploymentcenter.Packager
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
Console.WriteLine(" --allow-secrets Trotz gefundener Zugangsdaten veroeffentlichen");
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
Console.WriteLine(" --token <token> Token mit updateservice:publish");
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");