6 Commits
8.1.4 ... 8.2.0

40 changed files with 4355 additions and 11 deletions

View File

@@ -0,0 +1,95 @@
# Case 4678 — Passkey sign-in: design note
Status: decided 2026-11-02; being built. Release: Sockeye 8.2.0, then AyaNova.
Case notes: "Add passkey to sockeye first, confirm then add to AyaNova." So this is built for
AyaNova from the start, and Sockeye is the trial run. Everything marked **Decide** needs John's answer before code.
## How Sockeye signs in today
- `POST auth` checks the login and password. If two-factor is on, it returns a temporary token (`tt`) and the client asks for a TOTP code (`auth/tfa-authenticate`).
- Success goes through `ReturnUserCredsOnSuccessfulAuthentication`, which issues a 5-day JWT and stores it as the user's **single** `CurrentAuthToken`. Every request is checked against it, so a new login replaces the previous one.
- `LoginThrottle` (case 4726) refuses repeated failures by login name and address.
A passkey sign-in should end in the same `ReturnUserCredsOnSuccessfulAuthentication`, so everything after sign-in stays as it is.
## Library
- **ASP.NET Core 10's built-in passkeys belong to ASP.NET Core Identity.** Sockeye and AyaNova don't use Identity, so that support doesn't fit without a large change.
- **`Fido2` (fido2-net-lib)** is the established .NET library. NuGet today has **4.3.2** as the latest stable release (targets net8.0, which runs on .NET 10). 5.0.0 is still in preview (preview.9, 2026-10-29) and targets net10.0.
- **`Fido2` 4.3.2 stable,** approved by John 2026-11-02. Move to 5.x once it's released.
## Server
### Table (one schema step)
`auserpasskey`:
- id, userid (FK to auser, cascade delete)
- credential ID (bytea, unique), public key (bytea), signature counter, AAGUID, transports
- a name the user gives it ("YubiKey", "Phone")
- created, last used
### Routes (`auth/passkey/...`)
| Route | Who | Does |
|---|---|---|
| `register-options` | signed-in user | Returns creation options with a fresh challenge |
| `register` | signed-in user | Verifies the attestation and stores the passkey |
| `login-options` | anyone | Returns request options with a fresh challenge. Discoverable credentials, so no user name is needed first |
| `login` | anyone | Verifies the assertion, updates the counter, then calls `ReturnUserCredsOnSuccessfulAuthentication` |
| `GET` / `DELETE` list | signed-in user (or an admin, for any user) | See and remove one's passkeys |
- **Challenges:** kept in server memory for 5 minutes, keyed by a random ID that the client sends back. Sockeye runs as one process, so memory is enough. AyaNova is also one process per customer.
- **User verification is required** (a PIN or biometric on the device). A passkey sign-in is then two factors by itself, so it **skips the TOTP prompt**.
- **The throttle:** failed passkey sign-ins count against `LoginThrottle` by address.
- **Event log:** registering, removing and signing in with a passkey are logged, the same as password changes.
### Relying party (which site the passkey belongs to)
A passkey is bound to a domain, called the RP ID.
- Sockeye production: `sockeye.ayanova.com`. Local development: `localhost`; browsers allow passkeys on localhost without HTTPS.
- AyaNova: each customer's own host name.
So the RP ID and allowed origins come from configuration, `SOCKEYE_PASSKEY_RPID` and `SOCKEYE_PASSKEY_ORIGINS`, with a sensible default from the request host. They are never hard-coded.
## Client (Vue 2)
- **Login page:** a "Sign in with a passkey" button. It calls `navigator.credentials.get`, converting base64url to and from the browser's binary format.
- **User settings:** a Passkeys section that lists, adds and removes passkeys.
- **UI text uses translation keys,** because this goes to AyaNova (the standing rule for AyaNova-bound features).
- **Browser support:** all current browsers have passkeys. Sockeye's admin UI is used only by you.
## Decided (John, 2026-11-02)
1. **Password stays as a fallback** in 8.2.0. Later, an option lets a user turn off password sign-in once they have at least two passkeys.
2. **Admin recovery:** an admin can remove a user's passkeys. The superuser's break-glass reset (`SOCKEYE_SET_SUPERUSER_PW`, `AYANOVA_SET_SUPERUSER_PW`), which needs administrator access to the server, is the last resort. That is still standard practice for self-hosted software. It must restore plain password sign-in completely: set the password, turn off two-factor, and (once the option exists) turn password sign-in back on. Today it only sets the password, so a superuser who loses their authenticator app can't get in: AyaNova case **4775**. Sockeye's copy of the code gets the same fix inside 4678.
3. **One session per user** stays as it is (as in case 4688).
### Answers to John's questions
- **Mixed networks (self-hosted):**
- A passkey is tied to a domain name and only works over HTTPS (or on `localhost`).
- Users who reach the server at its domain name over HTTPS can use passkeys, including internal users if the office uses the same name (split DNS).
- Users who reach it by IP address or a plain-http local name can't. Browsers don't allow it, so they keep using their password and nothing breaks.
- Internal and external names on one domain can share passkeys: set the passkey domain to the parent domain and list both addresses as allowed origins.
- **How many passkeys:** up to 20 per user, each with its own name, each removable on its own.
- **QBI:** it signs in with login and password (with two-factor support) and is unaffected, because passkeys add a sign-in method and don't take the password away. A user who later turns password sign-in off can't use QBI; the setting and the manual must say so. Passkeys inside QBI itself (Windows' `webauthn.dll`) would be a separate case, if customers ask.
- **V8Migrate:** needs no passkey support and isn't blocked by passkeys. Its documentation should say to sign in with an account that uses a password, without two-factor, and without "password sign-in off".
### Documentation (AyaNova customer manual, with the AyaNova case)
- Passkeys need HTTPS and a domain name; how to set the passkey domain and allowed origins.
- Managing passkeys; recovery; the break-glass reset.
- QBI and V8Migrate: use an account with password sign-in on.
## Tests
- **Integration tests with a software authenticator:** the test project generates its own key pair and builds the `none` attestation and the assertion itself, so the full flow runs with no hardware. Cases:
- register, then sign in;
- a wrong challenge, a wrong origin, or a reused counter is refused;
- a removed passkey can't sign in;
- the throttle applies.
- **Hardware check:** you sign in once with a real device (Windows Hello or a phone) before release.
## Then AyaNova
The same table, routes and client pieces. Run `compare-with-raven.sh` to map the names. The changes for AyaNova:
- the RP ID comes from each customer's configuration;
- the customer manual needs a page on passkeys.
It would be its own AyaNova case, for 8.4.0.

View File

@@ -0,0 +1,63 @@
# Case 4688 — AI agent access (MCP): design note
Status: phase 1 built 2026-11-02. First version 2026-11-01; revised 2026-11-02 after John's
answers on agent Users, licensing, rate limits and OAuth. Release: Sockeye 8.2.0, then AyaNova.
Case notes: let AI agents (Claude Code and others) use Sockeye directly for everyday tasks. Try it
on Sockeye first, then build the same into AyaNova. Everything marked **Decide** needs John's answer before code.
## Decided (John, 2026-11-02)
1. **The package `ModelContextProtocol.AspNetCore` 2.3.0** is approved. The test project uses `ModelContextProtocol.Core` 2.3.0, the same family, for its MCP client.
2. **Rate limits:** the defaults in the table above.
3. **Phase 1 tools:** as listed. John expects agents mostly to *do* things rather than look them up, so phase 2's write tools matter most. For now the tools serve testing and development lookups.
4. **Token expiry:** 90 days by default, with "never" allowed.
## As built (phase 1, Sockeye 8.2.0)
- **Agent flag:** `auser.agent` (schema 29), the "AI agent" checkbox on the user form. The superuser can't be an agent. Agents are refused at the login page, by password and by passkey.
- **Tokens:**
- **Table `auserapitoken`:** id, userid, name, SHA-256 token hash, read-only, created, expires, last used, revoked.
- **Routes:** `agent-token/{userId}` (list), `POST agent-token` (create; the token is in that response only), `DELETE agent-token/{id}` (revoke).
- **Rights:** User rights.
- **Phase 1 tokens are read-only.**
- **Last-use time:** written at most once a minute.
- **The gate:** `AgentAccess`, a middleware after Sockeye's usual one.
- Only `/mcp` is affected.
- It needs `Authorization: Bearer sk_...` for an active agent User.
- It applies the rate limits: one chained `PartitionedRateLimiter`, with a per-agent token bucket, a per-agent concurrency limit and a server-wide concurrency limit.
- The concurrency limits queue a few calls rather than refuse them. Over the limit the answer is 429 with Retry-After, and it's logged.
- It then sets the request up as that User, so the tools see its roles.
- A person's sign-in token is not accepted at `/mcp`.
- **Settings:** `SOCKEYE_AGENT_RATE_BURST` (30), `SOCKEYE_AGENT_RATE_PER_SECOND` (5), `SOCKEYE_AGENT_CONCURRENT` (4), `SOCKEYE_AGENT_CONCURRENT_TOTAL` (8).
- **The MCP endpoint:** `/mcp`, stateless streamable HTTP, `SockeyeMcpTools`.
- Each tool checks read rights for its record type, as the UI does.
- Each returns at most 100 records.
- **Audit:** tool calls go to the **server log** with the agent's name, not the event log. One event row per call would bloat the event log the way health checks did (case 4749). Changes made by phase 2 write tools will go in the event log like any other change.
- **nginx:** `/mcp` can answer as a stream (server-sent events), so the production nginx location needs `proxy_buffering off` for `/mcp`. Check this at deploy.
## Tests
- **Integration tests:**
- agent User:
- an agent User can't sign in on the login page;
- a person's account is refused at `/mcp`.
- tokens:
- a token works;
- a revoked or expired token is refused;
- a read-only token can't write;
- a person's browser session survives while an agent works.
- rate limits:
- the per-agent limits return 429 with Retry-After;
- one agent at its limit doesn't block a second agent or a person;
- MCP:
- connect with the SDK's client, list the tools, call each one;
- an agent User without rights to a type gets nothing from it;
- results are capped.
- **Live check:** you connect it from Claude Code and use it for a week before phase 2.
## Order across the three 8.2.0 cases
4738 (advisory monitor) touches nothing else and goes first. 4678 (passkeys) and 4688 both change
sign-in; passkeys go first so the sign-in code changes in one place at a time. Passkeys are for
people signing in on the web page; agent Users never use that page, so the two don't overlap.

View File

@@ -0,0 +1,94 @@
# Case 4738 — Security advisory monitor: design note
Status: proposal for John's review, 2026-11-01. Nothing built yet. Release: Sockeye 8.2.0.
The case notes are the spec; this note checks them against the live sources and the Sockeye code,
and turns them into a build plan. Everything marked **Decide** needs John's answer before code.
## What the live sources confirm (checked 2026-11-01)
- **Ubuntu releases:** `https://ubuntu.com/security/releases.json` lists `noble` = 24.04 LTS and
`resolute` = 26.04 LTS. Both codenames in the case are right.
- **Notices:** `https://ubuntu.com/security/notices.json?release=<codename>&limit=&offset=` returns
`{notices, offset, limit, total_results}`. Each notice has `id` (`USN-8958-1`), `published`,
`title`, `summary`, `cves_ids`, and `release_packages`: a map from codename to a list of
`{name, version, description, is_source}`. Match on the entries with `is_source: true`.
One notice covers several releases, so the same USN shows up for noble and resolute.
- **CVEs:** `https://ubuntu.com/security/cves/<id>.json` has `priority`
(critical/high/medium/low/negligible), plus `cvss3`, `published` and `updated_at`.
Priority is there as the case expects.
- **CISA KEV:** `https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json`,
about 1.8 MB with 1,766 entries (catalogVersion 2026.10.30). Each entry has `cveID` and `dateAdded`.
- **Still to do at build time:** check the DTO field names against `https://ubuntu.com/security/api/spec.json`,
as the case asks. The fields above came from live responses, not the spec.
## What the fleet actually runs (ayanova-2, read-only check)
- 26.04 (resolute), kernel `7.0.0-38-generic`: the kernel's source package is `linux`, as the case assumed.
- .NET (`dotnet10`) and nginx come from Ubuntu's own archive, so Ubuntu's notices cover them.
- PostgreSQL 15 comes from **PGDG**: the known gap in the case. It is a real exposure on every droplet that runs Sockeye or AyaNova.
- 451 distinct source packages are installed.
## How it fits Sockeye
| Need | Existing Sockeye piece to follow |
|---|---|
| Run every few hours | A new `generator/SockBotSecurityAdvisories.cs`, called from the `JobsBiz` loop with its own interval, like `SockBotSubscriptionServerHealthChecks` (static `lastSweep`, interval constant, shorter in DEBUG) |
| Send the alert | A new notify event type, delivered through the existing subscription system (email or in-app). Not a hard-coded email |
| Source failing for more than 24 h | `NotifyEventHelper.AddOpsProblemEvent`, which already reaches whoever subscribes to `ServerOperationsProblem` |
| Outbound HTTP | Use the same HttpClient pattern as the health checks, with a User-Agent of `Sockeye/<version> (support@ayanova.com)` |
| Settings UI | A new admin page. Sockeye-only, so plain English text, no translation keys |
### New event types (`NotifyEventType`)
- `SecurityAdvisoryAlert = 43`: high or critical priority, or listed in KEV. Sent immediately.
- `SecurityAdvisoryDigest = 44`: the optional Wednesday digest of medium-priority matches.
The numbers 39 and 42 were retired in 8.1.4 and stay unused.
### Tables (one schema step)
- `asecurityasset`: id, name ("Ubuntu 26.04 fleet"), platform (`ubuntu:resolute`), components (text[] of source package names), alert threshold, digest threshold, active. This is the case's asset/watchlist model; phase 1 holds two rows.
- `asecurityadvisory`: the processed-advisory store. Columns: source, source ID (unique together), published, title, url, cve IDs (text[]), matched assets and packages, normalised severity, raw severity, KEV flag, outcome (alerted / digest / ignored / no match), processed timestamp.
- `asecuritycve`: the CVE cache. Columns: id, priority, cvss3, fetched, updated_at. A kernel USN lists dozens of CVEs, so each CVE is fetched once and refreshed only when stale.
- `asecuritysource`: per-source health. Columns: name, checkpoint (newest processed ID or date), last attempt, last success, last error.
The code follows the case's expansion design. An `IAdvisorySource` interface has one implementation, `UbuntuUsnSource`, and `CisaKevEnricher` is a separate step. Only these two exist in phase 1.
## Behaviour beyond the case
- **First run must not alert.** Without a checkpoint, the job would treat every notice back to the start as new. The first run records the last 30 days as processed without alerting. That is the case's dry run, done once automatically.
- **Dry-run report on demand:** an admin route that re-runs the last N days, sends nothing, and returns what *would* have alerted, digested or been ignored. This is the noise check the case asks for before going live.
- **KEV can change after the fact.** A CVE is often added to KEV days after its USN is out. Each run therefore also checks advisories already processed in the last 90 days against KEV entries whose `dateAdded` is newer than the last check, and alerts once if one turns up.
- **One alert per USN,** even when it matches both releases. The alert lists both releases.
## Decided (John, 2026-11-01)
1. **Watchlist: full inventory.** The settings page takes the pasted output of `dpkg-query -W -f='${source:Package}\n' | sort -u` for each release. Priority, not the package list, controls the noise.
2. **Interval:** every 3 hours.
3. **Wednesday digest:** in phase 1.
4. **PGDG gap:** in phase 1, as a second source, the PostgreSQL project's own security announcements (feed to be found and verified at build time). It watches the major versions in use (15 today).
Why the gap exists: Ubuntu ships one PostgreSQL major per release (18 on 26.04, 16 on 24.04), and the droplets run 15 from PGDG, which Ubuntu's notices never cover. Standardising PostgreSQL versions and sources everywhere is a separate, later job.
## As built (differences from the plan above)
- **Severity lookups are expensive on kernel notices.** Ubuntu has no batch query for CVE priority, so each CVE is one request (about a second, 25–60 KB). A kernel notice can list about 1,000 CVEs (USN-8887-1, `linux`: 1,050). Three measures keep this manageable:
- Lookups run four at a time.
- Each priority is cached for 30 days, since a priority rarely changes once set. Other kernel notices listing the same CVEs then cost nothing.
- The first run of a source skips lookups altogether, because it records quietly and needs no severity.
- **The dry run runs in the background.** `POST security-advisory/dry-run?days=` starts it, and `GET security-advisory/dry-run` returns its progress and report. A run that has to fill the cache takes minutes, longer than a request can stay open behind nginx.
- **The PostgreSQL source reads the HTML table** on `https://www.postgresql.org/support/security/`; the project publishes no feed.
- Severity comes from the CVSS score: 9 and up is critical, 7 to 9 high, 4 to 7 medium.
- The page has no dates, so the processed store decides what is new.
- A page with no rows counts as a failed read, so a layout change gets reported.
- **Platforms:** `ubuntu:noble`, `ubuntu:resolute`, `postgresql`. PostgreSQL components are major versions.
- **The admin page** is under Operations, "Security advisories". It shows:
- the sources' health;
- the watched assets, edited by pasting the package list;
- the dry run;
- the matched advisories of the last 60 days.
- **To receive anything,** subscribe to the event "Security advisory alert" and, for the digest, "Security advisory digest", by email or in-app, like any other notification.
## Tests
- The matching and severity rules are plain classes. Unit tests in `sockeye.tests` run them on saved notice and CVE JSON: a high-priority kernel notice alerts; a medium one goes to the digest; a notice for an unwatched package is ignored; a KEV hit alerts regardless of priority; the same USN never alerts twice.
- An integration test calls the dry-run route against the live feed and checks the report's shape. It sends nothing.

View File

@@ -0,0 +1,113 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Biz;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Api.Controllers
{
/// <summary>
/// case 4688: tokens an agent User connects to /mcp with. Administrators (User rights) create and revoke them on the
/// agent User's record; a token is shown once, at creation
/// </summary>
[ApiController]
[ApiVersion("8.0")]
[Route("api/v{version:apiVersion}/agent-token")]
[Produces("application/json")]
[Authorize]
public class AgentTokenController : ControllerBase
{
private readonly AyContext ct;
private readonly ApiServerState serverState;
public AgentTokenController(AyContext dbcontext, ApiServerState apiServerState)
{
ct = dbcontext;
serverState = apiServerState;
}
public sealed class NewTokenParam
{
public long UserId { get; set; }
public string Name { get; set; }
public int? ExpiresDays { get; set; }//null: the default (90); 0: never
}
/// <summary>
/// An agent User's tokens (never the tokens themselves)
/// </summary>
[HttpGet("{userId}")]
public async Task<IActionResult> GetTokens([FromRoute] long userId)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(await ct.UserApiToken.AsNoTracking().Where(z => z.UserId == userId).OrderByDescending(z => z.Created)
.Select(z => new { z.Id, z.Name, z.ReadOnly, z.Created, z.Expires, z.LastUsed, z.Revoked }).ToListAsync()));
}
/// <summary>
/// A new token for an agent User; the response is the only time the token is shown
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateToken([FromBody] NewTokenParam param)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
var user = await ct.User.AsNoTracking().FirstOrDefaultAsync(z => z.Id == param.UserId);
if (user == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
if (!user.Agent)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "userId", "Agent tokens are only for Users marked Agent"));
if (param.ExpiresDays < 0)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "expiresDays"));
var token = AgentAccess.NewToken();
var days = param.ExpiresDays ?? AgentAccess.DEFAULT_EXPIRY_DAYS;
var row = new UserApiToken
{
UserId = user.Id,
Name = string.IsNullOrWhiteSpace(param.Name) ? "Agent token" : StringUtil.MaxLength(param.Name.Trim(), 100),
TokenHash = AgentAccess.Hash(token),
ReadOnly = true,//phase 1 has only read-only tools
Created = DateTime.UtcNow,
Expires = days == 0 ? null : DateTime.UtcNow.AddDays(days)
};
ct.UserApiToken.Add(row);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserIdFromContext.Id(HttpContext.Items), user.Id, SockType.User, SockEvent.Modified, $"Agent token created: {row.Name}"), ct);
return Ok(ApiOkResponse.Response(new { row.Id, row.Name, row.Created, row.Expires, token }));
}
/// <summary>
/// Revoke a token: refused from the next request on
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> RevokeToken([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
var row = await ct.UserApiToken.FirstOrDefaultAsync(z => z.Id == id);
if (row == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
if (row.Revoked == null)
{
row.Revoked = DateTime.UtcNow;
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserIdFromContext.Id(HttpContext.Items), row.UserId, SockType.User, SockEvent.Modified, $"Agent token revoked: {row.Name}"), ct);
}
return NoContent();
}
}//eoc
}//eons

View File

@@ -95,7 +95,7 @@ namespace Sockeye.Api.Controllers
//Multiple users are allowed the same password and login
//Salt will differentiate them so get all users that match login, then try to match pw
var users = await ct.User.Where(z => z.Login == creds.Login && z.Active == true && z.AllowLogin == true).ToListAsync();
var users = await ct.User.Where(z => z.Login == creds.Login && z.Active == true && z.AllowLogin == true && z.Agent == false).ToListAsync();//case 4688: not agents
foreach (User u in users)
{
@@ -692,6 +692,198 @@ namespace Sockeye.Api.Controllers
}
////////////////////////////////////////////////////////////////////////////////
//case 4678: PASSKEYS
//A passkey sign-in counts as two factors (the device checks a PIN or biometric), so it skips the TOTP prompt.
//Bodies carry the browser's WebAuthn JSON as is; the Fido2 library reads it with its own serializer
public sealed class PasskeyParam
{
public string Id { get; set; }//from the matching options request
public string Name { get; set; }//registration only
public Newtonsoft.Json.Linq.JObject Response { get; set; }//the browser's PublicKeyCredential, as JSON
}
//failures are counted per address: a passkey sign-in has no login name, and one shared key would let anyone lock
//everyone out of passkey sign-in
private static string PasskeyThrottleKey(System.Net.IPAddress address) => $"passkey:{address}";
/// <summary>
/// The current user's passkeys
/// </summary>
[HttpGet("passkey")]
public async Task<IActionResult> GetPasskeys()
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
return Ok(ApiOkResponse.Response(await PasskeyListAsync(UserIdFromContext.Id(HttpContext.Items))));
}
/// <summary>
/// A user's passkeys (administrators)
/// </summary>
[HttpGet("passkey/user/{userId}")]
public async Task<IActionResult> GetUserPasskeys([FromRoute] long userId)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (userId != UserIdFromContext.Id(HttpContext.Items) && !Authorized.HasReadFullRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(await PasskeyListAsync(userId)));
}
private async Task<object> PasskeyListAsync(long userId) =>
await ct.UserPasskey.AsNoTracking().Where(z => z.UserId == userId).OrderBy(z => z.Created)
.Select(z => new { z.Id, z.Name, z.Created, z.LastUsed }).ToListAsync();
/// <summary>
/// Start adding a passkey to the current user: the options for the browser's navigator.credentials.create()
/// </summary>
[HttpPost("passkey/register-options")]
public async Task<IActionResult> PasskeyRegisterOptions()
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!PasskeyBiz.Configured)
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, PasskeyBiz.NOT_CONFIGURED));
var userId = UserIdFromContext.Id(HttpContext.Items);
if (await ct.UserPasskey.CountAsync(z => z.UserId == userId) >= PasskeyBiz.MAX_PER_USER)
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, $"A user can have at most {PasskeyBiz.MAX_PER_USER} passkeys"));
var user = await ct.User.AsNoTracking().FirstAsync(z => z.Id == userId);
var options = await PasskeyBiz.RegistrationOptionsAsync(ct, user);
var id = PasskeyBiz.Remember(options.ToJson(), userId);
if (id == null)
return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_SERVER_ERROR, null, "Too many passkey requests waiting, try again shortly"));
return Ok(ApiOkResponse.Response(new { id, options = Newtonsoft.Json.Linq.JObject.Parse(options.ToJson()) }));
}
/// <summary>
/// Finish adding a passkey: the browser's answer to the register-options request
/// </summary>
[HttpPost("passkey/register")]
public async Task<IActionResult> PasskeyRegister([FromBody] PasskeyParam param)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var userId = UserIdFromContext.Id(HttpContext.Items);
var optionsJson = PasskeyBiz.Take(param?.Id, userId);
if (optionsJson == null || param.Response == null)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_FAILED, null, "The passkey request expired or is unknown, please try again"));
try
{
var passkey = await PasskeyBiz.RegisterAsync(ct, userId, optionsJson, param.Response.ToString(Newtonsoft.Json.Formatting.None), param.Name);
await EventLogProcessor.LogEventToDatabaseAsync(new Event(userId, userId, SockType.User, SockEvent.Modified, $"Passkey added: {passkey.Name}"), ct);
return Ok(ApiOkResponse.Response(new { passkey.Id, passkey.Name, passkey.Created, passkey.LastUsed }));
}
catch (Exception ex) when (ex is Fido2NetLib.Fido2VerificationException || ex is System.Text.Json.JsonException || ex is ArgumentException)
{
log.LogWarning($"Passkey registration refused for user {userId}: {ex.Message}");
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_FAILED, null, "The passkey could not be verified"));
}
}
/// <summary>
/// Remove a passkey: the user's own, or anyone's for an administrator
/// </summary>
[HttpDelete("passkey/{id}")]
public async Task<IActionResult> DeletePasskey([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var passkey = await ct.UserPasskey.FirstOrDefaultAsync(z => z.Id == id);
if (passkey == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
var userId = UserIdFromContext.Id(HttpContext.Items);
if (passkey.UserId != userId && !Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
ct.UserPasskey.Remove(passkey);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(userId, passkey.UserId, SockType.User, SockEvent.Modified, $"Passkey removed: {passkey.Name}"), ct);
return NoContent();
}
/// <summary>
/// Remove all of a user's passkeys (the user, or an administrator for a user who lost theirs)
/// </summary>
[HttpDelete("passkey/user/{userId}")]
public async Task<IActionResult> DeleteUserPasskeys([FromRoute] long userId)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var me = UserIdFromContext.Id(HttpContext.Items);
if (userId != me && !Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
var all = await ct.UserPasskey.Where(z => z.UserId == userId).ToListAsync();
if (all.Count > 0)
{
ct.UserPasskey.RemoveRange(all);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(me, userId, SockType.User, SockEvent.Modified, $"All passkeys removed ({all.Count})"), ct);
}
return NoContent();
}
/// <summary>
/// Start a passkey sign-in: the options for the browser's navigator.credentials.get()
/// </summary>
[HttpPost("passkey/login-options")]
[AllowAnonymous]
public IActionResult PasskeyLoginOptions()
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!PasskeyBiz.Configured)
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, PasskeyBiz.NOT_CONFIGURED));
var address = HttpContext.Connection.RemoteIpAddress;
if (LoginThrottle.IsRefused(PasskeyThrottleKey(address), address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
var options = PasskeyBiz.SignInOptions();
var id = PasskeyBiz.Remember(options.ToJson(), 0);
if (id == null)
return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_SERVER_ERROR, null, "Too many passkey requests waiting, try again shortly"));
return Ok(ApiOkResponse.Response(new { id, options = Newtonsoft.Json.Linq.JObject.Parse(options.ToJson()) }));
}
/// <summary>
/// Finish a passkey sign-in: the browser's answer to the login-options request; on success the same as a login
/// </summary>
[HttpPost("passkey/login")]
[AllowAnonymous]
public async Task<IActionResult> PasskeyLogin([FromBody] PasskeyParam param)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var address = HttpContext.Connection.RemoteIpAddress;
var throttleKey = PasskeyThrottleKey(address);
if (LoginThrottle.IsRefused(throttleKey, address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
User user = null;
var optionsJson = PasskeyBiz.Take(param?.Id, 0);
if (optionsJson != null && param.Response != null)
{
try
{
var passkey = await PasskeyBiz.VerifySignInAsync(ct, optionsJson, param.Response.ToString(Newtonsoft.Json.Formatting.None));
if (passkey != null)
user = await ct.User.FirstOrDefaultAsync(z => z.Id == passkey.UserId && z.Active == true && z.AllowLogin == true && z.Agent == false);
}
catch (Exception ex) when (ex is Fido2NetLib.Fido2VerificationException || ex is System.Text.Json.JsonException || ex is ArgumentException)
{
log.LogWarning($"Passkey sign-in refused from {address}: {ex.Message}");
}
}
if (user == null)
{
RecordLoginFailure(throttleKey, address);
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
LoginThrottle.RecordSuccess(throttleKey);
return await ReturnUserCredsOnSuccessfulAuthentication(user);
}
//generate an internal JWT key for reporting purposes used by corejobnotify to send reports as manager account
internal static string GenRpt(long overrideLanguageId)
{

View File

@@ -485,6 +485,8 @@ namespace Sockeye.Api.Controllers
TranslationKeysToFetch.Add("NotifyEventSubscriptionServerExpiring");
TranslationKeysToFetch.Add("NotifyEventSubscriptionServerRequestReceived");
TranslationKeysToFetch.Add("NotifyEventLicenseTrialRequestReceived");
TranslationKeysToFetch.Add("NotifyEventSecurityAdvisoryAlert");
TranslationKeysToFetch.Add("NotifyEventSecurityAdvisoryDigest");
var LT = await TranslationBiz.GetSubsetStaticAsync(TranslationKeysToFetch, translationId);
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventObjectDeleted"], Id = (long)NotifyEventType.ObjectDeleted });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventObjectCreated"], Id = (long)NotifyEventType.ObjectCreated });
@@ -502,6 +504,8 @@ namespace Sockeye.Api.Controllers
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSubscriptionServerExpiring"], Id = (long)NotifyEventType.SubscriptionServerExpiring });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSubscriptionServerRequestReceived"], Id = (long)NotifyEventType.SubscriptionServerRequestReceived });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventLicenseTrialRequestReceived"], Id = (long)NotifyEventType.LicenseTrialRequestReceived });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSecurityAdvisoryAlert"], Id = (long)NotifyEventType.SecurityAdvisoryAlert });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSecurityAdvisoryDigest"], Id = (long)NotifyEventType.SecurityAdvisoryDigest });
}
else if (keyNameInLowerCase == StringUtil.TrimTypeName(typeof(NotifyDeliveryMethod).ToString()).ToLowerInvariant())

View File

@@ -0,0 +1,151 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Biz;
using Sockeye.Models;
namespace Sockeye.Api.Controllers
{
/// <summary>
/// case 4738: security advisory monitor: the watched assets, its current state, and a dry run.
/// Operations administrators only (GlobalOps rights)
/// </summary>
[ApiController]
[ApiVersion("8.0")]
[Route("api/v{version:apiVersion}/security-advisory")]
[Produces("application/json")]
[Authorize]
public class SecurityAdvisoryController : ControllerBase
{
private static readonly string[] PLATFORMS = { "ubuntu:noble", "ubuntu:resolute", "postgresql" };
private readonly AyContext ct;
private readonly ILogger<SecurityAdvisoryController> log;
private readonly ApiServerState serverState;
public SecurityAdvisoryController(AyContext dbcontext, ILogger<SecurityAdvisoryController> logger, ApiServerState apiServerState)
{
ct = dbcontext;
log = logger;
serverState = apiServerState;
}
/// <summary>
/// Watched assets
/// </summary>
[HttpGet("asset")]
public async Task<IActionResult> GetAssets()
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(await ct.SecurityAsset.AsNoTracking().OrderBy(z => z.Name).ToListAsync()));
}
/// <summary>
/// Add a watched asset (id 0) or replace one. Components may be pasted dpkg-query output: one name per line
/// </summary>
[HttpPost("asset")]
public async Task<IActionResult> SaveAsset([FromBody] SecurityAsset asset)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
asset.Name = asset.Name?.Trim();
asset.Components = SecurityAdvisoryPolicy.NormalizeComponents(asset.Components);
if (string.IsNullOrWhiteSpace(asset.Name))
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "name"));
if (!PLATFORMS.Contains(asset.Platform))
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "platform", $"Platform must be one of {string.Join(", ", PLATFORMS)}"));
if (asset.Components.Count == 0)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "components"));
if (!Enum.IsDefined(asset.AlertAt) || !Enum.IsDefined(asset.DigestAt) || asset.DigestAt > asset.AlertAt)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "digestAt", "The digest level can't be above the alert level"));
if (asset.Id == 0)
ct.SecurityAsset.Add(asset);
else
{
var existing = await ct.SecurityAsset.FirstOrDefaultAsync(z => z.Id == asset.Id);
if (existing == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
ct.Entry(existing).CurrentValues.SetValues(asset);
}
await ct.SaveChangesAsync();
return Ok(ApiOkResponse.Response(asset));
}
[HttpDelete("asset/{id}")]
public async Task<IActionResult> DeleteAsset([FromRoute] long id)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
var existing = await ct.SecurityAsset.FirstOrDefaultAsync(z => z.Id == id);
if (existing == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
ct.SecurityAsset.Remove(existing);
await ct.SaveChangesAsync();
return NoContent();
}
/// <summary>
/// Each source's health, and the matched advisories of the last days (default 60)
/// </summary>
[HttpGet("status")]
public async Task<IActionResult> GetStatus([FromQuery] int days = 60)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
var after = DateTime.UtcNow.AddDays(-Math.Clamp(days, 1, 365));
var sources = await ct.SecuritySource.AsNoTracking().OrderBy(z => z.Id).ToListAsync();
var advisories = await ct.SecurityAdvisory.AsNoTracking()
.Where(z => z.Processed > after && z.Outcome != AdvisoryOutcome.NoMatch)
.OrderByDescending(z => z.Published ?? z.Processed).Take(500).ToListAsync();
var processed = await ct.SecurityAdvisory.CountAsync(z => z.Processed > after);
return Ok(ApiOkResponse.Response(new { sources, advisories, processed }));
}
/// <summary>
/// Start a dry run: what the advisories of the last days (default 30, at most 90) would have done with the current
/// assets. Sends nothing and records nothing. It runs in the background; GET dry-run returns its progress and report
/// </summary>
[HttpPost("dry-run")]
public IActionResult StartDryRun([FromQuery] int days = 30)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!SockBotSecurityAdvisories.StartDryRun(Math.Clamp(days, 1, 90)))
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, "A dry run is already running"));
return Ok(ApiOkResponse.Response(SockBotSecurityAdvisories.LastDryRun));
}
/// <summary>
/// The latest dry run: whether it is still running, and its report once finished
/// </summary>
[HttpGet("dry-run")]
public IActionResult GetDryRun()
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(SockBotSecurityAdvisories.LastDryRun));
}
}//eoc
}//eons

