mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
5e66b54153
* fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history Harden the Admiral audit log without changing its tier or hub-only gating. - CSV export now defuses formula injection: any field that a spreadsheet would evaluate as a formula (leading = + - @, or a trigger behind leading whitespace, or a leading tab/CR) is prefixed with a single quote before RFC 4180 quoting. Audit summaries embed user-controlled resource names, so this closes a path where a crafted name could execute on export open. - Clamp page and limit to positive bounds on the list endpoint so a negative limit can no longer reach SQLite as "unlimited" and dump the whole table. - Bound the anomaly and stats history reads to a capped slice of recent rows so the analysis paths stay within fixed memory and latency on large histories instead of scanning the full retention window per request. - Surface failed audit list and stats fetches through the standard error toast instead of leaving the view silently stale. - Add a developer-mode-gated diagnostic log to the stats endpoint for parity with the list and export handlers. Covered by new unit and HTTP-integration tests (CSV neutralization through the real export route, pagination clamps, bounded history, stats endpoint, anomaly annotation) and verified end to end in the browser. * fix(audit-log): compute signal-rail stats with exact SQL aggregates Address review feedback on the earlier history-cap change. The cap was correct for the anomaly baseline but made the stats tiles (events, actors, failure rate, hourly series) silently undercount on a hub with more than the cap's worth of rows in the window, since they were derived from the capped row slice. - Add DatabaseService.getAuditStatsInputs: exact counts via SQL COUNT / COUNT(DISTINCT) / GROUP BY hour, and new-ip detection over the small DISTINCT (user, ip) pair sets. No row cap, so the tiles stay exact at any window size while memory stays bounded. - Reduce computeAuditStats to a pure formatter over those aggregates. - Keep the bounded history read only for the list endpoint's anomaly annotation, where a recent-activity baseline is an acceptable heuristic. - Skip the redundant load-failure toast when a fetch fails with a handled 401, so an expired session does not stack toasts on top of logout. Adds exactness tests for the aggregate counts, distinct-actor handling, and new-ip detection, and strengthens the pagination-clamp tests. * fix(audit-log): exclude future-dated rows and make the new-ip sample deterministic Two small parity fixes on the stats aggregates: upper-bound every current window by `now` so a future-dated row (clock skew or a fixture) cannot inflate the live counts, and order the new-ip pair scan so the sample actor shown in the tile detail is stable. Adds a test asserting a future row is excluded.
67 lines
2.6 KiB
TypeScript
67 lines
2.6 KiB
TypeScript
/**
|
|
* Tests for escapeCsvField: RFC 4180 quoting plus CSV formula-injection
|
|
* neutralization (CWE-1236). Audit-log export embeds user-controlled resource
|
|
* names, so any field can carry a leading formula trigger.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { escapeCsvField } from '../utils/csv';
|
|
|
|
describe('escapeCsvField', () => {
|
|
it('returns empty string for null and undefined', () => {
|
|
expect(escapeCsvField(null)).toBe('');
|
|
expect(escapeCsvField(undefined)).toBe('');
|
|
});
|
|
|
|
it('passes benign values through unchanged', () => {
|
|
expect(escapeCsvField('Created stack: web')).toBe('Created stack: web');
|
|
expect(escapeCsvField('admin')).toBe('admin');
|
|
expect(escapeCsvField(201)).toBe('201');
|
|
expect(escapeCsvField(0)).toBe('0');
|
|
});
|
|
|
|
it('quotes and doubles embedded quotes / commas / newlines', () => {
|
|
expect(escapeCsvField('a,b')).toBe('"a,b"');
|
|
expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""');
|
|
expect(escapeCsvField('line1\nline2')).toBe('"line1\nline2"');
|
|
});
|
|
|
|
it('prefixes a single quote on each formula trigger', () => {
|
|
expect(escapeCsvField('=1+1')).toBe("'=1+1");
|
|
expect(escapeCsvField('+1')).toBe("'+1");
|
|
expect(escapeCsvField('-1')).toBe("'-1");
|
|
expect(escapeCsvField('@SUM(A1)')).toBe("'@SUM(A1)");
|
|
expect(escapeCsvField('\tTAB')).toBe("'\tTAB");
|
|
// A leading CR is both defused and RFC 4180 quoted.
|
|
expect(escapeCsvField('\rCR')).toBe('"\'\rCR"');
|
|
});
|
|
|
|
it('defuses a trigger hidden behind leading whitespace', () => {
|
|
expect(escapeCsvField(' =cmd')).toBe("' =cmd");
|
|
expect(escapeCsvField(' +1')).toBe("' +1");
|
|
expect(escapeCsvField('\t=cmd')).toBe("'\t=cmd");
|
|
});
|
|
|
|
it('prefixes a negative number (conservative, by design)', () => {
|
|
expect(escapeCsvField(-5)).toBe("'-5");
|
|
});
|
|
|
|
it('neutralizes a HYPERLINK formula payload in a resource name', () => {
|
|
// Embedded quotes + commas force RFC 4180 quoting, so the cell is wrapped;
|
|
// the defused content (leading single quote) sits just inside the wrapper.
|
|
const malicious = '=HYPERLINK("http://evil","click")';
|
|
const out = escapeCsvField(malicious);
|
|
expect(out).toContain("'=HYPERLINK");
|
|
expect(out.startsWith('=')).toBe(false);
|
|
});
|
|
|
|
it('combines formula prefix with RFC 4180 quoting when needed', () => {
|
|
// Leading trigger AND an embedded comma: prefix first, then quote.
|
|
expect(escapeCsvField('=cmd,inject')).toBe('"\'=cmd,inject"');
|
|
});
|
|
|
|
it('does not alter a value where the trigger is not first', () => {
|
|
expect(escapeCsvField('stack=web')).toBe('stack=web');
|
|
expect(escapeCsvField('a-b-c')).toBe('a-b-c');
|
|
});
|
|
});
|