AISecurity.NETSecretsDeveloper Tools

Eén klik op 'Don't ask again' en je databasewachtwoord staat op GitHub: secrets in commando's bij .NET-teams

Claude Code bewaarde goedgekeurde shell-commando's inclusief wachtwoord in .claude/settings.local.json, en 215 publieke bestanden ontsnapten aan Gitleaks én TruffleHog. Wat .NET-teams hiervan leren over secrets in commandoregels.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 9 november 2026 · 11 min lezen
Eén klik op 'Don't ask again' en je databasewachtwoord staat op GitHub: secrets in commando's bij .NET-teams

Je zit midden in een migratie. De agent wil psql draaien tegen de testdatabase, met het wachtwoord gewoon in de regel, en vraagt om toestemming. Je hebt dit al vier keer goedgekeurd. Dus je klikt op "Yes, and don't ask again". Dat voelt als een instelling. Het blijkt een bestand te zijn.

Securitybedrijf Apiiro publiceerde op 5 november onderzoek naar precies die klik, en het stond dit weekend met 600 punten en ruim 400 reacties bovenaan Hacker News. Claude Code bewaart een goedgekeurd commando letterlijk als allow-regel in .claude/settings.local.json. Zit er een wachtwoord in het commando, dan zit het wachtwoord in dat bestand. Apiiro vond tussen 1 en 3 november 1.122 publieke kopieën van dat bestand met minstens één secret erin, samen 1.379 unieke secrets. Daarvan waren er 379 databasewachtwoorden, 42 GitHub-, GitLab- of npm-tokens en 23 AWS-sleutels.

Ik wil het hier niet hebben over de vraag of Anthropic dit beter had moeten oplossen. Dat had gemoeten, en daar kom ik kort op terug. Interessanter vind ik wat dit zegt over een gewoonte die in .NET-teams minstens zo diep zit: secrets in commandoregels.

Waarom de vangrail van Claude Code niet hielp

Claude Code probeert het bestand wel buiten git te houden. Volgens de documentatie voegt het de eerste keer dat het settings.local.json aanmaakt **/.claude/settings.local.json toe aan je globale git-excludes, meestal ~/.config/git/ignore. Dat werkt op jouw laptop. Op geen enkele andere plek.

Een globaal excludes-bestand reist niet mee. Je collega die de repo kloont heeft het niet. Je CI-runner evenmin. Apiiro noemt nog een paar gaten. Soms was de map nog geen git-repository toen het bestand ontstond. En soms volgde git het bestand al, en dan doet een ignore-regel helemaal niets meer.

