# ASP.NET Core

> Monitor an ASP.NET Core app: a middleware logging one JSON line per request with Serilog, Identity sign-in events, and beam monitor with the .NET rules.

Source: https://docs.agentbeam.com/cookbooks/frameworks/aspnet-core

## How Beam reads a ASP.NET Core app

There's no in-process SDK for this stack, so the app writes what Beam needs to its logs and [`beam monitor`](https://docs.agentbeam.com/agent-cli/app-monitoring#install-and-start) reads them on the same server or container. Only redacted findings leave it. Two kinds of line:

- **One JSON line per request**, with `method`, `path` (with the query string), `status`, `client_ip`, `user_agent`, `host` and `duration_ms`. The status is what lets Beam tell an attack your app refused from one it answered, and `client_ip` is the address Beam can [pause](#pause-attackers).
- **Security events as plain lines**, worded like this (Beam matches the wording and reads `user=` and `ip=`):

```text
login failed user=42 ip=203.0.113.7
login succeeded user=42 ip=203.0.113.7
granted admin role to user=99 by user=12
API key created user=12
{"event":"llm_usage","user":"42","model":"gpt-5","total_tokens":1834}
```

Use an opaque user id, never an email. New outbound hosts are only reported by the Node.js SDK; for this stack, restrict outbound traffic at the network instead.

## Before you start

Do steps 1 and 2 of the [general cookbook](https://docs.agentbeam.com/cookbooks/app-monitoring#step-1-turn-it-on-and-create-the-apps-token) first: App monitoring is on, the app is added, and its `beam_app_` token is in your secret store as `BEAM_APP_TOKEN`. Everything below needs `@agent-beam/beam` 0.2.32 or newer; pausing needs 0.2.35.

## Log each request with its status

```sh
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Formatting.Compact
dotnet add package Serilog.Sinks.File
```

```csharp
// Program.cs
using Serilog;
using Serilog.Formatting.Compact;

var requests = new LoggerConfiguration()
    .WriteTo.File(new CompactJsonFormatter(), "/var/log/app/requests.log")
    .CreateLogger();
var security = new LoggerConfiguration()
    .WriteTo.File(new CompactJsonFormatter(), "/var/log/app/security.log")
    .CreateLogger();

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Serilog.ILogger>(security);
var app = builder.Build();
app.UseForwardedHeaders(); // configure KnownProxies so RemoteIpAddress is the client

app.Use(async (context, next) =>
{
    var started = System.Diagnostics.Stopwatch.GetTimestamp();
    await next();
    var request = context.Request;
    requests.Information("{method} {path} {status} {client_ip} {user_agent} {host} {duration_ms}",
        request.Method,
        request.Path + request.QueryString,
        context.Response.StatusCode,
        context.Connection.RemoteIpAddress?.ToString(),
        request.Headers.UserAgent.ToString(),
        request.Host.ToString(),
        (long)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds);
});
```

Register it before your other middleware. The property names (`method`, `path`, `status` and so on) are what Beam reads.

## Log sign-ins, admins and keys

With ASP.NET Core Identity, after `PasswordSignInAsync`:

```csharp
var result = await signInManager.PasswordSignInAsync(input.Email, input.Password, false, lockoutOnFailure: true);
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
if (result.Succeeded) security.Information("login succeeded user={user} client_ip={client_ip}", userId.ToString(), ip);
else security.Warning("login failed user={user} client_ip={client_ip}", userId.ToString(), ip);
```

Where roles or keys change: `security.Information("granted admin role to user={user} by user={by}", target.Id.ToString(), actor.Id.ToString())` and `security.Information("API key created user={user}", user.Id.ToString())`. Pass ids as strings: Beam reads the `user` property only as a string.

## Run `beam monitor` next to the app

```sh
npm install -g @agent-beam/beam
beam monitor --stack dotnet --file /var/log/app/requests.log --file /var/log/app/security.log --app /srv/app
```

`--stack dotnet` turns on this framework's rules and rolls up attacks on frameworks you don't run; `--app` also checks the app's lockfiles daily (and would detect the stack on its own). Keep it running with systemd:

```ini
# /etc/systemd/system/beam-monitor.service
[Unit]
Description=AgentBeam App monitoring
After=network-online.target

[Service]
EnvironmentFile=/etc/beam/app.env
ExecStart=/usr/bin/env beam monitor --stack dotnet --file /var/log/app/requests.log --file /var/log/app/security.log --app /srv/app
Restart=always
User=beam

[Install]
WantedBy=multi-user.target
```

```sh
sudo install -m 600 /dev/null /etc/beam/app.env && echo "BEAM_APP_TOKEN=beam_app_..." | sudo tee /etc/beam/app.env >/dev/null
sudo systemctl daemon-reload && sudo systemctl enable --now beam-monitor
```

In Docker or Kubernetes, run the same command in a sidecar that mounts the log directory read-only, with `BEAM_APP_TOKEN` from a secret.
## Pause attackers

Beam can [pause](https://docs.agentbeam.com/agent-cli/app-monitoring#pausing-attackers) a sender caught scanning the app, guessing passwords or sending critical exploits. On this stack the web server in front of the app does the refusing: `beam monitor` writes the paused addresses to deny lists and reloads it. Save the lists once:

```sh
sudo install -d -o beam /etc/nginx/beam
beam monitor configure --deny-file /etc/nginx/beam/deny.conf \
  --auth-deny-file /etc/nginx/beam/deny-auth.conf --reload "sudo /usr/sbin/nginx -s reload"
```

```nginx
server {
  include /etc/nginx/beam/deny.conf;          # paused from the whole app
  location /Identity/Account/Login {
    include /etc/nginx/beam/deny-auth.conf;   # paused from sign-in only
    # ...your existing proxy_pass or fastcgi_pass
  }
  # ...your other locations
}
```

Let the `beam` user run the reload (`beam ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload` in `/etc/sudoers.d/beam`), run `sudo nginx -t`, and restart the service. Pausing starts in dry run: the dashboard's **Paused** list shows who would have been paused. Turn it on under **Settings > Pausing** once that list looks right. Pauses use the `client_ip` property, which is safe once `UseForwardedHeaders` lists your proxies in `KnownProxies`.

No nginx you control (Heroku, Render, Cloud Run)? Pauses still show on the dashboard; refuse the address at your CDN or load balancer.
## Prove it works

```sh
beam monitor status   # app, level, stack, usage
beam monitor test     # a test finding through your alert workflows
curl -s "https://your-app.example.com/?id=1%20UNION%20SELECT%201" -o /dev/null
```

Within a minute, the SQL injection test shows on **App monitoring**: under **Needs action** if the app answered 2xx, or counted under "needed nothing from you" if it refused it. Then sign in to a test account with a wrong password ten times and the right one once: **Sign-in succeeded after 10 failed attempts** appears as critical.
## Check dependencies on every pull request

```yaml
# .github/workflows/beam-cve.yml
name: Dependency check
on: [pull_request]
jobs:
  cve:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx -y @agent-beam/beam cve check . --fail-on high
        env:
          BEAM_APP_TOKEN: ${{ secrets.BEAM_APP_TOKEN }}
```

Turn on NuGet lock files (`<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>`) so `packages.lock.json` can be checked.
## What Beam watches for on this stack

Besides every [built-in rule](https://docs.agentbeam.com/agent-cli/app-monitoring#levels-and-detection) and the [attack-in-progress signals](https://docs.agentbeam.com/agent-cli/app-monitoring#signs-an-attack-is-underway):

| Rule | Means | Severity |
| --- | --- | --- |
| ASP.NET diagnostics served | `elmah.axd` or `trace.axd` answered 2xx | High |
| Telerik UI upload exploit | `Telerik.Web.UI.WebResource.axd?type=rau` (CVE-2019-18935) | Critical |
| web.config served | `web.config` answered 2xx | Critical |

Attacks on other frameworks (WordPress probes, Laravel or Spring exploits, and so on) are rolled up as **Not your stack** when there's no status to say otherwise, and never alert.
## Close what Beam would catch

Beam raises these when they're found; better that there's nothing to find:

- Use `UseExceptionHandler` and never the developer exception page in production.
- Remove ELMAH and tracing from public apps, and update or remove old Telerik UI.
- Set `ForwardedHeadersOptions.KnownProxies` so client addresses can't be spoofed.
## Coverage checklist

| Feature | Done when |
| --- | --- |
| Request log with status | Findings show **Answered**, **Refused** or **Errored** |
| Stack declared | `beam monitor` prints the stack at start |
| Sign-in events | `login failed` and `login succeeded` lines are written |
| Admin and key events | Role grants and new keys are logged |
| LLM spend | An `llm_usage` line follows each model call |
| Dependency checks | The CI job runs on pull requests |
| Alerts | Critical pages someone, high reaches a channel ([step 8](https://docs.agentbeam.com/cookbooks/app-monitoring#step-8-send-alerts-to-the-right-place)) |
| Pausing | `client_ip` in the request log, the deny lists included in nginx, and **Settings > Pausing** on after a dry run |
| Routine | Someone reads **Needs action** daily and **Attack analytics** weekly |
