Secrets and Dependencies

Keep secrets and personal data out of logs with classification and redaction, and keep NuGet packages and base images free of known flaws.

Syntaxbuilder.Logging.EnableRedaction(); builder.Services.AddRedaction(r => r.SetHmacRedactor(hmacSection, new DataClassificationSet(Personal))); [LoggerMessage(...)] static partial void Paid(ILogger l, [CustomerData] string email);

Most leaked credentials are not stolen from a vault; they are copied out of logs, error pages and build artifacts. The second risk is code you did not write: NuGet packages and the base image your container runs on.

Where secrets leak

  • Request URLs. The hosting diagnostics logger writes Request starting HTTP/1.1 GET https://.../orders?apiKey=... at Information. Never accept credentials in a query string; the templates' "Microsoft.AspNetCore": "Warning" level hides that line, but proxies and access logs keep it.
  • Objects logged whole. A record's generated ToString prints every property, so {Options} or {@Request} writes connection strings and card tokens.
  • Exception messages that contain connection strings or the SQL with values.
  • Configuration dumps. GetDebugView() prints every value unless you mask it.
  • HTTP logging. AddHttpLogging logs only allow-listed headers and writes [Redacted] for others such as Authorization and Cookie; adding them to the list, or logging bodies, undoes that.

Classification and redaction

The Microsoft.Extensions.Compliance.Redaction package makes redaction part of the logging pipeline instead of something each developer remembers. Define classifications (DataClassification("Shop", "Personal")), wrap them in attributes deriving from DataClassificationAttribute, and put those attributes on the parameters of [LoggerMessage] methods. AddRedaction maps classifications to redactors and builder.Logging.EnableRedaction() (from Microsoft.Extensions.Telemetry) applies them when the log is written.

RedactorOutputUse for
ErasingRedactor (the fallback)Empty stringSecrets: tokens, passwords, card data
HMAC redactorStable keyed hash with a key id prefixPersonal data you still need to correlate: one customer's email gives the same value in every line

Without EnableRedaction, classified parameters are dropped from the message rather than written in clear. The HMAC key is itself a secret: keep it in the secret store and keep the key id so you can rotate.

Dependencies

NuGet audit warns NU1901 to NU1904 (low to critical) on every restore, transitive packages included. The course's policy: pull requests and local builds show them as warnings, and high and critical advisories (NU1903, NU1904) become errors only in the main-branch build and a scheduled audit job, through a StrictAudit property only those jobs set; CI Quality Gates has the setup. Lock files and package source mapping, covered in NuGet and Central Packages, make CI restore exactly what was reviewed and stop a public package with an internal name from being pulled in. Add an SBOM per build so "are we affected?" by a new advisory is a search, not an investigation.

Base images

.NET ships security fixes monthly, usually on Patch Tuesday. An image built once and never rebuilt keeps the old runtime and old OS packages forever. Rebuild on a schedule and on every runtime release, pin by digest with an automated updater (Dependabot or Renovate) proposing the bumps, scan the image in CI, and prefer chiseled images, which ship far fewer packages to patch.

Example

Example · csharp
using Microsoft.Extensions.Compliance.Classification;
using Microsoft.Extensions.Compliance.Redaction;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
builder.Logging.EnableRedaction(); // Microsoft.Extensions.Telemetry

builder.Services.AddRedaction(redaction =>
{
    // Secrets are erased entirely.
    redaction.SetRedactor<ErasingRedactor>(new DataClassificationSet(ShopData.Secret));

    // Personal data becomes a keyed hash: the same email gives the same value in every log line.
    // appsettings: "Redaction": { "Hmac": { "KeyId": 7, "Key": "<from-secret-store>" } }
    redaction.SetHmacRedactor(builder.Configuration.GetSection("Redaction:Hmac"),
        new DataClassificationSet(ShopData.Personal));
});

var app = builder.Build();

app.MapPost("/payments", (PaymentRequest payment, ILogger<PaymentRequest> logger) =>
{
    PaymentLog.Authorised(logger, payment.OrderId, payment.CustomerEmail, payment.PaymentMethodToken, payment.Amount);
    return TypedResults.Accepted($"/payments/{payment.OrderId}");
});

app.Run();

// Logs: {"OrderId":"ord-1042","CustomerEmail":"7:S/x60+HSnSH1lpzgnrm/KA==","PaymentMethodToken":"","Amount":129.9}

public record PaymentRequest(string OrderId, string CustomerEmail, string PaymentMethodToken, decimal Amount);

public static class ShopData
{
    public static DataClassification Personal => new("Shop", "Personal");
    public static DataClassification Secret => new("Shop", "Secret");
}

public sealed class CustomerDataAttribute() : DataClassificationAttribute(ShopData.Personal);
public sealed class SecretDataAttribute() : DataClassificationAttribute(ShopData.Secret);

public static partial class PaymentLog
{
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Payment authorised for {OrderId} by {CustomerEmail} using {PaymentMethodToken}, amount {Amount}")]
    public static partial void Authorised(ILogger logger, string orderId,
        [CustomerData] string customerEmail, [SecretData] string paymentMethodToken, decimal amount);
}

