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
10 changes: 4 additions & 6 deletions apps/processor/src/services/__tests__/siem-chain-hashers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,10 @@ describe('recomputeSecurityAuditHash', () => {
});
});

it('null hashable fields round-trip as undefined (JSON-stringify omission)', () => {
// At write time, AuditEvent's optional fields are `undefined` when not
// passed, so JSON.stringify omits them. The DB stores null. When we read
// back, null must be treated as "field was undefined at write time" —
// i.e. omitted from the serialized object — otherwise the recomputed hash
// would include "riskScore":null etc. and diverge.
it('null hashable fields round-trip correctly with stableStringify', () => {
// Both sides normalize optional fields to null via `?? null` before
// stableStringify — so "riskScore":null is included in the serialized
// object on both the write side and the read side, and they match.
const timestamp = new Date('2026-04-10T00:00:00.000Z');
const event: AuditEvent = {
eventType: 'auth.logout',
Expand Down
60 changes: 23 additions & 37 deletions apps/processor/src/services/siem-chain-hashers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'crypto';
import { stableStringify } from '@pagespace/lib/utils/stable-stringify';

/**
* Per-source hash recomputation strategies for the SIEM delivery preflight.
Expand Down Expand Up @@ -65,37 +66,27 @@ export interface SecurityAuditHashableFields {
* Recompute the expected `logHash` for an activity_logs entry.
*
* Mirrors serializeLogDataForHash + computeLogHash in activity-logger.ts:
* 1. Build a hashable object with fields in the exact same key set the
* write side uses, coercing undefined → null the same way.
* 2. JSON.stringify with sorted keys (deterministic output).
* 1. Build hashable object with the same field set (PII excluded).
* 2. stableStringify — sorts keys at every depth for deterministic output.
* 3. SHA-256 of `previousHash + serialized`.
*
* IMPORTANT: the order of property writes in `hashableObject` does not
* matter because JSON.stringify is called with a sorted replacer-array
* argument; it's the sorted-keys guarantee that makes this deterministic.
*/
export function recomputeActivityLogHash(
data: ActivityLogHashableFields,
previousHash: string
): string {
const hashableObject = {
const serialized = stableStringify({
contentSnapshot: data.contentSnapshot ?? null,
driveId: data.driveId,
id: data.id,
timestamp: data.timestamp.toISOString(),
metadata: data.metadata ?? null,
newValues: data.newValues ?? null,
operation: data.operation,
resourceType: data.resourceType,
resourceId: data.resourceId,
driveId: data.driveId,
pageId: data.pageId ?? null,
contentSnapshot: data.contentSnapshot ?? null,
previousValues: data.previousValues ?? null,
newValues: data.newValues ?? null,
metadata: data.metadata ?? null,
};

const serialized = JSON.stringify(
hashableObject,
Object.keys(hashableObject).sort()
);
resourceId: data.resourceId,
resourceType: data.resourceType,
timestamp: data.timestamp.toISOString(),
});

return createHash('sha256').update(previousHash + serialized).digest('hex');
}
Expand All @@ -104,31 +95,26 @@ export function recomputeActivityLogHash(
* Recompute the expected `eventHash` for a security_audit_log entry.
*
* Mirrors computeSecurityEventHash in security-audit.ts:
* - Flat JSON.stringify (no sorted-keys replacer — V8 insertion order is
* relied on by the write side).
* - stableStringify sorts keys at every depth (including inside `details`).
* - SHA-256 of the serialized object; `previousHash` is a field INSIDE the
* serialized object, not prepended like activity_logs.
*
* Nullable fields map back to undefined so JSON.stringify omits them — the
* write-side AuditEvent uses optional fields, so `undefined` at write time
* becomes column-null in the DB. Reconstructing as `undefined` restores the
* original serialized shape. The key order here MUST match the write-side
* object literal exactly.
* - Nullable DB columns normalize to null (not undefined) — the write side
* uses `?? null` so JSON.stringify includes them explicitly.
*/
export function recomputeSecurityAuditHash(
data: SecurityAuditHashableFields,
previousHash: string
): string {
const serialized = JSON.stringify({
const serialized = stableStringify({
anomalyFlags: data.anomalyFlags ?? null,
details: data.details ?? null,
eventType: data.eventType,
serviceId: data.serviceId ?? undefined,
resourceType: data.resourceType ?? undefined,
resourceId: data.resourceId ?? undefined,
details: data.details ?? undefined,
riskScore: data.riskScore ?? undefined,
anomalyFlags: data.anomalyFlags ?? undefined,
timestamp: data.timestamp.toISOString(),
previousHash,
resourceId: data.resourceId ?? null,
resourceType: data.resourceType ?? null,
riskScore: data.riskScore ?? null,
serviceId: data.serviceId ?? null,
timestamp: data.timestamp.toISOString(),
});

return createHash('sha256').update(serialized).digest('hex');
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/app/api/track/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('/api/track', () => {
undefined,
'page_view',
{
metadata: { path: '/home', ip: '127.0.0.1', userAgent: 'unknown', timestamp: frozenDate.toISOString() },
metadata: { path: '/home', timestamp: frozenDate.toISOString() },
ip: '127.0.0.1',
userAgent: 'unknown',
}
Expand All @@ -108,7 +108,7 @@ describe('/api/track', () => {
expect(trackFeature).toHaveBeenCalledWith(
undefined,
'dark-mode',
{ feature: 'dark-mode', ip: '127.0.0.1', userAgent: 'unknown', timestamp: frozenDate.toISOString() }
{ feature: 'dark-mode', timestamp: frozenDate.toISOString() }
);
});

Expand All @@ -123,7 +123,7 @@ describe('/api/track', () => {
undefined,
'js',
'Uncaught TypeError',
{ type: 'js', message: 'Uncaught TypeError', ip: '127.0.0.1', userAgent: 'unknown', timestamp: frozenDate.toISOString() }
{ type: 'js', message: 'Uncaught TypeError', timestamp: frozenDate.toISOString() }
);
});

Expand All @@ -136,7 +136,7 @@ describe('/api/track', () => {
undefined,
'ui_click',
{
metadata: { label: 'nav-button', ip: '127.0.0.1', userAgent: 'unknown', timestamp: frozenDate.toISOString() },
metadata: { label: 'nav-button', timestamp: frozenDate.toISOString() },
ip: '127.0.0.1',
userAgent: 'unknown',
}
Expand All @@ -152,7 +152,7 @@ describe('/api/track', () => {
undefined,
'search',
{
metadata: { ip: '127.0.0.1', userAgent: 'unknown', timestamp: frozenDate.toISOString() },
metadata: { timestamp: frozenDate.toISOString() },
ip: '127.0.0.1',
userAgent: 'unknown',
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/api/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,11 @@ export async function POST(request: Request) {

const userAgent = request.headers.get('user-agent') || 'unknown';

// ip and userAgent are passed as top-level fields to logActivity where they
// land in dedicated PII columns excluded from the hash chain. Keeping them
// out of enrichedData prevents them from entering the hashed metadata JSONB.
const enrichedData = {
...data,
ip,
userAgent,
timestamp: new Date().toISOString(),
};

Expand Down
8 changes: 8 additions & 0 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"import": "./dist/utils/language-detection.js",
"require": "./dist/utils/language-detection.js"
},
"./utils/stable-stringify": {
"types": "./dist/utils/stable-stringify.d.ts",
"import": "./dist/utils/stable-stringify.js",
"require": "./dist/utils/stable-stringify.js"
},
"./pages/circular-reference-guard": {
"types": "./dist/pages/circular-reference-guard.d.ts",
"import": "./dist/pages/circular-reference-guard.js",
Expand Down Expand Up @@ -386,6 +391,9 @@
"utils/language-detection": [
"./dist/utils/language-detection.d.ts"
],
"utils/stable-stringify": [
"./dist/utils/stable-stringify.d.ts"
],
"pages/circular-reference-guard": [
"./dist/pages/circular-reference-guard.d.ts"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
verifySecurityAuditChain,
type SecurityChainVerificationResult,
} from '../security-audit-chain-verifier';
import { computeSecurityEventHash, type AuditEvent } from '../security-audit';

describe('security-audit-chain-verifier', () => {
beforeEach(() => {
Expand Down Expand Up @@ -227,6 +228,65 @@ describe('security-audit-chain-verifier', () => {
expect(result.breakPoint).toBeNull();
});

// Regression test for the canonical-serialization bug: when Postgres returns a
// JSONB column with keys in a different order than they were written, the
// verifier must still compute the same hash. stableStringify sorts keys at
// every depth, so insertion order must not matter.
it('verifies chain when Postgres JSONB round-trip reorders details keys', async () => {
const timestamp0 = new Date('2026-01-25T10:00:00.000Z');
const timestamp1 = new Date('2026-01-25T10:00:01.000Z');

const event0 = {
eventType: 'auth.login.success' as const,
serviceId: 'web',
details: { action: 'export', format: 'pdf', count: 3 },
} satisfies AuditEvent;
const event1 = {
eventType: 'data.read' as const,
serviceId: 'web',
details: { resource: 'page', nested: { z: 1, a: 2 } },
} satisfies AuditEvent;

const hash0 = computeSecurityEventHash(event0, 'genesis', timestamp0);
const hash1 = computeSecurityEventHash(event1, hash0, timestamp1);

// Simulate Postgres JSONB returning keys in a different order than written
mockEntries = [
{
id: 'audit-1',
eventType: 'auth.login.success',
userId: null, sessionId: null, serviceId: 'web',
resourceType: null, resourceId: null,
ipAddress: null, userAgent: null, geoLocation: null,
details: { count: 3, format: 'pdf', action: 'export' }, // reversed
riskScore: null, anomalyFlags: null,
timestamp: timestamp0,
previousHash: 'genesis',
eventHash: hash0,
},
{
id: 'audit-2',
eventType: 'data.read',
userId: null, sessionId: null, serviceId: 'web',
resourceType: null, resourceId: null,
ipAddress: null, userAgent: null, geoLocation: null,
details: { nested: { a: 2, z: 1 }, resource: 'page' }, // reversed
riskScore: null, anomalyFlags: null,
timestamp: timestamp1,
previousHash: hash0,
eventHash: hash1,
},
];

const result = await verifySecurityAuditChain();

expect(result.isValid).toBe(true);
expect(result.entriesVerified).toBe(2);
expect(result.validEntries).toBe(2);
expect(result.invalidEntries).toBe(0);
expect(result.breakPoint).toBeNull();
});

it('verifies the chain-link across a legacy event_type row (previousHash continuity)', async () => {
// Tighter invariant: not just hash validity of the legacy row, but that
// its eventHash chains correctly into the successor row.
Expand Down
42 changes: 42 additions & 0 deletions packages/lib/src/audit/__tests__/security-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,48 @@ describe('Security Audit Service', () => {
expect(hash).toHaveLength(64);
expect(typeof hash).toBe('string');
});

it('produces the same hash regardless of key order in details (canonical JSON)', () => {
const timestamp = new Date('2026-01-25T10:00:00Z');
const base = {
eventType: 'auth.login.success' as const,
serviceId: 'web',
resourceType: 'page',
resourceId: 'page-1',
};

// Simulate write-time key order vs Postgres JSONB read-back with different order
const hashA = computeSecurityEventHash(
{ ...base, details: { action: 'export', format: 'pdf', userId: 'u1' } },
'genesis',
timestamp
);
const hashB = computeSecurityEventHash(
{ ...base, details: { userId: 'u1', format: 'pdf', action: 'export' } },
'genesis',
timestamp
);

expect(hashA).toBe(hashB);
});

it('produces the same hash regardless of key order in nested details objects', () => {
const timestamp = new Date('2026-01-25T10:00:00Z');
const base = { eventType: 'data.read' as const };

const hashA = computeSecurityEventHash(
{ ...base, details: { meta: { z: 1, a: 2 }, top: 'value' } },
'prev',
timestamp
);
const hashB = computeSecurityEventHash(
{ ...base, details: { top: 'value', meta: { a: 2, z: 1 } } },
'prev',
timestamp
);

expect(hashA).toBe(hashB);
});
});

describe('initialize', () => {
Expand Down
21 changes: 11 additions & 10 deletions packages/lib/src/audit/security-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createHash } from 'crypto';
import { db, securityAuditLog, desc, sql } from '@pagespace/db';
import type { SecurityEventType, SelectSecurityAuditLog } from '@pagespace/db';
import { queryAuditEvents } from './audit-query';
import { stableStringify } from '../utils/stable-stringify';

/**
* Audit event input structure
Expand Down Expand Up @@ -77,19 +78,19 @@ export function computeSecurityEventHash(
previousHash: string,
timestamp: Date
): string {
const data = JSON.stringify({
const payload = {
anomalyFlags: event.anomalyFlags ?? null,
details: event.details ?? null,
eventType: event.eventType,
serviceId: event.serviceId,
resourceType: event.resourceType,
resourceId: event.resourceId,
details: event.details,
riskScore: event.riskScore,
anomalyFlags: event.anomalyFlags,
timestamp: timestamp.toISOString(),
previousHash,
Comment on lines +81 to 85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep legacy security-audit hash algorithm for old rows

computeSecurityEventHash now canonicalizes key order and normalizes missing optional fields to null, which changes the serialized payload format for every historical entry. Because verifySecurityAuditChain recomputes stored hashes using this function, pre-existing security_audit_log rows written with the previous JSON.stringify format will fail verification immediately after deployment (including rows that were previously verifiable). This will surface false tamper breaks unless verification supports both legacy and new hash formats (or hash-versioning is introduced).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new feature — the hash chain was not producing valid output prior to this PR (the cron container lacked OpenSSL so HMAC signatures were never generated, and the JSONB key-reorder bug meant stored hashes didn't match even when they were written). There are no valid historical chains to break compatibility with. The PR description has been updated to make this explicit.

});
resourceId: event.resourceId ?? null,
resourceType: event.resourceType ?? null,
riskScore: event.riskScore ?? null,
serviceId: event.serviceId ?? null,
timestamp: timestamp.toISOString(),
};

return createHash('sha256').update(data).digest('hex');
return createHash('sha256').update(stableStringify(payload)).digest('hex');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down
36 changes: 36 additions & 0 deletions packages/lib/src/monitoring/__tests__/activity-logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,42 @@ describe('activity-logger', () => {
const parsed = JSON.parse(serializeLogDataForHash(baseHashData));
expect(parsed.timestamp).toBe(baseHashData.timestamp.toISOString());
});

it('includes nested object content in previousValues, newValues, and metadata', () => {
const data = {
...baseHashData,
previousValues: { title: 'old', count: 5 },
newValues: { title: 'new', count: 6 },
metadata: { source: 'api', traceId: 'abc' },
};
const parsed = JSON.parse(serializeLogDataForHash(data));

expect(parsed.previousValues).toEqual({ title: 'old', count: 5 });
expect(parsed.newValues).toEqual({ title: 'new', count: 6 });
expect(parsed.metadata).toEqual({ source: 'api', traceId: 'abc' });
});

it('produces the same serialization regardless of key order in nested objects (canonical JSON)', () => {
const dataA = {
...baseHashData,
previousValues: { z: 1, a: 2 },
metadata: { source: 'api', traceId: 'xyz' },
};
const dataB = {
...baseHashData,
previousValues: { a: 2, z: 1 },
metadata: { traceId: 'xyz', source: 'api' },
};

expect(serializeLogDataForHash(dataA)).toBe(serializeLogDataForHash(dataB));
});

it('produces different serialization for different nested object values', () => {
const dataA = { ...baseHashData, metadata: { source: 'api' } };
const dataB = { ...baseHashData, metadata: { source: 'webhook' } };

expect(serializeLogDataForHash(dataA)).not.toBe(serializeLogDataForHash(dataB));
});
});

// ── computeLogHash ────────────────────────────────────────────────────────
Expand Down
Loading
Loading