summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Security/ExecutablePolicy.cs
blob: 1f361646df64b8fe4c7cca8fab8564df9be14608 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using System.Security.Cryptography;

namespace Agent.Windows.Security;

public sealed record ExecutableApproval(string Path, string Sha256);
public sealed record VerifiedExecutable(string CanonicalPath, string Sha256);

public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell)
{
    private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary(
        approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase);

    public VerifiedExecutable Verify(string executable)
    {
        string canonical = CanonicalPath.Resolve(executable);
        if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.");
        if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.");
        using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read);
        string actualHash = Convert.ToHexString(SHA256.HashData(stream));
        if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash)))
            throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.");
        return new(canonical, actualHash);
    }

    public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments)
    {
        if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
        if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase))
        {
            if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal))
                throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument.");
            return arguments[1];
        }
        return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null;
    }

    public static string ComputeSha256(string executable)
    {
        using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
        return Convert.ToHexString(SHA256.HashData(stream));
    }

    private static bool IsShell(string path) => Path.GetFileName(path) is string name &&
        (name.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase));
    private static string NormalizeHash(string hash)
    {
        string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
        if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash));
        return normalized;
    }
}