diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cc672fe --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.lockb binary diff=lockb \ No newline at end of file diff --git a/.github/workflows/biome.yml b/.github/workflows/biome.yml new file mode 100644 index 0000000..608ab72 --- /dev/null +++ b/.github/workflows/biome.yml @@ -0,0 +1,11 @@ +name: Biome +on: [push, pull_request] +jobs: + biome: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install + - run: bun run check + - run: git diff --exit-code \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..82295d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release +on: release +jobs: + biome: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install + - run: bun run check + - run: git diff --exit-code + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install + - run: bun test --coverage + release: + needs: [biome, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - uses: actions/setup-node@v3 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - run: bun install + - run: bun run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5daa9a3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,10 @@ +name: Test +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install + - run: bun test --coverage \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ef4e97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log + +# Caches + +.cache + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Dependency directories + +node_modules/ + +# Output of 'npm pack' + +*.tgz + +dist + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..94b0a09 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["biomejs.biome"], + "unwantedRecommendations": ["esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9ca45f5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "editor.formatOnSave": true, + "editor.formatOnPaste": false, + "editor.defaultFormatter": "biomejs.biome", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome", + "javascript.preferences.importModuleSpecifierEnding": "js" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome", + "typescript.preferences.importModuleSpecifierEnding": "js" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b33f71e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# owasp-logging + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.14. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..fecc60d --- /dev/null +++ b/biome.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.4.0/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..9381107 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..448b1b2 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "owasp-logging", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "author": { + "name": "Quinn Turner" + }, + "homepage": "https://github.com/quinnturner/owasp-logging#readme", + "bugs": { + "url": "https://github.com/quinnturner/owasp-logging/issues" + }, + "keywords": [ + "owasp", + "logging", + "security" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist" + ], + "scripts": { + "format": "biome format ./src --write", + "lint": "biome lint ./src", + "check": "biome check --apply ./src", + "build": "tsup --config tsup.config.ts" + }, + "devDependencies": { + "@biomejs/biome": "^1.4.0", + "bun-types": "^1.0.14", + "tsup": "^8.0.1", + "type-fest": "^4.8.2", + "typescript": "^5.3.2" + }, + "packageManager": "bun@1.0.14" +} \ No newline at end of file diff --git a/src/authentication.test.ts b/src/authentication.test.ts new file mode 100644 index 0000000..c8b2aa3 --- /dev/null +++ b/src/authentication.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "bun:test"; +import { + authn_impossible_travel, + authn_login_fail, + authn_login_fail_max, + authn_login_lock, + authn_login_success, + authn_login_successafterfail, + authn_password_change, + authn_password_change_fail, + authn_token_created, + authn_token_delete, + authn_token_reuse, + authn_token_revoked, +} from "./authentication.js"; + +const userId = "user123"; + +describe("authn_token_revoked", () => { + it("should return the correct string when tokenId is undefined", () => { + const result = authn_token_revoked(userId); + expect(result).toBe("authn_token_revoked:user123"); + }); + + it("should return the correct string when tokenId is defined", () => { + const tokenId = "token456"; + const result = authn_token_revoked(userId, tokenId); + expect(result).toBe("authn_token_revoked:user123,token456"); + }); +}); + +describe("authn_token_reuse", () => { + it("should return the correct string when tokenId is undefined", () => { + const result = authn_token_reuse(userId); + expect(result).toBe("authn_token_reuse:user123"); + }); + it("should return the correct string when tokenId is defined", () => { + const result = authn_token_reuse(userId, "token456"); + expect(result).toBe("authn_token_reuse:user123,token456"); + }); +}); + +describe("authn_impossible_travel", () => { + it("should return the correct string", () => { + const result = authn_impossible_travel(userId, "location1", "location2"); + expect(result).toBe("authn_impossible_travel:user123,location1,location2"); + }); +}); + +describe("authn_login_fail", () => { + it("should return the correct string", () => { + const result = authn_login_fail(userId); + expect(result).toBe("authn_login_fail:user123"); + }); +}); + +describe("authn_login_lock", () => { + it("should return the correct string when reason is undefined", () => { + const result = authn_login_lock(userId); + expect(result).toBe("authn_login_lock:user123"); + }); + it("should return the correct string when reason is defined", () => { + const result = authn_login_lock(userId, "reason"); + expect(result).toBe("authn_login_lock:user123,reason"); + }); +}); + +describe("authn_login_fail_max", () => { + it("should return the correct string when maxLimit is undefined", () => { + const result = authn_login_fail_max(userId); + expect(result).toBe("authn_login_fail_max:user123"); + }); + it("should return the correct string when maxLimit is defined", () => { + const result = authn_login_fail_max(userId, 3); + expect(result).toBe("authn_login_fail_max:user123,3"); + }); +}); + +describe("authn_login_success", () => { + it("should return the correct string", () => { + const result = authn_login_success(userId); + expect(result).toBe("authn_login_success:user123"); + }); +}); + +describe("authn_login_successafterfail", () => { + it("should return the correct string when provided a number as retry", () => { + const result = authn_login_successafterfail(userId, 1); + expect(result).toBe("authn_login_successafterfail:user123,1"); + }); + it("should return the correct string when provided a string as retry", () => { + const result = authn_login_successafterfail(userId, "1"); + expect(result).toBe("authn_login_successafterfail:user123,1"); + }); + it("should return the correct string when provided a bigint as retry", () => { + const result = authn_login_successafterfail(userId, BigInt(1)); + expect(result).toBe("authn_login_successafterfail:user123,1"); + }); +}); + +describe("authn_password_change", () => { + it("should return the correct string", () => { + const result = authn_password_change(userId); + expect(result).toBe("authn_password_change:user123"); + }); +}); + +describe("authn_password_change_fail", () => { + it("should return the correct string", () => { + const result = authn_password_change_fail(userId); + expect(result).toBe("authn_password_change_fail:user123"); + }); +}); + +describe("authn_token_created", () => { + it("should return the correct string and perfect type when not using spread", () => { + const result = authn_token_created(userId, "create", "update"); + expect(result).toBe("authn_token_created:user123,create,update"); + }); + it("should return the correct string and `string,string` type when using non const spread", () => { + const result = authn_token_created(userId, ...["create", "update"]); + // Note, the type of `result` is `authn_token_created:user123,${string},${string}` + expect(result).toBe("authn_token_created:user123,create,update"); + }); + it("should return the correct string and type when using const spread", () => { + const result = authn_token_created( + userId, + ...(["create", "update"] as const), + ); + expect(result).toBe("authn_token_created:user123,create,update"); + }); +}); + +describe("authn_token_delete", () => { + it("should return the correct string when tokenId is undefined", () => { + const result = authn_token_delete(userId); + expect(result).toBe("authn_token_delete:user123"); + }); + it("should return the correct string when tokenId is defined", () => { + const result = authn_token_delete("app-id"); + expect(result).toBe("authn_token_delete:app-id"); + }); +}); diff --git a/src/authentication.ts b/src/authentication.ts new file mode 100644 index 0000000..26020ab --- /dev/null +++ b/src/authentication.ts @@ -0,0 +1,411 @@ +import type { Join } from "type-fest"; + +/** + * All login events should be recorded including success. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * logger.info({ event: authn_login_success(userId) }, `User ${userId} login successfully`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_login_success:joebob1", + * "level": "INFO", + * "description": "User joebob1 login successfully", + * "requestId": "4c682970-ef75-4605-93f2-ab7cf5316d83" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_login_successuserid + */ +export function authn_login_success( + userId: U, +) { + return `authn_login_success:${userId}` as const; +} + +/** + * The user successfully logged in after previously failing. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * const retries = 2; + * logger.info({ event: authn_login_successafterfail(userId, retries) }, `User ${userId} login successfully`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_login_successafterfail:joebob1,2", + * "level": "INFO", + * "description": "User joebob1 login successfully.", + * "requestId": "b2b0fc16-cfc1-42cc-a1a7-102a11e7fa6e" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_login_successafterfailuseridretries + */ +export function authn_login_successafterfail< + U extends string, + R extends string | number | bigint, +>(userId: U, retries: R) { + return `authn_login_successafterfail:${userId},${retries}` as const; +} + +/** + * All login events should be recorded including failure. + * + * **Level:** `WARN` + * + * @example + * ```ts + * const userId = "joebob1"; + * const retries = 2; + * logger.warn({ event: authn_login_fail(userId) }, `User ${userId} login failed`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_login_fail:joebob1", + * "level": "WARN", + * "description": "User joebob1 login failed", + * "requestId": "b7c29e30-199e-4234-a8f1-fff0c12f1624" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_login_failuserid + */ +export function authn_login_fail( + userId: U, +) { + return `authn_login_fail:${userId}` as const; +} + +/** + * All login events should be recorded including failure. + * + * **Level:** `WARN` + * + * @example + * ```ts + * const userId = "joebob1"; + * const maxLimit = 3; + * logger.warn({ event: authn_login_fail_max(userId, maxLimit) }, `User ${userId} reached the login fail limit of ${maxLimit}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_login_fail_max:joebob1,3", + * "level": "WARN", + * "description": "User joebob1 reached the login fail limit of 3", + * "requestId": "b7c29e30-199e-4234-a8f1-fff0c12f1624" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_login_fail_maxuseridmaxlimitint + */ +export function authn_login_fail_max( + userId: U, +): `authn_login_fail_max:${U}`; +export function authn_login_fail_max< + U extends string | number | bigint, + L extends number, +>(userId: U, maxLimit: L): `authn_login_fail_max:${U},${L}`; +export function authn_login_fail_max< + U extends string | number | bigint, + L extends number, +>(userId: U, maxLimit?: L) { + return maxLimit === undefined + ? (`authn_login_fail_max:${userId}` as const) + : (`authn_login_fail_max:${userId},${maxLimit}` as const); +} + +/** + * When the feature exists to lock an account after _x_ retries or other condition, the lock should be logged with relevant data. + * + * **Level:** `WARN` + * + * **Reasons:** + * ```txt + * maxretries: The maximum number of retries was reached + * suspicious: Suspicious activity was observed on the account + * customer: The customer requested their account be locked + * other: Other + * ``` + * @example + * ```ts + * const userId = "joebob1"; + * const reason = 'maxretries'; + * logger.warn({ event: authn_login_lock(userId, reason) }, `User ${userId} login locked because ${reason} exceeded`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_login_lock:joebob1,maxretries", + * "level": "WARN", + * "description": "User joebob1 login locked because maxretries exceeded", + * "requestId": "e6a7c70a-7972-49c9-a056-1af31cecf334" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_login_lockuseridreason + */ +export function authn_login_lock< + U extends string | number | bigint, + R extends string | number | bigint, +>(userId: U, reason: R): `authn_login_lock:${U},${R}`; +export function authn_login_lock( + userId: U, +): `authn_login_lock:${U}`; +export function authn_login_lock< + U extends string, + R extends string | number | bigint, +>(userId: U, reason?: R) { + return reason === undefined + ? (`authn_login_lock:${userId}` as const) + : (`authn_login_lock:${userId},${reason}` as const); +} + +/** + * Every password change should be logged, including the userid that it was for. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * logger.info({ event: authn_password_change(userId) }, `User ${userId} has successfully changed their password`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_password_change:joebob1", + * "level": "INFO", + * "description": "User joebob1 has successfully changed their password", + * "requestId": "72b29ffe-4b2a-4eef-9b4b-e9dc36483d28" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_password_changeuserid + */ +export function authn_password_change( + userId: U, +) { + return `authn_password_change:${userId}` as const; +} + +/** + * An attempt to change a password that failed. May also trigger other events such as {@link authn_login_lock} + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * logger.info({ event: authn_password_change_fail(userId) }, `User ${userId} failed to changing their password`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_password_change:joebob1", + * "level": "INFO", + * "description": "User joebob1 failed to changing their password", + * "requestId": "e5c80286-e7b4-4add-ab67-24ce28c7f187" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_password_change_failuserid + */ +export function authn_password_change_fail( + userId: U, +) { + return `authn_password_change_fail:${userId}` as const; +} + +/** + * When a user is logged in from one city and suddenly appears in another, too far away to have traveled in a reasonable timeframe, this often indicates a potential account takeover. + * + * **Level:** `CRITICAL` + * + * @example + * ```ts + * const userId = "joebob1"; + * const location1 = "US-OR"; + * const location2 = "CN-SH"; + * logger.info({ event: authn_impossible_travel(userId, location1, location2) }, `User ${userId} has accessed the application in two distant cities at the same time`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_impossible_travel:joebob1,US-OR,CN-SH", + * "level": "CRITICAL", + * "description": "User joebob1 has accessed the application in two distant cities at the same time", + * "requestId": "c1a514a9-70f1-4496-8c69-ebf9337783f6" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_impossible_traveluseridregion1region2 + */ +export function authn_impossible_travel< + U extends string | number | bigint, + L1 extends string, + L2 extends string, +>(userId: U, location1: L1, location2: L2) { + return `authn_impossible_travel:${userId},${location1},${location2}` as const; +} + +/** + * When a token is created for service access it should be recorded. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * const entitlements = ["create", "read", "update"] as const; + * logger.info({ event: authn_token_created(userId, ...entitlements) }, `A token has been created for ${userId} with ${entitlements.join(",")}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "aws.foobar.com", + * "event": "authn_token_created:app.foobarapi.prod,create,read,update", + * "level": "INFO", + * "description": "A token has been created for app.foobarapi.prod with create,read,update", + * "requestId": "e6002b7c-87f4-45c9-abf6-f1e0f94523d5" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_token_createduserid-entitlements + */ +export function authn_token_created< + U extends string | number | bigint, + E extends readonly string[] | readonly number[] | readonly bigint[], +>(userId: U, ...entitlements: E) { + return `authn_token_created:${userId},${ + entitlements.join(",") as Join + }` as const; +} + +/** + * A token has been revoked for the given account. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * const tokenId = "xyz-abc-123-gfk"; + * logger.info({ event: authn_token_revoked(userId, tokenId) }, `Token ID: ${tokenId} was revoked for user ${userId}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "aws.foobar.com", + * "event": "authn_token_revoked:app.foobarapi.prod,xyz-abc-123-gfk", + * "level": "INFO", + * "description": "Token ID: xyz-abc-123-gfk was revoked for user app.foobarapi.prod", + * "requestId": "caa98ad0-4b9c-4b20-8633-6be104b1933b" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_token_revokeduseridtokenid + */ +export function authn_token_revoked( + userId: U, +): `authn_token_revoked:${U}`; +export function authn_token_revoked< + U extends string | number | bigint, + T extends string | number | bigint, +>(userId: U, tokenId: T): `authn_token_revoked:${U},${T}`; +export function authn_token_revoked< + U extends string | number | bigint, + T extends string | number | bigint, +>(userId: U, tokenId?: T) { + return tokenId === undefined + ? (`authn_token_revoked:${userId}` as const) + : (`authn_token_revoked:${userId},${tokenId}` as const); +} + +/** + * A previously revoked token was attempted to be reused. + * + * **Level:** `CRITICAL` + * + * @example + * ```ts + * const userId = "joebob1"; + * const tokenId = "xyz-abc-123-gfk"; + * logger.critical({ event: authn_token_reuse(userId, tokenId) }, `User ${userId} attempted to use token ID: ${tokenId} which was previously revoked`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "aws.foobar.com", + * "event": "authn_token_reuse:app.foobarapi.prod,xyz-abc-123-gfk", + * "level": "CRITICAL", + * "description": "User app.foobarapi.prod attempted to use token ID: xyz-abc-123-gfk which was previously revoked", + * "requestId": "1cfdc054-b87a-4533-9c9a-d329bca0da44" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_token_reuseuseridtokenid + */ +export function authn_token_reuse( + userId: U, +): `authn_token_reuse:${U}`; +export function authn_token_reuse< + U extends string | number | bigint, + T extends string | number | bigint, +>(userId: U, tokenId: T): `authn_token_reuse:${U},${T}`; +export function authn_token_reuse< + U extends string | number | bigint, + T extends string | number | bigint, +>(userId: U, tokenId?: T) { + return tokenId === undefined + ? (`authn_token_reuse:${userId}` as const) + : (`authn_token_reuse:${userId},${tokenId}` as const); +} + +/** + * A previously revoked token was attempted to be reused. + * + * **Level:** `WARN` + * + * @example + * ```ts + * const appId = 'foobarapi'; + * logger.warn({ event: authn_token_delete(appId) }, `The token for ${appId} has been deleted`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authn_token_delete:foobarapi", + * "level": "WARN", + * "description": "The token for foobarapi has been deleted", + * "requestId": "ecd6341b-bc7c-4301-9c75-f49427f59406" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authn_token_deleteappid + */ +export function authn_token_delete( + appId: A, +) { + return `authn_token_delete:${appId}` as const; +} diff --git a/src/authorization.test.ts b/src/authorization.test.ts new file mode 100644 index 0000000..2647976 --- /dev/null +++ b/src/authorization.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { authz_admin, authz_change, authz_fail } from "./authorization.js"; + +describe("authz_change", () => { + it("should return the correct string", () => { + const result = authz_change("user123", "role1", "role2"); + expect(result).toBe("authz_change:user123,role1,role2"); + }); +}); +describe("authz_admin", () => { + it("should return the correct authorization string", () => { + const userId = "user123"; + const event = "create"; + + const result = authz_admin(userId, event); + + expect(result).toBe("authz_admin:user123,create"); + }); +}); +describe("authz_fail", () => { + it("should return the correct string", () => { + const userId = "user123"; + const resource = "resource123"; + const result = authz_fail(userId, resource); + expect(result).toBe("authz_fail:user123,resource123"); + }); +}); diff --git a/src/authorization.ts b/src/authorization.ts new file mode 100644 index 0000000..2e2d643 --- /dev/null +++ b/src/authorization.ts @@ -0,0 +1,97 @@ +/** + * An attempt was made to access a resource which was unauthorized. + * + * **Level:** `CRITICAL` + * + * @example + * ```ts + * const userId = "joebob1"; + * const resource = "resource" + * logger.critical({ event: authz_fail(userId, resource) }, `User ${userId} attempted to access a resource without entitlement`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authz_fail:joebob1,resource", + * "level": "CRITICAL", + * "description": "User joebob1 attempted to access a resource without entitlement", + * "requestId": "00b13b12-51ab-49bc-94be-34c450804850" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authz_failuseridresource + */ +export function authz_fail< + U extends string | number | bigint, + R extends string | number | bigint, +>(userId: U, resource: R) { + return `authz_fail:${userId},${resource}` as const; +} + +/** + * The user or entity entitlements was changed. + * + * **Level:** `WARN` + * + * @example + * ```ts + * const userId = "joebob1"; + * const from = "user"; + * const to = "admin"; + * logger.warn({ event: authz_change(userId, from, to) }, `User ${userId} access was changed from ${from} to ${to}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authz_change:joebob1,user,admin", + * "level": "WARN", + * "description": "User joebob1 access was changed from user to admin", + * "requestId": "5e952d3b-97b6-4c20-a241-b3aa9a591647" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authz_changeuseridfromto + */ +export function authz_change< + U extends string | number | bigint, + F extends string | number | bigint, + T extends string | number | bigint, +>(userId: U, from: F, to: T) { + return `authz_change:${userId},${from},${to}` as const; +} + +/** + * All activity by privileged users such as admin should be recorded. + * + * **Level:** `WARN` + * + * @example + * ```ts + * const adminUserId = "joebob1"; + * const event = "user_privilege_change"; + * const userId = "foobarapi"; + * const from = "user"; + * const to = "admin"; + * logger.warn({ event: authz_admin(adminUserId, event) }, `Administrator ${adminUserId} has updated privileges of user ${userId} from ${from} to ${to}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "authz_admin:joebob1,user_privilege_change", + * "level": "WARN", + * "description": "Administrator joebob1 has updated privileges of user foobarapi from user to admin", + * "requestId": "e02ffa1d-99b6-4616-b54e-5f51ed208331" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#authz_changeuseridfromto + */ +export function authz_admin< + U extends string | number | bigint, + E extends string, +>(userId: U, event: E) { + return `authz_admin:${userId},${event}` as const; +} diff --git a/src/excessive-use.test.ts b/src/excessive-use.test.ts new file mode 100644 index 0000000..4d771a9 --- /dev/null +++ b/src/excessive-use.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test"; +import { excess_rate_limit_exceeded } from "./excessive-use.js"; + +describe("excess_rate_limit_exceeded", () => { + it("should return the correct string when userId and max are strings", () => { + const result = excess_rate_limit_exceeded("user123", "10"); + expect(result).toBe("excess_rate_limit_exceeded:user123,10"); + }); + + it("should return the correct string when userId and max are numbers", () => { + const result = excess_rate_limit_exceeded(123, 10); + expect(result).toBe("excess_rate_limit_exceeded:123,10"); + }); + + it("should return the correct string when userId and max are bigints", () => { + const result = excess_rate_limit_exceeded(BigInt(123), BigInt(10)); + expect(result).toBe("excess_rate_limit_exceeded:123,10"); + }); +}); diff --git a/src/excessive-use.ts b/src/excessive-use.ts new file mode 100644 index 0000000..1f0660c --- /dev/null +++ b/src/excessive-use.ts @@ -0,0 +1,30 @@ +/** + * Expected service limit ceilings should be established and alerted when exceeded, even if simply for managing costs and scaling. + * + * **Level:** `WARN` + * + * @example + * ```ts + * const user = "joebob1"; + * const max = 100000; + * logger.warn({ event: excess_rate_limit_exceeded(user, max) }, `User ${user} has exceeded max:${max} requests`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "excess_rate_limit_exceeded:app.foobarapi.prod,100000", + * "level": "WARN", + * "description": "User app.foobarapi.prod has exceeded max:100000 requests", + * "requestId": "e997c333-fd0a-4880-b6d0-27e871285e50" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#excess_rate_limit_exceededuseridmax + */ +export function excess_rate_limit_exceeded< + U extends string | number | bigint, + M extends string | number | bigint, +>(userId: U, max: M) { + return `excess_rate_limit_exceeded:${userId},${max}` as const; +} diff --git a/src/file-upload.test.ts b/src/file-upload.test.ts new file mode 100644 index 0000000..e0e7f95 --- /dev/null +++ b/src/file-upload.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; +import { + upload_complete, + upload_delete, + upload_stored, + upload_validation, +} from "./file-upload.js"; + +describe("upload_delete", () => { + it("should return the correct string when userId and fileId are strings", () => { + const result = upload_delete("user123", "file456"); + expect(result).toBe("upload_delete:user123,file456"); + }); + + it("should return the correct string when userId and fileId are numbers", () => { + const result = upload_delete(123, 456); + expect(result).toBe("upload_delete:123,456"); + }); + + it("should return the correct string when userId and fileId are bigints", () => { + const result = upload_delete(BigInt(123), BigInt(456)); + expect(result).toBe("upload_delete:123,456"); + }); +}); + +describe("upload_delete", () => { + it("should return the correct string when userId and fileId are strings", () => { + const result = upload_delete("user123", "file456"); + expect(result).toBe("upload_delete:user123,file456"); + }); + + it("should return the correct string when userId and fileId are numbers", () => { + const result = upload_delete(123, 456); + expect(result).toBe("upload_delete:123,456"); + }); + + it("should return the correct string when userId and fileId are bigints", () => { + const result = upload_delete(BigInt(123), BigInt(456)); + expect(result).toBe("upload_delete:123,456"); + }); +}); + +describe("upload_validation", () => { + it("should return the correct string", () => { + const result = upload_validation("file123", "vendor456", "PASSED"); + expect(result).toBe("upload_validation:file123,vendor456,PASSED"); + }); +}); + +describe("upload_delete", () => { + it("should return the correct string when userId and fileId are strings", () => { + const result = upload_delete("user123", "file456"); + expect(result).toBe("upload_delete:user123,file456"); + }); + + it("should return the correct string when userId and fileId are numbers", () => { + const result = upload_delete(123, 456); + expect(result).toBe("upload_delete:123,456"); + }); + + it("should return the correct string when userId and fileId are bigints", () => { + const result = upload_delete(BigInt(123), BigInt(456)); + expect(result).toBe("upload_delete:123,456"); + }); +}); + +describe("upload_stored", () => { + it("should return the correct string", () => { + const result = upload_stored("file123", "destination456"); + expect(result).toBe("upload_stored:file123,destination456"); + }); +}); + +describe("upload_delete", () => { + it("should return the correct string when userId and fileId are strings", () => { + const result = upload_delete("user123", "file456"); + expect(result).toBe("upload_delete:user123,file456"); + }); + + it("should return the correct string when userId and fileId are numbers", () => { + const result = upload_delete(123, 456); + expect(result).toBe("upload_delete:123,456"); + }); + + it("should return the correct string when userId and fileId are bigints", () => { + const result = upload_delete(BigInt(123), BigInt(456)); + expect(result).toBe("upload_delete:123,456"); + }); +}); + +describe("upload_validation", () => { + it("should return the correct string", () => { + const result = upload_validation("file123", "vendor456", "PASSED"); + expect(result).toBe("upload_validation:file123,vendor456,PASSED"); + }); +}); + +describe("upload_stored", () => { + it("should return the correct string", () => { + const result = upload_stored("file123", "destination456"); + expect(result).toBe("upload_stored:file123,destination456"); + }); +}); + +describe("upload_complete", () => { + it("should return the correct string when userId, filename, and type are provided", () => { + const result = upload_complete("user123", "file456.jpeg", "image/jpeg"); + expect(result).toBe("upload_complete:user123,file456.jpeg,image/jpeg"); + }); + + it("should return the correct string when userId and filename are provided, but type is undefined", () => { + const result = upload_complete("user123", "file456"); + expect(result).toBe("upload_complete:user123,file456"); + }); +}); diff --git a/src/file-upload.ts b/src/file-upload.ts new file mode 100644 index 0000000..66b8fc8 --- /dev/null +++ b/src/file-upload.ts @@ -0,0 +1,147 @@ +/** + * On successful file upload the first step in the validation process is that the upload has completed. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const user = "joebob1"; + * const filename = "user_generated_content.png"; + * const filetype = "image/png"; + * logger.info({ event: upload_complete(user, filename, filetype) }, `User ${user} has uploaded ${filename}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "upload_complete:joebob1,user_generated_content.png,PNG", + * "level": "INFO", + * "description": "User joebob1 has uploaded user_generated_content.png", + * "requestId": "6e48278f-5f2c-4af0-b821-9d83f1cce30b" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#upload_completeuseridfilenametype + */ +export function upload_complete< + U extends string | number | bigint, + N extends string, +>(userId: U, filename: N): `upload_complete:${U},${N}`; +export function upload_complete< + U extends string | number | bigint, + N extends string, + T extends string, +>(userId: U, filename: N, type: T): `upload_complete:${U},${N},${T}`; +export function upload_complete< + U extends string | number | bigint, + N extends string, + T extends string, +>(userId: U, filename: N, type?: T) { + return type === undefined + ? (`upload_complete:${userId},${filename}` as const) + : (`upload_complete:${userId},${filename},${type}` as const); +} + +/** + * One step in good file upload validation is to move/rename the file and when providing the content back to end users, never reference the original filename in the download. This is true both when storing in a filesystem as well as in block storage. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const filename = "user_generated_content.png"; + * const to = "kjsdhkrjhwijhsiuhdf000010202002"; + * logger.info({ event: upload_stored(filename, to) }, `File ${filename} was stored in the database with key ${to}`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "upload_stored:user_generated_content.png,kjsdhkrjhwijhsiuhdf000010202002", + * "level": "INFO", + * "description": "File user_generated_content.png was stored in the database with key abcdefghijk101010101", + * "requestId": "7d1fbc36-fc8b-4dc3-8e79-841216f48ada" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#upload_storedfilenamefromto + */ +export function upload_stored( + filename: N, + to: T, +) { + return `upload_stored:${filename},${to}` as const; +} + +/** + * All file uploads should have some validation performed, both for correctness (is in fact of file type x), and for safety (does not contain a virus). + * + * **Level:** `INFO`|`CRITICAL` + * + * @example + * ```ts + * const filename = "user_generated_content.png"; + * const randomFileName = randomUUID(); + * // Remember, it's good to use a random filename to prevent path traversal attacks! + * const filepath = `/tmp/user-uploads/joebob1/${randomFileName}`; + * const { status } = await performVirusScan(filepath); // PASSED, INCOMPLETE, FAILED + * const vendor = "virusscan"; // imagemagick, clamav, etc. + * if (status === "FAILED") { + * await fs.promises.rm(filepath, { force: true }) + * logger.critical({ event: upload_validation(filename, vendor, status) }, `File ${filename} ${status} virus scan and was purged`); + * } else { + * logger.info({ event: upload_validation(filename, vendor, status) }, `File ${filename} ${status} virus scan`); + * } + * + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "upload_validation:filename,virusscan:FAILED", + * "level": "CRITICAL", + * "description": "File user_generated_content.png FAILED virus scan and was purged", + * "requestId": "46f449eb-bd37-43ef-a5a6-8d76d80b8975" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#upload_validationfilenamevirusscanimagemagickfailedincompletepassed + */ +export function upload_validation< + N extends string, + V extends string, + S extends string, +>(filename: N, vendor: V, status: S) { + return `upload_validation:${filename},${vendor},${status}` as const; +} + +/** + * When a file is deleted for normal reasons it should be recorded. + * + * **Level:** `INFO` + * + * @example + * ```ts + * const userId = "joebob1"; + * const fileId = "kjsdhkrjhwijhsiuhdf000010202002"; + * logger.info({ event: upload_delete(userId, fileId) }, `User ${userId} has marked file ${fileId} for deletion`); + * ``` + * @example + * ```json + * { + * "datetime": "2019-01-01 00:00:00,000", + * "appid": "foobar.netportal_auth", + * "event": "upload_delete:joebob1,", + * "level": "INFO", + * "description": "User joebob1 has marked file abcdefghijk101010101 for deletion", + * "requestId": "d4fc4479-210b-4d89-ae1e-4fe7b595cbb4" + * } + * ``` + * @see https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html#upload_deleteuseridfileid + */ +export function upload_delete< + U extends string | number | bigint, + F extends string | number | bigint, +>(userId: U, fileId: F) { + return `upload_delete:${userId},${fileId}` as const; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..894d522 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export * from "./authentication.js"; +export * from "./authorization.js"; +export * from "./excessive-use.js"; +export * from "./file-upload.js"; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..0f0cf55 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "target": "es2022", + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node16" + }, + "include": ["src"], + "exclude": ["**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..81ef490 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": ["bun-types"] + } +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..c9e227a --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + splitting: false, + sourcemap: true, + format: ["cjs", "esm"], + dts: true, + minify: false, + tsconfig: "./tsconfig.build.json", + clean: true, + outDir: "dist", +});