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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 55 additions & 6 deletions src/resources/audit-log.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -50,10 +50,59 @@ export class AuditLogResource {
});
}

async getFileHistory(fullPath: string): Promise<AuditLogEntry[]> {
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<AuditLogEntry[]> {
const query = fileHistoryQuery(params);
const response = await this.http.request<AuditLogEntry[] | { logs?: AuditLogEntry[] }>(
'/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<string, unknown> {
// 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<string, unknown> = { 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;
}
24 changes: 24 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ export interface AuditLogSearchParams extends Partial<PaginationParams> {
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;
Expand Down
19 changes: 15 additions & 4 deletions tests/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -149,8 +160,8 @@ export const handlers = [
details: { filePath: 'C:\\Windows\\System32\\notepad.exe' },
},
],
})
),
});
}),

// Organizations
http.post(`${BASE_URL}/Organization/OrganizationGetChildOrganizationsByParameters`, () =>
Expand Down
123 changes: 123 additions & 0 deletions tests/unit/real-api-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading