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 Name="/tests/">
|
||||
<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.Tests/Volcanic.AgenticCore.Tests.csproj" />
|
||||
</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;
|
||||
_isWindows = isWindows;
|
||||
_filter = new EnvironmentFilter(options);
|
||||
HostProcessHardening.Apply(options, _logger);
|
||||
}
|
||||
|
||||
public ProcessSpawnContext Prepare(ProcessSpawnRequest request)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>Headless, extensible agent runtime (pi clone) organised as vertical slices.</Description>
|
||||
<!-- LibraryImport (prctl in HostProcessHardening) needs it; SYSLIB1062 otherwise. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<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>
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
||||
<ProjectReference Include="../Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user