View File

@@ -60,6 +60,10 @@ namespace Sockeye
_newLog.LogDebug("Health");
services.AddHealthChecks().AddDbContextCheck<AyContext>(); ;
//case 4688: MCP endpoint for AI agents (stateless: each call stands alone, nothing kept between calls)
services.AddHttpContextAccessor();
services.AddMcpServer().WithHttpTransport(options => options.Stateless = true).WithTools<SockeyeMcpTools>();
//Server state service for shutting people out of api
_newLog.LogDebug("ServerState service");
services.AddSingleton(new Sockeye.Api.ControllerHelpers.ApiServerState());
@@ -504,11 +508,15 @@ namespace Sockeye
//case 4688: /mcp is for agent Users with an agent token only; everything else passes through
app.Use((context, next) => AgentAccess.HandleAsync(context, next));
_newLog.LogDebug("Endpoints pipeline");
app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecks("/health");
endpoints.MapControllers();
endpoints.MapMcp(AgentAccess.MCP_PATH);//case 4688
});
#region SWAGGER

136
server/biz/AgentAccess.cs Normal file
View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.RateLimiting;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// case 4688: AI agent access. An agent is a User marked Agent; it can't sign in on the login page and connects only to
/// the MCP endpoint (/mcp) with an agent token. This is the gate in front of /mcp: it checks the token, applies the
/// per-agent rate limits, and sets the request up as that User so the tools run with its roles.
/// See devdocs/design/4688-agent-access-mcp.md
/// </summary>
internal static class AgentAccess
{
internal const string MCP_PATH = "/mcp";
internal const string TOKEN_PREFIX = "sk_";
internal const int DEFAULT_EXPIRY_DAYS = 90;
internal const string ITEM_TOKEN_ID = "AY_AGENT_TOKEN_ID";
internal const string ITEM_READ_ONLY = "AY_AGENT_READ_ONLY";
private static readonly ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("AgentAccess");
internal static byte[] Hash(string token) => SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token));
/// <summary>
/// A new token: sk_ and 32 random bytes in base64url. Returned once to show; only its hash is stored
/// </summary>
internal static string NewToken() =>
TOKEN_PREFIX + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
//the four limits of the design note; each setting can be overridden in server configuration
private static PartitionedRateLimiter<long> limiter;
private static PartitionedRateLimiter<long> Limiter => limiter ??= BuildLimiter();
private static PartitionedRateLimiter<long> BuildLimiter()
{
var perAgentRate = PartitionedRateLimiter.Create<long, long>(userId => RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = ServerBootConfig.SOCKEYE_AGENT_RATE_BURST,
TokensPerPeriod = ServerBootConfig.SOCKEYE_AGENT_RATE_PER_SECOND,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0,
AutoReplenishment = true
}));
//concurrency limits queue a little rather than refuse, so a burst of parallel calls is slowed, not failed
var perAgentConcurrent = PartitionedRateLimiter.Create<long, long>(userId => RateLimitPartition.GetConcurrencyLimiter(userId, _ => new ConcurrencyLimiterOptions
{
PermitLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT,
QueueLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT * 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
var allAgentsConcurrent = PartitionedRateLimiter.Create<long, int>(_ => RateLimitPartition.GetConcurrencyLimiter(0, _ => new ConcurrencyLimiterOptions
{
PermitLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT_TOTAL,
QueueLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT_TOTAL * 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
return PartitionedRateLimiter.CreateChained(perAgentRate, perAgentConcurrent, allAgentsConcurrent);
}
/// <summary>
/// Middleware: everything except /mcp passes straight through. /mcp needs a valid agent token
/// </summary>
internal static async Task HandleAsync(HttpContext context, Func<Task> next)
{
if (!context.Request.Path.StartsWithSegments(MCP_PATH))
{
await next();
return;
}
string auth = context.Request.Headers.Authorization.ToString();
string token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth.Substring(7).Trim() : null;
if (token == null || !token.StartsWith(TOKEN_PREFIX))
{
await RefuseAsync(context, 401, "An agent token is required (Authorization: Bearer sk_...)");
return;
}
var ct = context.RequestServices.GetService(typeof(AyContext)) as AyContext;
var hash = Hash(token);
var now = DateTime.UtcNow;
var row = await ct.UserApiToken.FirstOrDefaultAsync(z => z.TokenHash == hash && z.Revoked == null && (z.Expires == null || z.Expires > now));
var user = row == null ? null : await ct.User.AsNoTracking().Where(z => z.Id == row.UserId && z.Active && z.Agent)
.Select(z => new { z.Id, z.Name, z.Roles, z.UserType, translationId = z.UserOptions.TranslationId }).FirstOrDefaultAsync();
if (user == null)
{
await Task.Delay(ServerBootConfig.FAILED_AUTH_DELAY);
await RefuseAsync(context, 401, "The agent token is not valid, has expired or was revoked, or its User is not an active agent");
return;
}
using var lease = await Limiter.AcquireAsync(user.Id, 1, context.RequestAborted);
if (!lease.IsAcquired)
{
var retry = lease.TryGetMetadata(MetadataName.RetryAfter, out var after) ? Math.Max(1, (int)Math.Ceiling(after.TotalSeconds)) : 1;
log.LogWarning($"Agent \"{user.Name}\" rate limited, retry after {retry}s");
context.Response.Headers.RetryAfter = retry.ToString();
await RefuseAsync(context, 429, $"Rate limit reached for this agent, retry after {retry} seconds");
return;
}
context.Items["AY_ROLES"] = user.Roles;
context.Items["AY_USERNAME"] = user.Name;
context.Items["AY_USER_ID"] = user.Id;
context.Items["AY_TRANSLATION_ID"] = user.translationId;
context.Items["AY_USER_TYPE"] = user.UserType;
context.Items[ITEM_TOKEN_ID] = row.Id;
context.Items[ITEM_READ_ONLY] = row.ReadOnly;
//last use, at most once a minute so a busy agent doesn't write on every call
if (row.LastUsed == null || now - row.LastUsed > TimeSpan.FromMinutes(1))
{
row.LastUsed = now;
await ct.SaveChangesAsync();
}
await next();
}
private static async Task RefuseAsync(HttpContext context, int status, string message)
{
context.Response.StatusCode = status;
if (status == 401)
context.Response.Headers.WWWAuthenticate = "Bearer";
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(Newtonsoft.Json.JsonConvert.SerializeObject(new { error = message }));
}
}
}

View File

@@ -263,6 +263,10 @@ namespace Sockeye.Biz
await SockBotSubscriptionServerHealthChecks.DoWorkAsync();
if (!KeepOnWorking()) return;
//SOCKBOT - SECURITY ADVISORY MONITOR (case 4738)
await SockBotSecurityAdvisories.DoWorkAsync();
if (!KeepOnWorking()) return;
//JOB SWEEPER
await CoreJobSweeper.DoWorkAsync();
if (!KeepOnWorking()) return;

View File

@@ -36,8 +36,10 @@ Inspiring quotes used to help complete this huge project by myself
SubscriptionServerExpiring = 38,
//39 was SubscriptionServerLastUpdateAge (operating system update age), removed (case 4769): don't reuse it
SubscriptionServerRequestReceived = 40,
LicenseTrialRequestReceived = 41
LicenseTrialRequestReceived = 41,
//42 was PurchaseNotificationReceived, removed with purchases (case 4675): don't reuse it
SecurityAdvisoryAlert = 43,//case 4738: a high / critical / known exploited advisory affecting a watched asset
SecurityAdvisoryDigest = 44//case 4738: Wednesday digest of medium advisories, ahead of Thursday's updates
//NEW ITEMS REQUIRE translation KEYS
}

143
server/biz/PasskeyBiz.cs Normal file
View File

@@ -0,0 +1,143 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.EntityFrameworkCore;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// case 4678: passkey sign-in (WebAuthn, Fido2 library). See devdocs/design/4678-passkeys.md.
/// A passkey sign-in counts as two factors (the device checks a PIN or biometric), so it skips the TOTP prompt.
/// The options sent to the browser are kept in memory for a few minutes, keyed by a random id the browser sends back
/// </summary>
internal static class PasskeyBiz
{
internal const int MAX_PER_USER = 20;
private const int MAX_PENDING = 1000;
private static readonly TimeSpan PENDING_FOR = TimeSpan.FromMinutes(5);
private class Pending
{
public string OptionsJson;
public long UserId;//0 for a sign-in, the user for a registration
public DateTime Expires;
}
private static readonly ConcurrentDictionary<string, Pending> pending = new ConcurrentDictionary<string, Pending>();
internal static bool Configured => !string.IsNullOrWhiteSpace(ServerBootConfig.SOCKEYE_PASSKEY_RPID) && ServerBootConfig.SOCKEYE_PASSKEY_ORIGINS?.Count > 0;
internal const string NOT_CONFIGURED = "Passkeys are not set up on this server (SOCKEYE_PASSKEY_RPID and SOCKEYE_PASSKEY_ORIGINS)";
internal static Fido2 NewFido2() => new Fido2(new Fido2Configuration
{
ServerDomain = ServerBootConfig.SOCKEYE_PASSKEY_RPID,
ServerName = "Sockeye",
Origins = new HashSet<string>(ServerBootConfig.SOCKEYE_PASSKEY_ORIGINS)
});
/// <summary>
/// Keep the options until the browser answers; null when too many are waiting (a flood of anonymous requests)
/// </summary>
internal static string Remember(string optionsJson, long userId)
{
var now = DateTime.UtcNow;
foreach (var old in pending.Where(z => z.Value.Expires < now).Select(z => z.Key).ToList())
pending.TryRemove(old, out _);
if (pending.Count >= MAX_PENDING) return null;
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
pending[id] = new Pending { OptionsJson = optionsJson, UserId = userId, Expires = now + PENDING_FOR };
return id;
}
/// <summary>
/// The options for this id, used once; null if unknown, expired or for another user
/// </summary>
internal static string Take(string id, long userId)
{
if (string.IsNullOrWhiteSpace(id) || !pending.TryRemove(id, out var p)) return null;
if (p.Expires < DateTime.UtcNow || p.UserId != userId) return null;
return p.OptionsJson;
}
internal static async Task<CredentialCreateOptions> RegistrationOptionsAsync(AyContext ct, User user)
{
var existing = await ct.UserPasskey.AsNoTracking().Where(z => z.UserId == user.Id).ToListAsync();
var handle = existing.FirstOrDefault()?.UserHandle ?? RandomNumberGenerator.GetBytes(32);
return NewFido2().RequestNewCredential(new RequestNewCredentialParams
{
User = new Fido2User { Id = handle, Name = user.Login, DisplayName = user.Name },
ExcludeCredentials = existing.Select(z => new PublicKeyCredentialDescriptor(z.CredentialId)).ToList(),
AuthenticatorSelection = new AuthenticatorSelection
{
ResidentKey = ResidentKeyRequirement.Required,
UserVerification = UserVerificationRequirement.Required
},
AttestationPreference = AttestationConveyancePreference.None
});
}
internal static AssertionOptions SignInOptions() => NewFido2().GetAssertionOptions(new GetAssertionOptionsParams
{
AllowedCredentials = new List<PublicKeyCredentialDescriptor>(),
UserVerification = UserVerificationRequirement.Required
});
/// <summary>
/// Verify a new passkey and store it; throws Fido2VerificationException when the browser's answer doesn't check out
/// </summary>
internal static async Task<UserPasskey> RegisterAsync(AyContext ct, long userId, string optionsJson, string responseJson, string name)
{
var options = CredentialCreateOptions.FromJson(optionsJson);
var response = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(responseJson);
var credential = await NewFido2().MakeNewCredentialAsync(new MakeNewCredentialParams
{
AttestationResponse = response,
OriginalOptions = options,
IsCredentialIdUniqueToUserCallback = async (args, cancel) => !await ct.UserPasskey.AnyAsync(z => z.CredentialId == args.CredentialId, cancel)
});
var passkey = new UserPasskey
{
UserId = userId,
Name = string.IsNullOrWhiteSpace(name) ? "Passkey" : StringUtil.MaxLength(name.Trim(), 100),
CredentialId = credential.Id,
PublicKey = credential.PublicKey,
SignCount = credential.SignCount,
UserHandle = options.User.Id,
Created = DateTime.UtcNow
};
ct.UserPasskey.Add(passkey);
await ct.SaveChangesAsync();
return passkey;
}
/// <summary>
/// Verify a sign-in; the passkey used, or null when it isn't one of ours. Throws Fido2VerificationException when the
/// signature, challenge, origin or counter don't check out
/// </summary>
internal static async Task<UserPasskey> VerifySignInAsync(AyContext ct, string optionsJson, string responseJson)
{
var options = AssertionOptions.FromJson(optionsJson);
var response = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(responseJson);
var passkey = await ct.UserPasskey.FirstOrDefaultAsync(z => z.CredentialId == response.RawId);
if (passkey == null) return null;
var result = await NewFido2().MakeAssertionAsync(new MakeAssertionParams
{
AssertionResponse = response,
OriginalOptions = options,
StoredPublicKey = passkey.PublicKey,
StoredSignatureCounter = (uint)passkey.SignCount,
IsUserHandleOwnerOfCredentialIdCallback = (args, cancel) => Task.FromResult(args.UserHandle.SequenceEqual(passkey.UserHandle))
});
passkey.SignCount = result.SignCount;
passkey.LastUsed = DateTime.UtcNow;
await ct.SaveChangesAsync();
return passkey;
}
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Sockeye.Models;
namespace Sockeye.Biz
{
/// <summary>
/// case 4738: the rules that decide what an advisory means for us; no I/O, so it's unit tested on saved feed data
/// </summary>
internal static class SecurityAdvisoryPolicy
{
//Ubuntu's own priority for a CVE, the primary filter for Ubuntu notices
internal static AdvisorySeverity FromUbuntuPriority(string priority)
{
switch ((priority ?? "").Trim().ToLowerInvariant())
{
case "critical": return AdvisorySeverity.Critical;
case "high": return AdvisorySeverity.High;
case "medium": return AdvisorySeverity.Medium;
case "low": return AdvisorySeverity.Low;
case "negligible": return AdvisorySeverity.Negligible;
default: return AdvisorySeverity.Unknown;
}
}
//CVSS v3 base score bands, for sources that only give a score (PostgreSQL)
internal static AdvisorySeverity FromCvss(decimal? score)
{
if (score == null) return AdvisorySeverity.Unknown;
if (score >= 9.0m) return AdvisorySeverity.Critical;
if (score >= 7.0m) return AdvisorySeverity.High;
if (score >= 4.0m) return AdvisorySeverity.Medium;
if (score > 0m) return AdvisorySeverity.Low;
return AdvisorySeverity.Negligible;
}
internal static decimal? ParseScore(string s)
{
return decimal.TryParse((s ?? "").Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out decimal d) ? d : null;
}
/// <summary>
/// Match an advisory against the active assets. Outcome is the strongest over the matching assets: known exploited
/// or at/above an asset's alert level alerts; at/above its digest level goes in the digest. An unknown severity
/// (no CVE priority yet) counts as medium so it is seen, not dropped
/// </summary>
internal static (AdvisoryOutcome outcome, string matched) Evaluate(AdvisoryItem item, IEnumerable<SecurityAsset> assets)
{
var outcome = AdvisoryOutcome.NoMatch;
var matched = new List<string>();
var severity = item.Severity == AdvisorySeverity.Unknown ? AdvisorySeverity.Medium : item.Severity;
foreach (var asset in assets.Where(z => z.Active))
{
if (!item.Affected.TryGetValue(asset.Platform, out var affected)) continue;
var hits = affected.Intersect(asset.Components ?? new List<string>()).OrderBy(z => z).ToList();
if (hits.Count == 0) continue;
matched.Add($"{asset.Name}: {string.Join(", ", hits)}");
AdvisoryOutcome forAsset;
if (item.KnownExploited || severity >= asset.AlertAt)
forAsset = AdvisoryOutcome.Alerted;
else if (severity >= asset.DigestAt)
forAsset = AdvisoryOutcome.Digest;
else
forAsset = AdvisoryOutcome.Ignored;
if (forAsset > outcome) outcome = forAsset;
}
return (outcome, matched.Count == 0 ? null : string.Join("; ", matched));
}
/// <summary>
/// Clean up a pasted component list: dpkg-query output, or a comma / space separated list
/// </summary>
internal static List<string> NormalizeComponents(IEnumerable<string> input)
{
if (input == null) return new List<string>();
return input.SelectMany(z => (z ?? "").Split(new[] { '\n', '\r', ',', ' ', '\t' }, System.StringSplitOptions.RemoveEmptyEntries))
.Select(z => z.Trim().ToLowerInvariant())
.Where(z => z.Length > 0)
.Distinct()
.OrderBy(z => z, System.StringComparer.Ordinal)
.ToList();
}
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace Sockeye.Biz
{
//case 4738: advisory sources. Each Parse method takes the raw response so it can be tested on saved data
internal static class SecurityAdvisoryHttp
{
internal static string UserAgent => $"Sockeye/{Sockeye.Util.SockeyeVersion.VersionString} (support@ayanova.com)";
internal static async Task<string> GetStringAsync(HttpClient client, string url)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var res = await client.SendAsync(req);
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{url} returned {(int)res.StatusCode} {res.ReasonPhrase}");
return await res.Content.ReadAsStringAsync();
}
}
/// <summary>
/// Ubuntu Security Notices for the tracked releases (platforms "ubuntu:codename"); newest first, 20 a page,
/// paged back until older than the date given. A notice for several releases comes back once with each release's packages
/// </summary>
internal class UbuntuUsnSource : IAdvisorySource
{
internal const string NAME = "ubuntu-usn";
private const string BASE = "https://ubuntu.com/security";
private const int PAGE = 20, MAX_PAGES = 50;
public string Name => NAME;
public async Task<List<AdvisoryItem>> FetchAsync(HttpClient client, DateTime? since, IEnumerable<string> platforms)
{
var codenames = platforms.Where(z => z.StartsWith("ubuntu:")).Select(z => z.Substring(7)).Distinct().ToList();
var byId = new Dictionary<string, AdvisoryItem>();
foreach (var codename in codenames)
{
for (int page = 0; page < MAX_PAGES; page++)
{
var json = await SecurityAdvisoryHttp.GetStringAsync(client, $"{BASE}/notices.json?release={codename}&limit={PAGE}&offset={page * PAGE}&order=newest");
var items = ParseNotices(json, out int count);
foreach (var i in items)
{
if (since != null && i.Published < since) continue;
if (byId.TryGetValue(i.SourceId, out var existing))
foreach (var kv in i.Affected) existing.Affected[kv.Key] = kv.Value;
else
byId[i.SourceId] = i;
}
if (count < PAGE || since == null || items.Any(z => z.Published < since)) break;
}
}
return byId.Values.ToList();
}
internal static List<AdvisoryItem> ParseNotices(string json, out int count)
{
var ret = new List<AdvisoryItem>();
var notices = (JArray)JObject.Parse(json)["notices"];
count = notices.Count;
foreach (JObject n in notices)
{
var item = new AdvisoryItem
{
Source = NAME,
SourceId = (string)n["id"],
//published has no zone; Ubuntu's times are UTC
Published = DateTime.SpecifyKind((DateTime)n["published"], DateTimeKind.Utc),
Title = (string)n["title"],
Url = $"https://ubuntu.com/security/notices/{(string)n["id"]}",
CveIds = (n["cves_ids"] as JArray)?.Select(z => (string)z).ToList() ?? new List<string>()
};
if (n["release_packages"] is JObject rp)
foreach (var rel in rp.Properties())
item.Affected["ubuntu:" + rel.Name] = ((JArray)rel.Value)
.Where(p => (bool?)p["is_source"] == true)
.Select(p => ((string)p["name"]).ToLowerInvariant()).Distinct().ToList();
ret.Add(item);
}
return ret;
}
internal static async Task<string> FetchCvePriorityAsync(HttpClient client, string cveId)
{
var json = await SecurityAdvisoryHttp.GetStringAsync(client, $"{BASE}/cves/{cveId}.json");
return (string)JObject.Parse(json)["priority"];
}
}
/// <summary>
/// The PostgreSQL project's security page (platform "postgresql", components = major versions). It has no feed and
/// no dates: every row is returned and the processed store decides what is new. A page with no rows is an error,
/// so a layout change is reported, not taken for a quiet week
/// </summary>
internal class PostgresSecuritySource : IAdvisorySource
{
internal const string NAME = "postgresql";
public string Name => NAME;
public async Task<List<AdvisoryItem>> FetchAsync(HttpClient client, DateTime? since, IEnumerable<string> platforms)
{
var html = await SecurityAdvisoryHttp.GetStringAsync(client, "https://www.postgresql.org/support/security/");
var items = ParsePage(html);
if (items.Count == 0)
throw new FormatException("PostgreSQL security page had no advisories: its layout may have changed");
return items;
}
internal static List<AdvisoryItem> ParsePage(string html)
{
var byCve = new Dictionary<string, AdvisoryItem>();
foreach (Match row in Regex.Matches(html, @"<tr>\s*<td>(.*?)</tr>", RegexOptions.Singleline))
{
var cells = Regex.Matches(row.Value, @"<td>(.*?)</td>", RegexOptions.Singleline).Select(z => z.Groups[1].Value).ToList();
if (cells.Count < 5) continue;
var cve = Regex.Match(cells[0], @"CVE-\d{4}-\d+").Value;
if (cve.Length == 0 || byCve.ContainsKey(cve)) continue;
var score = Regex.Match(cells[3], @">(\d+(\.\d+)?)</a>").Groups[1].Value;
var component = Text(cells[3].Split("<br")[0]);
var item = new AdvisoryItem
{
Source = NAME,
SourceId = cve,
Title = $"{cve} {Text(cells[4].Split("<br")[0])} ({component})",
Url = $"https://www.postgresql.org/support/security/{cve}/",
CveIds = new List<string> { cve },
RawSeverity = score.Length == 0 ? null : $"CVSS {score}",
Severity = SecurityAdvisoryPolicy.FromCvss(SecurityAdvisoryPolicy.ParseScore(score))
};
item.Affected["postgresql"] = Text(cells[1]).Split(',').Select(z => z.Trim()).Where(z => z.Length > 0).ToList();
byCve[cve] = item;
}
return byCve.Values.ToList();
}
private static string Text(string html) => WebUtility.HtmlDecode(Regex.Replace(html, "<[^>]+>", "")).Trim();
}
/// <summary>
/// CISA's Known Exploited Vulnerabilities catalogue: the CVE ids that are being exploited in the wild, with the date added
/// </summary>
internal static class CisaKev
{
internal const string NAME = "cisa-kev";
internal static async Task<Dictionary<string, DateTime>> FetchAsync(HttpClient client)
{
var json = await SecurityAdvisoryHttp.GetStringAsync(client, "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
var ret = Parse(json);
if (ret.Count == 0)
throw new FormatException("CISA KEV feed had no entries");
return ret;
}
internal static Dictionary<string, DateTime> Parse(string json)
{
var ret = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
foreach (var v in (JArray)JObject.Parse(json)["vulnerabilities"])
{
var id = (string)v["cveID"];
if (string.IsNullOrWhiteSpace(id)) continue;
ret[id] = DateTime.TryParse((string)v["dateAdded"], System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var d) ? d : DateTime.MinValue;
}
return ret;
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
namespace Sockeye.Biz
{
//case 4738: security advisory monitor, see devdocs/design/4738-security-advisory-monitor.md
public enum AdvisorySeverity
{
Unknown = 0,
Negligible = 1,
Low = 2,
Medium = 3,
High = 4,
Critical = 5
}
public enum AdvisoryOutcome
{
NoMatch = 0,//affects nothing we watch
Ignored = 1,//matched, below the digest threshold
Digest = 2,//matched, goes in the Wednesday digest
Alerted = 3//matched, alerted at once
}
/// <summary>
/// An advisory from any source in one shape, before it is matched against the watched assets
/// </summary>
internal class AdvisoryItem
{
public string Source { get; set; }
public string SourceId { get; set; }
public DateTime? Published { get; set; }
public string Title { get; set; }
public string Url { get; set; }
public List<string> CveIds { get; set; } = new List<string>();
//affected components per platform, e.g. "ubuntu:resolute" -> source package names, "postgresql" -> major versions
public Dictionary<string, List<string>> Affected { get; set; } = new Dictionary<string, List<string>>();
public AdvisorySeverity Severity { get; set; }
public string RawSeverity { get; set; }
public bool KnownExploited { get; set; }
}
internal interface IAdvisorySource
{
string Name { get; }
/// <summary>
/// Advisories published since the date given (all the source has when null); throws when the source can't be read
/// </summary>
Task<List<AdvisoryItem>> FetchAsync(HttpClient client, DateTime? since, IEnumerable<string> platforms);
}
}

View File

@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Models;
namespace Sockeye.Biz
{
/// <summary>
/// case 4688 phase 1: the read-only tools an agent sees at /mcp. They run as the agent User (AgentAccess sets the request
/// up), check its rights per record type as the UI does, return only the fields an agent needs and at most MAX records.
/// Calls are logged to the server log (not the event log: one event row per call would bloat it, as health checks did, case 4749)
/// </summary>
[McpServerToolType]
public class SockeyeMcpTools
{
private const int MAX = 100;
private readonly AyContext ct;
private readonly HttpContext http;
private readonly ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("SockeyeMcpTools");
public SockeyeMcpTools(AyContext dbcontext, IHttpContextAccessor accessor)
{
ct = dbcontext;
http = accessor.HttpContext;
}
private AuthorizationRoles Roles => UserRolesFromContext.Roles(http.Items);
private void Require(SockType type, string tool)
{
log.LogInformation($"MCP {tool} by agent \"{http.Items["AY_USERNAME"]}\"");
if (!Authorized.HasReadFullRole(Roles, type))
throw new UnauthorizedAccessException($"This agent's roles don't allow reading {type} records");
}
private static int Cap(int? limit) => Math.Clamp(limit ?? 50, 1, MAX);
[McpServerTool(Name = "search", ReadOnly = true), Description("Search all Sockeye records (cases, customers, licences, subscription servers, trial requests...) for words or a phrase. Returns type, id and name of each match.")]
public async Task<object> SearchAsync(
[Description("Words or phrase to find")] string query,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
log.LogInformation($"MCP search by agent \"{http.Items["AY_USERNAME"]}\": {query}");
var r = await Search.DoSearchAsync(ct, UserTranslationIdFromContext.Id(http.Items), Roles, UserIdFromContext.Id(http.Items),
new Search.SearchRequestParameters { Phrase = query, MaxResults = Cap(limit) });
return new { total = r.TotalResultsFound, results = r.SearchResults.Select(z => new { type = z.Type.ToString(), z.Id, z.Name }) };
}
[McpServerTool(Name = "get_case", ReadOnly = true), Description("A case (GZCase) by its case number: name, notes, tags, customer, created and closed dates.")]
public async Task<object> GetCaseAsync([Description("The case number, e.g. 4675")] long caseNumber)
{
Require(SockType.GZCase, "get_case");
var c = await ct.GZCase.AsNoTracking().FirstOrDefaultAsync(z => z.CaseId == caseNumber);
if (c == null) return new { error = $"No case {caseNumber}" };
var customer = c.CustomerId == null ? null : await ct.Customer.AsNoTracking().Where(z => z.Id == c.CustomerId).Select(z => z.Name).FirstOrDefaultAsync();
return new { caseNumber = c.CaseId, c.Id, c.Name, c.Notes, c.Tags, customer, c.Created, c.Closed };
}
[McpServerTool(Name = "list_open_cases", ReadOnly = true), Description("Open cases, newest first, optionally only those with a tag (e.g. 1-priority, next-release, sockeye). Returns number, name, tags and created date; use get_case for the notes.")]
public async Task<object> ListOpenCasesAsync(
[Description("Only cases with this tag")] string tag = null,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
Require(SockType.GZCase, "list_open_cases");
var q = ct.GZCase.AsNoTracking().Where(z => z.Closed == null);
if (!string.IsNullOrWhiteSpace(tag))
{
var t = TagBiz.NormalizeTag(tag);
q = q.Where(z => z.Tags.Contains(t));
}
return await q.OrderByDescending(z => z.CaseId).Take(Cap(limit))
.Select(z => new { caseNumber = z.CaseId, z.Name, z.Tags, z.Created }).ToListAsync();
}
[McpServerTool(Name = "get_customer", ReadOnly = true), Description("A customer by id, or by name (the closest matches when the name is not exact): contact details, notes, licences, subscription servers and open cases.")]
public async Task<object> GetCustomerAsync(
[Description("Customer id")] long? id = null,
[Description("Customer name or part of it")] string name = null)
{
Require(SockType.Customer, "get_customer");
Customer c = null;
if (id != null)
c = await ct.Customer.AsNoTracking().FirstOrDefaultAsync(z => z.Id == id);
else if (!string.IsNullOrWhiteSpace(name))
{
var matches = await ct.Customer.AsNoTracking().Where(z => EF.Functions.ILike(z.Name, "%" + name.Trim() + "%")).OrderBy(z => z.Name).Take(MAX).ToListAsync();
if (matches.Count > 1 && !matches.Any(z => z.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)))
return new { matches = matches.Select(z => new { z.Id, z.Name, z.Active }) };
c = matches.FirstOrDefault(z => z.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)) ?? matches.FirstOrDefault();
}
if (c == null) return new { error = "No such customer" };
var licences = Authorized.HasReadFullRole(Roles, SockType.License)
? await ct.License.AsNoTracking().Where(z => z.CustomerId == c.Id).OrderByDescending(z => z.Created).Take(MAX)
.Select(z => new { z.Id, z.RegTo, product = z.PGroup.ToString(), z.DbId, z.Created, z.Active, z.FetchedOn, z.MaintenanceExpire, z.LicenseExpire, z.Users, z.TrialMode }).ToListAsync()
: null;
var servers = Authorized.HasReadFullRole(Roles, SockType.SubscriptionServer)
? await ct.SubscriptionServer.AsNoTracking().Where(z => z.CustomerId == c.Id).OrderBy(z => z.Name).Take(MAX)
.Select(z => new { z.Id, z.Name, state = z.ServerState.ToString(), z.Trial, z.SubscriptionExpire }).ToListAsync()
: null;
var cases = Authorized.HasReadFullRole(Roles, SockType.GZCase)
? await ct.GZCase.AsNoTracking().Where(z => z.CustomerId == c.Id && z.Closed == null).OrderByDescending(z => z.CaseId).Take(MAX)
.Select(z => new { caseNumber = z.CaseId, z.Name }).ToListAsync()
: null;
return new
{
c.Id, c.Name, c.Active, c.EmailAddress, c.Phone1, c.WebAddress, c.City, c.Region, c.Country, c.Notes, c.TechNotes, c.Tags,
licences, subscriptionServers = servers, openCases = cases
};
}
[McpServerTool(Name = "find_licence", ReadOnly = true), Description("Licences by registered name (or part of it) or by database id, newest first. The licence key text is never returned.")]
public async Task<object> FindLicenceAsync(
[Description("Registered to name, or part of it")] string registeredTo = null,
[Description("Database id the licence is for")] string dbId = null,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
Require(SockType.License, "find_licence");
if (string.IsNullOrWhiteSpace(registeredTo) && string.IsNullOrWhiteSpace(dbId))
return new { error = "Give registeredTo or dbId" };
var q = ct.License.AsNoTracking();
if (!string.IsNullOrWhiteSpace(registeredTo)) q = q.Where(z => EF.Functions.ILike(z.RegTo, "%" + registeredTo.Trim() + "%"));
if (!string.IsNullOrWhiteSpace(dbId)) q = q.Where(z => z.DbId == dbId.Trim());
return await q.OrderByDescending(z => z.Created).Take(Cap(limit))
.Select(z => new { z.Id, z.RegTo, product = z.PGroup.ToString(), z.DbId, z.CustomerId, z.Created, z.Active, z.FetchedOn, z.MaintenanceExpire, z.LicenseExpire, z.Users, z.TrialMode, z.FetchEmail }).ToListAsync();
}
[McpServerTool(Name = "list_subscription_servers", ReadOnly = true), Description("Hosted subscription servers with their state, latest health check and expiry; optionally only trial servers or one state (e.g. ActiveHosted, Requested, DeActivated, Destroyed).")]
public async Task<object> ListSubscriptionServersAsync(
[Description("Only servers in this state")] string state = null,
[Description("Only trial servers")] bool? trialOnly = null,
[Description("Most results to return, 1 to 100 (default 100)")] int? limit = null)
{
Require(SockType.SubscriptionServer, "list_subscription_servers");
var q = ct.SubscriptionServer.AsNoTracking();
if (!string.IsNullOrWhiteSpace(state))
{
if (!Enum.TryParse<ServerState>(state, true, out var s))
return new { error = $"Unknown state; one of {string.Join(", ", Enum.GetNames<ServerState>())}" };
q = q.Where(z => z.ServerState == s);
}
if (trialOnly == true) q = q.Where(z => z.Trial);
var servers = await q.OrderBy(z => z.Name).Take(Math.Clamp(limit ?? MAX, 1, MAX)).ToListAsync();
var ids = servers.Select(z => z.Id).ToList();
var health = await ct.SubscriptionServerHealth.AsNoTracking().Where(z => ids.Contains(z.SubscriptionServerId)).ToDictionaryAsync(z => z.SubscriptionServerId);
return servers.Select(z => new
{
z.Id, z.Name, state = z.ServerState.ToString(), z.Trial, z.SubscriptionExpire, z.CustomerSubDomain, z.CustomerId,
lastHealthStatus = health.TryGetValue(z.Id, out var h) ? h.LastHealthStatus : null,
lastHealthCheck = health.TryGetValue(z.Id, out var h2) ? h2.LastHealthCheck : null
});
}
[McpServerTool(Name = "list_trial_requests", ReadOnly = true), Description("Trial licence requests, newest first; by default only those still waiting for a decision (awaiting email validation or approval).")]
public async Task<object> ListTrialRequestsAsync(
[Description("Include all, not only those waiting")] bool all = false,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
Require(SockType.TrialLicenseRequest, "list_trial_requests");
var q = ct.TrialLicenseRequest.AsNoTracking();
if (!all) q = q.Where(z => z.Status == TrialRequestStatus.AwaitingEmailValidation || z.Status == TrialRequestStatus.AwaitingApproval);
return await q.OrderByDescending(z => z.Requested).Take(Cap(limit))
.Select(z => new { z.Id, z.CompanyName, z.ContactName, z.Email, z.EmailValidated, status = z.Status.ToString(), product = z.PGroup.ToString(), z.Requested, z.Processed, z.LicenseId }).ToListAsync();
}
[McpServerTool(Name = "get_event_log", ReadOnly = true), Description("Recent history of one record: who changed what and when. Type is a Sockeye record type such as GZCase, Customer, License, SubscriptionServer, TrialLicenseRequest; id is the record id (for a case, its id, not its case number).")]
public async Task<object> GetEventLogAsync(
[Description("Record type")] string type,
[Description("Record id")] long id,
[Description("Most events to return, 1 to 100 (default 50)")] int? limit = null)
{
if (!Enum.TryParse<SockType>(type, true, out var sockType))
return new { error = $"Unknown record type {type}" };
Require(sockType, "get_event_log");
var log = await EventLogProcessor.GetLogForObjectAsync(
new Api.Controllers.EventLogController.EventLogOptions { SockType = sockType, AyId = id, Limit = Cap(limit) },
UserTranslationIdFromContext.Id(http.Items), ct);
return new { log.Name, events = log.Events.Select(z => new { z.Date, user = z.Name, @event = z.Event.ToString(), z.Textra }) };
}
}
}

View File

@@ -45,9 +45,14 @@ namespace Sockeye.Biz
{
User dbObject = ct.User.FirstOrDefault(z => z.Id == 1);
dbObject.Password = Hasher.hash(dbObject.Salt, ServerBootConfig.SOCKEYE_SET_SUPERUSER_PW);
//case 4678 (as AyaNova case 4775): the break-glass reset restores plain password sign-in completely, so a
//superuser who lost their authenticator app gets in too; their passkeys stay and can be removed after
dbObject.TwoFactorEnabled = false;
dbObject.TotpSecret = null;
dbObject.TempToken = null;
ct.SaveChanges();
NotifyEventHelper.AddOpsProblemEvent("SOCKEYE_SET_SUPERUSER_PW setting was used at most recent server boot to reset SuperUser account password").Wait();
NotifyEventHelper.AddOpsProblemEvent("SOCKEYE_SET_SUPERUSER_PW setting was used at most recent server boot to reset SuperUser account password and turn off its two-factor sign-in").Wait();
}
}
@@ -605,6 +610,10 @@ namespace Sockeye.Biz
if (proposedObj.AllowLogin != currentObj.AllowLogin)
AddError(ApiErrorCode.NOT_AUTHORIZED, "AllowLogin");
//case 4688
if (proposedObj.Agent)
AddError(ApiErrorCode.NOT_AUTHORIZED, "Agent");
if (proposedObj.Name != currentObj.Name)
AddError(ApiErrorCode.NOT_AUTHORIZED, "Name");

View File

@@ -276,6 +276,12 @@ namespace Sockeye.Biz
//Special notification handling
switch (ne.EventType)
{
//case 4738: the advisory id and title say it all; the default subject would read "AY:-:<title>:<type>"
case NotifyEventType.SecurityAdvisoryAlert:
case NotifyEventType.SecurityAdvisoryDigest:
subject = $"{SubscriptionTypeName}: {name}";
body = ne.Message;
break;
// case NotifyEventType.CustomerServiceImminent:
// subject = SubscriptionTypeName;
// body = LT["NotifyEventCustomerServiceImminentMessage"].Replace("{0}", Sockeye.Util.DateUtil.FormatTimeSpan(advanceNotice, LT["TimeSpanDays"], LT["TimeSpanHours"], LT["TimeSpanMinutes"], LT["TimeSpanSeconds"]));

View File

@@ -0,0 +1,424 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// case 4738: security advisory monitor. Every 3 hours: reads the advisory sources for the watched assets, records
/// every advisory once, alerts at once on high / critical / known exploited, collects medium ones for the Wednesday
/// digest, and reports a source that has failed for more than a day. See devdocs/design/4738-security-advisory-monitor.md
/// </summary>
internal static class SockBotSecurityAdvisories
{
private static ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("SockBotSecurityAdvisories");
private static DateTime lastSweep = DateTime.MinValue;
#if (DEBUG)
private static readonly TimeSpan RUN_EVERY = new TimeSpan(0, 5, 0);//development: often enough to watch it work
#else
private static readonly TimeSpan RUN_EVERY = new TimeSpan(3, 0, 0);
#endif
private static readonly TimeSpan SOURCE_FAILING_AFTER = new TimeSpan(24, 0, 0);
private static readonly TimeSpan CVE_STALE_AFTER = new TimeSpan(30, 0, 0, 0);//priorities rarely change once set
private const int FIRST_RUN_DAYS = 30, LATE_KEV_DAYS = 90, DIGEST_HOUR = 6, CVE_PARALLEL = 4;
internal const string DIGEST_ROW = "digest";
private static IAdvisorySource[] Sources => new IAdvisorySource[] { new UbuntuUsnSource(), new PostgresSecuritySource() };
private static bool SourceApplies(IAdvisorySource s, IEnumerable<string> platforms) =>
s.Name == UbuntuUsnSource.NAME ? platforms.Any(z => z.StartsWith("ubuntu:")) : platforms.Contains("postgresql");
public static async Task DoWorkAsync()
{
if (DateTime.UtcNow - lastSweep < RUN_EVERY) return;
if (ServerBootConfig.MIGRATING) return;
lastSweep = DateTime.UtcNow;
try
{
await RunAsync();
}
catch (Exception ex)
{
log.LogError(ex, "SockBotSecurityAdvisories error running job");
await NotifyEventHelper.AddOpsProblemEvent("Security advisory monitor: error running job", ex);
}
}
private static HttpClient NewClient()
{
var client = ServiceProviderProvider.HttpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(60);
return client;
}
internal static async Task RunAsync()
{
using AyContext ct = ServiceProviderProvider.DBContext;
var assets = await ct.SecurityAsset.AsNoTracking().Where(z => z.Active).ToListAsync();
if (assets.Count == 0) return;
var platforms = assets.Select(z => z.Platform).Distinct().ToList();
var client = NewClient();
var kev = await TrackAsync(ct, CisaKev.NAME, () => CisaKev.FetchAsync(client));
if (kev != null)
await MarkSuccessAsync(ct, CisaKev.NAME);
foreach (var source in Sources.Where(z => SourceApplies(z, platforms)))
{
var state = await GetStateAsync(ct, source.Name);
bool firstRun = state.LastSuccess == null;
//overlap the last run by two days: Ubuntu sometimes publishes with an earlier date; the store removes repeats
DateTime? since = firstRun ? DateTime.UtcNow.AddDays(-FIRST_RUN_DAYS) : state.LastSuccess.Value.AddDays(-2);
var items = await TrackAsync(ct, source.Name, () => source.FetchAsync(client, since, platforms));
if (items == null) continue;
var ids = items.Select(z => z.SourceId).ToList();
var known = await ct.SecurityAdvisory.AsNoTracking().Where(z => z.Source == source.Name && ids.Contains(z.SourceId)).Select(z => z.SourceId).ToListAsync();
foreach (var item in items.Where(z => !known.Contains(z.SourceId)))
{
//the first run records quietly, so Ubuntu's CVE priorities aren't needed (they take a request each)
var (outcome, matched, cveLines) = await AssessAsync(ct, client, item, assets, kev, lookupPriorities: !firstRun);
//the first run of a source only records what is there, so turning the monitor on doesn't send old news
bool send = !firstRun && outcome == AdvisoryOutcome.Alerted;
ct.SecurityAdvisory.Add(ToRecord(item, outcome, matched, notified: firstRun || send || outcome < AdvisoryOutcome.Digest));
if (send)
await QueueAsync(ct, NotifyEventType.SecurityAdvisoryAlert, $"{item.SourceId} {item.Title}", AlertBody(item, matched, cveLines));
//the record and its notifications are saved together: if queueing fails, neither is, and the next run retries
await ct.SaveChangesAsync();
}
await MarkSuccessAsync(ct, source.Name);
}
if (kev != null)
await AlertNewlyExploitedAsync(ct, kev);
await SendDigestIfDueAsync(ct);
await ReportFailingSourcesAsync(ct);
}
/// <summary>
/// Severity and known-exploited flag for an advisory, then the outcome against the assets. Ubuntu's CVE priorities
/// are fetched only for advisories that match something
/// </summary>
internal static async Task<(AdvisoryOutcome outcome, string matched, List<string> cveLines)> AssessAsync(AyContext ct, HttpClient client,
AdvisoryItem item, List<SecurityAsset> assets, Dictionary<string, DateTime> kev, bool lookupPriorities = true)
{
var cveLines = new List<string>();
if (SecurityAdvisoryPolicy.Evaluate(item, assets).outcome == AdvisoryOutcome.NoMatch)
return (AdvisoryOutcome.NoMatch, null, cveLines);
item.KnownExploited = kev != null && item.CveIds.Any(z => kev.ContainsKey(z));
if (item.Source == UbuntuUsnSource.NAME && lookupPriorities)
{
var priorities = await CvePrioritiesAsync(ct, client, item.CveIds);
var worst = AdvisorySeverity.Unknown;
//worst first, so the CVE that caused the alert heads the list
foreach (var cve in item.CveIds.OrderByDescending(z => kev != null && kev.ContainsKey(z))
.ThenByDescending(z => SecurityAdvisoryPolicy.FromUbuntuPriority(priorities.GetValueOrDefault(z))))
{
priorities.TryGetValue(cve, out var priority);
var sev = SecurityAdvisoryPolicy.FromUbuntuPriority(priority);
if (sev > worst) worst = sev;
cveLines.Add($"{cve} ({priority ?? "priority unknown"}{(kev != null && kev.ContainsKey(cve) ? ", KNOWN EXPLOITED" : "")}) https://ubuntu.com/security/{cve}");
}
item.Severity = worst;
item.RawSeverity = worst == AdvisorySeverity.Unknown ? null : $"Ubuntu priority {worst.ToString().ToLowerInvariant()}";
}
else
cveLines.AddRange(item.CveIds.Select(z => $"{z}{(kev != null && kev.ContainsKey(z) ? " (KNOWN EXPLOITED)" : "")}"));
var (outcome, matched) = SecurityAdvisoryPolicy.Evaluate(item, assets);
return (outcome, matched, cveLines);
}
/// <summary>
/// Ubuntu's priority for each CVE: from the cache, the missing or stale ones fetched a few at a time (one request
/// each, about a second apiece; a kernel notice lists dozens) and saved together. A CVE that can't be read keeps
/// its cached priority or none (counted as medium), so one bad lookup never loses the advisory
/// </summary>
private static async Task<Dictionary<string, string>> CvePrioritiesAsync(AyContext ct, HttpClient client, List<string> cveIds)
{
var cached = await ct.SecurityCve.Where(z => cveIds.Contains(z.Id)).ToListAsync();
var ret = cached.ToDictionary(z => z.Id, z => z.Priority);
var stale = cveIds.Distinct().Where(id => !cached.Any(c => c.Id == id && DateTime.UtcNow - c.Fetched < CVE_STALE_AFTER)).ToList();
if (stale.Count == 0) return ret;
using var gate = new System.Threading.SemaphoreSlim(CVE_PARALLEL);
var fetched = await Task.WhenAll(stale.Select(async id =>
{
await gate.WaitAsync();
try { return (id, priority: await UbuntuUsnSource.FetchCvePriorityAsync(client, id), ok: true); }
catch (Exception ex)
{
log.LogWarning($"Could not read Ubuntu priority for {id}: {ex.Message}");
return (id, priority: (string)null, ok: false);
}
finally { gate.Release(); }
}));
foreach (var f in fetched.Where(z => z.ok))
{
ret[f.id] = f.priority;
var row = cached.FirstOrDefault(z => z.Id == f.id);
if (row == null)
ct.SecurityCve.Add(new SecurityCve { Id = f.id, Priority = f.priority, Fetched = DateTime.UtcNow });
else
{
row.Priority = f.priority;
row.Fetched = DateTime.UtcNow;
}
}
await ct.SaveChangesAsync();
return ret;
}
/// <summary>
/// A CVE is often added to the known exploited list days after its advisory: alert once when that happens
/// </summary>
private static async Task AlertNewlyExploitedAsync(AyContext ct, Dictionary<string, DateTime> kev)
{
var after = DateTime.UtcNow.AddDays(-LATE_KEV_DAYS);
var candidates = await ct.SecurityAdvisory.Where(z => z.Processed > after && !z.KnownExploited
&& (z.Outcome == AdvisoryOutcome.Digest || z.Outcome == AdvisoryOutcome.Ignored)).ToListAsync();
foreach (var a in candidates.Where(z => z.CveIds != null && z.CveIds.Any(c => kev.ContainsKey(c))))
{
a.KnownExploited = true;
a.Outcome = AdvisoryOutcome.Alerted;
a.Notified = true;
var exploited = a.CveIds.Where(c => kev.ContainsKey(c));
await QueueAsync(ct, NotifyEventType.SecurityAdvisoryAlert, $"{a.SourceId} {a.Title}",
$"Now known to be exploited: {string.Join(", ", exploited)} (CISA KEV)\n"
+ $"Affects: {a.Matched}\nSeverity: {a.RawSeverity ?? a.Severity.ToString()}\n{a.Url}\n");
await ct.SaveChangesAsync();
}
}
private static async Task SendDigestIfDueAsync(AyContext ct)
{
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.Local);
if (local.DayOfWeek != DayOfWeek.Wednesday || local.Hour < DIGEST_HOUR) return;
var state = await GetStateAsync(ct, DIGEST_ROW);
if (state.LastSuccess != null && TimeZoneInfo.ConvertTimeFromUtc(state.LastSuccess.Value, TimeZoneInfo.Local).Date == local.Date) return;
var pending = await ct.SecurityAdvisory.Where(z => z.Outcome == AdvisoryOutcome.Digest && !z.Notified).OrderBy(z => z.Published).ToListAsync();
state.LastAttempt = state.LastSuccess = DateTime.UtcNow;
if (pending.Count > 0)
{
var sb = new StringBuilder($"{pending.Count} medium security advisories since the last digest, for Thursday's updates:\n\n");
foreach (var a in pending)
sb.Append($"{a.SourceId} {a.Title}\n {a.RawSeverity ?? a.Severity.ToString()}; {a.Matched}\n {a.Url}\n\n");
pending.ForEach(z => z.Notified = true);
await QueueAsync(ct, NotifyEventType.SecurityAdvisoryDigest, $"{pending.Count} advisories", sb.ToString());
}
await ct.SaveChangesAsync();
}
private static async Task ReportFailingSourcesAsync(AyContext ct)
{
var now = DateTime.UtcNow;
foreach (var s in await ct.SecuritySource.Where(z => z.Id != DIGEST_ROW && z.LastError != null).ToListAsync())
{
var since = s.LastSuccess ?? s.FirstAttempt ?? now;
if (now - since < SOURCE_FAILING_AFTER) continue;
if (s.FailureNotified != null && now - s.FailureNotified < SOURCE_FAILING_AFTER) continue;
s.FailureNotified = now;
await ct.SaveChangesAsync();
await NotifyEventHelper.AddOpsProblemEvent($"Security advisory monitor: source {s.Id} has not been read successfully since "
+ $"{(s.LastSuccess == null ? "it was added" : s.LastSuccess.Value.ToString("u"))}. Last error: {s.LastError}");
}
}
/// <summary>
/// Run a fetch and record the source's health; null when it failed (the error is kept on the source row)
/// </summary>
private static async Task<T> TrackAsync<T>(AyContext ct, string sourceName, Func<Task<T>> fetch) where T : class
{
var state = await GetStateAsync(ct, sourceName);
state.LastAttempt = DateTime.UtcNow;
state.FirstAttempt ??= state.LastAttempt;
try
{
var ret = await fetch();
state.LastError = null;
state.FailureNotified = null;
await ct.SaveChangesAsync();
return ret;
}
catch (Exception ex)
{
log.LogWarning($"Security advisory source {sourceName} failed: {ex.Message}");
state.LastError = ex.Message;
await ct.SaveChangesAsync();
return null;
}
}
//LastSuccess is set by the caller once the source's advisories are recorded, so a failure part way is retried next run
private static async Task<SecuritySource> GetStateAsync(AyContext ct, string name)
{
var s = await ct.SecuritySource.FirstOrDefaultAsync(z => z.Id == name);
if (s == null)
{
s = new SecuritySource { Id = name };
ct.SecuritySource.Add(s);
await ct.SaveChangesAsync();
}
return s;
}
internal static async Task MarkSuccessAsync(AyContext ct, string name)
{
var s = await GetStateAsync(ct, name);
s.LastSuccess = DateTime.UtcNow;
await ct.SaveChangesAsync();
}
private static SecurityAdvisory ToRecord(AdvisoryItem item, AdvisoryOutcome outcome, string matched, bool notified) => new SecurityAdvisory
{
Source = item.Source,
SourceId = item.SourceId,
Published = item.Published,
Title = item.Title,
Url = item.Url,
CveIds = item.CveIds,
Matched = matched,
Severity = item.Severity,
RawSeverity = item.RawSeverity,
KnownExploited = item.KnownExploited,
Outcome = outcome,
Notified = notified,
Processed = DateTime.UtcNow
};
internal static string AlertBody(AdvisoryItem item, string matched, List<string> cveLines)
{
var sb = new StringBuilder();
sb.Append($"{item.SourceId}: {item.Title}\n");
sb.Append($"Severity: {item.RawSeverity ?? item.Severity.ToString()}{(item.KnownExploited ? ", KNOWN EXPLOITED (CISA KEV)" : "")}\n");
sb.Append($"Affects: {matched}\n");
if (item.Published != null) sb.Append($"Published: {item.Published.Value:u}\n");
sb.Append($"{item.Url}\n");
if (cveLines.Count > 0)
sb.Append("\nCVEs:\n").Append(string.Join("\n", cveLines)).Append('\n');
return sb.ToString();
}
/// <summary>
/// Queue a notification for everyone subscribed to the event type (email or in-app, per their subscription).
/// Not saved here: the caller saves it together with the change that caused it
/// </summary>
private static async Task QueueAsync(AyContext ct, NotifyEventType eventType, string name, string message)
{
var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == eventType).ToListAsync();
foreach (var sub in subs)
{
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
ct.NotifyEvent.Add(new NotifyEvent
{
EventType = eventType,
UserId = sub.UserId,
NotifySubscriptionId = sub.Id,
Name = name.Length > 250 ? name.Substring(0, 250) : name,
Message = message,
SockType = SockType.NoType,
ObjectId = 0,
EventDate = DateTime.UtcNow
});
}
log.LogInformation($"Security advisory {eventType} queued for {subs.Count} subscription(s): {name}");
}
/// <summary>
/// The latest dry run. It runs in the background because looking up CVE priorities can take minutes, longer
/// than a request may stay open behind nginx; the page polls for the report
/// </summary>
internal class DryRunState
{
public bool Running { get; set; }
public int Days { get; set; }
public DateTime? Started { get; set; }
public DateTime? Finished { get; set; }
public string Error { get; set; }
public List<object> Report { get; set; }
}
private static readonly object dryRunLock = new object();
internal static DryRunState LastDryRun { get; private set; } = new DryRunState();
/// <summary>
/// Start a dry run unless one is already running; false if one is
/// </summary>
internal static bool StartDryRun(int days)
{
lock (dryRunLock)
{
if (LastDryRun.Running) return false;
LastDryRun = new DryRunState { Running = true, Days = days, Started = DateTime.UtcNow };
}
var state = LastDryRun;
_ = Task.Run(async () =>
{
try
{
using AyContext ct = ServiceProviderProvider.DBContext;
state.Report = await DryRunAsync(ct, days);
}
catch (Exception ex)
{
log.LogError(ex, "Security advisory dry run failed");
state.Error = ex.Message;
}
finally
{
state.Finished = DateTime.UtcNow;
state.Running = false;
}
});
return true;
}
/// <summary>
/// What the last days of advisories would have done, sending and recording nothing (only the CVE cache is filled)
/// </summary>
internal static async Task<List<object>> DryRunAsync(AyContext ct, int days)
{
var assets = await ct.SecurityAsset.AsNoTracking().Where(z => z.Active).ToListAsync();
var platforms = assets.Select(z => z.Platform).Distinct().ToList();
var client = NewClient();
var ret = new List<object>();
Dictionary<string, DateTime> kev = null;
try { kev = await CisaKev.FetchAsync(client); }
catch (Exception ex) { ret.Add(new { source = CisaKev.NAME, error = ex.Message }); }
foreach (var source in Sources.Where(z => SourceApplies(z, platforms)))
{
List<AdvisoryItem> items;
try { items = await source.FetchAsync(client, DateTime.UtcNow.AddDays(-days), platforms); }
catch (Exception ex) { ret.Add(new { source = source.Name, error = ex.Message }); continue; }
var ids = items.Select(z => z.SourceId).ToList();
var known = await ct.SecurityAdvisory.AsNoTracking().Where(z => z.Source == source.Name && ids.Contains(z.SourceId)).Select(z => z.SourceId).ToListAsync();
foreach (var item in items.OrderByDescending(z => z.Published))
{
var (outcome, matched, _) = await AssessAsync(ct, client, item, assets, kev);
ret.Add(new
{
source = item.Source,
sourceId = item.SourceId,
published = item.Published,
title = item.Title,
url = item.Url,
severity = item.Severity.ToString(),
rawSeverity = item.RawSeverity,
knownExploited = item.KnownExploited,
outcome = outcome.ToString(),
matched,
alreadyProcessed = known.Contains(item.SourceId)
});
}
}
return ret;
}
}
}

View File

@@ -63,6 +63,12 @@ namespace Sockeye.Models
public virtual DbSet<GZCase> GZCase { get; set; }
public virtual DbSet<SubscriptionServer> SubscriptionServer { get; set; }
public virtual DbSet<SubscriptionServerHealth> SubscriptionServerHealth { get; set; }
public virtual DbSet<SecurityAsset> SecurityAsset { get; set; }
public virtual DbSet<SecurityAdvisory> SecurityAdvisory { get; set; }
public virtual DbSet<SecurityCve> SecurityCve { get; set; }
public virtual DbSet<SecuritySource> SecuritySource { get; set; }
public virtual DbSet<UserPasskey> UserPasskey { get; set; }
public virtual DbSet<UserApiToken> UserApiToken { get; set; }
public virtual DbSet<Subscription> Subscription { get; set; }

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using Sockeye.Biz;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: every advisory the monitor has processed, matched or not, so none is ever handled twice
/// (unique on source + source id)
/// </summary>
public class SecurityAdvisory
{
public long Id { get; set; }
public string Source { get; set; }
public string SourceId { get; set; }
public DateTime? Published { get; set; }
public string Title { get; set; }
public string Url { get; set; }
public List<string> CveIds { get; set; }
public string Matched { get; set; }//which assets and components matched, for display
public AdvisorySeverity Severity { get; set; }
public string RawSeverity { get; set; }
public bool KnownExploited { get; set; }
public AdvisoryOutcome Outcome { get; set; }
public bool Notified { get; set; }//false when recorded quietly (first run of a source) or still waiting for the digest
public DateTime Processed { get; set; }
}//eoc
}//eons

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Sockeye.Biz;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: something the security advisory monitor watches, e.g. "Ubuntu 26.04 fleet" (platform ubuntu:resolute,
/// components = every installed source package) or "PostgreSQL" (platform postgresql, components = major versions)
/// </summary>
public class SecurityAsset
{
public long Id { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string Platform { get; set; }
public List<string> Components { get; set; }
public bool Active { get; set; }
public AdvisorySeverity AlertAt { get; set; } = AdvisorySeverity.High;
public AdvisorySeverity DigestAt { get; set; } = AdvisorySeverity.Medium;
}//eoc
}//eons

View File

@@ -0,0 +1,17 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: cache of Ubuntu's priority for a CVE; a kernel notice lists dozens, so each is fetched once
/// and refreshed only when stale
/// </summary>
public class SecurityCve
{
[Key]
public string Id { get; set; }
public string Priority { get; set; }
public DateTime Fetched { get; set; }
}//eoc
}//eons

View File

@@ -0,0 +1,20 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: health of each advisory source (ubuntu-usn, postgresql, cisa-kev) so a source that has silently
/// stopped working is reported instead of looking like a quiet week; the row "digest" records the last digest sent
/// </summary>
public class SecuritySource
{
[Key]
public string Id { get; set; }
public DateTime? FirstAttempt { get; set; }
public DateTime? LastAttempt { get; set; }
public DateTime? LastSuccess { get; set; }
public string LastError { get; set; }
public DateTime? FailureNotified { get; set; }
}//eoc
}//eons

View File

@@ -18,6 +18,7 @@ namespace Sockeye.Models
public uint Concurrency { get; set; }
public bool Active { get; set; }
public bool AllowLogin { get; set; }
public bool Agent { get; set; }//case 4688: an AI agent: no login page sign-in, connects only to /mcp with an agent token
[Required, MaxLength(255)]
public string Name { get; set; }
public AuthorizationRoles Roles { get; set; }
@@ -56,6 +57,7 @@ namespace Sockeye.Models
[Required]
public bool Active { get; set; }
public bool AllowLogin { get; set; }
public bool Agent { get; set; }//case 4688: an AI agent: no login page sign-in, connects only to /mcp with an agent token
[Required, MaxLength(255)]
public string Name { get; set; }
public DateTime? LastLogin { get; set; }

View File

@@ -0,0 +1,21 @@
using System;
namespace Sockeye.Models
{
/// <summary>
/// case 4688: a token an agent User connects to /mcp with. Only its SHA-256 hash is kept; the token itself is shown
/// once when created
/// </summary>
public class UserApiToken
{
public long Id { get; set; }
public long UserId { get; set; }
public string Name { get; set; }
public byte[] TokenHash { get; set; }
public bool ReadOnly { get; set; }
public DateTime Created { get; set; }
public DateTime? Expires { get; set; }//null: never
public DateTime? LastUsed { get; set; }
public DateTime? Revoked { get; set; }
}//eoc
}//eons

View File

@@ -0,0 +1,21 @@
using System;
namespace Sockeye.Models
{
/// <summary>
/// case 4678: a passkey a user can sign in with (any number per user, up to PasskeyBiz.MAX_PER_USER, each named)
/// </summary>
public class UserPasskey
{
public long Id { get; set; }
public long UserId { get; set; }
public string Name { get; set; }
public byte[] CredentialId { get; set; }
public byte[] PublicKey { get; set; }
public long SignCount { get; set; }
//the user's id as the passkey knows it: random, the same for all of one user's passkeys
public byte[] UserHandle { get; set; }
public DateTime Created { get; set; }
public DateTime? LastUsed { get; set; }
}//eoc
}//eons

View File

@@ -4,8 +4,8 @@
</PropertyGroup>
<PropertyGroup>
<GenerateFullPaths>true</GenerateFullPaths>
<Version>8.1.4</Version>
<FileVersion>8.1.4.0</FileVersion>
<Version>8.2.0</Version>
<FileVersion>8.2.0.0</FileVersion>
<ApplicationIcon>sockeye.ico</ApplicationIcon>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<noWarn>1591</noWarn>
@@ -18,6 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fido2" Version="4.3.2" /><!-- case 4678: passkeys -->
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.3.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Bogus" Version="35.6.5" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />

View File

@@ -20,12 +20,12 @@ namespace Sockeye.Util
/////////// CHANGE THIS ON NEW SCHEMA UPDATE ////////////////////
//!!!!WARNING: BE SURE TO UPDATE THE DbUtil::EmptyBizDataFromDatabaseForSeedingOrImportingAsync WHEN NEW TABLES ADDED!!!!
private const int DESIRED_SCHEMA_LEVEL = 26;
private const int DESIRED_SCHEMA_LEVEL = 29;
internal const long EXPECTED_COLUMN_COUNT = 522;
internal const long EXPECTED_INDEX_COUNT = 77;
internal const long EXPECTED_CHECK_CONSTRAINTS = 251;
internal const long EXPECTED_FOREIGN_KEY_CONSTRAINTS = 35;
internal const long EXPECTED_COLUMN_COUNT = 571;
internal const long EXPECTED_INDEX_COUNT = 89;
internal const long EXPECTED_CHECK_CONSTRAINTS = 284;
internal const long EXPECTED_FOREIGN_KEY_CONSTRAINTS = 37;
internal const long EXPECTED_VIEWS = 0;
internal const long EXPECTED_ROUTINES = 2;
@@ -1783,6 +1783,101 @@ $BODY$ LANGUAGE PLPGSQL STABLE");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 27)
{
LogUpdateMessage(log);
//case 4738: security advisory monitor: watched assets, every advisory processed (once), Ubuntu CVE priority
//cache, and each source's health
await ExecQueryAsync("CREATE TABLE asecurityasset (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, "
+ "platform TEXT NOT NULL, components TEXT[] NOT NULL DEFAULT '{}', active BOOL NOT NULL DEFAULT true, "
+ "alertat INTEGER NOT NULL DEFAULT 4, digestat INTEGER NOT NULL DEFAULT 3)");
await ExecQueryAsync("CREATE TABLE asecurityadvisory (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source TEXT NOT NULL, "
+ "sourceid TEXT NOT NULL, published TIMESTAMPTZ, title TEXT, url TEXT, cveids TEXT[], matched TEXT, "
+ "severity INTEGER NOT NULL DEFAULT 0, rawseverity TEXT, knownexploited BOOL NOT NULL DEFAULT false, "
+ "outcome INTEGER NOT NULL DEFAULT 0, notified BOOL NOT NULL DEFAULT false, processed TIMESTAMPTZ NOT NULL, "
+ "CONSTRAINT asecurityadvisory_source_sourceid_unique UNIQUE (source, sourceid))");
await ExecQueryAsync("CREATE INDEX asecurityadvisory_processed_idx ON asecurityadvisory (processed)");
await ExecQueryAsync("CREATE TABLE asecuritycve (id TEXT PRIMARY KEY, priority TEXT, fetched TIMESTAMPTZ NOT NULL)");
await ExecQueryAsync("CREATE TABLE asecuritysource (id TEXT PRIMARY KEY, firstattempt TIMESTAMPTZ, lastattempt TIMESTAMPTZ, "
+ "lastsuccess TIMESTAMPTZ, lasterror TEXT, failurenotified TIMESTAMPTZ)");
foreach (var lang in new[] { "en", "es", "fr", "de" })
{
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, 'NotifyEventSecurityAdvisoryAlert', 'Security advisory alert' FROM atranslation t where t.baselanguage = '{lang}'");
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, 'NotifyEventSecurityAdvisoryDigest', 'Security advisory digest' FROM atranslation t where t.baselanguage = '{lang}'");
}
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 28)
{
LogUpdateMessage(log);
//case 4678: passkeys, any number per user (a cap in code), removed with the user
await ExecQueryAsync("CREATE TABLE auserpasskey (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "
+ "userid BIGINT NOT NULL REFERENCES auser (id) ON DELETE CASCADE, name TEXT NOT NULL, credentialid BYTEA NOT NULL UNIQUE, "
+ "publickey BYTEA NOT NULL, signcount BIGINT NOT NULL DEFAULT 0, userhandle BYTEA NOT NULL, "
+ "created TIMESTAMPTZ NOT NULL, lastused TIMESTAMPTZ)");
await ExecQueryAsync("CREATE INDEX auserpasskey_userid_idx ON auserpasskey (userid)");
//UI text (AyaNova-bound feature, so translation keys; English in every language until translated)
var keys = new System.Collections.Generic.Dictionary<string, string>
{
{ "Passkeys", "Passkeys" },
{ "PasskeyIntro", "Sign in with your device's fingerprint, face or PIN instead of a password. Your password still works." },
{ "PasskeyAdd", "Add a passkey" },
{ "PasskeyName", "Name for this passkey (e.g. Office PC, Phone)" },
{ "PasskeyCreated", "Added" },
{ "PasskeyLastUsed", "Last used" },
{ "PasskeyNone", "No passkeys yet" },
{ "PasskeyRemove", "Remove passkey" },
{ "PasskeyRemoveAll", "Remove all passkeys" },
{ "PasskeyNotSupported", "Passkeys can't be used here: they need HTTPS and the server's domain name, and a browser that supports them" }
};
foreach (var lang in new[] { "en", "es", "fr", "de" })
foreach (var k in keys)
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, '{k.Key}', '{k.Value.Replace("'", "''")}' FROM atranslation t where t.baselanguage = '{lang}'");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 29)
{
LogUpdateMessage(log);
//case 4688: agent Users (no login page sign-in; connect only to /mcp) and their tokens (only the hash is kept)
await ExecQueryAsync("ALTER TABLE auser ADD COLUMN agent BOOL NOT NULL DEFAULT false");
await ExecQueryAsync("CREATE TABLE auserapitoken (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "
+ "userid BIGINT NOT NULL REFERENCES auser (id) ON DELETE CASCADE, name TEXT NOT NULL, tokenhash BYTEA NOT NULL UNIQUE, "
+ "readonly BOOL NOT NULL DEFAULT true, created TIMESTAMPTZ NOT NULL, expires TIMESTAMPTZ, lastused TIMESTAMPTZ, revoked TIMESTAMPTZ)");
await ExecQueryAsync("CREATE INDEX auserapitoken_userid_idx ON auserapitoken (userid)");
//UI text (AyaNova-bound feature, so translation keys; English in every language until translated)
var agentKeys = new System.Collections.Generic.Dictionary<string, string>
{
{ "UserAgent", "AI agent" },
{ "UserAgentHint", "An AI agent can't sign in here; it connects with an agent token to the MCP endpoint" },
{ "AgentTokens", "Agent tokens" },
{ "AgentTokenCreate", "Create agent token" },
{ "AgentTokenName", "Name for this token (e.g. John's workstation)" },
{ "AgentTokenExpiresDays", "Expires after days (0 = never)" },
{ "AgentTokenShowOnce", "Copy this token now. It won't be shown again." },
{ "AgentTokenExpires", "Expires" },
{ "AgentTokenNever", "Never" },
{ "AgentTokenLastUsed", "Last used" },
{ "AgentTokenRevoke", "Revoke" },
{ "AgentTokenRevoked", "Revoked" },
{ "AgentTokenNone", "No agent tokens" }
};
foreach (var lang in new[] { "en", "es", "fr", "de" })
foreach (var k in agentKeys)
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, '{k.Key}', '{k.Value.Replace("'", "''")}' FROM atranslation t where t.baselanguage = '{lang}'");
await SetSchemaLevelAsync(++currentSchema);
}
//#########################################

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Configuration;
@@ -82,6 +83,16 @@ namespace Sockeye.Util
//SECURITY
internal static string SOCKEYE_SET_SUPERUSER_PW { get; set; }
//case 4678: passkeys belong to a domain (the relying party id, e.g. sockeye.ayanova.com) and are accepted only from
//the listed origins (e.g. https://sockeye.ayanova.com); passkey sign-in is off when not set (debug builds default to localhost)
internal static string SOCKEYE_PASSKEY_RPID { get; set; }
internal static List<string> SOCKEYE_PASSKEY_ORIGINS { get; set; }
//case 4688: limits per agent User at /mcp (requests: a burst, then a steady rate; calls at once) and for all agents
//together, so agents are fast but can't overload the server. Defaults built in; each can be set in configuration
internal static int SOCKEYE_AGENT_RATE_BURST { get; set; }
internal static int SOCKEYE_AGENT_RATE_PER_SECOND { get; set; }
internal static int SOCKEYE_AGENT_CONCURRENT { get; set; }
internal static int SOCKEYE_AGENT_CONCURRENT_TOTAL { get; set; }
//HELPFUL INFORMATION FOR DIAGNOSTICS
internal static Dictionary<string, string> BOOT_DIAGNOSTIC_INFO { get; set; } = new Dictionary<string, string>();
@@ -156,6 +167,25 @@ namespace Sockeye.Util
//backdoor back door password superuser reset
SOCKEYE_SET_SUPERUSER_PW = config.GetValue<string>("SOCKEYE_SET_SUPERUSER_PW");
//case 4688: agent limits
SOCKEYE_AGENT_RATE_BURST = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_RATE_BURST") ?? 30);
SOCKEYE_AGENT_RATE_PER_SECOND = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_RATE_PER_SECOND") ?? 5);
SOCKEYE_AGENT_CONCURRENT = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_CONCURRENT") ?? 4);
SOCKEYE_AGENT_CONCURRENT_TOTAL = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_CONCURRENT_TOTAL") ?? 8);
//case 4678: passkeys
SOCKEYE_PASSKEY_RPID = config.GetValue<string>("SOCKEYE_PASSKEY_RPID")?.Trim();
SOCKEYE_PASSKEY_ORIGINS = (config.GetValue<string>("SOCKEYE_PASSKEY_ORIGINS") ?? "")
.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries).Select(z => z.TrimEnd('/')).ToList();
#if (DEBUG)
if (string.IsNullOrWhiteSpace(SOCKEYE_PASSKEY_RPID))
{
SOCKEYE_PASSKEY_RPID = "localhost";
if (SOCKEYE_PASSKEY_ORIGINS.Count == 0)
SOCKEYE_PASSKEY_ORIGINS = new List<string> { "http://localhost:8080", "http://localhost:7676" };
}
#endif
//REPORT RENDERING
//RENDER ENGINE PATH

