Skip to content
Draft
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"src/process_commandline.cc"
],
"include_dirs": [],
"libraries": [ 'psapi.lib' ],
"libraries": [ 'psapi.lib', 'advapi32.lib' ],
"msvs_configuration_attributes": {
"SpectreMitigation": "Spectre"
},
Expand Down
22 changes: 22 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;
Expand Down
164 changes: 163 additions & 1 deletion lib/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +29,154 @@ function pollUntil(makePromise: () => Promise<boolean>, 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<string>((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) => {
Expand Down Expand Up @@ -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(() => {
Expand Down
98 changes: 98 additions & 0 deletions src/addon.cc
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,105 @@
*--------------------------------------------------------------------------------------------*/

#include <napi.h>
#include <windows.h>
#include <aclapi.h>
#include <sddl.h>
#include <memory>
#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<void, decltype(&CloseHandle)> 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<void, decltype(&LocalFree)> 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<const char16_t*>(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<void, decltype(&LocalFree)> 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<Napi::String>().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());

Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions typings/windows-process-tree.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

export enum ProcessDataFlag {
None = 0,
Memory = 1,
Expand Down
Loading