feat(processes): make the host process non-dumpable on Linux
HostProcessHardening calls prctl(PR_SET_DUMPABLE, 0) through LibraryImport, once per process, from the ProcessSpawnPolicy constructor, and warns when DOTNET_EnableDiagnostics is not 0. Test projects consume the one-shot call with the flag off in a module initializer. A HardeningProbe helper backs a Linux-only /proc integration test.
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
|
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
|
||||||
|
<Project Path="tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
|
||||||
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
|
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
|
||||||
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />
|
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Features.Processes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Linux: makes the host non-dumpable (<c>prctl(PR_SET_DUMPABLE, 0)</c>) so a same-user child can't read
|
||||||
|
/// <c>/proc/<pid>/environ</c> or ptrace the host (spec §8). Idempotent: the first call wins.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class HostProcessHardening
|
||||||
|
{
|
||||||
|
private const int PrSetDumpable = 4;
|
||||||
|
private static readonly Hardener Shared = new(SetDumpable, OperatingSystem.IsLinux(), Environment.GetEnvironmentVariable);
|
||||||
|
|
||||||
|
/// <summary>Applies the hardening once per process; later calls are no-ops whatever their options say.</summary>
|
||||||
|
public static void Apply(ProcessEnvironmentOptions options, ILogger logger) => Shared.Apply(options, logger);
|
||||||
|
|
||||||
|
[LibraryImport("libc", SetLastError = true)]
|
||||||
|
private static partial int prctl(int option, nuint arg2, nuint arg3, nuint arg4, nuint arg5);
|
||||||
|
|
||||||
|
private static int SetDumpable(int value) =>
|
||||||
|
prctl(PrSetDumpable, (nuint)value, 0, 0, 0) == 0 ? 0 : Marshal.GetLastPInvokeError();
|
||||||
|
|
||||||
|
/// <summary>The testable core: <c>setDumpable</c> returns 0 on success, else the errno.</summary>
|
||||||
|
internal sealed class Hardener(Func<int, int> setDumpable, bool isLinux, Func<string, string?> getEnv)
|
||||||
|
{
|
||||||
|
private int _applied;
|
||||||
|
|
||||||
|
public void Apply(ProcessEnvironmentOptions options, ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
if (Interlocked.Exchange(ref _applied, 1) == 1) return;
|
||||||
|
if (!options.HardenHostProcess)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Host process hardening disabled: agent processes can read this process's environment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLinux)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Host process hardening skipped: only Linux needs it");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var errno = setDumpable(0);
|
||||||
|
if (errno == 0) logger.LogInformation("Host process hardened: not dumpable");
|
||||||
|
else logger.LogWarning("Host process hardening failed: prctl(PR_SET_DUMPABLE, 0) failed with errno {Errno}", errno);
|
||||||
|
if (getEnv("DOTNET_EnableDiagnostics") != "0" && getEnv("DOTNET_EnableDiagnostics_IPC") != "0")
|
||||||
|
logger.LogWarning("The .NET diagnostic IPC socket is on and hands out this process's environment to the same user; set DOTNET_EnableDiagnostics=0 before starting the host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public sealed class ProcessSpawnPolicy : IProcessSpawnPolicy
|
|||||||
_environment = environment ?? CurrentEnvironment;
|
_environment = environment ?? CurrentEnvironment;
|
||||||
_isWindows = isWindows;
|
_isWindows = isWindows;
|
||||||
_filter = new EnvironmentFilter(options);
|
_filter = new EnvironmentFilter(options);
|
||||||
|
HostProcessHardening.Apply(options, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProcessSpawnContext Prepare(ProcessSpawnRequest request)
|
public ProcessSpawnContext Prepare(ProcessSpawnRequest request)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Description>Headless, extensible agent runtime (pi clone) organised as vertical slices.</Description>
|
<Description>Headless, extensible agent runtime (pi clone) organised as vertical slices.</Description>
|
||||||
|
<!-- LibraryImport (prctl in HostProcessHardening) needs it; SYSLIB1062 otherwise. -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
<ProjectReference Include="../Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Volcanic.AgenticCore.Features.Processes;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Extensions.Tests;
|
||||||
|
|
||||||
|
/// <summary>Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable.</summary>
|
||||||
|
internal static class NoHostHardening
|
||||||
|
{
|
||||||
|
[ModuleInitializer]
|
||||||
|
internal static void Disable() =>
|
||||||
|
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance);
|
||||||
|
}
|
||||||
11
tests/Volcanic.AgenticCore.HardeningProbe/Program.cs
Normal file
11
tests/Volcanic.AgenticCore.HardeningProbe/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Test helper: `dotnet Volcanic.AgenticCore.HardeningProbe.dll harden|plain`. Prints "ok" once hardening ran, then
|
||||||
|
// waits until stdin closes so the test can read /proc/<pid>/environ in between.
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Volcanic.AgenticCore.Features.Processes;
|
||||||
|
|
||||||
|
var harden = args.Length > 0 && args[0] == "harden";
|
||||||
|
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = harden }, NullLogger.Instance);
|
||||||
|
Console.WriteLine("ok");
|
||||||
|
Console.Out.Flush();
|
||||||
|
await Console.In.ReadToEndAsync();
|
||||||
|
return 0;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>false</IsTestProject>
|
||||||
|
<Description>Helper for HostProcessHardeningTests: applies hardening, prints ok, waits for stdin to close.</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Volcanic.AgenticCore.Features.Processes;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||||
|
|
||||||
|
/// <summary>Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable.</summary>
|
||||||
|
internal static class NoHostHardening
|
||||||
|
{
|
||||||
|
[ModuleInitializer]
|
||||||
|
internal static void Disable() =>
|
||||||
|
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Volcanic.AgenticCore.Features.Processes;
|
||||||
|
using Volcanic.AgenticCore.Tests.Support;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Tests.Processes;
|
||||||
|
|
||||||
|
public class HostProcessHardeningTests
|
||||||
|
{
|
||||||
|
private static readonly ProcessEnvironmentOptions On = new();
|
||||||
|
private static readonly ProcessEnvironmentOptions Off = new() { HardenHostProcess = false };
|
||||||
|
private static Func<string, string?> Env(string? diagnostics = "0", string? ipc = null) =>
|
||||||
|
name => name switch { "DOTNET_EnableDiagnostics" => diagnostics, "DOTNET_EnableDiagnostics_IPC" => ipc, _ => null };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Linux_with_the_flag_on_calls_prctl_with_zero()
|
||||||
|
{
|
||||||
|
var calls = new List<int>();
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
new HostProcessHardening.Hardener(v => { calls.Add(v); return 0; }, isLinux: true, Env()).Apply(On, logger);
|
||||||
|
Assert.Equal([0], calls);
|
||||||
|
Assert.True(logger.Has(LogLevel.Information, "Host process hardened: not dumpable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_second_apply_does_not_call_prctl_again()
|
||||||
|
{
|
||||||
|
var calls = 0;
|
||||||
|
var hardener = new HostProcessHardening.Hardener(_ => { calls++; return 0; }, isLinux: true, Env());
|
||||||
|
hardener.Apply(On, new RecordingLogger());
|
||||||
|
hardener.Apply(On, new RecordingLogger());
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_failing_prctl_logs_a_warning_and_does_not_throw()
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
new HostProcessHardening.Hardener(_ => 22, isLinux: true, Env()).Apply(On, logger);
|
||||||
|
Assert.True(logger.Has(LogLevel.Warning, "prctl(PR_SET_DUMPABLE, 0) failed"));
|
||||||
|
Assert.True(logger.Has(LogLevel.Warning, "22"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Non_linux_does_not_call_prctl()
|
||||||
|
{
|
||||||
|
var calls = 0;
|
||||||
|
new HostProcessHardening.Hardener(_ => { calls++; return 0; }, isLinux: false, Env(null)).Apply(On, new RecordingLogger());
|
||||||
|
Assert.Equal(0, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flag_off_does_not_call_prctl_and_logs_a_warning()
|
||||||
|
{
|
||||||
|
var calls = 0;
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
new HostProcessHardening.Hardener(_ => { calls++; return 0; }, isLinux: true, Env()).Apply(Off, logger);
|
||||||
|
Assert.Equal(0, calls);
|
||||||
|
Assert.True(logger.Has(LogLevel.Warning, "Host process hardening disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_DOTNET_EnableDiagnostics_logs_a_warning()
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
new HostProcessHardening.Hardener(_ => 0, isLinux: true, Env(diagnostics: null)).Apply(On, logger);
|
||||||
|
Assert.True(logger.Has(LogLevel.Warning, "DOTNET_EnableDiagnostics=0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0", null)]
|
||||||
|
[InlineData(null, "0")]
|
||||||
|
public void DOTNET_EnableDiagnostics_zero_suppresses_the_warning(string? diagnostics, string? ipc)
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
new HostProcessHardening.Hardener(_ => 0, isLinux: true, Env(diagnostics, ipc)).Apply(On, logger);
|
||||||
|
Assert.False(logger.Has(LogLevel.Warning, "DOTNET_EnableDiagnostics"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hardened_process_environ_is_unreadable_to_the_same_user()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux()) return;
|
||||||
|
if (Environment.UserName == "root") return; // root reads every /proc entry; the guarantee is for the bot's own user
|
||||||
|
|
||||||
|
var (hardenedExit, _, hardenedErr) = await ReadProbeEnvironAsync(harden: true);
|
||||||
|
Assert.NotEqual(0, hardenedExit);
|
||||||
|
Assert.Contains("Permission denied", hardenedErr);
|
||||||
|
|
||||||
|
var (plainExit, plainOut, _) = await ReadProbeEnvironAsync(harden: false);
|
||||||
|
Assert.Equal(0, plainExit);
|
||||||
|
Assert.Contains("PROBE_MARKER=probe-marker-value", plainOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(int Exit, string Stdout, string Stderr)> ReadProbeEnvironAsync(bool harden)
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
var dll = Path.Combine(dir, "Volcanic.AgenticCore.HardeningProbe.dll");
|
||||||
|
var psi = new ProcessStartInfo("dotnet") { RedirectStandardInput = true, RedirectStandardOutput = true, UseShellExecute = false };
|
||||||
|
if (!File.Exists(Path.Combine(dir, "Volcanic.AgenticCore.HardeningProbe.runtimeconfig.json")))
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add("exec");
|
||||||
|
psi.ArgumentList.Add("--runtimeconfig");
|
||||||
|
psi.ArgumentList.Add(Path.Combine(dir, "Volcanic.AgenticCore.Tests.runtimeconfig.json"));
|
||||||
|
}
|
||||||
|
psi.ArgumentList.Add(dll);
|
||||||
|
psi.ArgumentList.Add(harden ? "harden" : "plain");
|
||||||
|
psi.Environment["PROBE_MARKER"] = "probe-marker-value";
|
||||||
|
|
||||||
|
using var probe = Process.Start(psi)!;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal("ok", await probe.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(30)));
|
||||||
|
var cat = new ProcessStartInfo("cat") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||||
|
cat.ArgumentList.Add($"/proc/{probe.Id}/environ");
|
||||||
|
using var reader = Process.Start(cat)!;
|
||||||
|
var stdoutTask = reader.StandardOutput.ReadToEndAsync();
|
||||||
|
var stderrTask = reader.StandardError.ReadToEndAsync();
|
||||||
|
await reader.WaitForExitAsync();
|
||||||
|
return (reader.ExitCode, (await stdoutTask).Replace('\0', '\n'), await stderrTask);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
probe.StandardInput.Close();
|
||||||
|
if (!probe.WaitForExit(10_000)) probe.Kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/Volcanic.AgenticCore.Tests/Support/NoHostHardening.cs
Normal file
13
tests/Volcanic.AgenticCore.Tests/Support/NoHostHardening.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Volcanic.AgenticCore.Features.Processes;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Tests.Support;
|
||||||
|
|
||||||
|
/// <summary>Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable.</summary>
|
||||||
|
internal static class NoHostHardening
|
||||||
|
{
|
||||||
|
[ModuleInitializer]
|
||||||
|
internal static void Disable() =>
|
||||||
|
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance);
|
||||||
|
}
|
||||||
@@ -14,5 +14,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
|
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
|
||||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="../Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user