View File

@@ -5,7 +5,7 @@ namespace Sockeye.Util
/// </summary>
internal static class SockeyeVersion
{
public const string VersionString = "8.1.4";
public const string VersionString = "8.2.0";
public const string FullNameAndVersion = "Sockeye server " + VersionString;
public const string CurrentApiVersion="v8";
}//eoc

View File

@@ -0,0 +1,192 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using ModelContextProtocol.Client;
namespace sockeye_integration
{
/// <summary>
/// case 4688: AI agents. An agent is a User marked Agent: it can't sign in on the login page, connects only to /mcp with
/// an agent token, runs with its own roles, and is rate limited. Each test makes its own agent User and removes it
/// </summary>
public class Agents
{
private const string MCP_URL = "http://localhost:7676/mcp";
private const int BIZ_ADMIN_RESTRICTED = 1;//reads business records, changes nothing
[Fact]
public async Task AgentUsesTheToolsWithItsToken()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
long caseId = 0;
try
{
//a case to look up
string caseName = Util.Uniquify("case 4688 agent test");
ApiResponse a = await Util.PostAsync("gzcase", adminToken, new JObject { ["name"] = caseName, ["notes"] = "agent test notes", ["tags"] = new JArray() }.ToString());
Util.ValidateDataReturnResponseOk(a);
caseId = a.ObjectResponse["data"]["id"].Value<long>();
long caseNumber = a.ObjectResponse["data"]["caseId"].Value<long>();
var (tokenId, token) = await CreateTokenAsync(adminToken, agentId);
await using (var client = await ConnectAsync(token))
{
var tools = (await client.ListToolsAsync()).Select(z => z.Name).ToList();
tools.Should().BeEquivalentTo("search", "get_case", "list_open_cases", "get_customer", "find_licence",
"list_subscription_servers", "list_trial_requests", "get_event_log");
var result = await client.CallToolAsync("get_case", new Dictionary<string, object> { ["caseNumber"] = caseNumber });
(result.IsError ?? false).Should().BeFalse(Text(result));
Text(result).Should().Contain(caseName).And.Contain("agent test notes");
//results are capped at 100 whatever is asked for
result = await client.CallToolAsync("list_open_cases", new Dictionary<string, object> { ["limit"] = 500 });
JArray.Parse(Text(result)).Count.Should().BeLessOrEqualTo(100);
//every other tool runs (its queries work against the real database)
foreach (var (tool, args) in new (string, Dictionary<string, object>)[]
{
("search", new() { ["query"] = "test" }),
("list_open_cases", new() { ["tag"] = "sockeye", ["limit"] = 5 }),
("get_customer", new() { ["name"] = "a" }),
("find_licence", new() { ["registeredTo"] = "a", ["limit"] = 5 }),
("list_subscription_servers", new() { ["trialOnly"] = true, ["limit"] = 5 }),
("list_trial_requests", new() { ["all"] = true, ["limit"] = 5 }),
("get_event_log", new() { ["type"] = "GZCase", ["id"] = caseId })
})
{
result = await client.CallToolAsync(tool, args);
(result.IsError ?? false).Should().BeFalse($"{tool}: {Text(result)}");
}
}
//the token stops working once revoked
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"agent-token/{tokenId}", adminToken), 204);
(await RawMcpAsync(token)).StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
finally
{
if (caseId != 0) await Util.DeleteAsync($"gzcase/{caseId}", adminToken);
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
[Fact]
public async Task AgentRolesApply()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: 0);
try
{
var (_, token) = await CreateTokenAsync(adminToken, agentId);
await using var client = await ConnectAsync(token);
var result = await client.CallToolAsync("list_open_cases", new Dictionary<string, object>());
(result.IsError ?? false).Should().BeTrue("an agent with no roles can't read cases");
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
[Fact]
public async Task AgentCantSignInOnTheLoginPageAndPeopleCantUseMcp()
{
string adminToken = await TestUser.GetTokenAsync();
string login = Util.Uniquify("agent4688").Replace(" ", "");
string password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18));
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED, login: login, password: password);
try
{
Util.ValidateHTTPStatusCode(await Util.PostAsync("auth", null, new JObject { ["login"] = login, ["password"] = password }.ToString()), 401);
//a person's sign-in token isn't accepted at /mcp, and a person can't be given an agent token
(await RawMcpAsync(adminToken)).StatusCode.Should().Be(HttpStatusCode.Unauthorized);
long me = await TestUser.GetUserIdAsync();
Util.ValidateHTTPStatusCode(await Util.PostAsync("agent-token", adminToken, new JObject { ["userId"] = me }.ToString()), 400);
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
[Fact]
public async Task AgentIsRateLimited()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
try
{
var (_, token) = await CreateTokenAsync(adminToken, agentId);
//well past the default burst of 30 at once: some are refused with a time to retry after
var responses = await Task.WhenAll(Enumerable.Range(0, 60).Select(_ => RawMcpAsync(token)));
var limited = responses.Where(z => (int)z.StatusCode == 429).ToList();
limited.Should().NotBeEmpty();
limited[0].Headers.RetryAfter.Should().NotBeNull();
responses.Count(z => z.IsSuccessStatusCode).Should().BeGreaterThan(0);
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
private static async Task<McpClient> ConnectAsync(string token)
{
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(MCP_URL),
AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = "Bearer " + token }
});
return await McpClient.CreateAsync(transport);
}
private static string Text(ModelContextProtocol.Protocol.CallToolResult result) =>
string.Concat(result.Content.OfType<ModelContextProtocol.Protocol.TextContentBlock>().Select(z => z.Text));
//one MCP request (tools/list) without the client library, to see the HTTP status
private static readonly HttpClient raw = new HttpClient();
private static async Task<HttpResponseMessage> RawMcpAsync(string token)
{
var req = new HttpRequestMessage(HttpMethod.Post, MCP_URL)
{
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}", Encoding.UTF8, "application/json")
};
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
req.Headers.TryAddWithoutValidation("Accept", "application/json, text/event-stream");
return await raw.SendAsync(req);
}
private static async Task<long> CreateUserAsync(string adminToken, bool agent, int roles, string login = null, string password = null)
{
login ??= Util.Uniquify("agent4688").Replace(" ", "");
ApiResponse a = await Util.PostAsync("user", adminToken, new JObject
{
["name"] = login,
["login"] = login,
["password"] = password ?? Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)),
["active"] = true,
["allowLogin"] = true,
["agent"] = agent,
["roles"] = roles,
["userType"] = 2,//NotService
["tags"] = new JArray()
}.ToString());
Util.ValidateDataReturnResponseOk(a);
return a.ObjectResponse["data"]["id"].Value<long>();
}
private static async Task<(long id, string token)> CreateTokenAsync(string adminToken, long userId)
{
ApiResponse a = await Util.PostAsync("agent-token", adminToken, new JObject { ["userId"] = userId, ["name"] = "integration test" }.ToString());
Util.ValidateDataReturnResponseOk(a);
((string)a.ObjectResponse["data"]["token"]).Should().StartWith("sk_");
return (a.ObjectResponse["data"]["id"].Value<long>(), (string)a.ObjectResponse["data"]["token"]);
}
}//eoc
}//eons

