Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Logger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static void Initialize(string logDirectory, string version = "Unknown", b
WriteToFile(LogLevel.Info, $"Process Architecture: {System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture}");
WriteToFile(LogLevel.Info, $"OS Architecture: {System.Runtime.InteropServices.RuntimeInformation.OSArchitecture}");
WriteToFile(LogLevel.Info, $"Working Directory: {Environment.CurrentDirectory}");
WriteToFile(LogLevel.Info, $"Command Line: {Environment.CommandLine}");
WriteToFile(LogLevel.Info, $"Command Line: {BootstrapMate.Core.CommandLineRedaction.Redact(Environment.CommandLine)}");
WriteToFile(LogLevel.Info, $"Is Interactive: {Environment.UserInteractive}");
WriteToFile(LogLevel.Info, $"Current User: {System.Security.Principal.WindowsIdentity.GetCurrent().Name}");
WriteToFile(LogLevel.Info, $"Verbose Console: {verboseConsole}");
Expand Down
24 changes: 15 additions & 9 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,27 @@ static int Main(string[] args)
bool verboseMode = args.Any(arg => arg.Equals("--verbose", StringComparison.OrdinalIgnoreCase) ||
arg.Equals("-v", StringComparison.Ordinal));

// Before anything is read from ProgramData\ManagedBootstrap: a standard user can
// create files under ProgramData, and this process trusts what it finds there.
// Before the configuration loads: stale 32-bit settings go, and credential
// headers move out of the user-readable policy and settings keys.
var startupNotes = new List<string>();
if (IsRunningAsAdministrator())
{
startupNotes.AddRange(DataDirectoryGuard.Secure().Select(n => $"Data directory: {n}"));
startupNotes.AddRange(LegacyRegistry.RemoveStaleWow64Settings());
startupNotes.AddRange(SecretStore.MigrateReadableCopies());
}

// Policy or saved settings can turn either on; a CLI switch cannot turn them off.
silentMode |= ConfigManager.Instance.Config.SilentMode;
verboseMode |= ConfigManager.Instance.Config.VerboseMode;

// Before anything is read from ProgramData\ManagedBootstrap: a standard user can
// create files under ProgramData, and this process trusts what it finds there.
var dataDirectoryNotes = IsRunningAsAdministrator()
? DataDirectoryGuard.Secure()
: new List<string>();

Logger.Initialize(LogDirectory, Version, verboseMode, silentMode);
Logger.Debug("Main() called with arguments: " + string.Join(" ", args));
foreach (var note in dataDirectoryNotes)
Logger.Debug("Main() called with arguments: " + string.Join(" ", CommandLineRedaction.Redact(args)));
foreach (var note in startupNotes)
{
Logger.Warning($"Data directory: {note}");
Logger.Warning(note);
}
foreach (var retired in ConfigManager.Instance.RetiredSettingsPresent)
{
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ Before building, set up your environment variables:

3. **Install your code signing certificate** in the Current User certificate store

## Where settings are read

Settings come from policy (`HKLM\SOFTWARE\Policies\BootstrapMate`, written by Intune or Group
Policy) and then machine settings (`HKLM\SOFTWARE\BootstrapMate\Settings`). Both are read from
the 64-bit registry view only. A copy of the settings key in `WOW6432Node` is stale. The MSI
removes it, and the CLI removes it once and logs which values it removed. The policy key is
shared between the two views and is never removed.

`AuthorizationHeader` and `ReportingHeader` carry credentials, so they live in
`HKLM\SOFTWARE\BootstrapMate\Secrets`. Only SYSTEM and Administrators can read that key.
Deliver a header through policy as usual. The next elevated run moves it into that key and
blanks the copy in policy, which every user can read. The policy value is emptied, not deleted,
so the setting still shows as managed. The GUI shows a saved header masked and saves a new one
only after Unlock. BootstrapMate never writes a header to its logs, `session.json` or
`last-run.json`, and it redacts `--headers` from every logged command line.

## Reporting

When a run completes, BootstrapMate can POST a vendor-neutral JSON run summary to an optional endpoint, turning "did this PC provision cleanly?" into a fleet-dashboard query. The payload is plain JSON and not tied to any specific backend — any service accepting a JSON POST (a custom collector, ReportMate, MunkiReport, etc.) can consume it. Both the Windows and macOS clients emit the same schema.
Expand Down
2 changes: 1 addition & 1 deletion SessionLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ private void WriteSessionFile(string status, DateTime? end = null)
["os_version"] = System.Environment.OSVersion.ToString(),
["user"] = System.Environment.UserName,
["pid"] = System.Environment.ProcessId.ToString(),
["command_line"] = System.Environment.CommandLine
["command_line"] = BootstrapMate.Core.CommandLineRedaction.Redact(System.Environment.CommandLine)
},
Summary = new SessionSummary { Events = _events, Errors = _errors, Warnings = _warnings }
};
Expand Down
21 changes: 21 additions & 0 deletions installer/Product.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@
<RegistryValue Root="HKLM" Key="SOFTWARE\BootstrapMate" Name="DataDirectoryAcl" Value="1" Type="integer" KeyPath="yes" />
</Component>