Op Hacker News vatte een reageerder het ketentje goed samen: "don't ask again" schrijft het wachtwoord naar een projectbestand, en ook in een private repo kan iedereen met leestoegang het dan zien. Boris Cherny van Anthropic reageerde in dezelfde draad dat ze een paar richtingen gaan prototypen, waaronder ingebouwde secret scanning. Opvallend detail uit de berichtgeving: een bugmelding hierover (#5544) bestond al sinds augustus 2025 en werd in januari automatisch gesloten wegens inactiviteit.

Het echte probleem is ouder dan AI-agents

Haal de agent weg en kijk wat er overblijft. Een wachtwoord dat als argument aan een proces wordt meegegeven. Dat was altijd al een slecht idee. Het belandt in je shell-history. Op Linux kan elke gebruiker op dezelfde machine het via ps of /proc/<pid>/cmdline lezen zolang het proces draait. Het komt terecht in CI-logs als iemand set -x aanzet. De agent heeft er alleen een plek bij gemaakt waar het permanent en in een committable bestand terechtkomt.

In de .NET-wereld zie ik dit patroon het vaakst bij EF Core:

dotnet ef database update --connection "Host=db.test.internal;Database=orders;Username=app;Password=Zomer2026!"

Die --connection-optie is handig, en precies daarom gebruiken mensen hem. Laat je een agent migraties draaien en keur je dit commando permanent goed, dan staat die connection string vanaf dat moment in een JSON-bestand in je werkmap. Hetzelfde geldt voor sqlcmd -P, mysql -p<wachtwoord>, PGPASSWORD=... psql en curl -H "Authorization: Bearer ...".

De betere vorm laat het secret uit de regel. Voor dotnet ef is dat makkelijk, want de tooling start je eigen host en leest dus dezelfde configuratie als je app. Staat de connection string in user-secrets of in een omgevingsvariabele, dan is dit genoeg:

dotnet ef database update

En de allow-regel die de agent opslaat is dan Bash(dotnet ef database update:*). Saai, en er staat niets in dat iemand kan misbruiken. Hoe je user-secrets en Key Vault inricht beschreef ik eerder al in het stuk over agents die je .env lezen, dus dat sla ik hier over.

Het beste wachtwoord is geen wachtwoord

Wat je niet kunt plakken, kun je ook niet lekken. Voor Azure SQL en Azure Database for PostgreSQL kun je het wachtwoord helemaal weglaten en met een Entra-identiteit inloggen. Lokaal is dat je az login, in productie een managed identity.

Voor SQL Server via Microsoft.Data.SqlClient is het één sleutel in de connection string:

Server=tcp:orders-sql.database.windows.net;Database=orders;Authentication=Active Directory Default;Encrypt=True;

Voor PostgreSQL via Npgsql haal je periodiek een token op en geef je dat als wachtwoord mee:

using Azure.Core;
using Azure.Identity;
using Npgsql;

var credential = new DefaultAzureCredential();
var tokenContext = new TokenRequestContext(
    ["https://ossrdbms-aad.database.windows.net/.default"]);

// Program.cs, na WebApplication.CreateBuilder(args); vereist Npgsql.DependencyInjection
builder.Services.AddNpgsqlDataSource(
    "Host=orders-pg.postgres.database.azure.com;Database=orders;Username=orders-api;SSL Mode=Require",
    dataSource => dataSource.UsePeriodicPasswordProvider(
        async (_, ct) => (await credential.GetTokenAsync(tokenContext, ct)).Token,
        successRefreshInterval: TimeSpan.FromMinutes(55),
        failureRefreshInterval: TimeSpan.FromSeconds(10)));

Zo'n token leeft ongeveer een uur. Lekt hij toch via een log of een agentbestand, dan is het venster klein. Vergelijk dat met een databasewachtwoord dat al drie jaar hetzelfde is omdat niemand weet welke services het gebruiken. Apiiro meldde dat bij minstens één getroffen organisatie de gelekte credentials nog actief waren. Dat verbaast me niets.

Niet elke klant draait op Azure, en niet elke database ondersteunt dit. Voor de rest geldt: geef de agent een eigen account met alleen de rechten die hij nodig heeft, op een database die je morgen kunt weggooien.

Repo-hygiëne die wel meereist

De globale excludes van Claude Code zijn een persoonlijke instelling. Wat het team beschermt, staat in de repo zelf. Zet dit in je .gitignore:

# Lokale agent-instellingen: per ontwikkelaar, nooit committen
**/.claude/settings.local.json

Het gedeelde .claude/settings.json mag je wel committen. Apiiro telde ook secrets in dat bestand, dus behandel het als code: review elke wijziging. Je kunt er ook deny-regels in zetten, zodat de agent sowieso van bepaalde bestanden afblijft:

{
  "permissions": {
    "allow": [
      "Bash(dotnet build:*)",
      "Bash(dotnet test:*)",
      "Bash(dotnet ef database update:*)"
    ],
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./**/secrets.json)"
    ]
  }
}

Vergeet je Docker-images niet. Een groot deel van de .NET-Dockerfiles die ik tegenkom begint met COPY . . in de build-stage. Staat .claude/ in je werkmap, dan zit het in je build-context en mogelijk in een tussenlaag. Een .gitignore helpt daar niet, want Docker leest alleen .dockerignore:

.git
.claude/
**/.env
**/secrets.json
**/bin/
**/obj/

Een scanner die commando's herkent

Het pijnlijkste getal uit het onderzoek gaat over detectie. Gitleaks markeerde 564 van de bestanden, TruffleHog 643, en 215 bestanden ontsnapten aan allebei. Logisch eigenlijk. Die tools zijn sterk op secrets met een herkenbare vorm, zoals een ghp_-token of een AWS-sleutel die met AKIA begint. Een wachtwoord als Zomer2026! achter PGPASSWORD= heeft geen vorm. Je herkent het alleen aan de context: het commando eromheen.