View File

@@ -0,0 +1,243 @@
using System.Security.Cryptography;
using System.Text;
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
namespace sockeye_integration
{
/// <summary>
/// case 4678: passkey registration and sign-in, end to end, with a software authenticator (below) instead of a device.
/// Uses its own temporary user: a sign-in replaces that user's session, which would break other tests if it were the
/// shared test user. Needs the server's passkey settings for localhost (debug builds default to them)
/// </summary>
public class Passkeys
{
private const string ORIGIN = "http://localhost:7676";
[Fact]
public async Task RegisterSignInAndRemove()
{
string adminToken = await TestUser.GetTokenAsync();
var (userId, login, password) = await CreateUserAsync(adminToken);
try
{
string token = await PasswordLoginAsync(login, password);
var device = new SoftAuthenticator();
//register
ApiResponse a = await Util.PostAsync("auth/passkey/register-options", token, "{}");
Util.ValidateDataReturnResponseOk(a);
var create = (JObject)a.ObjectResponse["data"];
((string)create["options"]["rp"]["id"]).Should().Be("localhost");
a = await Util.PostAsync("auth/passkey/register", token, new JObject
{
["id"] = create["id"],
["name"] = "Test device",
["response"] = device.Create(create["options"], ORIGIN)
}.ToString());
Util.ValidateDataReturnResponseOk(a);
long passkeyId = a.ObjectResponse["data"]["id"].Value<long>();
//sign in: the token is for this user
a = await PasskeyLoginAsync(device, ORIGIN);
Util.ValidateDataReturnResponseOk(a);
((string)a.ObjectResponse["data"]["name"]).Should().Be(login);
((string)a.ObjectResponse["data"]["token"]).Should().NotBeNullOrEmpty();
//a signature counter that doesn't go up (a cloned device) is refused
device.Counter--;
Util.ValidateHTTPStatusCode(await PasskeyLoginAsync(device, ORIGIN), 401);
device.Counter++;
//a wrong origin (another site) is refused
Util.ValidateHTTPStatusCode(await PasskeyLoginAsync(device, "https://evil.example.com"), 401);
//an options id is good once only
a = await Util.PostAsync("auth/passkey/login-options", null, "{}");
var get = (JObject)a.ObjectResponse["data"];
var answer = device.Get(get["options"], ORIGIN);
Util.ValidateDataReturnResponseOk(await Util.PostAsync("auth/passkey/login", null, new JObject { ["id"] = get["id"], ["response"] = answer }.ToString()));
Util.ValidateHTTPStatusCode(await Util.PostAsync("auth/passkey/login", null, new JObject { ["id"] = get["id"], ["response"] = answer }.ToString()), 401);
//listed, with its last use; the user's session token from the last sign-in
token = (string)(await PasskeyLoginAsync(device, ORIGIN)).ObjectResponse["data"]["token"];
a = await Util.GetAsync("auth/passkey", token);
var list = (JArray)a.ObjectResponse["data"];
list.Should().HaveCount(1);
list[0]["lastUsed"].Type.Should().NotBe(JTokenType.Null);
//removed: can't sign in with it any more
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"auth/passkey/{passkeyId}", token), 204);
Util.ValidateHTTPStatusCode(await PasskeyLoginAsync(device, ORIGIN), 401);
}
finally
{
await Util.DeleteAsync($"user/{userId}", adminToken);
}
}
[Fact]
public async Task RegistrationFromAnotherOriginIsRefused()
{
string adminToken = await TestUser.GetTokenAsync();
var (userId, login, password) = await CreateUserAsync(adminToken);
try
{
string token = await PasswordLoginAsync(login, password);
ApiResponse a = await Util.PostAsync("auth/passkey/register-options", token, "{}");
var create = (JObject)a.ObjectResponse["data"];
a = await Util.PostAsync("auth/passkey/register", token, new JObject
{
["id"] = create["id"],
["response"] = new SoftAuthenticator().Create(create["options"], "https://evil.example.com")
}.ToString());
Util.ValidateHTTPStatusCode(a, 400);
a = await Util.GetAsync("auth/passkey", token);
((JArray)a.ObjectResponse["data"]).Should().BeEmpty();
}
finally
{
await Util.DeleteAsync($"user/{userId}", adminToken);
}
}
private static async Task<ApiResponse> PasskeyLoginAsync(SoftAuthenticator device, string origin)
{
ApiResponse a = await Util.PostAsync("auth/passkey/login-options", null, "{}");
Util.ValidateDataReturnResponseOk(a);
var get = (JObject)a.ObjectResponse["data"];
return await Util.PostAsync("auth/passkey/login", null, new JObject { ["id"] = get["id"], ["response"] = device.Get(get["options"], origin) }.ToString());
}
private static async Task<(long id, string login, string password)> CreateUserAsync(string adminToken)
{
string login = Util.Uniquify("pk4678").Replace(" ", "");
string password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18));
ApiResponse a = await Util.PostAsync("user", adminToken, new JObject
{
["name"] = login,
["login"] = login,
["password"] = password,
["active"] = true,
["allowLogin"] = true,
["roles"] = 0,
["userType"] = 2,//NotService
["tags"] = new JArray()
}.ToString());
Util.ValidateDataReturnResponseOk(a);
return (a.ObjectResponse["data"]["id"].Value<long>(), login, password);
}
private static async Task<string> PasswordLoginAsync(string login, string password)
{
ApiResponse a = await Util.PostAsync("auth", null, new JObject { ["login"] = login, ["password"] = password }.ToString());
Util.ValidateDataReturnResponseOk(a);
return (string)a.ObjectResponse["data"]["token"];
}
/// <summary>
/// A passkey device in software: a P-256 key, "none" attestation, user present and verified, a signature counter
/// </summary>
private class SoftAuthenticator
{
private readonly ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly byte[] credentialId = RandomNumberGenerator.GetBytes(32);
private byte[] userHandle;
public uint Counter = 0;
public JObject Create(JToken options, string origin)
{
userHandle = B64UrlDecode((string)options["user"]["id"]);
var clientData = ClientData("webauthn.create", (string)options["challenge"], origin);
var p = key.ExportParameters(false);
var cose = Cbor.Map(new (object, object)[] { (1, 2), (3, -7), (-1, 1), (-2, p.Q.X), (-3, p.Q.Y) });
var authData = AuthData((string)options["rp"]["id"], 0x45, Counter,
new byte[16].Concat(new[] { (byte)(credentialId.Length >> 8), (byte)credentialId.Length }).Concat(credentialId).Concat(cose).ToArray());
var attestation = Cbor.Map(new (object, object)[] { ("fmt", "none"), ("attStmt", Cbor.Map(Array.Empty<(object, object)>())), ("authData", authData) });
return Credential(new JObject
{
["clientDataJSON"] = B64Url(clientData),
["attestationObject"] = B64Url(attestation),
["transports"] = new JArray("internal")
});
}
public JObject Get(JToken options, string origin)
{
Counter++;
var clientData = ClientData("webauthn.get", (string)options["challenge"], origin);
var authData = AuthData((string)options["rpId"], 0x05, Counter, Array.Empty<byte>());
var signature = key.SignData(authData.Concat(SHA256.HashData(clientData)).ToArray(), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return Credential(new JObject
{
["clientDataJSON"] = B64Url(clientData),
["authenticatorData"] = B64Url(authData),
["signature"] = B64Url(signature),
["userHandle"] = B64Url(userHandle)
});
}
private JObject Credential(JObject response) => new JObject
{
["id"] = B64Url(credentialId),
["rawId"] = B64Url(credentialId),
["type"] = "public-key",
["response"] = response,
["clientExtensionResults"] = new JObject()
};
private static byte[] ClientData(string type, string challenge, string origin) =>
Encoding.UTF8.GetBytes(new JObject { ["type"] = type, ["challenge"] = challenge, ["origin"] = origin, ["crossOrigin"] = false }.ToString(Newtonsoft.Json.Formatting.None));
//rpIdHash, flags (UP 0x01, UV 0x04, AT 0x40), counter (big-endian), then attested credential data when registering
private static byte[] AuthData(string rpId, byte flags, uint counter, byte[] attested) =>
SHA256.HashData(Encoding.UTF8.GetBytes(rpId))
.Concat(new[] { flags, (byte)(counter >> 24), (byte)(counter >> 16), (byte)(counter >> 8), (byte)counter })
.Concat(attested).ToArray();
}
private static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] B64UrlDecode(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
/// <summary>
/// The little CBOR the software authenticator needs: maps, small integers, text and byte strings
/// </summary>
private static class Cbor
{
public static byte[] Map((object key, object value)[] items)
{
var ret = Head(5, (ulong)items.Length).ToList();
foreach (var (k, v) in items) { ret.AddRange(Item(k)); ret.AddRange(Item(v)); }
return Mark(ret.ToArray());
}
private static byte[] Item(object o) => o switch
{
int i when i >= 0 => Head(0, (ulong)i),
int i => Head(1, (ulong)(-1 - i)),
string s => Head(3, (ulong)Encoding.UTF8.GetByteCount(s)).Concat(Encoding.UTF8.GetBytes(s)).ToArray(),
byte[] b when IsEncodedMap(b) => b,
byte[] b => Head(2, (ulong)b.Length).Concat(b).ToArray(),
_ => throw new ArgumentException("unsupported CBOR item")
};
//maps built by Map() are already encoded; marked by reference so they are embedded, not wrapped as bytes
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<byte[], object> encodedMaps = new();
private static bool IsEncodedMap(byte[] b) => encodedMaps.TryGetValue(b, out _);
public static byte[] Mark(byte[] map) { encodedMaps.Add(map, null); return map; }
private static byte[] Head(int major, ulong n)
{
byte m = (byte)(major << 5);
if (n < 24) return new[] { (byte)(m | n) };
if (n < 256) return new[] { (byte)(m | 24), (byte)n };
return new[] { (byte)(m | 25), (byte)(n >> 8), (byte)n };
}
}
}//eoc
}//eons

