fix(stack-activity): per-stack history integrity, attribution, sanitization (#1228)

* fix(stack-activity): per-stack history integrity, attribution, sanitization

Address the Stack Activity audit findings (PR 1 of 2):

- Per-stack history integrity: drop the per-insert 100-row prune in
  addNotificationHistory that evicted quieter stacks' history whenever
  another stack got chatty. Periodic cleanupOldNotifications now caps
  per (node, stack) at 500 rows and per-node unattached system events
  at 1000 rows, on top of the existing 30-day retention. Signature
  takes an options bag and returns a per-stage summary so MonitorService
  can log what actually ran each cycle.

- Actor attribution: thread req.user?.username through every
  notifyActionFailure call site and add synthetic actors at service
  emit sites (system:autoheal, system:scheduler, system:image-update,
  system:docker-events, system:blueprint, system:monitor, system:policy).
  The timeline renders system actors as "via <Label>" so an autoheal
  redeploy is no longer indistinguishable from a user redeploy.

- Message sanitization: new sanitizeNotificationMessage at
  NotificationService.dispatchAlert strips KEY=VALUE pairs whose key
  ends in TOKEN/KEY/PASSWORD/SECRET/CREDENTIALS/AUTH, scrubs HTTP basic
  auth in URLs and Bearer tokens, collapses COMPOSE_DIR paths, and
  truncates to 1000 chars. Applied to the stored history and to every
  downstream Discord/Slack/webhook channel. The ImageUpdateService
  recovery-path direct DB write also runs through the sanitizer.

- Composite pagination cursor: getStackActivity now accepts a
  (timestamp, id) cursor (?before=&beforeId=). The legacy timestamp-only
  form silently dropped events when a single compose up emitted many
  events sharing one millisecond. Route rejects beforeId without before.

- Frontend hardening: distinct error state with retry button (initial
  fetch failure no longer renders as the genuine empty state), strict
  positive-integer parsing on cursor params, overrequest-by-1 pagination
  so the last page does not leave a dead "Load more" click, runtime
  guard on liveEvents merge that validates the level union, per-minute
  day-bucket recompute so an open panel does not stay on "Today" past
  midnight.

No tier, role, or capability gate touched. Route permission gate
remains stack:read on the named stack.

* fix(stack-activity): sanitizer covers lowercase env vars and per-node compose dir

External review surfaced two leak paths in the message sanitizer:

- The sensitive-key regex was uppercase-only. Compose env names are
  conventionally uppercase but lowercase forms (db_password, jwt_secret,
  github_token) are valid and do leak through the same Docker and
  compose-parse error paths. Make the regex case-insensitive and tighten
  it to also catch bare TOKEN= / KEY= / PASSWORD= without a prefix word,
  while still leaving BYPASS, COMPASS, and similar non-secret keys alone.

- The compose-dir path collapse only read process.env.COMPOSE_DIR, but
  the real resolution chain is node.compose_dir (per-node DB override)
  -> process.env.COMPOSE_DIR -> /app/compose. A node with a custom
  compose_dir could still leak absolute paths into stored history and
  downstream channels. Route both the dispatchAlert call and the
  ImageUpdateService recovery-path direct write through
  NodeRegistry.getInstance().getComposeDir(localNodeId) so the
  collapse covers every resolution outcome.

Tests now assert lowercase keys are redacted and that BYPASS-style
non-secrets stay intact in both cases. notification-routing mock
extended to stub the new getComposeDir call.

* chore(stack-activity): a11y roles, visibility-aware tick, live-disconnect signal

Close three small follow-ups on the per-stack activity timeline:

- A11y: each day-group gets role="list" and each event row gets
  role="listitem" so screen readers traverse the timeline as a list
  instead of a wall of text. The day-group container also carries an
  aria-label naming the bucket.

- Visibility-aware day-bucket tick: the 60s setInterval that re-derives
  Today/Yesterday/Earlier now short-circuits when document.hidden, so a
  backgrounded panel does not re-render every minute for no visible
  effect.

- Live-disconnect signal: useNotifications dispatches a
  sencho:notifications-connection custom event on WebSocket open and
  close. The timeline listens and, when explicitly disconnected, shows
  a one-line "Live updates offline; reconnecting…" hint above the list.
  The sidebar ticker already surfaces fleet-wide connection state; this
  adds an in-context cue for users who are focused on a single stack.

Stack-name case normalization was considered and rejected: stack names
are case-permissive per the isValidStackName validator, and lowercasing
on read or write would silently rename or hide a user's "MyApp" stack.

* ci(stack-activity): drop unnecessary escape in URL_BASIC_AUTH regex

ESLint no-useless-escape errored on \- inside the character class
[a-zA-Z0-9+.\-] at notificationMessage.ts:14. Move the dash to the
end of the class so it's an unambiguous literal and the escape is no
longer required. Behavior is identical; sanitizer tests still pass.

* revert(stack-activity): drop unvalidated E2E spec from this PR

The spec was committed without ever running against a real Docker
daemon, then failed in CI when it ran for the first time: deploy
returned 200 but no notification appeared on the activity endpoint
within the polling window, suggesting either a deploy-notification
race or a node-id resolution mismatch in the CI environment.

Backend unit tests (route + composite cursor + sanitizer) and
frontend component tests cover the same logic. The E2E spec will
land in a dedicated follow-up once it has been authored against a
working CI environment.
This commit is contained in:
Anso
2026-05-25 21:09:00 -04:00
committed by GitHub
parent 117f590332
commit 2d56ea958a
24 changed files with 852 additions and 133 deletions
@@ -0,0 +1,169 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
let tmpDir: string;
let db: any;
beforeAll(async () => {
tmpDir = await setupTestDb();
const { DatabaseService } = await import('../services/DatabaseService');
db = DatabaseService.getInstance();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
db.deleteAllNotifications(0);
});
describe('sanitizeNotificationMessage', () => {
it('passes plain messages through unchanged', () => {
expect(sanitizeNotificationMessage('Stack deployed in 4.2s')).toBe('Stack deployed in 4.2s');
});
it('redacts KEY=VALUE pairs whose key matches a sensitive suffix', () => {
const raw = 'compose parse failed: DB_PASSWORD=hunter2 missing closing quote';
expect(sanitizeNotificationMessage(raw)).toBe('compose parse failed: DB_PASSWORD=<redacted> missing closing quote');
});
it('redacts API_KEY, SECRET, TOKEN, CREDENTIAL variants', () => {
expect(sanitizeNotificationMessage('STRIPE_API_KEY=sk_live_abc')).toContain('STRIPE_API_KEY=<redacted>');
expect(sanitizeNotificationMessage('JWT_SECRET=eyJabc')).toContain('JWT_SECRET=<redacted>');
expect(sanitizeNotificationMessage('GITHUB_TOKEN=ghp_abc')).toContain('GITHUB_TOKEN=<redacted>');
expect(sanitizeNotificationMessage('AWS_CREDENTIALS=foo')).toContain('AWS_CREDENTIALS=<redacted>');
});
it('redacts every sensitive KEY=VALUE pair in a single message', () => {
const raw = 'compose env: DB_PASSWORD=hunter2 API_KEY=abc OTHER=keep';
const out = sanitizeNotificationMessage(raw);
expect(out).toContain('DB_PASSWORD=<redacted>');
expect(out).toContain('API_KEY=<redacted>');
expect(out).toContain('OTHER=keep');
});
it('leaves bare PASS-suffix keys like BYPASS and COMPASS unredacted', () => {
expect(sanitizeNotificationMessage('BYPASS=true')).toBe('BYPASS=true');
expect(sanitizeNotificationMessage('COMPASS_URL=https://example.com')).toBe('COMPASS_URL=https://example.com');
});
it('leaves non-sensitive uppercase KEY=VALUE untouched', () => {
expect(sanitizeNotificationMessage('NODE_ENV=production')).toBe('NODE_ENV=production');
expect(sanitizeNotificationMessage('PORT=1852')).toBe('PORT=1852');
});
it('redacts lowercase sensitive keys (compose env vars are commonly lowercase)', () => {
expect(sanitizeNotificationMessage('db_password=foo')).toBe('db_password=<redacted>');
expect(sanitizeNotificationMessage('jwt_secret=abc')).toBe('jwt_secret=<redacted>');
expect(sanitizeNotificationMessage('github_token=ghp_xyz')).toBe('github_token=<redacted>');
});
it('still leaves bare PASS-suffix lowercase keys unredacted', () => {
expect(sanitizeNotificationMessage('bypass=true')).toBe('bypass=true');
expect(sanitizeNotificationMessage('compass_url=https://example.com')).toBe('compass_url=https://example.com');
});
it('redacts HTTP basic auth in URLs', () => {
const raw = 'pull failed for https://user:pa55@registry.example.com/img';
expect(sanitizeNotificationMessage(raw)).toBe('pull failed for https://user:<redacted>@registry.example.com/img');
});
it('redacts bearer tokens', () => {
const raw = 'auth header was Bearer abc123xyzdef456';
expect(sanitizeNotificationMessage(raw)).toBe('auth header was Bearer <redacted>');
});
it('truncates messages longer than 1000 characters', () => {
const raw = 'x'.repeat(2000);
const out = sanitizeNotificationMessage(raw);
expect(out.length).toBeLessThanOrEqual(1000);
expect(out.endsWith('… [truncated]')).toBe(true);
});
it('collapses COMPOSE_DIR path prefixes', () => {
const out = sanitizeNotificationMessage(
'file not found: /opt/docker/sencho/compose/myapp/.env',
{ composeDir: '/opt/docker/sencho/compose' },
);
expect(out).toBe('file not found: <compose-dir>/myapp/.env');
});
});
describe('DatabaseService.getStackActivity', () => {
it('returns only events for the requested (node, stack)', () => {
const base = Date.now();
db.addNotificationHistory(0, { level: 'info', message: 'a-evt', timestamp: base, stack_name: 'a' });
db.addNotificationHistory(0, { level: 'info', message: 'b-evt', timestamp: base + 1, stack_name: 'b' });
db.addNotificationHistory(0, { level: 'info', message: 'a-evt-2', timestamp: base + 2, stack_name: 'a' });
const aOnly = db.getStackActivity(0, 'a', { limit: 50 });
expect(aOnly.map((e: any) => e.message)).toEqual(['a-evt-2', 'a-evt']);
});
it('honors limit and orders newest first', () => {
const base = Date.now();
for (let i = 0; i < 5; i++) {
db.addNotificationHistory(0, { level: 'info', message: `e-${i}`, timestamp: base + i, stack_name: 's' });
}
const out = db.getStackActivity(0, 's', { limit: 3 });
expect(out.length).toBe(3);
expect(out.map((e: any) => e.message)).toEqual(['e-4', 'e-3', 'e-2']);
});
it('legacy timestamp-only cursor excludes equal-or-newer rows', () => {
const base = Date.now();
for (let i = 0; i < 5; i++) {
db.addNotificationHistory(0, { level: 'info', message: `e-${i}`, timestamp: base + i * 10, stack_name: 's' });
}
// Cursor at base+20 (= e-2). before=base+20 means timestamp < base+20, so only e-0/e-1 returned.
const out = db.getStackActivity(0, 's', { limit: 50, before: base + 20 });
expect(out.map((e: any) => e.message)).toEqual(['e-1', 'e-0']);
});
it('composite (timestamp, id) cursor drops only the cursor row and older when many share a ms', () => {
const ts = Date.now();
const ids: number[] = [];
for (let i = 0; i < 5; i++) {
const row = db.addNotificationHistory(0, { level: 'info', message: `e-${i}`, timestamp: ts, stack_name: 's' });
ids.push(row.id);
}
// ids[0..4] all share ts. Cursor at the third row's id should return ids[0] and ids[1].
const out = db.getStackActivity(0, 's', { limit: 50, before: ts, beforeId: ids[2] });
const returnedIds = out.map((e: any) => e.id);
expect(returnedIds).toEqual([ids[1], ids[0]]);
});
it('documents the legacy timestamp-only cursor drops same-ms rows (kept for backward compat)', () => {
const ts = Date.now();
for (let i = 0; i < 5; i++) {
db.addNotificationHistory(0, { level: 'info', message: `e-${i}`, timestamp: ts, stack_name: 's' });
}
// The composite-cursor test above proves the fix; this test pins the legacy form's
// behavior so a client that omits beforeId gets a predictable (if lossy) result.
const out = db.getStackActivity(0, 's', { limit: 50, before: ts });
expect(out).toEqual([]);
});
it('returns empty array when stack has no events', () => {
db.addNotificationHistory(0, { level: 'info', message: 'other', timestamp: Date.now(), stack_name: 'other' });
const out = db.getStackActivity(0, 'missing', { limit: 50 });
expect(out).toEqual([]);
});
});
describe('DatabaseService.addNotificationHistory (no per-insert prune)', () => {
it('keeps a quiet stack visible even after a chatty stack writes past the old 100-row per-node cap', () => {
const ts = Date.now();
db.addNotificationHistory(0, { level: 'info', message: 'first', timestamp: ts, stack_name: 'a' });
// The old per-insert prune kept only the newest 100 rows per node, regardless of stack.
// Writing 150 rows for stack b would have evicted 'first' from stack a under the old rule.
for (let i = 0; i < 150; i++) {
db.addNotificationHistory(0, { level: 'info', message: `chatty-${i}`, timestamp: ts + i + 1, stack_name: 'b' });
}
const aActivity = db.getStackActivity(0, 'a', { limit: 50 });
expect(aActivity.map((e: any) => e.message)).toEqual(['first']);
});
});