From b4eaf3348cf8361b24bb5403dd6cb5c4517f57dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 18 Mar 2025 18:13:18 -0400 Subject: [PATCH] collect events from cipher form --- libs/common/src/tools/log/ecs-format/core.ts | 26 ++-- libs/common/src/tools/log/ecs-format/index.ts | 2 +- .../src/tools/log/ecs-format/service.ts | 8 +- libs/common/src/tools/log/logger.ts | 113 ++++++++++++++++++ libs/common/src/tools/providers.ts | 3 + .../services/default-cipher-form.service.ts | 23 ++++ 6 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 libs/common/src/tools/log/logger.ts diff --git a/libs/common/src/tools/log/ecs-format/core.ts b/libs/common/src/tools/log/ecs-format/core.ts index 8e9447ce041..c1d69081d9b 100644 --- a/libs/common/src/tools/log/ecs-format/core.ts +++ b/libs/common/src/tools/log/ecs-format/core.ts @@ -1,5 +1,18 @@ import { Primitive } from "type-fest"; +export type EcsEventType = + | "access" + | "admin" + | "allowed" + | "creation" + | "deletion" + | "denied" + | "end" + | "error" + | "info" + | "start" + | "user"; + /** Elastic Common Schema log format - core fields. */ export interface EcsFormat { @@ -18,18 +31,7 @@ export interface EcsFormat { event: { kind?: "alert" | "enrichment" | "event" | "metric" | "state"; category?: "api" | "authentication" | "iam" | "process" | "session"; - type?: - | "access" - | "admin" - | "allowed" - | "creation" - | "deletion" - | "denied" - | "end" - | "error" - | "info" - | "start" - | "user"; + type?: EcsEventType; outcome?: "failure" | "success" | "unknown"; }; } diff --git a/libs/common/src/tools/log/ecs-format/index.ts b/libs/common/src/tools/log/ecs-format/index.ts index e067c80135a..56f08595678 100644 --- a/libs/common/src/tools/log/ecs-format/index.ts +++ b/libs/common/src/tools/log/ecs-format/index.ts @@ -1,4 +1,4 @@ -export { EcsFormat } from "./core"; +export { EcsFormat, EcsEventType } from "./core"; export { ErrorFormat } from "./error"; export { EventFormat } from "./event"; export { LogFormat } from "./log"; diff --git a/libs/common/src/tools/log/ecs-format/service.ts b/libs/common/src/tools/log/ecs-format/service.ts index 24cf6972f06..0475abcf7ff 100644 --- a/libs/common/src/tools/log/ecs-format/service.ts +++ b/libs/common/src/tools/log/ecs-format/service.ts @@ -3,8 +3,10 @@ import { EcsFormat } from "./core"; export type ServiceFormat = EcsFormat & { /** documents the program providing the log */ service: { - /** Which kind of client is it? */ - name: "android" | "cli" | "desktop" | "extension" | "ios" | "web"; + /** Which kind of client is it? + * @remarks this contains the output of `BrowserPlatformUtilsService.getDeviceString()` in practice. + */ + name: string; /** identifies the service as a type of client device */ type: "client"; @@ -18,6 +20,6 @@ export type ServiceFormat = EcsFormat & { environment: "production" | "testing" | "development" | "local"; /** the unique identifier(s) for this client installation */ - version: "2025.3.1-innovation-sprint"; + version: string; }; }; diff --git a/libs/common/src/tools/log/logger.ts b/libs/common/src/tools/log/logger.ts new file mode 100644 index 00000000000..09ef89ea6ab --- /dev/null +++ b/libs/common/src/tools/log/logger.ts @@ -0,0 +1,113 @@ +import { BehaviorSubject, SubjectLike, filter, first, from, map, zip } from "rxjs"; +import { Primitive } from "type-fest"; + +import { Account, AccountService } from "../../auth/abstractions/account.service"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { UserActionEvent } from "../achievements/types"; + +import { ServiceFormat, UserFormat, EcsEventType } from "./ecs-format"; +import { SemanticLogger } from "./semantic-logger.abstraction"; + +export abstract class UserEventLogProvider { + abstract create: (account: Account) => UserEventLogger; +} + +type BaselineType = Omit; + +type EventInfo = { + action: string; + labels?: Record; + tags?: Array; +}; + +export class UserEventLogger { + constructor( + idService: AppIdService, + utilService: PlatformUtilsService, + accountService: AccountService, + private now: () => number, + private log: SemanticLogger, + private events$: SubjectLike, + ) { + zip( + from(idService.getAppId()), + from(utilService.getApplicationVersion()), + accountService.activeAccount$.pipe( + filter((account) => !!account), + first(), + ), + ) + .pipe( + map( + ([appId, version, account]) => + ({ + event: { + kind: "event", + category: "session", + }, + service: { + name: utilService.getDeviceString(), + type: "client", + node: { + name: appId, + }, + environment: "local", + version, + }, + user: { + // `account` verified not-null via `filter` + id: account!.id, + email: (account!.emailVerified && account!.email) || undefined, + }, + }) satisfies BaselineType, + ), + ) + .subscribe((next) => this.baseline$.next(next)); + } + + private readonly baseline$ = new BehaviorSubject(null); + + creation(event: EventInfo) { + this.collect("creation", event); + } + + deletion(event: EventInfo) { + this.collect("deletion", event); + } + + info(event: EventInfo) { + this.collect("info", event); + } + + access(event: EventInfo) { + this.collect("access", event); + } + + private collect(type: EcsEventType, info: EventInfo) { + const { value: baseline } = this.baseline$; + + if (!baseline) { + // TODO: buffer logs and stream them when `baseline$` becomes available. + this.log.error("baseline log not available; dropping user event"); + return; + } + + const event = structuredClone(this.baseline$.value) as UserActionEvent; + event["@timestamp"] = this.now(); + + event.event.type = type; + event.action = info.action; + event.tags = info.tags && info.tags.filter((t) => !!t); + + if (info.labels) { + const entries = Object.keys(info.labels) + .filter((k) => !!info.labels![k]) + .map((k) => [k, info.labels![k]] as const); + const labels = Object.fromEntries(entries); + event.labels = labels; + } + + this.events$.next(event); + } +} diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index a22a22addc5..37e98ffe8e8 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -2,6 +2,7 @@ import { PolicyService } from "../admin-console/abstractions/policy/policy.servi import { ExtensionService } from "./extension/extension.service"; import { LogProvider } from "./log"; +import { UserEventLogProvider } from "./log/logger"; /** Provides access to commonly-used cross-cutting services. */ export type SystemServiceProvider = { @@ -13,4 +14,6 @@ export type SystemServiceProvider = { /** Event monitoring and diagnostic interfaces */ readonly log: LogProvider; + + readonly event: UserEventLogProvider; }; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 98286e4bbb2..c05e9711b36 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -6,7 +6,9 @@ import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { UserEventLogProvider } from "@bitwarden/common/tools/log/logger"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -22,6 +24,7 @@ export class DefaultCipherFormService implements CipherFormService { private cipherService: CipherService = inject(CipherService); private accountService: AccountService = inject(AccountService); private apiService: ApiService = inject(ApiService); + private system = inject(UserEventLogProvider); async decryptCipher(cipher: Cipher): Promise { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -32,6 +35,7 @@ export class DefaultCipherFormService implements CipherFormService { async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise { // Passing the original cipher is important here as it is responsible for appending to password history + const activeUser = await firstValueFrom(this.accountService.activeAccount$); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const encryptedCipher = await this.cipherService.encrypt( cipher, @@ -40,12 +44,22 @@ export class DefaultCipherFormService implements CipherFormService { null, config.originalCipher ?? null, ); + const event = this.system.create(activeUser); + const labels = { + "vault-item-type": CipherType[cipher.type], + "vault-item-uri-quantity": cipher.type === CipherType.Login ? cipher.login.uris.length : null, + }; + const tags = [ + cipher.attachments.length > 0 ? "with-attachment" : null, + cipher.folderId ? "with-folder" : null, + ]; let savedCipher: Cipher; // Creating a new cipher if (cipher.id == null) { savedCipher = await this.cipherService.createWithServer(encryptedCipher, config.admin); + event.creation({ action: "vault-item-added", labels, tags }); return await savedCipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId), ); @@ -68,10 +82,15 @@ export class DefaultCipherFormService implements CipherFormService { cipher.collectionIds, activeUserId, ); + event.info({ action: "vault-item-shared-with-organization", labels, tags }); + // If the collectionIds are the same, update the cipher normally } else if (isSetEqual(originalCollectionIds, newCollectionIds)) { savedCipher = await this.cipherService.updateWithServer(encryptedCipher, config.admin); + event.info({ action: "vault-item-updated", labels, tags }); } else { + tags.push("collection"); + // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds encryptedCipher.collectionIds = config.originalCipher.collectionIds; @@ -92,12 +111,16 @@ export class DefaultCipherFormService implements CipherFormService { activeUserId, ); } + + event.deletion({ action: "vault-item-moved", labels, tags }); } // Its possible the cipher was made no longer available due to collection assignment changes // e.g. The cipher was moved to a collection that the user no longer has access to if (savedCipher == null) { return null; + } else { + event.creation({ action: "vault-item-moved", labels, tags }); } return await savedCipher.decrypt(