Sicherheitsluecken S1, S2 und S3 schliessen
Neues Testprojekt tests/ClawdDotNet.Tools.Tests. Die Angriffsfaelle aus der Bestandsaufnahme bleiben darin dauerhaft als Testfaelle dokumentiert — zusammen mit Gegenproben, damit die Fixes nicht zu streng werden und legitime Nutzung blockieren. S3 — DirectAPI gab die abgerufene URL als Quelle an das Modell zurueck, samt API-Schluessel im Query-String. Der Schluessel landete damit im Konversationskontext, wurde bei jedem Folgeschritt erneut gesendet, in ChatContext.json geschrieben und in die Logs uebernommen. UrlSanitizer maskiert sensible Query-Parameter; auch die Fehlermeldungen sind betroffen und werden bereinigt. S2 — Die Kanal-/Video-Angabe wurde ungeprueft in eine Argument-Zeichenkette fuer yt-dlp interpoliert. UseShellExecute=false verhindert Shell-Metazeichen, nicht aber Options-Injection: yt-dlp kennt die Option --exec, die beliebige Befehle ausfuehrt. Kritisch, weil der Agent untrusted Inhalte verarbeitet — eine Prompt-Injection darin konnte ihn dazu bringen, genau so einen Wert zu setzen. YouTubeUrl validiert Handles und URLs gegen die zulaessigen YouTube-Hosts und lehnt alles ab, was mit einem Bindestrich beginnt. Die Argumente gehen jetzt einzeln ueber ProcessStartInfo.ArgumentList, die Adresse steht hinter dem Optionsende-Trenner. Der ffmpeg-Aufruf wurde ebenso umgestellt. S1 — Die Tabellen-Whitelist suchte den erlaubten Namen als Teilzeichenkette irgendwo im Statement, auch in Kommentaren. Bei einer Freigabe fuer prices genuegte deshalb ein DELETE auf users mit einem Kommentar, der prices enthielt, um eine beliebige Tabelle zu loeschen. Umgekehrt galten harmlose Abfragen, die ein Schluesselwort nur als Wert enthielten, faelschlich als Schreibzugriff. SqlGuard entfernt zuerst Kommentare und String-Literale, lehnt mehrere Statements ab, bestimmt die Operation am ersten Schluesselwort und extrahiert Tabellennamen gezielt hinter FROM/JOIN/INTO/UPDATE/TABLE — inklusive kommagetrennter Listen mit Aliassen. JEDE referenzierte Tabelle muss freigegeben sein, nicht irgendeine. Ohne Whitelist wird nichts durchgelassen. Die MongoDB-Pruefung vergleicht den Collection-Namen jetzt exakt statt per Teilzeichenkette. 178 Tests gruen (91 Core, 87 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
69b5704add
commit
e5067cae70
@@ -17,6 +17,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
|
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
|
||||||
|
<Project Path="tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Project Path="ClawdDotNet.csproj" />
|
<Project Path="ClawdDotNet.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -528,8 +528,8 @@ Siehe K3.
|
|||||||
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
|
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
|
||||||
2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben
|
2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben
|
||||||
3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben
|
3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben
|
||||||
4. S2 yt-dlp-Injection
|
4. ~~S2 yt-dlp-Injection~~ ✅ behoben
|
||||||
5. S3 API-Key-Leak
|
5. ~~S3 API-Key-Leak~~ ✅ behoben
|
||||||
|
|
||||||
**Kurzfristig — größter Nutzen pro Aufwand**
|
**Kurzfristig — größter Nutzen pro Aufwand**
|
||||||
6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`)
|
6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`)
|
||||||
@@ -542,7 +542,7 @@ Siehe K3.
|
|||||||
11. T4 Proaktiv statt reaktiv kompaktieren
|
11. T4 Proaktiv statt reaktiv kompaktieren
|
||||||
|
|
||||||
**Mittelfristig — Fundament**
|
**Mittelfristig — Fundament**
|
||||||
11. S1 DatabaseTool absichern
|
11. ~~S1 DatabaseTool absichern~~ ✅ behoben (`SqlGuard`)
|
||||||
12. Memory-Tool (K1)
|
12. Memory-Tool (K1)
|
||||||
13. F-A1 Freigabe-Workflow + F-A2 Audit-Log
|
13. F-A1 Freigabe-Workflow + F-A2 Audit-Log
|
||||||
14. K3 Testprojekt
|
14. K3 Testprojekt
|
||||||
|
|||||||
@@ -95,22 +95,22 @@ public sealed class DatabaseTool : IAgentTool
|
|||||||
|
|
||||||
var accessLevel = GetAccessLevel(context);
|
var accessLevel = GetAccessLevel(context);
|
||||||
|
|
||||||
// Sicherheitsprüfungen
|
// Statement analysieren: Kommentare, Mehrfach-Statements und String-Literale
|
||||||
if (IsAdminAttempt(sql))
|
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
|
||||||
{
|
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
|
||||||
if (accessLevel != DatabaseAccessLevel.Admin)
|
|
||||||
return ToolResult.Fail("Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
|
|
||||||
}
|
|
||||||
else if (IsWriteAttempt(sql))
|
|
||||||
{
|
|
||||||
if (accessLevel == DatabaseAccessLevel.ReadOnly)
|
|
||||||
return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tabellen-Whitelist-Prüfung
|
if (!inspection.IsValid)
|
||||||
if (!IsTableAllowed(sql, context))
|
return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}");
|
||||||
|
|
||||||
|
switch (inspection.Operation)
|
||||||
{
|
{
|
||||||
return ToolResult.Fail("Sicherheitsfehler: Zugriff auf eine oder mehrere Tabellen im Statement ist nicht erlaubt.");
|
case SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin:
|
||||||
|
return ToolResult.Fail(
|
||||||
|
"Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
|
||||||
|
|
||||||
|
case SqlOperation.Write when accessLevel == DatabaseAccessLevel.ReadOnly:
|
||||||
|
return ToolResult.Fail(
|
||||||
|
"Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
|
||||||
}
|
}
|
||||||
|
|
||||||
using DbConnection conn = type switch
|
using DbConnection conn = type switch
|
||||||
@@ -152,7 +152,7 @@ public sealed class DatabaseTool : IAgentTool
|
|||||||
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
|
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
|
||||||
if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich.");
|
if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich.");
|
||||||
|
|
||||||
if (!IsTableAllowed(collectionName, context))
|
if (!IsCollectionAllowed(collectionName, context))
|
||||||
{
|
{
|
||||||
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt.");
|
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt.");
|
||||||
}
|
}
|
||||||
@@ -186,18 +186,6 @@ public sealed class DatabaseTool : IAgentTool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsWriteAttempt(string sql)
|
|
||||||
{
|
|
||||||
var lower = sql.ToLowerInvariant();
|
|
||||||
return lower.Contains("insert") || lower.Contains("update") || lower.Contains("delete");
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsAdminAttempt(string sql)
|
|
||||||
{
|
|
||||||
var lower = sql.ToLowerInvariant();
|
|
||||||
return lower.Contains("drop") || lower.Contains("alter") || lower.Contains("create") || lower.Contains("truncate");
|
|
||||||
}
|
|
||||||
|
|
||||||
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
|
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
|
||||||
{
|
{
|
||||||
if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
|
if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
|
||||||
@@ -215,16 +203,24 @@ public sealed class DatabaseTool : IAgentTool
|
|||||||
return DatabaseAccessLevel.ReadOnly;
|
return DatabaseAccessLevel.ReadOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsTableAllowed(string input, AgentToolContext context)
|
internal static List<string> GetAllowedTables(AgentToolContext context)
|
||||||
{
|
{
|
||||||
if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je)
|
if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je
|
||||||
|
|| je.ValueKind != JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var allowed = je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList();
|
return je.EnumerateArray()
|
||||||
var inputLower = input.ToLowerInvariant();
|
.Select(x => x.GetString())
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.ToList()!;
|
||||||
|
}
|
||||||
|
|
||||||
return allowed.Any(t => t != null && inputLower.Contains(t));
|
/// <summary>
|
||||||
}
|
/// MongoDB kennt kein SQL — hier genügt der exakte Vergleich des Collection-Namens.
|
||||||
|
/// Bisher wurde auch hier per Teilzeichenkette geprüft.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCollectionAllowed(string collection, AgentToolContext context)
|
||||||
|
=> GetAllowedTables(context).Contains(collection, StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Database;
|
||||||
|
|
||||||
|
public enum SqlOperation
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Read, // SELECT, SHOW, DESCRIBE, EXPLAIN, WITH
|
||||||
|
Write, // INSERT, UPDATE, DELETE, MERGE, REPLACE
|
||||||
|
Schema // CREATE, DROP, ALTER, TRUNCATE, GRANT, …
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SqlInspection(
|
||||||
|
bool IsValid,
|
||||||
|
string? Error,
|
||||||
|
SqlOperation Operation,
|
||||||
|
IReadOnlyList<string> Tables);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft LLM-erzeugtes SQL, bevor es ausgeführt wird.
|
||||||
|
///
|
||||||
|
/// Die frühere Prüfung suchte den erlaubten Tabellennamen als Teilzeichenkette
|
||||||
|
/// irgendwo im Statement — auch in einem Kommentar oder String-Literal. Bei
|
||||||
|
/// allowedTables ["prices"] und ReadWrite genügte deshalb
|
||||||
|
///
|
||||||
|
/// DELETE FROM users -- prices
|
||||||
|
///
|
||||||
|
/// um eine beliebige andere Tabelle zu löschen. Ebenso wurde die Operation über
|
||||||
|
/// Teilzeichenketten bestimmt, wodurch harmlose Abfragen wie
|
||||||
|
/// <c>SELECT … WHERE note='update'</c> fälschlich als Schreibzugriff galten.
|
||||||
|
///
|
||||||
|
/// Diese Klasse geht anders vor:
|
||||||
|
/// 1. Kommentare und String-Literale werden entfernt, bevor irgendetwas geprüft wird.
|
||||||
|
/// 2. Mehrere Statements in einem Aufruf werden abgelehnt.
|
||||||
|
/// 3. Die Operation ergibt sich aus dem ersten Schlüsselwort, nicht aus Vorkommen.
|
||||||
|
/// 4. Tabellennamen werden gezielt hinter FROM/JOIN/INTO/UPDATE/… extrahiert;
|
||||||
|
/// JEDER davon muss auf der Whitelist stehen.
|
||||||
|
/// </summary>
|
||||||
|
public static class SqlGuard
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> ReadKeywords =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH" };
|
||||||
|
|
||||||
|
private static readonly HashSet<string> WriteKeywords =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE", "UPSERT" };
|
||||||
|
|
||||||
|
private static readonly HashSet<string> SchemaKeywords =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE",
|
||||||
|
"RENAME", "ATTACH", "DETACH", "PRAGMA", "SET", "CALL", "EXEC", "EXECUTE"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Schlüsselwörter, nach denen eine Tabellenliste folgt.</summary>
|
||||||
|
private static readonly Regex TableKeyword = new(
|
||||||
|
@"\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>Beendet eine Tabellenliste.</summary>
|
||||||
|
private static readonly Regex ListTerminator = new(
|
||||||
|
@"\b(?:WHERE|GROUP|ORDER|HAVING|LIMIT|ON|USING|SET|VALUES|UNION|EXCEPT|INTERSECT|" +
|
||||||
|
@"JOIN|INNER|LEFT|RIGHT|FULL|CROSS|OUTER|NATURAL|SELECT|RETURNING|WINDOW|FETCH|OFFSET|" +
|
||||||
|
@"ADD|DROP|RENAME|MODIFY|ALTER)\b",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>Erster Bezeichner eines Listeneintrags — ein evtl. folgender Alias wird ignoriert.</summary>
|
||||||
|
private static readonly Regex LeadingIdentifier = new(
|
||||||
|
@"^[\s`\[""]*(?<table>[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static SqlInspection Inspect(string? sql, IReadOnlyCollection<string> allowedTables)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sql))
|
||||||
|
return Invalid("Leeres SQL-Statement.");
|
||||||
|
|
||||||
|
// Kommentare sind für maschinell erzeugtes SQL ohne Nutzen, aber das ideale
|
||||||
|
// Versteck, um eine erlaubte Tabelle vorzutäuschen.
|
||||||
|
if (ContainsComment(sql))
|
||||||
|
return Invalid("SQL-Kommentare sind nicht erlaubt.");
|
||||||
|
|
||||||
|
var normalized = RemoveStringLiterals(sql);
|
||||||
|
|
||||||
|
if (HasMultipleStatements(normalized))
|
||||||
|
return Invalid("Mehrere Statements in einem Aufruf sind nicht erlaubt.");
|
||||||
|
|
||||||
|
var operation = DetermineOperation(normalized);
|
||||||
|
if (operation == SqlOperation.Unknown)
|
||||||
|
return Invalid("Statement-Art konnte nicht bestimmt werden.");
|
||||||
|
|
||||||
|
var tables = ExtractTables(normalized);
|
||||||
|
if (tables.Count == 0)
|
||||||
|
return new SqlInspection(false, "Keine Tabelle im Statement erkannt.", operation, tables);
|
||||||
|
|
||||||
|
// Fail closed: ohne Whitelist wird nichts durchgelassen.
|
||||||
|
if (allowedTables.Count == 0)
|
||||||
|
return new SqlInspection(false, "Keine Tabellen freigegeben (allowedTables fehlt).", operation, tables);
|
||||||
|
|
||||||
|
var allowed = new HashSet<string>(allowedTables, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var denied = tables.Where(t => !allowed.Contains(StripSchema(t))).ToList();
|
||||||
|
|
||||||
|
if (denied.Count > 0)
|
||||||
|
{
|
||||||
|
return new SqlInspection(false,
|
||||||
|
$"Zugriff auf nicht freigegebene Tabelle(n): {string.Join(", ", denied)}. " +
|
||||||
|
$"Erlaubt sind: {string.Join(", ", allowedTables)}.",
|
||||||
|
operation, tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SqlInspection(true, null, operation, tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bausteine ───
|
||||||
|
|
||||||
|
private static SqlInspection Invalid(string error)
|
||||||
|
=> new(false, error, SqlOperation.Unknown, []);
|
||||||
|
|
||||||
|
private static bool ContainsComment(string sql)
|
||||||
|
=> sql.Contains("--", StringComparison.Ordinal)
|
||||||
|
|| sql.Contains("/*", StringComparison.Ordinal)
|
||||||
|
|| sql.Contains('#');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ersetzt den Inhalt von String-Literalen durch Leerraum, damit dort weder
|
||||||
|
/// Schlüsselwörter noch Tabellennamen erkannt werden.
|
||||||
|
/// </summary>
|
||||||
|
private static string RemoveStringLiterals(string sql)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(sql.Length);
|
||||||
|
var inSingle = false;
|
||||||
|
var inDouble = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < sql.Length; i++)
|
||||||
|
{
|
||||||
|
var c = sql[i];
|
||||||
|
|
||||||
|
if (inSingle)
|
||||||
|
{
|
||||||
|
if (c == '\'')
|
||||||
|
{
|
||||||
|
// Verdoppeltes Apostroph ist ein escaptes Zeichen, kein Ende.
|
||||||
|
if (i + 1 < sql.Length && sql[i + 1] == '\'') { i++; continue; }
|
||||||
|
inSingle = false;
|
||||||
|
sb.Append(' ');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inDouble)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (i + 1 < sql.Length && sql[i + 1] == '"') { i++; continue; }
|
||||||
|
inDouble = false;
|
||||||
|
sb.Append(' ');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '\'':
|
||||||
|
inSingle = true;
|
||||||
|
sb.Append(' ');
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
inDouble = true;
|
||||||
|
sb.Append(' ');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sb.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasMultipleStatements(string normalized)
|
||||||
|
{
|
||||||
|
var trimmed = normalized.TrimEnd();
|
||||||
|
var semicolon = trimmed.IndexOf(';');
|
||||||
|
|
||||||
|
// Ein abschließendes Semikolon ist in Ordnung, alles danach nicht.
|
||||||
|
return semicolon >= 0 && semicolon < trimmed.Length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SqlOperation DetermineOperation(string normalized)
|
||||||
|
{
|
||||||
|
var firstWord = normalized
|
||||||
|
.Split([' ', '\t', '\r', '\n', '(', ';'], StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (firstWord is null)
|
||||||
|
return SqlOperation.Unknown;
|
||||||
|
|
||||||
|
if (SchemaKeywords.Contains(firstWord)) return SqlOperation.Schema;
|
||||||
|
if (WriteKeywords.Contains(firstWord)) return SqlOperation.Write;
|
||||||
|
if (ReadKeywords.Contains(firstWord)) return SqlOperation.Read;
|
||||||
|
|
||||||
|
return SqlOperation.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ExtractTables(string normalized)
|
||||||
|
{
|
||||||
|
var tables = new List<string>();
|
||||||
|
|
||||||
|
foreach (Match keyword in TableKeyword.Matches(normalized))
|
||||||
|
{
|
||||||
|
var rest = normalized[(keyword.Index + keyword.Length)..];
|
||||||
|
tables.AddRange(ParseTableList(rest));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest die auf ein Schlüsselwort folgende Tabellenliste — auch mehrere durch Komma
|
||||||
|
/// getrennte Einträge samt Aliassen ("FROM prices p, users u").
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> ParseTableList(string rest)
|
||||||
|
{
|
||||||
|
// Die Liste endet am nächsten Schlüsselwort oder an einer öffnenden Klammer
|
||||||
|
// (Spaltenliste bei INSERT, Unterabfrage bei FROM).
|
||||||
|
var end = rest.Length;
|
||||||
|
|
||||||
|
var terminator = ListTerminator.Match(rest);
|
||||||
|
if (terminator.Success)
|
||||||
|
end = Math.Min(end, terminator.Index);
|
||||||
|
|
||||||
|
var parenthesis = rest.IndexOf('(');
|
||||||
|
if (parenthesis >= 0)
|
||||||
|
end = Math.Min(end, parenthesis);
|
||||||
|
|
||||||
|
var segment = rest[..end];
|
||||||
|
|
||||||
|
foreach (var entry in segment.Split(','))
|
||||||
|
{
|
||||||
|
var match = LeadingIdentifier.Match(entry);
|
||||||
|
if (match.Success)
|
||||||
|
yield return match.Groups["table"].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"schema.tabelle" → "tabelle" (die Whitelist listet Tabellennamen).</summary>
|
||||||
|
private static string StripSchema(string table)
|
||||||
|
{
|
||||||
|
var dot = table.LastIndexOf('.');
|
||||||
|
return dot >= 0 ? table[(dot + 1)..] : table;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,6 +293,9 @@ public sealed class DirectApiTool : IAgentTool
|
|||||||
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
|
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
|
||||||
HttpClient http, string url, CancellationToken ct)
|
HttpClient http, string url, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen.
|
||||||
|
var safeUrl = UrlSanitizer.Sanitize(url);
|
||||||
|
|
||||||
HttpResponseMessage response;
|
HttpResponseMessage response;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -300,7 +303,7 @@ public sealed class DirectApiTool : IAgentTool
|
|||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
return (null, ToolResult.Fail($"API nicht erreichbar: {url} → {ex.Message}"));
|
return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var body = await response.Content.ReadAsStringAsync(ct);
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
@@ -309,7 +312,7 @@ public sealed class DirectApiTool : IAgentTool
|
|||||||
{
|
{
|
||||||
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
||||||
return (null, ToolResult.Fail(
|
return (null, ToolResult.Fail(
|
||||||
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {url}\nAntwort: {preview}"));
|
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob die Antwort überhaupt JSON ist
|
// Prüfen ob die Antwort überhaupt JSON ist
|
||||||
@@ -318,7 +321,7 @@ public sealed class DirectApiTool : IAgentTool
|
|||||||
{
|
{
|
||||||
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
||||||
return (null, ToolResult.Fail(
|
return (null, ToolResult.Fail(
|
||||||
$"API hat kein JSON zurückgegeben ({url}). Antwort: {preview}"));
|
$"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -330,7 +333,7 @@ public sealed class DirectApiTool : IAgentTool
|
|||||||
{
|
{
|
||||||
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
||||||
return (null, ToolResult.Fail(
|
return (null, ToolResult.Fail(
|
||||||
$"Ungültiges JSON von {url}: {ex.Message}\nAntwort: {preview}"));
|
$"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +351,9 @@ public sealed class DirectApiTool : IAgentTool
|
|||||||
{
|
{
|
||||||
fetchedAt = fetchedAt,
|
fetchedAt = fetchedAt,
|
||||||
dataAsOf = dataAsOf,
|
dataAsOf = dataAsOf,
|
||||||
source = source,
|
// Ohne Bereinigung ginge der API-Schlüssel im Query-String an das Modell,
|
||||||
|
// in den persistierten Kontext und in die Logs.
|
||||||
|
source = UrlSanitizer.Sanitize(source),
|
||||||
data = data
|
data = data
|
||||||
};
|
};
|
||||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
namespace ClawdDotNet.Tools.DirectAPI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entfernt Zugangsdaten aus URLs, bevor diese das Tool verlassen.
|
||||||
|
///
|
||||||
|
/// Hintergrund: Die abgerufene URL wurde als "source" an das Modell zurückgegeben —
|
||||||
|
/// samt "apikey=" im Query-String. Damit landete der Schlüssel im Konversationskontext,
|
||||||
|
/// wurde bei jedem Folgeschritt erneut an den Anbieter gesendet, in ChatContext.json
|
||||||
|
/// auf die Platte geschrieben und in die Logs übernommen.
|
||||||
|
/// </summary>
|
||||||
|
public static class UrlSanitizer
|
||||||
|
{
|
||||||
|
private static readonly string[] SensitiveParameters =
|
||||||
|
[
|
||||||
|
"apikey", "api_key", "key", "token", "access_token", "apitoken",
|
||||||
|
"secret", "password", "auth", "signature", "sig"
|
||||||
|
];
|
||||||
|
|
||||||
|
private const string Mask = "***";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ersetzt die Werte sicherheitsrelevanter Query-Parameter durch eine Maske.
|
||||||
|
/// Der Rest der URL bleibt lesbar, damit die Herkunft der Daten nachvollziehbar ist.
|
||||||
|
/// </summary>
|
||||||
|
public static string Sanitize(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
return url ?? "";
|
||||||
|
|
||||||
|
var queryStart = url.IndexOf('?');
|
||||||
|
if (queryStart < 0)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
var baseUrl = url[..queryStart];
|
||||||
|
var query = url[(queryStart + 1)..];
|
||||||
|
|
||||||
|
// Fragment abtrennen, damit es hinten wieder angehängt werden kann.
|
||||||
|
var fragment = "";
|
||||||
|
var fragmentStart = query.IndexOf('#');
|
||||||
|
if (fragmentStart >= 0)
|
||||||
|
{
|
||||||
|
fragment = query[fragmentStart..];
|
||||||
|
query = query[..fragmentStart];
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = query.Split('&');
|
||||||
|
for (var i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
var separator = parts[i].IndexOf('=');
|
||||||
|
if (separator < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var name = parts[i][..separator];
|
||||||
|
if (IsSensitive(name))
|
||||||
|
parts[i] = name + "=" + Mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl + "?" + string.Join("&", parts) + fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSensitive(string parameterName)
|
||||||
|
=> SensitiveParameters.Contains(parameterName.Trim().ToLowerInvariant());
|
||||||
|
}
|
||||||
@@ -415,36 +415,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
|||||||
|
|
||||||
// --- Helper ---
|
// --- Helper ---
|
||||||
|
|
||||||
/// <summary>
|
// Channel-Auflösung liegt jetzt in YouTubeUrl.TryResolveChannelUrl — dort wird die
|
||||||
/// Löst Channel-Angaben auf: Handle (@Name / Name), Channel-URL, oder volle URL.
|
// Eingabe validiert, bevor sie an einen externen Prozess geht.
|
||||||
/// </summary>
|
|
||||||
private static string ResolveChannelUrl(string channelInput)
|
|
||||||
{
|
|
||||||
var input = channelInput.Trim();
|
|
||||||
|
|
||||||
// Bereits eine URL → direkt nutzen
|
|
||||||
if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
input.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return input;
|
|
||||||
|
|
||||||
// Handle ohne @ → hinzufügen
|
|
||||||
if (!input.StartsWith('@'))
|
|
||||||
input = "@" + input;
|
|
||||||
|
|
||||||
return $"https://www.youtube.com/{input}/videos";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string?> GetLatestVideoIdAsync(string channelInput, string ytDlpPath, CancellationToken ct)
|
private async Task<string?> GetLatestVideoIdAsync(string channelInput, string ytDlpPath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var channelUrl = ResolveChannelUrl(channelInput);
|
if (!YouTubeUrl.TryResolveChannelUrl(channelInput, out var channelUrl, out _))
|
||||||
var psi = new ProcessStartInfo(ytDlpPath, $"--print \"%(id)s\" --playlist-end 1 {channelUrl}")
|
return null;
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo(ytDlpPath)
|
||||||
{
|
{
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
};
|
};
|
||||||
|
foreach (var arg in YouTubeUrl.BuildLatestVideoIdArgs(channelUrl))
|
||||||
|
psi.ArgumentList.Add(arg);
|
||||||
|
|
||||||
using var process = Process.Start(psi);
|
using var process = Process.Start(psi);
|
||||||
if (process == null) return null;
|
if (process == null) return null;
|
||||||
@@ -464,30 +452,39 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Eingabe validieren, bevor irgendetwas an einen externen Prozess geht.
|
||||||
|
if (!YouTubeUrl.TryResolveChannelUrl(url, out var safeUrl, out var urlError))
|
||||||
|
throw new ArgumentException($"Ungültige YouTube-Adresse: {urlError}");
|
||||||
|
|
||||||
var targetDir = Path.Combine(workspacePath, "YTTranscript");
|
var targetDir = Path.Combine(workspacePath, "YTTranscript");
|
||||||
var tmpDir = Path.Combine(targetDir, "tmp");
|
var tmpDir = Path.Combine(targetDir, "tmp");
|
||||||
if (!Directory.Exists(tmpDir)) Directory.CreateDirectory(tmpDir);
|
if (!Directory.Exists(tmpDir)) Directory.CreateDirectory(tmpDir);
|
||||||
|
|
||||||
// 1. Video-ID ermitteln
|
// 1. Video-ID ermitteln
|
||||||
var videoId = await GetLatestVideoIdAsync(url, ytDlpPath, ct);
|
var videoId = await GetLatestVideoIdAsync(safeUrl, ytDlpPath, ct);
|
||||||
if (string.IsNullOrWhiteSpace(videoId)) videoId = Guid.NewGuid().ToString();
|
if (string.IsNullOrWhiteSpace(videoId)) videoId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
// 2. Audio herunterladen
|
// 2. Audio herunterladen
|
||||||
string audioFile;
|
string audioFile;
|
||||||
|
var ffmpegDir = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg"
|
||||||
|
? Path.GetDirectoryName(ffmpegPath)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (hasFfmpeg)
|
if (hasFfmpeg)
|
||||||
{
|
{
|
||||||
// Mit ffmpeg: yt-dlp konvertiert direkt zu mp3
|
// Mit ffmpeg: yt-dlp konvertiert direkt zu mp3
|
||||||
audioFile = Path.Combine(tmpDir, $"{videoId}.mp3");
|
audioFile = Path.Combine(tmpDir, $"{videoId}.mp3");
|
||||||
var ffmpegLocArg = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg"
|
await RunYtDlpAsync(ytDlpPath,
|
||||||
? $"--ffmpeg-location \"{Path.GetDirectoryName(ffmpegPath)}\" "
|
YouTubeUrl.BuildAudioDownloadArgs(audioFile, safeUrl, ffmpegDir, convertToMp3: true),
|
||||||
: "";
|
logger, ct);
|
||||||
await RunYtDlpAsync(ytDlpPath, $"{ffmpegLocArg}-x --audio-format mp3 -o \"{audioFile}\" {url}", logger, ct);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Ohne ffmpeg: Audio im Originalformat herunterladen
|
// Ohne ffmpeg: Audio im Originalformat herunterladen
|
||||||
var outputTemplate = Path.Combine(tmpDir, $"{videoId}.%(ext)s");
|
var outputTemplate = Path.Combine(tmpDir, $"{videoId}.%(ext)s");
|
||||||
await RunYtDlpAsync(ytDlpPath, $"-x -o \"{outputTemplate}\" {url}", logger, ct);
|
await RunYtDlpAsync(ytDlpPath,
|
||||||
|
YouTubeUrl.BuildAudioDownloadArgs(outputTemplate, safeUrl, null, convertToMp3: false),
|
||||||
|
logger, ct);
|
||||||
audioFile = ""; // wird unten gesucht
|
audioFile = ""; // wird unten gesucht
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,16 +537,22 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunYtDlpAsync(string ytDlpPath, string arguments, ILogger logger, CancellationToken ct)
|
/// <summary>
|
||||||
|
/// Startet yt-dlp. Die Argumente werden einzeln übergeben (ArgumentList), damit
|
||||||
|
/// kein Wert versehentlich als weitere Option interpretiert werden kann.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunYtDlpAsync(string ytDlpPath, List<string> arguments, ILogger logger, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo(ytDlpPath, arguments)
|
var psi = new ProcessStartInfo(ytDlpPath)
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
RedirectStandardError = true
|
RedirectStandardError = true
|
||||||
};
|
};
|
||||||
|
foreach (var arg in arguments)
|
||||||
|
psi.ArgumentList.Add(arg);
|
||||||
|
|
||||||
using var process = Process.Start(psi);
|
using var process = Process.Start(psi);
|
||||||
if (process != null)
|
if (process != null)
|
||||||
@@ -596,13 +599,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
|||||||
// ffmpeg: 20-Minuten-Segmente als mp3 (unter 25 MB Whisper-Limit bei q:a 5)
|
// ffmpeg: 20-Minuten-Segmente als mp3 (unter 25 MB Whisper-Limit bei q:a 5)
|
||||||
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
|
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
|
||||||
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
|
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
|
||||||
var psi = new ProcessStartInfo(ffmpegExe,
|
var psi = new ProcessStartInfo(ffmpegExe)
|
||||||
$"-i \"{filePath}\" -f segment -segment_time 1200 -c:a libmp3lame -q:a 5 \"{chunkPattern}\"")
|
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
RedirectStandardError = true
|
RedirectStandardError = true
|
||||||
};
|
};
|
||||||
|
foreach (var arg in new[]
|
||||||
|
{
|
||||||
|
"-i", filePath,
|
||||||
|
"-f", "segment",
|
||||||
|
"-segment_time", "1200",
|
||||||
|
"-c:a", "libmp3lame",
|
||||||
|
"-q:a", "5",
|
||||||
|
chunkPattern
|
||||||
|
})
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add(arg);
|
||||||
|
}
|
||||||
|
|
||||||
using var process = Process.Start(psi);
|
using var process = Process.Start(psi);
|
||||||
if (process is not null)
|
if (process is not null)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.SocialMediaManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft und normalisiert YouTube-Adressen, bevor sie an yt-dlp übergeben werden.
|
||||||
|
///
|
||||||
|
/// Hintergrund (S2): Die Eingabe wurde ungeprüft in eine Argument-Zeichenkette
|
||||||
|
/// interpoliert. <c>UseShellExecute = false</c> verhindert zwar Shell-Metazeichen,
|
||||||
|
/// nicht aber Options-Injection: yt-dlp kennt <c>--exec</c>, das beliebige Befehle nach
|
||||||
|
/// dem Download ausführt. Ein Wert wie
|
||||||
|
/// <c>--exec "cmd /c …" https://youtube.com/…</c> führte damit zu Codeausführung.
|
||||||
|
///
|
||||||
|
/// Kritisch ist das, weil der Agent untrusted Inhalte verarbeitet (Videotitel, Posts,
|
||||||
|
/// Mails) — eine Prompt-Injection darin kann ihn dazu bringen, genau so einen Wert zu
|
||||||
|
/// setzen.
|
||||||
|
/// </summary>
|
||||||
|
public static class YouTubeUrl
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedHosts =
|
||||||
|
[
|
||||||
|
"youtube.com", "www.youtube.com", "m.youtube.com",
|
||||||
|
"music.youtube.com", "youtu.be", "www.youtu.be"
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Handles wie "@ChannelName" — bewusst eng gefasst.</summary>
|
||||||
|
private static readonly Regex HandlePattern = new(
|
||||||
|
@"^@?[A-Za-z0-9._-]{1,100}$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Löst eine Channel-Angabe zu einer vollständigen URL auf.
|
||||||
|
/// Akzeptiert ein Handle ("@Name" oder "Name") oder eine YouTube-URL.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryResolveChannelUrl(string? input, out string url, out string? error)
|
||||||
|
{
|
||||||
|
url = "";
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
var value = input?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (value.Length == 0)
|
||||||
|
{
|
||||||
|
error = "Kanal-Angabe fehlt.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alles, was wie eine Option aussieht, sofort ablehnen — auch wenn die
|
||||||
|
// Argumentübergabe inzwischen sauber quotet.
|
||||||
|
if (value.StartsWith('-'))
|
||||||
|
{
|
||||||
|
error = "Kanal-Angabe darf nicht mit '-' beginnen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!TryValidateUrl(value, out error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
url = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HandlePattern.IsMatch(value))
|
||||||
|
{
|
||||||
|
error = "Ungültiges Kanal-Handle. Erlaubt sind Buchstaben, Ziffern, Punkt, " +
|
||||||
|
"Bindestrich und Unterstrich.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = value.StartsWith('@') ? value : "@" + value;
|
||||||
|
url = $"https://www.youtube.com/{handle}/videos";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prüft eine Video- oder Kanal-URL gegen die erlaubten Hosts.</summary>
|
||||||
|
public static bool TryValidateUrl(string? input, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
var value = input?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (value.StartsWith('-'))
|
||||||
|
{
|
||||||
|
error = "URL darf nicht mit '-' beginnen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
error = "Keine gültige URL.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
error = $"Nicht unterstütztes Schema '{uri.Scheme}'. Erlaubt sind http und https.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AllowedHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
error = $"Host '{uri.Host}' ist nicht erlaubt. Zulässig sind nur YouTube-Adressen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut die Argumentliste, um die neueste Video-Id eines Kanals abzufragen.
|
||||||
|
/// Die Liste wird an <c>ProcessStartInfo.ArgumentList</c> übergeben, wo jedes
|
||||||
|
/// Element als genau ein Argument ankommt.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> BuildLatestVideoIdArgs(string channelUrl) =>
|
||||||
|
[
|
||||||
|
"--print", "%(id)s",
|
||||||
|
"--playlist-end", "1",
|
||||||
|
"--", // beendet die Optionsliste
|
||||||
|
channelUrl
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Baut die Argumentliste für den Audio-Download.</summary>
|
||||||
|
public static List<string> BuildAudioDownloadArgs(
|
||||||
|
string outputPath, string videoUrl, string? ffmpegDirectory, bool convertToMp3)
|
||||||
|
{
|
||||||
|
var args = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(ffmpegDirectory))
|
||||||
|
{
|
||||||
|
args.Add("--ffmpeg-location");
|
||||||
|
args.Add(ffmpegDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.Add("-x");
|
||||||
|
|
||||||
|
if (convertToMp3)
|
||||||
|
{
|
||||||
|
args.Add("--audio-format");
|
||||||
|
args.Add("mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
args.Add("-o");
|
||||||
|
args.Add(outputPath);
|
||||||
|
args.Add("--");
|
||||||
|
args.Add(videoUrl);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ClawdDotNet.Tools.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using ClawdDotNet.Tools.Database;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.Database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S1 aus der Bestandsaufnahme: Die Tabellen-Whitelist suchte den erlaubten Namen als
|
||||||
|
/// Teilzeichenkette irgendwo im Statement — auch in einem Kommentar. Bei
|
||||||
|
/// allowedTables ["prices"] genügte deshalb "DELETE FROM users -- prices", um eine
|
||||||
|
/// beliebige andere Tabelle zu löschen.
|
||||||
|
///
|
||||||
|
/// Die Gegenproben sind genauso wichtig wie die Angriffe: Ein zu strenger Filter, der
|
||||||
|
/// legitime Abfragen blockiert, wäre im Betrieb genauso unbrauchbar.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlGuardTests
|
||||||
|
{
|
||||||
|
private static readonly string[] Allowed = ["prices", "signals", "portfolio"];
|
||||||
|
|
||||||
|
private static SqlInspection Inspect(string sql) => SqlGuard.Inspect(sql, Allowed);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Angriffe — alle müssen abgelehnt werden
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DELETE FROM users -- prices")]
|
||||||
|
[InlineData("DELETE FROM users /* prices */")]
|
||||||
|
[InlineData("DROP TABLE users -- prices")]
|
||||||
|
[InlineData("UPDATE users SET admin=1 -- prices")]
|
||||||
|
[InlineData("SELECT * FROM users # prices")]
|
||||||
|
public void Kommentare_koennen_keine_erlaubte_Tabelle_vortaeuschen(string sql)
|
||||||
|
{
|
||||||
|
var result = Inspect(sql);
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeFalse($"'{sql}' muss abgelehnt werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SELECT * FROM users WHERE note = 'prices'")]
|
||||||
|
[InlineData("INSERT INTO users (name) VALUES ('prices')")]
|
||||||
|
[InlineData("SELECT * FROM users WHERE a = \"prices\"")]
|
||||||
|
public void String_Literale_koennen_keine_erlaubte_Tabelle_vortaeuschen(string sql)
|
||||||
|
{
|
||||||
|
Inspect(sql).IsValid.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SELECT * FROM prices; DROP TABLE users")]
|
||||||
|
[InlineData("SELECT * FROM prices; DELETE FROM users")]
|
||||||
|
[InlineData("SELECT 1; SELECT 2")]
|
||||||
|
public void Mehrere_Statements_werden_abgelehnt(string sql)
|
||||||
|
{
|
||||||
|
var result = Inspect(sql);
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Error.ShouldContain("Mehrere Statements");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SELECT * FROM prices JOIN users ON users.id = prices.uid")]
|
||||||
|
[InlineData("SELECT * FROM prices, users")]
|
||||||
|
[InlineData("INSERT INTO users SELECT * FROM prices")]
|
||||||
|
public void Ein_einziger_unerlaubter_Join_reicht_zur_Ablehnung(string sql)
|
||||||
|
{
|
||||||
|
// Entscheidend: JEDE referenzierte Tabelle muss erlaubt sein, nicht irgendeine.
|
||||||
|
var result = Inspect(sql);
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Error.ShouldContain("users");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ohne_Whitelist_wird_nichts_durchgelassen()
|
||||||
|
{
|
||||||
|
SqlGuard.Inspect("SELECT * FROM prices", []).IsValid.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Leere_Statements_werden_abgelehnt(string? sql)
|
||||||
|
{
|
||||||
|
SqlGuard.Inspect(sql, Allowed).IsValid.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Gegenproben — legitime Abfragen müssen durchgehen
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SELECT * FROM prices")]
|
||||||
|
[InlineData("SELECT ticker, close FROM prices WHERE ticker = 'NVDA' ORDER BY ts DESC LIMIT 10")]
|
||||||
|
[InlineData("SELECT * FROM prices JOIN signals ON signals.ticker = prices.ticker")]
|
||||||
|
[InlineData("select * from PRICES")]
|
||||||
|
[InlineData("SELECT * FROM prices;")]
|
||||||
|
public void Erlaubte_Leseabfragen_gehen_durch(string sql)
|
||||||
|
{
|
||||||
|
var result = Inspect(sql);
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeTrue(result.Error);
|
||||||
|
result.Operation.ShouldBe(SqlOperation.Read);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SELECT * FROM prices WHERE note = 'update'")]
|
||||||
|
[InlineData("SELECT * FROM prices WHERE kommentar = 'bitte delete beachten'")]
|
||||||
|
[InlineData("SELECT * FROM prices WHERE aktion = 'drop'")]
|
||||||
|
public void Harmlose_Schluesselwoerter_in_Werten_gelten_nicht_als_Schreibzugriff(string sql)
|
||||||
|
{
|
||||||
|
// Der alte Substring-Test stufte diese Abfragen fälschlich als Schreib- bzw.
|
||||||
|
// Strukturzugriff ein und blockierte sie für ReadOnly-Agenten.
|
||||||
|
var result = Inspect(sql);
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeTrue(result.Error);
|
||||||
|
result.Operation.ShouldBe(SqlOperation.Read);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("UPDATE prices SET close = 1 WHERE id = 2", SqlOperation.Write)]
|
||||||
|
[InlineData("INSERT INTO signals (ticker) VALUES ('NVDA')", SqlOperation.Write)]
|
||||||
|
[InlineData("DELETE FROM portfolio WHERE id = 5", SqlOperation.Write)]
|
||||||
|
[InlineData("DROP TABLE prices", SqlOperation.Schema)]
|
||||||
|
[InlineData("ALTER TABLE prices ADD COLUMN x INT", SqlOperation.Schema)]
|
||||||
|
[InlineData("TRUNCATE TABLE prices", SqlOperation.Schema)]
|
||||||
|
public void Die_Operation_wird_am_ersten_Schluesselwort_bestimmt(string sql, SqlOperation expected)
|
||||||
|
{
|
||||||
|
var result = Inspect(sql);
|
||||||
|
|
||||||
|
result.Operation.ShouldBe(expected);
|
||||||
|
result.IsValid.ShouldBeTrue(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Schema_qualifizierte_Namen_werden_erkannt()
|
||||||
|
{
|
||||||
|
var result = Inspect("SELECT * FROM crypto.prices WHERE id = 1");
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeTrue(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Der_Fehlertext_nennt_die_abgelehnte_Tabelle_und_die_erlaubten()
|
||||||
|
{
|
||||||
|
var result = Inspect("SELECT * FROM geheim");
|
||||||
|
|
||||||
|
result.Error.ShouldContain("geheim");
|
||||||
|
result.Error.ShouldContain("prices");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escaptes_Apostroph_im_Literal_bricht_die_Erkennung_nicht()
|
||||||
|
{
|
||||||
|
var result = Inspect("SELECT * FROM prices WHERE name = 'O''Brien'");
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeTrue(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using ClawdDotNet.Tools.DirectAPI;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.DirectAPI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S3 aus der Bestandsaufnahme: Die abgerufene URL ging als "source" an das Modell —
|
||||||
|
/// inklusive apikey= im Query-String. Der Schlüssel landete damit im Kontext, wurde
|
||||||
|
/// bei jedem Folgeschritt erneut gesendet und in ChatContext.json geschrieben.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UrlSanitizerTests
|
||||||
|
{
|
||||||
|
private const string Secret = "GEHEIM-abc123XYZ";
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://api.twelvedata.com/quote?symbol=NVDA&apikey=GEHEIM-abc123XYZ")]
|
||||||
|
[InlineData("https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=X&apikey=GEHEIM-abc123XYZ")]
|
||||||
|
[InlineData("https://api.example.com/v1?api_key=GEHEIM-abc123XYZ&x=1")]
|
||||||
|
[InlineData("https://api.example.com/v1?token=GEHEIM-abc123XYZ")]
|
||||||
|
[InlineData("https://api.example.com/v1?access_token=GEHEIM-abc123XYZ")]
|
||||||
|
[InlineData("https://api.example.com/v1?KEY=GEHEIM-abc123XYZ")]
|
||||||
|
[InlineData("https://api.example.com/v1?ApiKey=GEHEIM-abc123XYZ&other=2")]
|
||||||
|
[InlineData("https://api.example.com/v1?secret=GEHEIM-abc123XYZ")]
|
||||||
|
[InlineData("https://api.example.com/v1?a=1&apikey=GEHEIM-abc123XYZ#fragment")]
|
||||||
|
public void Sensible_Parameter_werden_maskiert(string url)
|
||||||
|
{
|
||||||
|
var sanitized = UrlSanitizer.Sanitize(url);
|
||||||
|
|
||||||
|
sanitized.ShouldNotContain(Secret);
|
||||||
|
sanitized.ShouldContain("***");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Harmlose_Parameter_bleiben_lesbar()
|
||||||
|
{
|
||||||
|
var sanitized = UrlSanitizer.Sanitize(
|
||||||
|
"https://api.twelvedata.com/quote?symbol=NVDA&interval=1day&apikey=GEHEIM-abc123XYZ");
|
||||||
|
|
||||||
|
sanitized.ShouldContain("symbol=NVDA");
|
||||||
|
sanitized.ShouldContain("interval=1day");
|
||||||
|
sanitized.ShouldContain("apikey=***");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Der_Pfad_bleibt_erhalten()
|
||||||
|
{
|
||||||
|
var sanitized = UrlSanitizer.Sanitize("https://api.massive.com/v2/aggs/ticker/NVDA?apikey=x");
|
||||||
|
|
||||||
|
sanitized.ShouldStartWith("https://api.massive.com/v2/aggs/ticker/NVDA?");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ein_Fragment_bleibt_erhalten()
|
||||||
|
{
|
||||||
|
var sanitized = UrlSanitizer.Sanitize("https://x.de/a?apikey=geheim#abschnitt");
|
||||||
|
|
||||||
|
sanitized.ShouldEndWith("#abschnitt");
|
||||||
|
sanitized.ShouldNotContain("geheim");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://api.example.com/v1")]
|
||||||
|
[InlineData("https://api.example.com/v1?symbol=NVDA")]
|
||||||
|
public void URLs_ohne_sensible_Parameter_bleiben_unveraendert(string url)
|
||||||
|
{
|
||||||
|
UrlSanitizer.Sanitize(url).ShouldBe(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Leere_Eingaben_fuehren_nicht_zu_einem_Fehler(string? url)
|
||||||
|
{
|
||||||
|
Should.NotThrow(() => UrlSanitizer.Sanitize(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ein_Parameter_ohne_Wert_stoert_nicht()
|
||||||
|
{
|
||||||
|
var sanitized = UrlSanitizer.Sanitize("https://x.de/a?flag&apikey=geheim");
|
||||||
|
|
||||||
|
sanitized.ShouldContain("flag");
|
||||||
|
sanitized.ShouldNotContain("geheim");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ein_Schluessel_als_Teil_eines_anderen_Namens_wird_nicht_faelschlich_maskiert()
|
||||||
|
{
|
||||||
|
// "monkey" enthält "key", ist aber kein sensibler Parameter.
|
||||||
|
var sanitized = UrlSanitizer.Sanitize("https://x.de/a?monkey=banane");
|
||||||
|
|
||||||
|
sanitized.ShouldBe("https://x.de/a?monkey=banane");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using ClawdDotNet.Tools.SocialMediaManager;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.SocialMedia;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S2 aus der Bestandsaufnahme: Die Kanal-/Video-Angabe wurde ungeprüft in eine
|
||||||
|
/// Argument-Zeichenkette für yt-dlp interpoliert. UseShellExecute=false verhindert
|
||||||
|
/// Shell-Metazeichen, nicht aber Options-Injection — yt-dlp kennt --exec, das beliebige
|
||||||
|
/// Befehle ausführt.
|
||||||
|
///
|
||||||
|
/// Die Angriffsfälle bleiben hier dauerhaft als Testfälle dokumentiert.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class YouTubeUrlTests
|
||||||
|
{
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Angriffe
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("--exec cmd.exe https://www.youtube.com/@kanal")]
|
||||||
|
[InlineData("--exec=calc.exe")]
|
||||||
|
[InlineData("-o /tmp/evil")]
|
||||||
|
[InlineData("--config-location /tmp/evil.conf")]
|
||||||
|
[InlineData("--paths /windows/system32")]
|
||||||
|
[InlineData("-")]
|
||||||
|
public void Optionsartige_Eingaben_werden_abgelehnt(string input)
|
||||||
|
{
|
||||||
|
YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse();
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://evil.com/video")]
|
||||||
|
[InlineData("https://youtube.com.attacker.net/@kanal")]
|
||||||
|
[InlineData("https://notyoutube.com/@kanal")]
|
||||||
|
[InlineData("http://192.168.178.10:8418/Richard/ClawdDotNet.git")]
|
||||||
|
[InlineData("file:///C:/Windows/win.ini")]
|
||||||
|
[InlineData("ftp://example.com/datei")]
|
||||||
|
public void Fremde_Hosts_und_Schemata_werden_abgelehnt(string url)
|
||||||
|
{
|
||||||
|
YouTubeUrl.TryResolveChannelUrl(url, out _, out var error).ShouldBeFalse();
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("kanal name")] // Leerzeichen
|
||||||
|
[InlineData("kanal\"name")] // Anführungszeichen
|
||||||
|
[InlineData("kanal\nname")] // Zeilenumbruch
|
||||||
|
[InlineData("kanal;name")]
|
||||||
|
[InlineData("kanal&name")]
|
||||||
|
[InlineData("../../etc/passwd")]
|
||||||
|
[InlineData("@kanal/../../evil")]
|
||||||
|
public void Unsaubere_Handles_werden_abgelehnt(string input)
|
||||||
|
{
|
||||||
|
YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse();
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Leere_Eingaben_werden_abgelehnt(string? input)
|
||||||
|
{
|
||||||
|
YouTubeUrl.TryResolveChannelUrl(input, out _, out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Gegenproben — legitime Eingaben müssen weiter funktionieren
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("@Computerphile", "https://www.youtube.com/@Computerphile/videos")]
|
||||||
|
[InlineData("Computerphile", "https://www.youtube.com/@Computerphile/videos")]
|
||||||
|
[InlineData("kanal_mit-punkt.name", "https://www.youtube.com/@kanal_mit-punkt.name/videos")]
|
||||||
|
public void Handles_werden_zu_Kanal_URLs(string input, string expected)
|
||||||
|
{
|
||||||
|
YouTubeUrl.TryResolveChannelUrl(input, out var url, out var error).ShouldBeTrue(error);
|
||||||
|
url.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ")]
|
||||||
|
[InlineData("https://youtube.com/@kanal/videos")]
|
||||||
|
[InlineData("https://m.youtube.com/watch?v=abc")]
|
||||||
|
[InlineData("https://music.youtube.com/watch?v=abc")]
|
||||||
|
[InlineData("https://youtu.be/dQw4w9WgXcQ")]
|
||||||
|
public void Echte_YouTube_URLs_werden_durchgelassen(string url)
|
||||||
|
{
|
||||||
|
YouTubeUrl.TryResolveChannelUrl(url, out var resolved, out var error).ShouldBeTrue(error);
|
||||||
|
resolved.ShouldBe(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Argumentbildung
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Die_URL_steht_hinter_dem_Optionsende_Trenner()
|
||||||
|
{
|
||||||
|
// "--" beendet die Optionsliste. Selbst wenn ein Wert wie eine Option aussähe,
|
||||||
|
// würde yt-dlp ihn danach als Adresse behandeln.
|
||||||
|
var args = YouTubeUrl.BuildLatestVideoIdArgs("https://www.youtube.com/@kanal/videos");
|
||||||
|
|
||||||
|
var separator = args.IndexOf("--");
|
||||||
|
separator.ShouldBeGreaterThanOrEqualTo(0);
|
||||||
|
args[^1].ShouldBe("https://www.youtube.com/@kanal/videos");
|
||||||
|
args.IndexOf(args[^1]).ShouldBeGreaterThan(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jedes_Argument_ist_ein_eigener_Eintrag()
|
||||||
|
{
|
||||||
|
// Entscheidend: Kein Eintrag darf mehrere Argumente enthalten — sonst könnten
|
||||||
|
// sie beim Start wieder aufgetrennt werden.
|
||||||
|
var args = YouTubeUrl.BuildAudioDownloadArgs(
|
||||||
|
@"C:\Pfad mit Leerzeichen\datei.mp3",
|
||||||
|
"https://www.youtube.com/watch?v=abc",
|
||||||
|
@"C:\ffmpeg\bin",
|
||||||
|
convertToMp3: true);
|
||||||
|
|
||||||
|
args.ShouldContain(@"C:\Pfad mit Leerzeichen\datei.mp3");
|
||||||
|
args.ShouldContain(@"C:\ffmpeg\bin");
|
||||||
|
args.ShouldContain("--audio-format");
|
||||||
|
args.ShouldContain("mp3");
|
||||||
|
args[^1].ShouldBe("https://www.youtube.com/watch?v=abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ohne_ffmpeg_entfaellt_die_Konvertierung()
|
||||||
|
{
|
||||||
|
var args = YouTubeUrl.BuildAudioDownloadArgs(
|
||||||
|
"/tmp/x.%(ext)s", "https://youtu.be/abc", null, convertToMp3: false);
|
||||||
|
|
||||||
|
args.ShouldNotContain("--audio-format");
|
||||||
|
args.ShouldNotContain("--ffmpeg-location");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user