summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Security/ExecutablePolicy.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows/Security/ExecutablePolicy.cs')
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs87
1 files changed, 66 insertions, 21 deletions
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
index 6c72724..02306fd 100644
--- a/src/Agent.Windows/Security/ExecutablePolicy.cs
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -2,46 +2,91 @@ 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 enum EntryExecutableClassification { ApprovedEntryExecutable, ApprovedShellExecutable, RuntimeComponent, WorkspaceArtifact }
-public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell)
+public sealed record ApprovedEntryExecutable(string Alias, string ResolvedPath, string Sha256, bool IsShell, string? RuntimeRoot);
+public sealed record VerifiedEntryExecutable(string ResolvedPath, string Sha256, EntryExecutableClassification Classification, string? Alias = null);
+
+public sealed class EntryExecutablePolicy(
+ IEnumerable<ApprovedEntryExecutable> approvals,
+ IEnumerable<string> runtimeRoots,
+ IEnumerable<string> artifactRoots)
{
- private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary(
- approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase);
+ private readonly IReadOnlyDictionary<string, ApprovedEntryExecutable> _aliases = approvals.ToDictionary(
+ approval => approval.Alias, Normalize, StringComparer.OrdinalIgnoreCase);
+ private readonly string[] _runtimeRoots = runtimeRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ private readonly string[] _artifactRoots = artifactRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- public VerifiedExecutable Verify(string executable)
+ public VerifiedEntryExecutable VerifyEntry(string executable, string? baseDirectory = null)
{
- string canonical = CanonicalPath.Resolve(executable);
- if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell");
- 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.", "process", canonical, "execute", "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.", "process", canonical, "execute", "sandbox.process.approvals");
- return new(canonical, actualHash);
+ if (_aliases.TryGetValue(executable, out ApprovedEntryExecutable? byAlias)) return VerifyApproved(byAlias);
+ string candidate = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable);
+ string resolved;
+ try { resolved = ResolvedPath.Resolve(candidate); }
+ catch (FileNotFoundException)
+ {
+ throw new SandboxPolicyException("process.alias_or_path_unknown", $"Executable Alias or Logical Path does not resolve to an existing file: '{executable}'.",
+ "process", Path.GetFullPath(candidate), "execute", "Use a configured Executable Alias or an existing Runtime Component/development Workspace Artifact path.");
+ }
+ if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)))
+ return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.RuntimeComponent);
+ if (_artifactRoots.Any(root => IsAtOrBelow(resolved, root)))
+ return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.WorkspaceArtifact);
+ throw new SandboxPolicyException("process.entry_unknown", $"External Unknown Executable cannot be started as an Entry Process: '{resolved}'.",
+ "process", resolved, "execute", "Configure an Executable Alias and approve it, or place the Workspace Artifact in a development Workspace.");
}
- public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments)
+ public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null)
{
- if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
+ string executablePath = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable);
+ string resolved = _aliases.TryGetValue(executable, out ApprovedEntryExecutable? approval) ? approval.ResolvedPath : ResolvedPath.Resolve(executablePath);
+ if (!Path.GetFileName(resolved).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 ResolveArgument(arguments[1], baseDirectory);
}
- return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null;
+ return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? ResolveArgument(arguments[0], baseDirectory) : null;
+ }
+
+ public void VerifyManagedEntryPoint(string path)
+ {
+ string resolved = ResolvedPath.Resolve(path);
+ if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)) || _artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return;
+ throw new SandboxPolicyException("process.managed_entry_unknown", $"Managed Entry Artifact is outside every confirmed Runtime Root and development Workspace: '{resolved}'.",
+ "process", resolved, "execute", "Place the artifact in a development Workspace or confirm its Runtime Root.");
}
public static string ComputeSha256(string executable)
{
- using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
+ using FileStream stream = new(ResolvedPath.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 ApprovedEntryExecutable Normalize(ApprovedEntryExecutable approval) => approval with
+ {
+ ResolvedPath = ResolvedPath.Resolve(approval.ResolvedPath),
+ Sha256 = NormalizeHash(approval.Sha256),
+ RuntimeRoot = approval.RuntimeRoot is null ? null : ResolvedPath.Resolve(approval.RuntimeRoot)
+ };
+
+ private static VerifiedEntryExecutable VerifyApproved(ApprovedEntryExecutable approval)
+ {
+ string resolved = ResolvedPath.Resolve(approval.ResolvedPath);
+ string actualHash = ComputeSha256(resolved);
+ if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approval.Sha256), Convert.FromHexString(actualHash)))
+ throw new SandboxPolicyException("process.hash_denied", $"Approved Entry Executable SHA-256 changed: '{resolved}'.",
+ "process", resolved, "execute", $"Re-run executable approve for Alias '{approval.Alias}'.");
+ return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias);
+ }
+
+ private static bool IsAtOrBelow(string candidate, string root) =>
+ candidate.Equals(root, StringComparison.OrdinalIgnoreCase) ||
+ candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ private static string ResolveArgument(string path, string? baseDirectory) => Path.IsPathFullyQualified(path)
+ ? path : Path.GetFullPath(path, baseDirectory ?? Environment.CurrentDirectory);
+
private static string NormalizeHash(string hash)
{
string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();