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>
227 lines
8.9 KiB
C#
227 lines
8.9 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Tools;
|
|
using Microsoft.Data.SqlClient;
|
|
using Microsoft.Extensions.Logging;
|
|
using MongoDB.Bson;
|
|
using MongoDB.Driver;
|
|
using MySqlConnector;
|
|
using Npgsql;
|
|
|
|
namespace ClawdDotNet.Tools.Database;
|
|
|
|
public enum DatabaseAccessLevel
|
|
{
|
|
ReadOnly,
|
|
ReadWrite,
|
|
Admin
|
|
}
|
|
|
|
public sealed class DatabaseTool : IAgentTool
|
|
{
|
|
public string Name => "Database";
|
|
|
|
public string Description => "Erlaubt den Zugriff auf SQL (MySQL, Postgres, MSSQL) und NoSQL (MongoDB) Datenbanken mit verschiedenen Zugriffsebenen.";
|
|
|
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["query", "insert", "upsert"],
|
|
"description": "Die auszuführende Aktion"
|
|
},
|
|
"sql": {
|
|
"type": "string",
|
|
"description": "Das auszuführende SQL Statement (nur für SQL-Typen)"
|
|
},
|
|
"collection": {
|
|
"type": "string",
|
|
"description": "Der Name der Collection (nur für MongoDB)"
|
|
},
|
|
"filter": {
|
|
"type": "string",
|
|
"description": "JSON-Filter (nur für MongoDB)"
|
|
},
|
|
"document": {
|
|
"type": "string",
|
|
"description": "JSON-Dokument zum Einfügen/Update (nur für MongoDB)"
|
|
}
|
|
},
|
|
"required": ["action"]
|
|
}
|
|
""").RootElement.Clone();
|
|
|
|
public async Task<ToolResult> ExecuteAsync(
|
|
JsonElement input,
|
|
AgentToolContext context,
|
|
CancellationToken ct)
|
|
{
|
|
var action = input.GetProperty("action").GetString()
|
|
?? throw new ArgumentException("'action' is required");
|
|
|
|
var type = context.ToolConfig.TryGetValue("type", out var t) ? t?.ToString()?.ToLowerInvariant() : null;
|
|
var connectionString = context.ToolConfig.TryGetValue("connectionString", out var cs) ? cs?.ToString() : null;
|
|
|
|
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(connectionString))
|
|
{
|
|
return ToolResult.Fail("Konfigurationsfehler: 'type' und 'connectionString' sind erforderlich.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (type == "mongodb")
|
|
{
|
|
return await HandleMongoAsync(action, input, connectionString, context, ct);
|
|
}
|
|
else
|
|
{
|
|
return await HandleSqlAsync(type, action, input, connectionString, context, ct);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
context.Logger.LogError(ex, "Fehler bei Database Aktion {Action} ({Type})", action, type);
|
|
return ToolResult.Fail($"Fehler: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> HandleSqlAsync(string type, string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var sql = input.TryGetProperty("sql", out var s) ? s.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(sql)) return ToolResult.Fail("'sql' ist erforderlich.");
|
|
|
|
var accessLevel = GetAccessLevel(context);
|
|
|
|
// Statement analysieren: Kommentare, Mehrfach-Statements und String-Literale
|
|
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
|
|
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
|
|
|
|
if (!inspection.IsValid)
|
|
return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}");
|
|
|
|
switch (inspection.Operation)
|
|
{
|
|
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
|
|
{
|
|
"mysql" => new MySqlConnection(connectionString),
|
|
"postgres" => new NpgsqlConnection(connectionString),
|
|
"mssql" => new SqlConnection(connectionString),
|
|
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
|
|
};
|
|
|
|
await conn.OpenAsync(ct);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
|
|
if (action == "query")
|
|
{
|
|
using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
var results = new List<Dictionary<string, object>>();
|
|
while (await reader.ReadAsync(ct))
|
|
{
|
|
var row = new Dictionary<string, object>();
|
|
for (int i = 0; i < reader.FieldCount; i++)
|
|
{
|
|
row[reader.GetName(i)] = reader.GetValue(i);
|
|
}
|
|
results.Add(row);
|
|
}
|
|
return ToolResult.Ok(JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
else
|
|
{
|
|
var affected = await cmd.ExecuteNonQueryAsync(ct);
|
|
return ToolResult.Ok($"{affected} Zeilen betroffen.");
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> HandleMongoAsync(string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich.");
|
|
|
|
if (!IsCollectionAllowed(collectionName, context))
|
|
{
|
|
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt.");
|
|
}
|
|
|
|
var accessLevel = GetAccessLevel(context);
|
|
var client = new MongoClient(connectionString);
|
|
var dbName = new MongoUrl(connectionString).DatabaseName;
|
|
var db = client.GetDatabase(dbName);
|
|
var collection = db.GetCollection<BsonDocument>(collectionName);
|
|
|
|
if (action == "query")
|
|
{
|
|
var filterJson = input.TryGetProperty("filter", out var f) ? f.GetString() : "{}";
|
|
var filter = BsonDocument.Parse(filterJson);
|
|
var docs = await collection.Find(filter).Limit(100).ToListAsync(ct);
|
|
var results = docs.Select(d => d.ToJson()).ToList();
|
|
return ToolResult.Ok("[" + string.Join(",", results) + "]");
|
|
}
|
|
else if (action == "insert")
|
|
{
|
|
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
|
|
var docJson = input.TryGetProperty("document", out var d) ? d.GetString() : throw new ArgumentException("'document' erforderlich.");
|
|
var doc = BsonDocument.Parse(docJson);
|
|
await collection.InsertOneAsync(doc, cancellationToken: ct);
|
|
return ToolResult.Ok("Dokument erfolgreich eingefügt.");
|
|
}
|
|
else // upsert
|
|
{
|
|
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
|
|
return ToolResult.Fail("Upsert für MongoDB noch nicht voll implementiert.");
|
|
}
|
|
}
|
|
|
|
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
|
|
{
|
|
if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
|
|
{
|
|
if (Enum.TryParse<DatabaseAccessLevel>(val.ToString(), true, out var level))
|
|
return level;
|
|
}
|
|
|
|
// Rückfall auf altes 'allowWrite' für Abwärtskompatibilität
|
|
if (context.ToolConfig.TryGetValue("allowWrite", out var aw) && aw is JsonElement je && je.GetBoolean())
|
|
{
|
|
return DatabaseAccessLevel.ReadWrite;
|
|
}
|
|
|
|
return DatabaseAccessLevel.ReadOnly;
|
|
}
|
|
|
|
internal static List<string> GetAllowedTables(AgentToolContext context)
|
|
{
|
|
if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je
|
|
|| je.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return je.EnumerateArray()
|
|
.Select(x => x.GetString())
|
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
|
.ToList()!;
|
|
}
|
|
|
|
/// <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);
|
|
}
|