Conversation
⛔ Snyk checks have failed. 3 issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Greptile SummaryThis PR implements local Windows account discovery for the PAM AD discovery pipeline. It adds WinRM-based enumeration (via Key concerns before merging:
Confidence Score: 2/5
Important Files Changed
Last reviewed commit: 58b8950 |
|
|
||
| const LDAP_TIMEOUT = 30 * 1000; | ||
| const LDAP_PAGE_SIZE = 500; | ||
| const WINRM_PORT = 5985; |
There was a problem hiding this comment.
Unencrypted WinRM (HTTP port 5985)
WINRM_PORT = 5985 is the plain-HTTP WinRM port. All traffic — including NTLM authentication handshakes and PowerShell output — is transmitted in cleartext between the gateway endpoint and the Windows Server. An attacker with access to the internal network segment can capture domain credentials and command output through passive sniffing.
The secure port for WinRM is 5986 (HTTPS/SSL). The runPowershell call should be made over the encrypted channel to avoid credential exposure on the wire.
| const WINRM_PORT = 5985; | |
| const WINRM_PORT = 5986; |
If HTTP is intentionally supported for development/lab environments, this should be configurable per-discovery-source (similar to how configuration.port is used for LDAP), not hardcoded.
| let responseData = Buffer.alloc(0); | ||
|
|
||
| socket.on("data", (chunk: Buffer) => { | ||
| responseData = Buffer.concat([responseData, chunk]); | ||
|
|
||
| // DNS-over-TCP frames each message with a 2-byte big-endian length prefix | ||
| // wait until we have the full frame before decoding | ||
| if (responseData.length >= 2) { | ||
| const msgLen = responseData.readUInt16BE(0); | ||
| if (responseData.length >= 2 + msgLen) { | ||
| const response = dnsPacket.streamDecode(responseData); | ||
| const aRecord = response.answers?.find((a) => a.type === "A"); | ||
| socket.destroy(); | ||
| resolve(aRecord && "data" in aRecord ? (aRecord.data as string) : null); | ||
| } | ||
| } |
There was a problem hiding this comment.
Unbounded buffer growth — potential DoS
The responseData buffer grows without any size cap. A malicious or misbehaving DNS server behind the gateway could stream an arbitrarily large TCP payload, causing the Node.js process to exhaust heap memory.
DNS-over-TCP messages are framed with a 2-byte length prefix, so the maximum legitimate DNS response is 65 535 bytes. Adding a guard before concatenating prevents memory exhaustion:
| let responseData = Buffer.alloc(0); | |
| socket.on("data", (chunk: Buffer) => { | |
| responseData = Buffer.concat([responseData, chunk]); | |
| // DNS-over-TCP frames each message with a 2-byte big-endian length prefix | |
| // wait until we have the full frame before decoding | |
| if (responseData.length >= 2) { | |
| const msgLen = responseData.readUInt16BE(0); | |
| if (responseData.length >= 2 + msgLen) { | |
| const response = dnsPacket.streamDecode(responseData); | |
| const aRecord = response.answers?.find((a) => a.type === "A"); | |
| socket.destroy(); | |
| resolve(aRecord && "data" in aRecord ? (aRecord.data as string) : null); | |
| } | |
| } | |
| socket.on("data", (chunk: Buffer) => { | |
| responseData = Buffer.concat([responseData, chunk]); | |
| // Enforce a reasonable upper bound (DNS-over-TCP max is 65 535 bytes + 2-byte length prefix) | |
| if (responseData.length > 65537) { | |
| socket.destroy(); | |
| resolve(null); | |
| return; | |
| } | |
| // DNS-over-TCP frames each message with a 2-byte big-endian length prefix | |
| // wait until we have the full frame before decoding | |
| if (responseData.length >= 2) { | |
| const msgLen = responseData.readUInt16BE(0); | |
| if (responseData.length >= 2 + msgLen) { | |
| const response = dnsPacket.streamDecode(responseData); | |
| const aRecord = response.answers?.find((a) => a.type === "A"); | |
| socket.destroy(); | |
| resolve(aRecord && "data" in aRecord ? (aRecord.data as string) : null); | |
| } | |
| } | |
| }); |
| } | ||
|
|
||
| const parsed = JSON.parse(stdout) as TWinRmLocalUser | TWinRmLocalUser[]; | ||
| return Array.isArray(parsed) ? parsed : [parsed]; |
There was a problem hiding this comment.
JSON.parse without dedicated error handling
If PowerShell emits warnings, progress output, or non-JSON error text before the actual JSON payload (common when RSAT is not installed or the user count is exactly zero), JSON.parse(stdout) will throw a SyntaxError. The outer try/catch (lines ~870-919) will surface this as a generic WinRM enumeration failure, losing the actual parse-error detail.
A targeted catch gives better diagnostics:
| } | |
| const parsed = JSON.parse(stdout) as TWinRmLocalUser | TWinRmLocalUser[]; | |
| return Array.isArray(parsed) ? parsed : [parsed]; | |
| let parsed: TWinRmLocalUser | TWinRmLocalUser[]; | |
| try { | |
| parsed = JSON.parse(stdout) as TWinRmLocalUser | TWinRmLocalUser[]; | |
| } catch (parseErr) { | |
| logger.warn({ hostname: computer.dNSHostName || computer.cn, stdout }, "WinRM: failed to parse Get-LocalUser output as JSON"); | |
| return []; | |
| } | |
| return Array.isArray(parsed) ? parsed : [parsed]; |
| const internalMetadata = { | ||
| accountType: WindowsAccountType.User | ||
| } as TWindowsAccountMetadata; |
There was a problem hiding this comment.
Local account metadata fields not stored
The TWinRmLocalUser type carries LastLogon, PasswordLastSet, Description, Enabled, and SID.Value, but the internalMetadata stored for each local account only includes accountType. As a result:
- The "Last Logon" column in the Accounts tab always shows
"-"for local accounts. Enabledstate is not tracked, so disabled accounts appear indistinguishable from active ones.- The
SID.Valuefield is useful as a stable fingerprint but is discarded.
Consider extending TWindowsAccountMetadata (or creating a dedicated local-account metadata type) to preserve at least enabled, lastLogon, and sid for display and policy purposes.
| @@ -259,6 +261,7 @@ | |||
| "tweetnacl": "^1.0.3", | |||
There was a problem hiding this comment.
Deprecated transitive dependencies via winrm-client
winrm-client@0.0.11 is a pre-release (0.0.x) package that pulls in ntlm-client@0.1.1, which in turn depends on request@2.88.2. The request library is officially deprecated and explicitly notes it is no longer supported (see its own changelog). It also brings in tough-cookie@2.5.0 and har-validator@5.1.5, both of which are flagged as deprecated in the generated package-lock.json.
Using an unmaintained NTLM/HTTP stack at this level exposes the authentication channel to unpatched CVEs. Before shipping, it's worth evaluating:
- Whether
winrm-clienthas an alternative that uses a maintained HTTP client (e.g., one built onundiciornode-fetch). - Pinning
winrm-clientto a specific integrity hash and regularly auditing withnpm auditgiven the unmaintained transitive tree.
Context
Screenshots
Steps to verify the change
Type
Checklist
type(scope): short description(scope is optional, e.g.,fix: prevent crash on syncorfix(api): handle null response).