<!-- Credential headers live in HKLM\SOFTWARE\BootstrapMate\Secrets, readable only by
SYSTEM and Administrators; the CLI moves any header it finds in the user-readable
policy or settings keys there. -->
<Component Id="SecretsAndLegacyRegistry" Directory="INSTALLDIR" Guid="5C8D2E7F-3B1A-4F69-A0D4-8E2B7C6F1A93">
<RegistryKey Root="HKLM" Key="SOFTWARE\BootstrapMate\Secrets" ForceCreateOnInstall="yes">
<PermissionEx Sddl="D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)" />
</RegistryKey>
<RegistryValue Root="HKLM" Key="SOFTWARE\BootstrapMate" Name="SecretsStore" Value="1" Type="integer" KeyPath="yes" />
</Component>

<!-- Settings are read from the 64-bit view only; this removes the stale 32-bit copy
(WOW6432Node\BootstrapMate\Settings). The component is 32-bit so the key resolves in
that view. Never list SOFTWARE\Policies here: it is shared between the views, so
its 32-bit path is the live policy key. -->
<Component Id="LegacyWow64Settings" Directory="TARGETDIR" Bitness="always32" Guid="9E4A6C2B-71D3-4B8F-A5E0-2D6C8F1B3A47">
<RemoveRegistryKey Root="HKLM" Key="SOFTWARE\BootstrapMate\Settings" Action="removeOnInstall" />
<RegistryValue Root="HKLM" Key="SOFTWARE\BootstrapMate" Name="LegacySettingsCleanup" Value="1" Type="integer" KeyPath="yes" />
</Component>

<!-- Machine-level fallback settings: ConfigManager reads HKLM\SOFTWARE\BootstrapMate\Settings
when no CSP policy is present. CSP at HKLM\SOFTWARE\Policies\BootstrapMate still overrides. -->
<Component Id="MachineSettings" Directory="INSTALLDIR" Guid="C1F2B3A4-D5E6-4789-9012-A1B2C3D4E5F6">
Expand Down Expand Up @@ -173,6 +192,8 @@
<ComponentRef Id="RegistryEntries" />
<ComponentRef Id="MachineSettings" />
<ComponentRef Id="DataDirectoryAcl" />
<ComponentRef Id="SecretsAndLegacyRegistry" />
<ComponentRef Id="LegacyWow64Settings" />
<ComponentRef Id="SetPathComponent" />
<ComponentRef Id="StartMenuShortcut" />
<ComponentRef Id="LegacyStartMenuCleanup" />
Expand Down
7 changes: 2 additions & 5 deletions src/BootstrapMate.App/ViewModels/PrefsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,8 @@ public List<string> BuildRunArguments()
args.Add(ManifestUrl);
}

if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
{
args.Add("--headers");
args.Add(AuthorizationHeader);
}
// No --headers: the header is saved to the protected store, where the elevated run
// reads it, and never travels on a command line.

if (DryRun) args.Add("--dry-run");
if (SilentMode) args.Add("--silent");
Expand Down
63 changes: 63 additions & 0 deletions src/BootstrapMate.Core/CommandLineRedaction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace BootstrapMate.Core;

/// <summary>
/// Hides the value of every switch that carries a credential before a command line is
/// written to a log or session record.
/// </summary>
public static class CommandLineRedaction
{
public const string Mask = "<redacted>";

/// <summary>Switches whose value is a credential.</summary>
public static readonly string[] SecretSwitches = ["--headers", "--reporting-header"];

public static IEnumerable<string> Redact(IEnumerable<string> args)
{
bool hideNext = false;
foreach (var arg in args)
{
if (hideNext)
{
hideNext = false;
yield return Mask;
continue;
}
var match = SecretSwitches.FirstOrDefault(s =>
arg.StartsWith(s + "=", StringComparison.OrdinalIgnoreCase) ||
arg.StartsWith(s + ":", StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
yield return $"{arg[..(match.Length + 1)]}{Mask}";
continue;
}
hideNext = SecretSwitches.Contains(arg, StringComparer.OrdinalIgnoreCase);
yield return arg;
}
}

/// <summary>
/// The same for a whole command line as Windows quotes it. Quoting is honoured well
/// enough to find a value; the result is for display only.
/// </summary>
public static string Redact(string commandLine) => string.Join(" ", Redact(Split(commandLine)).Select(Quote));

private static IEnumerable<string> Split(string commandLine)
{
var current = new System.Text.StringBuilder();
bool quoted = false, any = false;
foreach (var c in commandLine)
{
if (c == '"') { quoted = !quoted; any = true; continue; }
if (char.IsWhiteSpace(c) && !quoted)
{
if (any) { yield return current.ToString(); current.Clear(); any = false; }
continue;
}
current.Append(c);
any = true;
}
if (any) yield return current.ToString();
}

private static string Quote(string arg) => arg.Length == 0 || arg.Any(char.IsWhiteSpace) ? $"\"{arg}\"" : arg;
}
26 changes: 23 additions & 3 deletions src/BootstrapMate.Core/ConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ bool Skip(string key) => management.IsManaged(key) ||
void WriteString(string key, string? value)
{
if (Skip(key)) return;
if (OperatingSystem.IsWindows() && SecretStore.IsSecret(key))
{
// Credentials never go to the user-readable settings key.
SecretStore.Write(key, value);
return;
}
WriteRegistryValue(key, value ?? string.Empty, RegistryValueKind.String);
}

Expand Down Expand Up @@ -201,6 +207,21 @@ private void LoadManagementAndMachineSettings()

LoadFromMachineRegistry();
LoadFromManagement(management);
LoadSecrets();
}

