From 560375bae9bac672c36af9907dbe70e16d96e178 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Mon, 12 Oct 2026 14:31:06 -0700 Subject: [PATCH] Add native process user SID and owner-only ACL APIs Expose Windows token lookup and asynchronous protected DACL updates without subprocesses, enabling the fix for microsoft/vscode#332710. Include declarations, documentation and Windows ACL regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 31 ++++++ binding.gyp | 2 +- lib/index.ts | 22 ++++ lib/test.ts | 164 +++++++++++++++++++++++++++++- src/addon.cc | 98 ++++++++++++++++++ typings/windows-process-tree.d.ts | 12 +++ 6 files changed, 327 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3b2d7b6..56cb996 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,37 @@ if (process.platform === 'win32') { For the full API look at the [typings file](./typings/windows-process-tree.d.ts). +### Current user SID + +`getCurrentProcessUserSid()` synchronously returns the SID of the current +process's user (for example, `S-1-5-21-...`) using Windows token APIs, without +launching `whoami.exe` or another process. It throws if the lookup fails or the +platform is not Windows. + +```ts +import { getCurrentProcessUserSid } from '@vscode/windows-process-tree'; + +if (process.platform === 'win32') { + const sid = getCurrentProcessUserSid(); +} +``` + +### Owner-only ACLs + +`await setOwnerOnlyAcl(path)` replaces an existing file or directory's DACL using +native Windows security APIs on a worker thread, without launching subprocesses. +It disables parent ACL inheritance and grants full control to the current process +user, SYSTEM and Administrators. Directory grants are inherited by child files and +directories. Existing explicit grants are replaced; ownership and auditing are +unchanged. The promise rejects on invalid input, a Windows API error, or a +non-Windows platform. + +```ts +import { setOwnerOnlyAcl } from '@vscode/windows-process-tree'; + +await setOwnerOnlyAcl(directoryPath); +``` + ## Why a native node module? The current convention is to run wmic.exe to query a particular process ID and then parse the output like so: diff --git a/binding.gyp b/binding.gyp index 11a5e71..1cc84f1 100644 --- a/binding.gyp +++ b/binding.gyp @@ -15,7 +15,7 @@ "src/process_commandline.cc" ], "include_dirs": [], - "libraries": [ 'psapi.lib' ], + "libraries": [ 'psapi.lib', 'advapi32.lib' ], "msvs_configuration_attributes": { "SpectreMitigation": "Spectre" }, diff --git a/lib/index.ts b/lib/index.ts index 7b53aad..5ba694e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -16,6 +16,28 @@ export enum ProcessDataFlag { type RequestCallback = (processList: IProcessInfo[]) => void; +/** + * Returns the SID of the current process's user without spawning a process. + * Throws if the token cannot be queried or the platform is not Windows. + */ +export function getCurrentProcessUserSid(): string { + if (process.platform !== 'win32') { + throw new Error('getCurrentProcessUserSid is only implemented on Windows'); + } + return native.getCurrentProcessUserSid(); +} + +/** + * Replaces a file or directory's DACL with inheritable full-control grants for the + * current process user, SYSTEM and Administrators, with parent inheritance disabled. + */ +export async function setOwnerOnlyAcl(path: string): Promise { + if (process.platform !== 'win32') { + throw new Error('setOwnerOnlyAcl is only implemented on Windows'); + } + await native.setOwnerOnlyAcl(path); +} + // requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls // to this cannot be done at the same time. let requestInProgress = false; diff --git a/lib/test.ts b/lib/test.ts index e24bd80..4313ccf 100644 --- a/lib/test.ts +++ b/lib/test.ts @@ -5,8 +5,10 @@ import * as assert from 'assert'; import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; -import { getProcessTree, getProcessList, getProcessCpuUsage, ProcessDataFlag, buildProcessTree, filterProcessList } from './index'; +import { getProcessTree, getProcessList, getProcessCpuUsage, getCurrentProcessUserSid, setOwnerOnlyAcl, ProcessDataFlag, buildProcessTree, filterProcessList } from './index'; import { Worker, isMainThread } from 'worker_threads'; import { IProcessCpuInfo, IProcessInfo, IProcessTreeNode } from '@vscode/windows-process-tree'; import * as promises from './promises'; @@ -27,6 +29,154 @@ function pollUntil(makePromise: () => Promise, cb: () => void, interval if (isWindows) { const native = require('../build/Release/windows_process_tree.node'); + describe('getCurrentProcessUserSid', () => { + it('should return the current user SID via the native and public APIs', () => { + const sid = getCurrentProcessUserSid(); + assert.match(sid, /^S-1-\d+(?:-\d+)+$/); + assert.strictEqual(sid, native.getCurrentProcessUserSid()); + }); + + it('should return the same SID on repeated calls', () => { + const sid = getCurrentProcessUserSid(); + for (let i = 0; i < 1000; i++) { + assert.strictEqual(getCurrentProcessUserSid(), sid); + } + }); + + it('should return the process user SID from a worker thread', async () => { + const sid = await new Promise((resolve, reject) => { + let workerSid: string; + const worker = new Worker(` + const { parentPort, workerData } = require('worker_threads'); + parentPort.postMessage(require(workerData).getCurrentProcessUserSid()); + `, { eval: true, workerData: require.resolve('./index') }); + worker.once('message', (message: string) => { workerSid = message; }); + worker.once('error', reject); + worker.once('exit', code => { + if (code !== 0) { + reject(new Error(`Worker exited with code ${code}`)); + } else { + resolve(workerSid); + } + }); + }); + assert.strictEqual(sid, getCurrentProcessUserSid()); + }); + }); + + describe('setOwnerOnlyAcl', () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-process-tree-acl-')); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function powershell(script: string): string { + const powershellDirectory = path.join(process.env.SystemRoot!, 'System32', 'WindowsPowerShell', 'v1.0'); + const env: NodeJS.ProcessEnv = { ...process.env, ACL_TEST_PATH: directory }; + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'psmodulepath') { + delete env[key]; + } + } + env.PSModulePath = path.join(powershellDirectory, 'Modules'); + return child_process.execFileSync( + path.join(powershellDirectory, 'powershell.exe'), + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference = 'Stop'; ${script}`], + { encoding: 'utf8', env } + ).trim(); + } + + it('should reject invalid native arguments', () => { + for (const value of [undefined, null, 1, {}, '', 'invalid\0path']) { + assert.throws(() => native.setOwnerOnlyAcl(value), TypeError); + } + }); + + it('should reject invalid paths through the public API', async () => { + await assert.rejects(setOwnerOnlyAcl(''), TypeError); + await assert.rejects(setOwnerOnlyAcl(path.join(directory, 'missing')), /SetNamedSecurityInfoW failed: 2/); + }); + + it('should replace grants, protect inheritance and preserve ownership for Unicode paths', async () => { + const target = path.join(directory, 'owner-only \u4e16\u754c'); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, 'existing.txt'), ''); + const owner = powershell(` + $parentAcl = Get-Acl -LiteralPath $env:ACL_TEST_PATH + $parentAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'), + 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) + Set-Acl -LiteralPath $env:ACL_TEST_PATH -AclObject $parentAcl + $target = (Get-ChildItem -LiteralPath $env:ACL_TEST_PATH -Directory).FullName + $acl = Get-Acl -LiteralPath $target + $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-546'), + 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) + Set-Acl -LiteralPath $target -AclObject $acl + $existing = Join-Path $target 'existing.txt' + $existingAcl = Get-Acl -LiteralPath $existing + $existingAcl.SetAccessRuleProtection($false, $false) + @($existingAcl.Access | Where-Object { -not $_.IsInherited }) | ForEach-Object { + [void]$existingAcl.RemoveAccessRuleSpecific($_) + } + Set-Acl -LiteralPath $existing -AclObject $existingAcl + $acl.Owner + `); + + await setOwnerOnlyAcl(target); + await setOwnerOnlyAcl(target); + fs.writeFileSync(path.join(target, 'new.txt'), ''); + fs.mkdirSync(path.join(target, 'child')); + fs.writeFileSync(path.join(target, 'child', 'nested.txt'), ''); + + const actual = JSON.parse(powershell(` + $target = (Get-ChildItem -LiteralPath $env:ACL_TEST_PATH -Directory).FullName + $paths = @($target, (Join-Path $target 'existing.txt'), (Join-Path $target 'new.txt'), + (Join-Path $target 'child'), (Join-Path $target 'child\\nested.txt')) + @($paths | ForEach-Object { + $acl = Get-Acl -LiteralPath $_ + @{ + owner = $acl.Owner + protected = $acl.AreAccessRulesProtected + access = @($acl.Access | ForEach-Object { + @{ + sid = $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + rights = [int]$_.FileSystemRights + type = [int]$_.AccessControlType + inheritance = [int]$_.InheritanceFlags + propagation = [int]$_.PropagationFlags + inherited = $_.IsInherited + } + } | Sort-Object { $_.sid }) + } + }) | ConvertTo-Json -Depth 5 -Compress + `)); + const sids = [...new Set([getCurrentProcessUserSid(), 'S-1-5-18', 'S-1-5-32-544'])].sort(); + const expected = (isDirectory: boolean, inherited: boolean) => ({ + owner, + protected: !inherited, + access: sids.map(sid => ({ + sid, rights: 2032127, type: 0, inheritance: isDirectory ? 3 : 0, propagation: 0, inherited + })) + }); + assert.deepStrictEqual(actual, [ + expected(true, false), expected(false, true), expected(false, true), expected(true, true), expected(false, true) + ]); + }); + + it('should apply ACLs to files and support concurrent calls', async () => { + const files = Array.from({ length: 5 }, (_, i) => path.join(directory, `${i}.txt`)); + files.forEach(file => fs.writeFileSync(file, 'test')); + await Promise.all(files.map(file => setOwnerOnlyAcl(file))); + assert.deepStrictEqual(files.map(file => fs.readFileSync(file, 'utf8')), files.map(() => 'test')); + }); + }); + describe('getAllProcesses', () => { it('should return a list containing this process via native API', (done) => { native.getProcessList((list) => { @@ -455,6 +605,18 @@ if (isWindows) { }); }); } else { + describe('setOwnerOnlyAcl', () => { + it('should reject platforms other than Windows', async () => { + await assert.rejects(setOwnerOnlyAcl('directory'), /only implemented on Windows/); + }); + }); + + describe('getCurrentProcessUserSid', () => { + it('should reject platforms other than Windows', () => { + assert.throws(() => getCurrentProcessUserSid(), /only implemented on Windows/); + }); + }); + describe('getProcessTree', () => { it('should not be implemented on Windows', () => { assert.throws(() => { diff --git a/src/addon.cc b/src/addon.cc index 5253960..fa83661 100644 --- a/src/addon.cc +++ b/src/addon.cc @@ -4,9 +4,105 @@ *--------------------------------------------------------------------------------------------*/ #include +#include +#include +#include +#include #include "cpu_worker.h" #include "process_worker.h" +std::wstring CurrentProcessUserSid(Napi::Env env) { + HANDLE rawToken = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &rawToken)) { + throw Napi::Error::New(env, "OpenProcessToken failed: " + std::to_string(GetLastError())); + } + std::unique_ptr token(rawToken, &CloseHandle); + + struct { + TOKEN_USER user; + BYTE sid[SECURITY_MAX_SID_SIZE]; + } tokenUser = {}; + DWORD returnedLength = 0; + if (!GetTokenInformation(token.get(), TokenUser, &tokenUser, sizeof(tokenUser), &returnedLength)) { + throw Napi::Error::New(env, "GetTokenInformation failed: " + std::to_string(GetLastError())); + } + + LPWSTR rawSid = nullptr; + if (!ConvertSidToStringSidW(tokenUser.user.User.Sid, &rawSid)) { + throw Napi::Error::New(env, "ConvertSidToStringSidW failed: " + std::to_string(GetLastError())); + } + std::unique_ptr sid(rawSid, &LocalFree); + return std::wstring(rawSid); +} + +Napi::Value GetCurrentProcessUserSid(const Napi::CallbackInfo& args) { + const auto sid = CurrentProcessUserSid(args.Env()); + return Napi::String::New(args.Env(), reinterpret_cast(sid.c_str())); +} + +class SetOwnerOnlyAclWorker : public Napi::AsyncWorker { + public: + SetOwnerOnlyAclWorker(Napi::Env env, std::wstring path) + : Napi::AsyncWorker(env), + deferred_(Napi::Promise::Deferred::New(env)), + path_(std::move(path)), + sddl_(L"D:P(A;OICI;FA;;;" + CurrentProcessUserSid(env) + L")(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)") {} + + Napi::Promise Promise() { return deferred_.Promise(); } + + void Execute() override { + PSECURITY_DESCRIPTOR rawDescriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl_.c_str(), SDDL_REVISION_1, &rawDescriptor, nullptr)) { + SetError("ConvertStringSecurityDescriptorToSecurityDescriptorW failed: " + std::to_string(GetLastError())); + return; + } + std::unique_ptr descriptor(rawDescriptor, &LocalFree); + + PACL dacl = nullptr; + BOOL present = FALSE; + BOOL defaulted = FALSE; + if (!GetSecurityDescriptorDacl(descriptor.get(), &present, &dacl, &defaulted)) { + SetError("GetSecurityDescriptorDacl failed: " + std::to_string(GetLastError())); + return; + } + if (!present || !dacl) { + SetError("Owner-only security descriptor has no DACL."); + return; + } + + const DWORD result = SetNamedSecurityInfoW( + &path_[0], SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, nullptr, dacl, nullptr); + if (result != ERROR_SUCCESS) { + SetError("SetNamedSecurityInfoW failed: " + std::to_string(result)); + } + } + + void OnOK() override { deferred_.Resolve(Env().Undefined()); } + void OnError(const Napi::Error& error) override { deferred_.Reject(error.Value()); } + + private: + Napi::Promise::Deferred deferred_; + std::wstring path_; + std::wstring sddl_; +}; + +Napi::Value SetOwnerOnlyAcl(const Napi::CallbackInfo& args) { + const auto env = args.Env(); + if (args.Length() != 1 || !args[0].IsString()) { + throw Napi::TypeError::New(env, "SetOwnerOnlyAcl expects a path string."); + } + const auto path = args[0].As().Utf16Value(); + if (path.empty() || path.find(u'\0') != std::u16string::npos) { + throw Napi::TypeError::New(env, "SetOwnerOnlyAcl expects a non-empty path without null characters."); + } + auto* worker = new SetOwnerOnlyAclWorker(env, std::wstring(path.begin(), path.end())); + const auto promise = worker->Promise(); + worker->Queue(); + return promise; +} + void GetProcessList(const Napi::CallbackInfo& args) { Napi::Env env(args.Env()); @@ -51,6 +147,8 @@ void GetProcessCpuUsage(const Napi::CallbackInfo& args) { } Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports.Set("getCurrentProcessUserSid", Napi::Function::New(env, GetCurrentProcessUserSid)); + exports.Set("setOwnerOnlyAcl", Napi::Function::New(env, SetOwnerOnlyAcl)); exports.Set("getProcessList", Napi::Function::New(env, GetProcessList)); exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage)); return exports; diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts index 70e242b..2eca5e5 100644 --- a/typings/windows-process-tree.d.ts +++ b/typings/windows-process-tree.d.ts @@ -4,6 +4,18 @@ *--------------------------------------------------------------------------------------------*/ declare module '@vscode/windows-process-tree' { + /** + * Returns the SID of the current process's user without spawning a process. + * Throws if the token cannot be queried or the platform is not Windows. + */ + export function getCurrentProcessUserSid(): string; + + /** + * Replaces a file or directory's DACL with inheritable full-control grants for the + * current process user, SYSTEM and Administrators, with parent inheritance disabled. + */ + export function setOwnerOnlyAcl(path: string): Promise; + export enum ProcessDataFlag { None = 0, Memory = 1,