From d57f69e3bb946d92c16db1a40f2a5733cc2e91fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 23:43:00 +0000 Subject: [PATCH] fix!: require hostname or computerId for audit file history ActionLogGetAllForFileHistoryV2 returns HTTP 417 Missing Parameters unless fullPath is sent with hostname or computerId. getFileHistory previously forwarded only fullPath (WYREAI-386). BREAKING CHANGE: auditLog.getFileHistory now takes { fullPath, hostname?, computerId? } instead of a fullPath string. Missing fullPath, or missing both hostname and computerId, throws before the request is sent. Co-authored-by: Aaron Sachs --- CHANGELOG.md | 9 ++ README.md | 9 +- src/resources/audit-log.ts | 61 +++++++++++-- src/types/index.ts | 24 +++++ tests/mocks/handlers.ts | 19 +++- tests/unit/real-api-contracts.test.ts | 123 ++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d2e07..31899e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `auditLog.getFileHistory()` sent only `fullPath` to + `ActionLogGetAllForFileHistoryV2`. The OpenAPI spec marks every query + parameter optional, but the live Portal API returns HTTP 417 + "Missing Parameters. Unable to load details." unless `fullPath` plus + `hostname` or `computerId` is supplied (WYREAI-386 / EpiOn + `threatlocker_audit_file_history`). The method now takes + `{ fullPath, hostname?, computerId? }` and throws before the request + when `fullPath` or both identifiers are missing. A bare-array response + is unwrapped, with the previous `{ logs }` shape kept as a fallback. - `HttpClient` sent the organization-scoping header as `OrganizationId`, which the real ThreatLocker Portal API does not recognize — the correct header is `ManagedOrganizationId` (confirmed against the live API's diff --git a/README.md b/README.md index 510e6e0..da95125 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,13 @@ const { items: logs } = await client.auditLog.search({ fromDate: '2024-01-01', toDate: '2024-12-31', }); + +// File history for one path. fullPath plus hostname or computerId is required; +// the Portal API returns HTTP 417 Missing Parameters when only fullPath is sent. +const history = await client.auditLog.getFileHistory({ + fullPath: 'C:\\Windows\\System32\\notepad.exe', + hostname: 'WS-01', +}); ``` ## Authentication @@ -103,7 +110,7 @@ const client = new ThreatLockerClient({ | `computers` | `list()`, `get(id)`, `getCheckins()` | Manage computers and check-ins | | `computerGroups` | `list()`, `getDropdown()` | Computer group management | | `approvalRequests` | `list()`, `get(id)`, `getPendingCount()`, `getPermitApplication(id)` | Application approval workflow | -| `auditLog` | `search()`, `get(id)`, `getFileHistory(path)` | Unified audit and action logs | +| `auditLog` | `search()`, `get(id)`, `getFileHistory({ fullPath, hostname \| computerId })` | Unified audit and action logs | | `organizations` | `listChildren()`, `getAuthKey()`, `listForMoveComputers()` | Organization management | ### Multi-Tenant Operations diff --git a/src/resources/audit-log.ts b/src/resources/audit-log.ts index 1f24bd5..a60d2b5 100644 --- a/src/resources/audit-log.ts +++ b/src/resources/audit-log.ts @@ -1,5 +1,5 @@ import type { HttpClient } from '../http.js'; -import type { AuditLogEntry, AuditLogSearchParams, PaginatedResponse } from '../types/index.js'; +import type { AuditLogEntry, AuditLogSearchParams, FileHistoryParams, PaginatedResponse } from '../types/index.js'; import { unwrapPaginatedResponse } from '../pagination.js'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -50,10 +50,59 @@ export class AuditLogResource { }); } - async getFileHistory(fullPath: string): Promise { - const response = await this.http.request<{ logs?: AuditLogEntry[] }>('/ActionLog/ActionLogGetAllForFileHistoryV2', { - params: { fullPath }, - }); - return response.logs || []; + /** + * File history for one path on one computer. + * + * `GET /ActionLog/ActionLogGetAllForFileHistoryV2` (OpenAPI: "Get All File + * History by hostname and fullpath"). Query params are `fullPath`, + * `hostname`, `computerId` (UUID), plus optional `sourceTableId`, + * `pageNumber`, `pageSize`. The spec lists them all as optional; the live + * API returns HTTP 417 "Missing Parameters. Unable to load details." unless + * `fullPath` and at least one of `hostname` or `computerId` are sent. + * Incomplete calls throw here and are not sent. + */ + async getFileHistory(params: FileHistoryParams): Promise { + const query = fileHistoryQuery(params); + const response = await this.http.request( + '/ActionLog/ActionLogGetAllForFileHistoryV2', + { params: query }, + ); + // Sibling list endpoints return a bare JSON array. This method originally + // unwrapped `{ logs }`; keep that shape as a fallback. + if (Array.isArray(response)) return response; + return response?.logs ?? []; + } +} + +const FILE_HISTORY_PARAM_ERROR = + 'auditLog.getFileHistory requires fullPath and either hostname or computerId. ' + + 'ActionLogGetAllForFileHistoryV2 returns HTTP 417 "Missing Parameters. Unable to load details." ' + + 'when only fullPath is sent. Pass { fullPath, hostname } or { fullPath, computerId }.'; + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function fileHistoryQuery(params: FileHistoryParams): Record { + // A string (the previous signature) has typeof 'string', so this also + // rejects getFileHistory(fullPath) instead of forwarding a bad request. + if (typeof params !== 'object' || params === null) { + throw new Error(FILE_HISTORY_PARAM_ERROR); } + const fullPath = nonEmptyString(params.fullPath); + const hostname = nonEmptyString(params.hostname); + const computerId = nonEmptyString(params.computerId); + if (!fullPath || (!hostname && !computerId)) { + throw new Error(FILE_HISTORY_PARAM_ERROR); + } + + const query: Record = { fullPath }; + if (hostname) query.hostname = hostname; + if (computerId) query.computerId = computerId; + if (params.sourceTableId != null) query.sourceTableId = params.sourceTableId; + if (params.pageNumber != null) query.pageNumber = params.pageNumber; + if (params.pageSize != null) query.pageSize = params.pageSize; + return query; } diff --git a/src/types/index.ts b/src/types/index.ts index eb4aed0..fd3c1c8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -110,6 +110,30 @@ export interface AuditLogSearchParams extends Partial { computerId?: number; } +/** + * Query for `ActionLogGetAllForFileHistoryV2`. + * + * The published OpenAPI spec marks every parameter optional, but the live + * Portal API returns HTTP 417 "Missing Parameters. Unable to load details." + * unless `fullPath` plus one of `hostname` or `computerId` is present. + */ +export interface FileHistoryParams { + /** Full file path. Required. */ + fullPath: string; + /** + * Computer hostname. Required unless `computerId` is set. + */ + hostname?: string; + /** + * Computer GUID (not a numeric id). Required unless `hostname` is set. + */ + computerId?: string; + /** ActionLog = 1, DenyActionLog = 2, BaselineActionLog = 3, EventLogActionLog = 4. */ + sourceTableId?: number; + pageNumber?: number; + pageSize?: number; +} + // Organization types export interface Organization { id: number; diff --git a/tests/mocks/handlers.ts b/tests/mocks/handlers.ts index fe7f76f..217a4f4 100644 --- a/tests/mocks/handlers.ts +++ b/tests/mocks/handlers.ts @@ -136,8 +136,19 @@ export const handlers = [ }); }), - http.get(`${BASE_URL}/ActionLog/ActionLogGetAllForFileHistoryV2`, () => - HttpResponse.json({ + http.get(`${BASE_URL}/ActionLog/ActionLogGetAllForFileHistoryV2`, ({ request }) => { + const url = new URL(request.url); + const fullPath = url.searchParams.get('fullPath'); + const hostname = url.searchParams.get('hostname'); + const computerId = url.searchParams.get('computerId'); + // Live API: 417 "Missing Parameters" unless fullPath plus hostname or computerId. + if (!fullPath || (!hostname && !computerId)) { + return HttpResponse.json( + { LoggerId: 'x', StatusCode: 417, Message: 'Missing Parameters. Unable to load details.' }, + { status: 417 }, + ); + } + return HttpResponse.json({ logs: [ { id: 1, @@ -149,8 +160,8 @@ export const handlers = [ details: { filePath: 'C:\\Windows\\System32\\notepad.exe' }, }, ], - }) - ), + }); + }), // Organizations http.post(`${BASE_URL}/Organization/OrganizationGetChildOrganizationsByParameters`, () => diff --git a/tests/unit/real-api-contracts.test.ts b/tests/unit/real-api-contracts.test.ts index e1910ed..e9df6b4 100644 --- a/tests/unit/real-api-contracts.test.ts +++ b/tests/unit/real-api-contracts.test.ts @@ -168,6 +168,129 @@ describe('auditLog.search — date-range + paramsFieldsDto + usenewsearch contra }); }); +describe('auditLog.getFileHistory — fullPath + hostname|computerId contract', () => { + // OpenAPI (ActionLogGetAllForFileHistoryV2, "Get All File History by + // hostname and fullpath") lists fullPath, hostname, and computerId (UUID) + // as optional query params. The live API returns HTTP 417 + // "Missing Parameters. Unable to load details." unless fullPath plus one + // of hostname or computerId is actually sent. WYREAI-386 / EpiOn: + // getFileHistory(fullPath) forwarded only fullPath. + const arm = (mode: 'ok' | 'enforce-417' = 'ok') => { + const seen: URL[] = []; + server.use( + http.get(`${BASE_URL}/ActionLog/ActionLogGetAllForFileHistoryV2`, ({ request }) => { + const url = new URL(request.url); + seen.push(url); + const fullPath = url.searchParams.get('fullPath'); + const hostname = url.searchParams.get('hostname'); + const computerId = url.searchParams.get('computerId'); + if (mode === 'enforce-417' && (!fullPath || (!hostname && !computerId))) { + return HttpResponse.json( + { LoggerId: 'x', StatusCode: 417, Message: 'Missing Parameters. Unable to load details.' }, + { status: 417 }, + ); + } + return HttpResponse.json([{ actionType: 'Execute', fullPath: fullPath ?? '' }]); + }), + ); + return seen; + }; + + it('sends fullPath and hostname as query params', async () => { + const seen = arm(); + const result = await client.auditLog.getFileHistory({ + fullPath: 'C:\\Windows\\System32\\notepad.exe', + hostname: 'WS-01', + }); + const url = seen[0]; + expect(url.searchParams.get('fullPath')).toBe('C:\\Windows\\System32\\notepad.exe'); + expect(url.searchParams.get('hostname')).toBe('WS-01'); + expect(url.searchParams.get('computerId')).toBeNull(); + expect(result).toHaveLength(1); + }); + + it('sends computerId when hostname is omitted', async () => { + const seen = arm(); + await client.auditLog.getFileHistory({ + fullPath: 'C:\\app.exe', + computerId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }); + expect(seen[0].searchParams.get('fullPath')).toBe('C:\\app.exe'); + expect(seen[0].searchParams.get('computerId')).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(seen[0].searchParams.get('hostname')).toBeNull(); + }); + + it('sends both identifiers and optional paging params when supplied', async () => { + const seen = arm(); + await client.auditLog.getFileHistory({ + fullPath: 'C:\\app.exe', + hostname: ' WS-01 ', + computerId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + sourceTableId: 2, + pageNumber: 3, + pageSize: 50, + }); + const params = seen[0].searchParams; + expect(params.get('hostname')).toBe('WS-01'); + expect(params.get('computerId')).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(params.get('sourceTableId')).toBe('2'); + expect(params.get('pageNumber')).toBe('3'); + expect(params.get('pageSize')).toBe('50'); + }); + + it('unwraps a { logs } body as well as a bare array', async () => { + server.use( + http.get(`${BASE_URL}/ActionLog/ActionLogGetAllForFileHistoryV2`, () => + HttpResponse.json({ logs: [{ actionType: 'Execute', fullPath: 'C:\\app.exe' }] }), + ), + ); + const result = await client.auditLog.getFileHistory({ + fullPath: 'C:\\app.exe', + hostname: 'WS-01', + }); + expect(result).toEqual([{ actionType: 'Execute', fullPath: 'C:\\app.exe' }]); + }); + + it('rejects a bare fullPath string before any request', async () => { + const seen = arm('enforce-417'); + await expect( + client.auditLog.getFileHistory('C:\\app.exe' as unknown as { fullPath: string }), + ).rejects.toThrow(/fullPath and either hostname or computerId/i); + expect(seen).toHaveLength(0); + }); + + it('rejects a missing computer identifier before any request', async () => { + const seen = arm('enforce-417'); + await expect(client.auditLog.getFileHistory({ fullPath: 'C:\\app.exe' })).rejects.toThrow(/417/); + await expect( + client.auditLog.getFileHistory({ fullPath: 'C:\\app.exe', hostname: ' ', computerId: '' }), + ).rejects.toThrow(/Missing Parameters/); + expect(seen).toHaveLength(0); + }); + + it('rejects a blank fullPath before any request', async () => { + const seen = arm('enforce-417'); + await expect( + client.auditLog.getFileHistory({ fullPath: ' ', hostname: 'WS-01' }), + ).rejects.toThrow(/fullPath/); + expect(seen).toHaveLength(0); + }); + + it('does not hit the live 417 when hostname or computerId is present', async () => { + const seen = arm('enforce-417'); + await expect( + client.auditLog.getFileHistory({ fullPath: 'C:\\app.exe', hostname: 'WS-01' }), + ).resolves.toHaveLength(1); + await expect( + client.auditLog.getFileHistory({ + fullPath: 'C:\\app.exe', + computerId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }), + ).resolves.toHaveLength(1); + expect(seen).toHaveLength(2); + }); +}); + describe('organization-scoping header contract', () => { // Confirmed against the live API's OpenAPI security schemes // (portalapi.*.threatlocker.com/swagger) and the official docs