/// <summary>
/// Credential headers come from the protected store, which only an elevated process can
/// read; a readable copy left in policy or settings is used only until an elevated run
/// moves it there.
/// </summary>
private void LoadSecrets()
{
if (OperatingSystem.IsWindows())
{
Config.AuthorizationHeader = SecretStore.Read("AuthorizationHeader") ?? Config.AuthorizationHeader;
Config.ReportingHeader = SecretStore.Read("ReportingHeader") ?? Config.ReportingHeader;
}
}

private void LoadFromManagement(ManagementDetector management)
Expand Down Expand Up @@ -273,10 +294,9 @@ private void LoadFromManagement(ManagementDetector management)

private void LoadFromMachineRegistry()
{
// HKLM\SOFTWARE\BootstrapMate\Settings — read from both registry views so
// a 32-bit MSI write is visible to the 64-bit binary and vice-versa.
// HKLM\SOFTWARE\BootstrapMate\Settings, 64-bit view only: the MSI and the GUI write
// there, and a stale 32-bit copy (WOW6432Node) used to outrank them.
LoadFromHive(RegistryHive.LocalMachine, ConfigSource.MachineSettings, RegistryView.Registry64);
LoadFromHive(RegistryHive.LocalMachine, ConfigSource.MachineSettings, RegistryView.Registry32);
}

private void LoadFromHive(RegistryHive hive, ConfigSource source, RegistryView view = RegistryView.Default)
Expand Down
57 changes: 57 additions & 0 deletions src/BootstrapMate.Core/LegacyRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Runtime.Versioning;
using Microsoft.Win32;

namespace BootstrapMate.Core;

/// <summary>
/// Settings and policy are read from the 64-bit registry view only. Older builds also read
/// the 32-bit view (WOW6432Node), where stale copies outranked what the GUI and the MSI
/// write. Those copies are removed once, and each removal is reported.
/// </summary>
[SupportedOSPlatform("windows")]
public static class LegacyRegistry
{
/// <summary>Set under HKLM\SOFTWARE\BootstrapMate once the 32-bit copies are gone.</summary>
public const string DoneMarker = "Wow64SettingsRemoved";

/// <summary>
/// 32-bit view keys that used to be read as settings. Only the settings key: the
/// policy key (SOFTWARE\Policies) is shared between the two views, so its "32-bit copy"
/// is the live policy itself and must never be removed.
/// </summary>
public static readonly string[] StaleKeys = [BootstrapMateConstants.MachineSettingsRegistryPath];

/// <summary>Removes the stale 32-bit keys once. Must run elevated. Returns what it removed.</summary>
public static List<string> RemoveStaleWow64Settings()
{
var notes = new List<string>();
try
{
using var base64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
using var tool = base64.CreateSubKey(@"SOFTWARE\BootstrapMate", writable: true);
if (tool.GetValue(DoneMarker) is int done && done != 0) return notes;

using var base32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
foreach (var path in StaleKeys)
{
using (var key = base32.OpenSubKey(path))
{
if (key is null) continue;
// Names only: a value here may be a credential.
var names = key.GetValueNames().Where(n => n.Length > 0).ToArray();
var below = path[@"SOFTWARE\".Length..];
notes.Add($@"Removed stale HKLM\SOFTWARE\WOW6432Node\{below}" +
(names.Length > 0 ? $" ({string.Join(", ", names)})" : ""));
}
base32.DeleteSubKeyTree(path, throwOnMissingSubKey: false);
}

tool.SetValue(DoneMarker, 1, RegistryValueKind.DWord);
}
catch (Exception ex)
{
notes.Add($"Could not remove stale 32-bit settings: {ex.Message}");
}
return notes;
}
}
8 changes: 6 additions & 2 deletions src/BootstrapMate.Core/ManagementDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,14 @@ public HashSet<string> AllManagedKeys()
private static string[] GetAliases(string canonicalKey)
=> KeyAliases.TryGetValue(canonicalKey, out var aliases) ? aliases : [canonicalKey];

/// <summary>Every policy value name that sets <paramref name="canonicalKey"/>.</summary>
public static string[] AliasesFor(string canonicalKey) => GetAliases(canonicalKey);

private static object? FindRegistryValue(string[] keyNames)
{
// Check both 64-bit and 32-bit registry views
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
// 64-bit view only: Intune and Group Policy write there, and a stale 32-bit copy
// (WOW6432Node) must never outrank it.
foreach (var view in new[] { RegistryView.Registry64 })
{
try
{
Expand Down
Loading
Loading