View File

@@ -0,0 +1,79 @@
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
namespace sockeye_integration
{
/// <summary>
/// case 4738: the security advisory monitor's routes. The test's own asset is inactive, so it never changes what the
/// monitor watches; the matching rules themselves are unit tested in sockeye.tests
/// </summary>
public class SecurityAdvisories
{
[Fact]
public async Task AssetIsSavedWithPastedComponentsCleanedUpAndDeleted()
{
string token = await TestUser.GetTokenAsync();
var asset = new JObject
{
["id"] = 0,
["name"] = Util.Uniquify("case 4738 test asset"),
["platform"] = "ubuntu:noble",
["components"] = new JArray("openssl\r\nLinux\nopenssl\n\nsystemd"),
["active"] = false,
["alertAt"] = 4,
["digestAt"] = 3
};
ApiResponse a = await Util.PostAsync("security-advisory/asset", token, asset.ToString());
Util.ValidateDataReturnResponseOk(a);
long id = a.ObjectResponse["data"]["id"].Value<long>();
try
{
a = await Util.GetAsync("security-advisory/asset", token);
var saved = ((JArray)a.ObjectResponse["data"]).Single(z => z["id"].Value<long>() == id);
saved["components"].ToObject<List<string>>().Should().Equal("linux", "openssl", "systemd");
saved["active"].Value<bool>().Should().BeFalse();
}
finally
{
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"security-advisory/asset/{id}", token), 204);
}
}
[Fact]
public async Task InvalidAssetIsRefused()
{
string token = await TestUser.GetTokenAsync();
var asset = new JObject { ["id"] = 0, ["name"] = "x", ["platform"] = "windows", ["components"] = new JArray("a"), ["alertAt"] = 4, ["digestAt"] = 3 };
Util.ValidateHTTPStatusCode(await Util.PostAsync("security-advisory/asset", token, asset.ToString()), 400);
asset["platform"] = "postgresql";
asset["components"] = new JArray();
Util.ValidateHTTPStatusCode(await Util.PostAsync("security-advisory/asset", token, asset.ToString()), 400);
asset["components"] = new JArray("15");
asset["digestAt"] = 5;//digest above alert
Util.ValidateHTTPStatusCode(await Util.PostAsync("security-advisory/asset", token, asset.ToString()), 400);
}
[Fact]
public async Task StatusAndDryRunStateAreReadable()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("security-advisory/status", token);
Util.ValidateDataReturnResponseOk(a);
a.ObjectResponse["data"]["sources"].Type.Should().Be(JTokenType.Array);
a.ObjectResponse["data"]["advisories"].Type.Should().Be(JTokenType.Array);
a = await Util.GetAsync("security-advisory/dry-run", token);
Util.ValidateDataReturnResponseOk(a);
a.ObjectResponse["data"]["running"].Type.Should().Be(JTokenType.Boolean);
}
[Fact]
public async Task NewEventTypesAreOffered()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("enum-list/list/NotifyEventType", token);
var ids = ((JArray)a.ObjectResponse["data"]).Select(z => z["id"].Value<int>()).ToList();
ids.Should().Contain(new[] { 43, 44 });
}
}//eoc
}//eons

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol.Core" Version="2.3.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />

View File

@@ -0,0 +1,58 @@
{
"title": "CISA Catalog of Known Exploited Vulnerabilities",
"catalogVersion": "2026.10.30",
"dateReleased": "2026-10-30T17:02:45.3665Z",
"count": 3,
"vulnerabilities": [
{
"cveID": "CVE-2026-118527",
"vendorProject": "Ivanti",
"product": "Connect Secure",
"vulnerabilityName": "Ivanti Connect Secure Generation of Predictable Numbers or Identifiers Vulnerability",
"dateAdded": "2026-10-30",
"shortDescription": "Ivanti Connect Secure contains a generation of predictable numbers or identifiers vulnerability that allows a remote authenticated attacker to upload files with attacker-controlled content to a known location on disk. This vulnerability can be chained with CVE-2026-104935 and CVE-2026-111357.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-13",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "No",
"notes": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935?language=en_US ; ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-118527",
"cwes": [
"CWE-340"
]
},
{
"cveID": "CVE-2026-111357",
"vendorProject": "Ivanti",
"product": "Connect Secure",
"vulnerabilityName": "Ivanti Connect Secure Missing Authentication for Critical Function Vulnerability",
"dateAdded": "2026-10-30",
"shortDescription": "Ivanti Connect Secure contains a missing authentication for critical function vulnerability that could allow a local authenticated attacker to escalate their privileges. This vulnerability can be chained with CVE-2026-104935 and CVE-2026-118527.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-02",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "Yes",
"notes": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935?language=en_US ; https://www.cisa.gov/news-events/alerts/2026/10/13/cisa-urges-patching-newly-exploited-vulnerability-ivanti-connect-secure ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-111357",
"cwes": [
"CWE-306",
"CWE-862",
"CWE-345"
]
},
{
"cveID": "CVE-2026-97229",
"vendorProject": "GitLab",
"product": "Community Edition and Enterprise Edition",
"vulnerabilityName": "GitLab Community Edition and Enterprise Edition Integer Overflow or Wraparound Vulnerability",
"dateAdded": "2026-10-29",
"shortDescription": "GitLab Community Edition and Enterprise Edition contain a integer overflow or wraparound vulnerability that could allow an unauthenticated user to execute arbitrary code on the GitLab server via a specially crafted regular expression in a CI/CD configuration, due to insufficient regular expression validation.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-01",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "Yes",
"notes": "Other products can potentially be affected by the same underlying Ruby/Onigmo Integer Overflow issue in CI/CD Regular Expression Compiler. For more information please see: https://www.ruby-lang.org/en/news/2026/10/23/regexp-fixes-for-gitlab-cves ; https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-4-2-released/ ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-97229",
"cwes": [
"CWE-190"
]
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
using Sockeye.Biz;
using Sockeye.Models;
namespace sockeye.tests;
/// <summary>
/// case 4738: the security advisory monitor's parsing and rules, on saved copies of the real feeds (Fixtures,
/// fetched 2026-11-01: Ubuntu notices for resolute, the PostgreSQL security page, three CISA KEV entries)
/// </summary>
public class SecurityAdvisoryTests
{
private static string Fixture(string name) => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
private static SecurityAsset Asset(string platform, params string[] components) => new SecurityAsset
{
Name = platform,
Platform = platform,
Components = components.ToList(),
Active = true,
AlertAt = AdvisorySeverity.High,
DigestAt = AdvisorySeverity.Medium
};
private static AdvisoryItem Item(AdvisorySeverity severity, string platform = "ubuntu:resolute", params string[] packages)
{
var i = new AdvisoryItem { Source = "test", SourceId = "T-1", Severity = severity };
i.Affected[platform] = packages.Length == 0 ? new List<string> { "openssl" } : packages.ToList();
return i;
}
[Fact]
public void UbuntuNoticesParseWithSourcePackagesPerRelease()
{
var items = UbuntuUsnSource.ParseNotices(Fixture("ubuntu-notices.json"), out int count);
Assert.Equal(8, count);
var dotnet = items.Single(z => z.SourceId == "USN-8958-1");
Assert.Equal(DateTimeKind.Utc, dotnet.Published.Value.Kind);
Assert.Equal(new[] { "dotnet10" }, dotnet.Affected["ubuntu:resolute"]);
Assert.Equal(new[] { "dotnet10", "dotnet8" }, dotnet.Affected["ubuntu:noble"].OrderBy(z => z));
Assert.Equal(3, dotnet.CveIds.Count);
Assert.Equal("https://ubuntu.com/security/notices/USN-8958-1", dotnet.Url);
Assert.Empty(items.Single(z => z.SourceId == "USN-8947-1").CveIds);
}
[Fact]
public void PostgresSecurityPageParses()
{
var items = PostgresSecuritySource.ParsePage(Fixture("postgresql-security.html"));
Assert.True(items.Count > 20, $"only {items.Count} rows parsed");
Assert.Equal(items.Count, items.Select(z => z.SourceId).Distinct().Count());
var dump = items.Single(z => z.SourceId == "CVE-2026-19385");
Assert.Contains("15", dump.Affected["postgresql"]);
Assert.Equal(AdvisorySeverity.High, dump.Severity);
Assert.Equal("CVSS 8.8", dump.RawSeverity);
Assert.Contains("pg_dump", dump.Title);
Assert.EndsWith("(client)", dump.Title);
Assert.Equal(AdvisorySeverity.Medium, items.Single(z => z.SourceId == "CVE-2026-18024").Severity);
}
[Fact]
public void KevParses()
{
var kev = CisaKev.Parse(Fixture("cisa-kev.json"));
Assert.Equal(3, kev.Count);
Assert.Equal(new DateTime(2026, 10, 30), kev["CVE-2026-118527"].Date);
Assert.True(kev.ContainsKey("cve-2026-97229"), "lookup ignores case");
}
[Theory]
[InlineData(AdvisorySeverity.Critical, AdvisoryOutcome.Alerted)]
[InlineData(AdvisorySeverity.High, AdvisoryOutcome.Alerted)]
[InlineData(AdvisorySeverity.Medium, AdvisoryOutcome.Digest)]
[InlineData(AdvisorySeverity.Unknown, AdvisoryOutcome.Digest)]
[InlineData(AdvisorySeverity.Low, AdvisoryOutcome.Ignored)]
[InlineData(AdvisorySeverity.Negligible, AdvisoryOutcome.Ignored)]
public void SeverityDecidesOutcome(AdvisorySeverity severity, AdvisoryOutcome expected)
{
var (outcome, matched) = SecurityAdvisoryPolicy.Evaluate(Item(severity), new[] { Asset("ubuntu:resolute", "openssl", "linux") });
Assert.Equal(expected, outcome);
Assert.Equal("ubuntu:resolute: openssl", matched);
}
[Fact]
public void KnownExploitedAlertsWhateverTheSeverity()
{
var item = Item(AdvisorySeverity.Low);
item.KnownExploited = true;
Assert.Equal(AdvisoryOutcome.Alerted, SecurityAdvisoryPolicy.Evaluate(item, new[] { Asset("ubuntu:resolute", "openssl") }).outcome);
}
[Fact]
public void UnwatchedPackageOrReleaseOrInactiveAssetDoesNotMatch()
{
var assets = new[] { Asset("ubuntu:resolute", "linux") };
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(Item(AdvisorySeverity.Critical), assets).outcome);
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(Item(AdvisorySeverity.Critical, "ubuntu:noble", "linux"), assets).outcome);
var inactive = Asset("ubuntu:resolute", "openssl");
inactive.Active = false;
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(Item(AdvisorySeverity.Critical), new[] { inactive }).outcome);
}
[Fact]
public void StrongestOutcomeOverAssetsAndAllMatchesListed()
{
var item = Item(AdvisorySeverity.Medium);
item.Affected["ubuntu:noble"] = new List<string> { "openssl" };
var strict = Asset("ubuntu:noble", "openssl");
strict.Name = "24.04 fleet";
strict.AlertAt = AdvisorySeverity.Medium;
var normal = Asset("ubuntu:resolute", "openssl");
normal.Name = "26.04 fleet";
var (outcome, matched) = SecurityAdvisoryPolicy.Evaluate(item, new[] { normal, strict });
Assert.Equal(AdvisoryOutcome.Alerted, outcome);
Assert.Equal("26.04 fleet: openssl; 24.04 fleet: openssl", matched);
}
[Fact]
public void PostgresMatchesOnMajorVersion()
{
var item = PostgresSecuritySource.ParsePage(Fixture("postgresql-security.html")).Single(z => z.SourceId == "CVE-2026-19385");
Assert.Equal(AdvisoryOutcome.Alerted, SecurityAdvisoryPolicy.Evaluate(item, new[] { Asset("postgresql", "15") }).outcome);
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(item, new[] { Asset("postgresql", "13") }).outcome);
}
[Theory]
[InlineData("9.8", AdvisorySeverity.Critical)]
[InlineData("7.0", AdvisorySeverity.High)]
[InlineData("6.9", AdvisorySeverity.Medium)]
[InlineData("3.1", AdvisorySeverity.Low)]
[InlineData("", AdvisorySeverity.Unknown)]
public void CvssBands(string score, AdvisorySeverity expected)
{
Assert.Equal(expected, SecurityAdvisoryPolicy.FromCvss(SecurityAdvisoryPolicy.ParseScore(score)));
}
[Fact]
public void PastedDpkgOutputIsCleanedUp()
{
var pasted = new[] { "openssl\r\nLinux\nsystemd\n\nopenssl\n", "nginx, glibc" };
Assert.Equal(new[] { "glibc", "linux", "nginx", "openssl", "systemd" }, SecurityAdvisoryPolicy.NormalizeComponents(pasted));
}
}

View File

@@ -14,6 +14,10 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>