When to use it

  • Log payment authorisations with the customer's email as a stable HMAC value and the payment token erased, so support can trace one customer without seeing raw data.
  • Fail the scheduled audit or main build when a transitive package gets a high-severity advisory (NU1903), while pull requests only show it as a warning.
  • Rebuild and rescan the orders API image weekly so a monthly .NET runtime patch reaches production without a code change.

More examples

Records and configuration dumps that do not leak

<code>GetDebugView</code> shows every key, its value and the provider it came from, which is the fastest way to debug configuration in a deployed pod and the fastest way to leak it; the <code>processValue</code> callback masks values by path. Mask by allow-list, because secret keys rarely all contain the words a deny-list looks for, and never log the full view in production, where it lands in the central log store from every pod. A record's <code>ToString</code> is built from <code>PrintMembers</code>, so defining it yourself controls every place the record is turned into text, including string interpolation and log arguments.

Example · csharp
using System.Text;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Development only, and masked by allow-list: a value prints only when its
// path is known to be harmless. A deny-list of words misses Smtp:Pass,
// Payments:Stripe:Sk or a URL with credentials under Database:Url.
if (app.Environment.IsDevelopment())
{
    string[] safePrefixes = ["Logging", "AllowedHosts", "Urls", "Kestrel:Endpoints", "Orders:PageSize"];
    var view = builder.Configuration.GetDebugView(context =>
        safePrefixes.Any(prefix => context.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            ? context.Value ?? ""
            : "***");
    app.Logger.LogInformation("Effective configuration:{NewLine}{Configuration}", Environment.NewLine, view);
}

app.MapGet("/", () => "ok");
app.Run();

public sealed record PaymentGatewayOptions
{
    public required Uri BaseAddress { get; init; }
    public required string MerchantId { get; init; }
    public required string ApiKey { get; init; }

    // Replace the generated member list so ToString and logging never print the key.
    private bool PrintMembers(StringBuilder builder)
    {
        builder.Append($"BaseAddress = {BaseAddress}, MerchantId = {MerchantId}, ApiKey = ***");
        return true;
    }
}

Directory.Build.props: audit, lock files and errors

Putting the settings in <code>Directory.Build.props</code> applies them to every project in the repository. An advisory published overnight shows as a warning on pull requests instead of blocking unrelated changes, while the scheduled audit and <code>main</code> builds fail on NU1903 and NU1904 and still only warn on moderate ones (see CI Quality Gates). <code>RestoreLockedMode</code> makes a CI restore fail if the lock file does not match, so a floating or transitive version cannot change between review and release; GitHub Actions sets <code>CI=true</code>.

Example · xml
<Project>
  <PropertyGroup>
    <!-- Audit direct and transitive packages on every restore. -->
    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditMode>all</NuGetAuditMode>
    <NuGetAuditLevel>moderate</NuGetAuditLevel>

    <!-- Advisories stay warnings in pull-request and local builds. -->
    <WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>

    <!-- packages.lock.json per project; CI restores with locked mode. -->
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
    <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
  </PropertyGroup>

  <!-- High (NU1903) and critical (NU1904) advisories fail the scheduled audit and
       main-branch builds, which pass -p:StrictAudit=true. -->
  <PropertyGroup Condition="'$(StrictAudit)' == 'true'">
    <WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
  </PropertyGroup>
</Project>

A supply-chain job in GitHub Actions

The schedule matters as much as the push trigger: an advisory published on Wednesday affects code that has not changed in months. The gate is the restore with <code>StrictAudit=true</code>; the vulnerable-package listing only prints. The SBOM records every package and version in the published output, which turns a new CVE into a search rather than an investigation. Scanning the built image catches OS package flaws in the base image that NuGet audit never sees, assuming Trivy is installed on the runner.

Example · yaml
name: supply-chain
on:
  push:
    branches: [main]
  schedule:
    - cron: "0 6 * * 3"   # weekly, so new advisories surface without a code change

jobs:
  audit:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      # Only scheduled and main runs reach this job, so high and critical advisories fail here.
      - name: Restore exactly what the lock files say
        run: dotnet restore --locked-mode -p:StrictAudit=true

      # A report for the log, not a gate: it exits 0 even when it finds something.
      - name: Report vulnerable packages
        run: dotnet list package --vulnerable --include-transitive

      - name: Publish and generate an SBOM
        run: |
          dotnet publish src/Orders.Api -o out
          dotnet tool install --global Microsoft.Sbom.DotNetTool
          sbom-tool generate -b out -bc src/Orders.Api -pn orders-api -pv ${{ github.sha }} -ps "Example Shop" -nsb https://sbom.example.com

      - name: Build and scan the image
        run: |
          docker build -t orders-api:${{ github.sha }} .
          trivy image --exit-code 1 --severity HIGH,CRITICAL orders-api:${{ github.sha }}

Discussion

  • Be the first to comment on this lesson.