Dus schrijf je een kleine aanvulling die op die context let. Met de file-based apps van .NET 10 is dat één bestand zonder projectbestand:

// tools/secretscan.cs
// Pre-commit: dotnet run tools/secretscan.cs
// CI, hele repo: dotnet run tools/secretscan.cs -- --all
using System.Diagnostics;
using System.Text.RegularExpressions;

// (?![$%{]) slaat verwijzingen als $PGPASS, %PWD% en {Password} over.
var rules = new (string Name, Regex Pattern)[]
{
    ("PGPASSWORD inline", new Regex(@"PGPASSWORD=(?![$%{])[^\s""';]{3,}")),
    ("sshpass -p", new Regex(@"sshpass\s+-p\s*(?![$%{])\S{3,}")),
    ("mysql -p<wachtwoord>", new Regex(@"\bmysql\b.*\s-p(?![$%{\s])\S{3,}")),
    ("sqlcmd -P", new Regex(@"\bsqlcmd\b.*\s-P\s+(?![$%{])\S{3,}")),
    ("Password= in connection string", new Regex(@"(?i)\b(password|pwd)\s*=\s*(?![$%{])[^;""'\s]{3,}")),
    ("credentials in URL", new Regex(@"(?i)\b[a-z][a-z0-9+.-]*://[^\s:/@""']+:(?![$%{])[^\s@/""']{3,}@")),
    ("Authorization-header", new Regex(@"(?i)authorization:\s*(bearer|basic)\s+(?![$%{])[A-Za-z0-9._~+/=-]{12,}")),
};

var listing = args.Contains("--all")
    ? Git("ls-files")
    : Git("diff", "--cached", "--name-only", "--diff-filter=ACMR");

var findings = 0;
foreach (var path in listing.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
    if (path.EndsWith(".claude/settings.local.json", StringComparison.OrdinalIgnoreCase))
    {
        Console.Error.WriteLine($"{path}: lokaal agentbestand hoort niet in git");
        findings++;
        continue;
    }

    // Lees de versie uit de index, niet uit de werkmap: dat is wat je commit.
    var lines = Git("show", $":{path}").Split('\n');
    for (var i = 0; i < lines.Length; i++)
    {
        foreach (var (name, pattern) in rules)
        {
            if (pattern.IsMatch(lines[i]))
            {
                // Bewust zonder de waarde: de scanner zelf mag niet lekken.
                Console.Error.WriteLine($"{path}:{i + 1}: {name}");
                findings++;
            }
        }
    }
}

if (findings > 0)
{
    Console.Error.WriteLine($"{findings} mogelijke secret(s) gevonden.");
    Console.Error.WriteLine("Vals alarm? Commit met --no-verify en leg in de PR uit waarom.");
}

return findings > 0 ? 1 : 0;

static string Git(params string[] args)
{
    var psi = new ProcessStartInfo("git")
    {
        RedirectStandardOutput = true,
        UseShellExecute = false,
    };
    foreach (var arg in args) psi.ArgumentList.Add(arg);

    using var process = Process.Start(psi)!;
    var output = process.StandardOutput.ReadToEnd();
    process.WaitForExit();
    return output;
}

Koppel het aan een hook die je in de repo meelevert:

mkdir -p .githooks
printf '#!/bin/sh\nexec dotnet run tools/secretscan.cs\n' > .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks

Die laatste regel moet elke ontwikkelaar één keer draaien, en daar zit dezelfde zwakte als bij de globale excludes. Daarom draait dezelfde scan met --all ook in CI, naast Gitleaks. De hook is voor snelle feedback. De CI-check is de echte poort.

Verwacht wat valse meldingen. Password=dev in een devcontainer-config gaat af, en dat vind ik prima. Een lokaal wachtwoord dat in git staat, belandt vroeg of laat ergens anders. Elke git ls-files-regel start bovendien een proces, dus op een monorepo met 40.000 bestanden wil je de --all-modus beperken tot tekstbestanden of vervangen door één git grep. Voor een gemiddelde klantrepo merk je er niets van.

Liever één tool? Gitleaks accepteert eigen regels bovenop de standaardset. Zet in .gitleaks.toml een regel per commandovorm:

[extend]
useDefault = true

[[rules]]
id = "inline-pgpassword"
description = "PGPASSWORD direct in een commando"
regex = '''PGPASSWORD=([^\s$%{'";]{3,})'''
keywords = ["pgpassword"]

[[rules]]
id = "connection-string-password"
description = "Password= in een connection string"
regex = '''(?i)\b(?:password|pwd)\s*=\s*([^\s;'"$%{]{3,})'''
keywords = ["password", "pwd"]

Als het al gelekt is

Kijk eerst of het bestand ooit in je geschiedenis heeft gestaan, in elke branch:

git log --all --oneline -- ':(glob)**/.claude/settings.local.json'

Krijg je output, dan is de volgorde belangrijk. Eerst roteren, dan opruimen. Elk wachtwoord en elke token in dat bestand behandel je als publiek, ook als de repo private is. Zet het nieuwe secret in een vault of user-secrets. Pas daarna haal je het bestand uit git:

git rm --cached .claude/settings.local.json
git commit -m "Stop met tracken van lokale agent-instellingen"

Geschiedenis herschrijven met git filter-repo kan, maar het verandert weinig aan een publieke repo. Forks en clones hebben de oude commits al. Rotatie is de enige stap die echt telt.

Ruim daarna de allow-regels zelf op. In Claude Code zie je ze via /permissions. Gooi alles weg waar een waarde in staat die je niet op een whiteboard zou schrijven.

Wat ik klanten nu adviseer

Mijn standaard voor agent-gedreven werk ziet er sinds dit weekend zo uit. De agent krijgt geen productie-credentials, punt. Op Azure praat de app wachtwoordloos met de database. Elke repo heeft de .claude/settings.local.json-regel in .gitignore en een .dockerignore die .claude/ uitsluit. In CI draait naast Gitleaks een scan die op commandovormen let, omdat de standaardregels juist die vorm missen.

En wie "don't ask again" klikt, kijkt eerst wat er in het commando staat. Dat kost twee seconden.

Veelgestelde vragen over agent-instellingen en gelekte secrets

Geldt dit probleem alleen voor Claude Code? Apiiro onderzocht specifiek .claude/settings.local.json, maar het mechanisme is algemeen: elke tool die goedgekeurde commando's letterlijk bewaart, bewaart ook de secrets die erin staan. Controleer bij elke agent of IDE-extensie die je gebruikt waar die toestemmingen op schijf terechtkomen en of dat pad in je projectmap ligt.

Is een private repository genoeg bescherming? Nee, want iedereen met leestoegang, elke CI-runner en elke integratie met repo-rechten kan het bestand lezen. Een private repo verkleint het publiek, maar een databasewachtwoord hoort ook niet bij elke stagiair of elke third-party app met leesrechten terecht te komen.

Moet ik de git-geschiedenis herschrijven na een lek? Rotatie gaat altijd voor, omdat het oude secret na een publieke push als gecompromitteerd geldt, ongeacht wat je daarna met de geschiedenis doet. Herschrijven met git filter-repo is vooral zinvol bij een private repo om te voorkomen dat nieuwe clones het oude secret meekrijgen, en vraagt dat alle teamleden opnieuw klonen.

Werkt wachtwoordloze toegang ook buiten Azure? Ja, AWS RDS ondersteunt IAM-authenticatie met kortlevende tokens en Google Cloud SQL heeft IAM-database-authenticatie, beide te combineren met de periodieke password provider van Npgsql. On-premises SQL Server kan met Windows-authenticatie werken, zodat er geen wachtwoord in de connection string staat.

Verder lezen


Conclusie: Het lek in Claude Code is een bug in een tool. Daaronder zit een gewoonte die we zelf hebben: wachtwoorden typen in commando's. Haal ze uit de regel, laat waar het kan de identiteit het werk doen, en scan op de context van een secret, want wie alleen op vorm scant, mist bijna een op de vijf gelekte bestanden.

Bronnen: Apiiro: Claude Code's "Don't Ask Again" exposes passwords in 1,122 public GitHub files · Hacker News-discussie · samenvatting met scannercijfers (DEV Community).

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op