diff --git a/README.md b/README.md index 662dd94..2f47b2f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ Express API for the TalentTrust decentralized freelancer escrow protocol. Handles contract metadata, reputation, and integration with Stellar/Soroban. +## Incident Response Playbook + +The backend now exposes responder-ready incident runbooks for outage triage, recovery, and postmortems. + +- `GET /api/v1/incident-response` returns the available runbook summaries +- `GET /api/v1/incident-response/:runbookId` returns a full runbook +- Supported runbooks: `api-outage`, `data-integrity`, `security-breach` + +Detailed reviewer-oriented documentation lives in [docs/backend/incident-response-playbook.md](/Users/mac/Documents/github/wave/Talenttrust-Backend/docs/backend/incident-response-playbook.md). + ## Prerequisites - Node.js 18+ @@ -23,6 +33,9 @@ npm run build # Run tests npm test +# Run tests with coverage +npm test -- --coverage + # Start dev server (with hot reload) npm run dev @@ -40,6 +53,12 @@ npm start | `npm test` | Run Jest tests | | `npm run lint` | Run ESLint | +## Security Notes + +- Runbook identifiers are validated to accept only lowercase letters, numbers, and hyphens. +- Recovery guidance explicitly avoids bypassing authentication, rate limiting, and audit controls. +- Security-sensitive incidents require evidence preservation, least-privilege recovery access, and controlled communications. + ## Contributing 1. Fork the repo and create a branch from `main`. diff --git a/docs/backend/incident-response-playbook.md b/docs/backend/incident-response-playbook.md new file mode 100644 index 0000000..99ec46f --- /dev/null +++ b/docs/backend/incident-response-playbook.md @@ -0,0 +1,39 @@ +# Incident Response Playbook + +This backend exposes responder-facing incident runbooks at `GET /api/v1/incident-response` and `GET /api/v1/incident-response/:runbookId`. + +## Scope + +- Outage triage +- Recovery execution +- Postmortem follow-up +- Security constraints during incident handling + +## Runbooks + +### `api-outage` + +- Use for widespread API unavailability, elevated latency, or sustained 5xx errors. +- Prioritize blast-radius confirmation, rollback or failover, and a stable observation window before closing the incident. + +### `data-integrity` + +- Use for corruption, missing data, duplicated records, or reconciliation drift. +- Prioritize evidence preservation, write containment, verified restore paths, and dual-review for repair actions. + +### `security-breach` + +- Use for suspected compromise, unauthorized access, or credential exposure. +- Prioritize containment, credential rotation, rebuild from trusted images, and controlled communications. + +## Security Notes + +- Reject malformed runbook identifiers to avoid path-style abuse and undefined lookups. +- Keep incident artifacts and raw logs confidential because they may contain sensitive customer or operational data. +- Do not weaken authentication, rate limiting, or audit controls to speed up recovery. + +## Review Guidance + +- Runbook content is versioned with the backend so API clients and docs stay aligned. +- `lastReviewed` should be updated whenever runbook steps or security assumptions change. +- Add new runbooks through `src/incidentResponse.data.ts` and extend tests before exposing them. diff --git a/package-lock.json b/package-lock.json index 6392ca6..e698b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" @@ -55,6 +57,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -963,6 +966,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1084,6 +1110,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -1165,6 +1198,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1251,6 +1291,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1386,6 +1450,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1855,6 +1933,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1905,6 +2006,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -1983,6 +2091,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2012,6 +2130,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -2138,6 +2267,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2290,6 +2435,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2345,6 +2497,41 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2572,6 +2759,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4623,6 +4826,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 4522e40..6614da1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" diff --git a/src/app.test.ts b/src/app.test.ts new file mode 100644 index 0000000..ca38904 --- /dev/null +++ b/src/app.test.ts @@ -0,0 +1,73 @@ +import request from 'supertest'; + +import { createApp } from './app'; + +describe('app routes', () => { + const app = createApp(); + + it('returns the health payload', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + status: 'ok', + service: 'talenttrust-backend', + }); + }); + + it('returns the incident response catalog', async () => { + const response = await request(app).get('/api/v1/incident-response'); + + expect(response.status).toBe(200); + expect(response.body.count).toBe(3); + expect(response.body.runbooks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'api-outage', + title: 'API Outage Triage and Recovery', + }), + ]), + ); + }); + + it('returns the contracts placeholder payload', async () => { + const response = await request(app).get('/api/v1/contracts'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ contracts: [] }); + }); + + it('returns a detailed runbook by id', async () => { + const response = await request(app).get('/api/v1/incident-response/data-integrity'); + + expect(response.status).toBe(200); + expect(response.body.runbook).toMatchObject({ + id: 'data-integrity', + title: 'Data Integrity Incident Response', + securityNotes: expect.arrayContaining([ + 'Manual data repair requires dual review for production commands.', + ]), + }); + }); + + it('accepts normalized runbook ids in the route', async () => { + const response = await request(app).get('/api/v1/incident-response/%20SECURITY-BREACH%20'); + + expect(response.status).toBe(200); + expect(response.body.runbook.id).toBe('security-breach'); + }); + + it('returns 400 for invalid runbook ids', async () => { + const response = await request(app).get('/api/v1/incident-response/security%2Fbreach'); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/Invalid runbook id/); + }); + + it('returns 404 for unknown runbook ids', async () => { + const response = await request(app).get('/api/v1/incident-response/unknown-runbook'); + + expect(response.status).toBe(404); + expect(response.body.error).toContain('unknown-runbook'); + }); +}); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..8787521 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,24 @@ +import express, { Request, Response } from 'express'; + +import { incidentResponseRouter } from './incidentResponse.routes'; + +/** + * Creates the Express application with repository-scoped routes. + */ +export function createApp() { + const app = express(); + + app.use(express.json()); + + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', service: 'talenttrust-backend' }); + }); + + app.get('/api/v1/contracts', (_req: Request, res: Response) => { + res.json({ contracts: [] }); + }); + + app.use('/api/v1/incident-response', incidentResponseRouter); + + return app; +} diff --git a/src/health.test.ts b/src/health.test.ts deleted file mode 100644 index a9fa0e8..0000000 --- a/src/health.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('health', () => { - it('should pass', () => { - expect(true).toBe(true); - }); -}); diff --git a/src/incidentResponse.data.ts b/src/incidentResponse.data.ts new file mode 100644 index 0000000..beae78e --- /dev/null +++ b/src/incidentResponse.data.ts @@ -0,0 +1,110 @@ +import { IncidentRunbook } from './incidentResponse.types'; + +const RUNBOOKS: IncidentRunbook[] = [ + { + id: 'api-outage', + title: 'API Outage Triage and Recovery', + severity: 'critical', + outageSignals: [ + 'Elevated 5xx rates across public APIs', + 'Health check failures from multiple regions', + 'Sustained latency above service SLOs', + ], + lastReviewed: '2026-03-25', + objective: + 'Restore API availability quickly while preserving evidence for root-cause analysis.', + triage: [ + 'Confirm blast radius using health checks, logs, and infrastructure telemetry.', + 'Identify whether the issue is isolated to a dependency, deployment, or data store.', + 'Assign an incident commander and freeze unrelated production changes.', + 'Capture timestamps, failing endpoints, and error samples for the incident timeline.', + ], + recovery: [ + 'Rollback the most recent risky change if the incident correlates with a deployment.', + 'Fail over to known-good infrastructure or scale healthy replicas to stabilize traffic.', + 'Apply narrowly scoped mitigations first, then validate error and latency recovery.', + 'Announce recovery status only after service health remains stable for a full observation window.', + ], + postmortem: [ + 'Document trigger, impact window, customer impact, and detection gaps.', + 'Record what changed during mitigation, including approvals and residual risk.', + 'Create remediation items with owners and deadlines for reliability improvements.', + ], + securityNotes: [ + 'Do not disable authentication or rate limiting to restore traffic.', + 'Treat raw logs and incident artifacts as sensitive because they may contain customer metadata.', + ], + }, + { + id: 'data-integrity', + title: 'Data Integrity Incident Response', + severity: 'critical', + outageSignals: [ + 'Unexpected contract state transitions', + 'Detected checksum or reconciliation mismatches', + 'User reports of missing or duplicated records', + ], + lastReviewed: '2026-03-25', + objective: + 'Contain data corruption, prevent further writes, and restore trusted state with auditable recovery steps.', + triage: [ + 'Pause non-essential write paths that could spread corruption.', + 'Scope affected tenants, tables, and time ranges using audit trails and backups.', + 'Preserve forensic evidence before running corrective scripts or manual edits.', + 'Coordinate with application owners before replaying jobs or restoring data.', + ], + recovery: [ + 'Recover from the latest verified backup or replay trusted events into a clean target.', + 'Validate restored records against reconciliation reports before reopening writes.', + 'Use least-privilege access for all recovery scripts and record every data-change command.', + 'Monitor for repeated corruption signals after service restoration.', + ], + postmortem: [ + 'Explain how corrupted state entered the system and why controls did not stop it.', + 'List validation, backup, and rollback improvements needed to reduce recurrence.', + 'Attach evidence showing restored data matched a trusted source of truth.', + ], + securityNotes: [ + 'Manual data repair requires dual review for production commands.', + 'Never restore from unverified backups or ad hoc exports with unclear provenance.', + ], + }, + { + id: 'security-breach', + title: 'Security Breach Containment and Recovery', + severity: 'high', + outageSignals: [ + 'Suspicious authentication or privilege escalation activity', + 'Unexpected configuration changes in production', + 'Indicators of compromise from dependency or host monitoring', + ], + lastReviewed: '2026-03-25', + objective: + 'Contain suspected compromise, preserve evidence, and restore trusted operations without expanding attacker access.', + triage: [ + 'Activate the security incident commander and restrict production access to responders.', + 'Isolate affected credentials, hosts, workloads, or tokens without destroying forensic evidence.', + 'Assess whether customer data, secrets, or signing keys may have been exposed.', + 'Escalate legal and stakeholder communications based on confirmed scope, not speculation.', + ], + recovery: [ + 'Rotate compromised secrets, revoke sessions, and rebuild affected systems from trusted images.', + 'Patch exploited vulnerabilities and validate security controls before reconnecting isolated systems.', + 'Restore service access in phases while monitoring for re-entry or persistence.', + 'Retain immutable evidence copies for post-incident investigation and compliance follow-up.', + ], + postmortem: [ + 'Document attack path, detection timeline, affected assets, and containment delays.', + 'Track follow-up work for hardening, monitoring, and credential management.', + 'Confirm customer notification and regulatory obligations were evaluated and completed.', + ], + securityNotes: [ + 'Do not disclose unverified breach details in public channels.', + 'Recovered systems must come from a trusted rebuild path, not in-place cleanup alone.', + ], + }, +]; + +export function getIncidentRunbookData(): readonly IncidentRunbook[] { + return RUNBOOKS; +} diff --git a/src/incidentResponse.routes.ts b/src/incidentResponse.routes.ts new file mode 100644 index 0000000..8f23e9d --- /dev/null +++ b/src/incidentResponse.routes.ts @@ -0,0 +1,38 @@ +import { Router, Request, Response } from 'express'; + +import { + getIncidentRunbookById, + isValidRunbookId, + listIncidentRunbooks, +} from './incidentResponse.service'; + +export const incidentResponseRouter = Router(); + +incidentResponseRouter.get('/', (_req: Request, res: Response) => { + const runbooks = listIncidentRunbooks(); + + res.json({ + runbooks, + count: runbooks.length, + }); +}); + +incidentResponseRouter.get('/:runbookId', (req: Request, res: Response) => { + const normalizedRunbookId = req.params.runbookId.trim().toLowerCase(); + + if (!isValidRunbookId(normalizedRunbookId)) { + return res.status(400).json({ + error: 'Invalid runbook id. Use lowercase letters, numbers, and hyphens only.', + }); + } + + const runbook = getIncidentRunbookById(normalizedRunbookId); + + if (!runbook) { + return res.status(404).json({ + error: `Runbook '${normalizedRunbookId}' was not found.`, + }); + } + + return res.json({ runbook }); +}); diff --git a/src/incidentResponse.service.test.ts b/src/incidentResponse.service.test.ts new file mode 100644 index 0000000..bef9186 --- /dev/null +++ b/src/incidentResponse.service.test.ts @@ -0,0 +1,59 @@ +import { + getIncidentRunbookById, + isValidRunbookId, + listIncidentRunbooks, +} from './incidentResponse.service'; + +describe('incidentResponse.service', () => { + it('lists the supported runbook summaries', () => { + const runbooks = listIncidentRunbooks(); + + expect(runbooks).toHaveLength(3); + expect(runbooks[0]).toEqual({ + id: 'api-outage', + title: 'API Outage Triage and Recovery', + severity: 'critical', + outageSignals: expect.arrayContaining(['Elevated 5xx rates across public APIs']), + lastReviewed: '2026-03-25', + }); + }); + + it('returns a defensive copy for summaries', () => { + const [first] = listIncidentRunbooks(); + first.outageSignals.push('tampered'); + + const [freshCopy] = listIncidentRunbooks(); + expect(freshCopy.outageSignals).not.toContain('tampered'); + }); + + it('finds a runbook by id using normalized input', () => { + const runbook = getIncidentRunbookById(' SECURITY-BREACH '); + + expect(runbook).toMatchObject({ + id: 'security-breach', + title: 'Security Breach Containment and Recovery', + }); + expect(runbook?.triage.length).toBeGreaterThan(0); + expect(runbook?.recovery.length).toBeGreaterThan(0); + expect(runbook?.postmortem.length).toBeGreaterThan(0); + }); + + it('returns a defensive copy for full runbooks', () => { + const runbook = getIncidentRunbookById('api-outage'); + runbook?.triage.push('tampered'); + + const freshCopy = getIncidentRunbookById('api-outage'); + expect(freshCopy?.triage).not.toContain('tampered'); + }); + + it('rejects invalid runbook ids', () => { + expect(isValidRunbookId('api-outage')).toBe(true); + expect(isValidRunbookId('../etc/passwd')).toBe(false); + expect(isValidRunbookId('UPPERCASE')).toBe(false); + expect(getIncidentRunbookById('../etc/passwd')).toBeNull(); + }); + + it('returns null for a valid but unknown runbook id', () => { + expect(getIncidentRunbookById('missing-runbook')).toBeNull(); + }); +}); diff --git a/src/incidentResponse.service.ts b/src/incidentResponse.service.ts new file mode 100644 index 0000000..ac2f7fb --- /dev/null +++ b/src/incidentResponse.service.ts @@ -0,0 +1,44 @@ +import { getIncidentRunbookData } from './incidentResponse.data'; +import { IncidentRunbook, IncidentRunbookSummary } from './incidentResponse.types'; + +const RUNBOOK_ID_PATTERN = /^[a-z0-9-]{1,64}$/; + +function cloneRunbook(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * Validates runbook identifiers before they are used for lookup. + */ +export function isValidRunbookId(runbookId: string): boolean { + return RUNBOOK_ID_PATTERN.test(runbookId); +} + +/** + * Returns catalog metadata for all incident runbooks. + */ +export function listIncidentRunbooks(): IncidentRunbookSummary[] { + return getIncidentRunbookData().map(({ id, title, severity, outageSignals, lastReviewed }) => + cloneRunbook({ + id, + title, + severity, + outageSignals, + lastReviewed, + }), + ); +} + +/** + * Looks up a runbook by identifier and returns a defensive copy. + */ +export function getIncidentRunbookById(runbookId: string): IncidentRunbook | null { + const normalizedRunbookId = runbookId.trim().toLowerCase(); + + if (!isValidRunbookId(normalizedRunbookId)) { + return null; + } + + const runbook = getIncidentRunbookData().find(({ id }) => id === normalizedRunbookId); + return runbook ? cloneRunbook(runbook) : null; +} diff --git a/src/incidentResponse.types.ts b/src/incidentResponse.types.ts new file mode 100644 index 0000000..9c2de90 --- /dev/null +++ b/src/incidentResponse.types.ts @@ -0,0 +1,21 @@ +/** + * Summary view returned by the incident response catalog endpoint. + */ +export interface IncidentRunbookSummary { + id: string; + title: string; + severity: 'high' | 'critical'; + outageSignals: string[]; + lastReviewed: string; +} + +/** + * Full runbook definition exposed to responders. + */ +export interface IncidentRunbook extends IncidentRunbookSummary { + objective: string; + triage: string[]; + recovery: string[]; + postmortem: string[]; + securityNotes: string[]; +} diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..3078250 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,48 @@ +import request from 'supertest'; + +describe('index server bootstrap', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + process.env.NODE_ENV = 'test'; + jest.resetModules(); + }); + + afterAll(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it('exports an app instance without auto-starting in test mode', async () => { + const { app } = await import('./index'); + + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + }); + + it('starts and stops the server on an ephemeral port', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const { startServer } = await import('./index'); + + const server = startServer(0); + + await new Promise((resolve, reject) => { + server.on('listening', resolve); + server.on('error', reject); + }); + + expect(server.address()).not.toBeNull(); + + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/index.ts b/src/index.ts index dd2fd8f..c709644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,19 @@ -import express, { Request, Response } from 'express'; +import { Server } from 'http'; -const app = express(); -const PORT = process.env.PORT || 3001; +import { createApp } from './app'; -app.use(express.json()); +export const app = createApp(); -app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', service: 'talenttrust-backend' }); -}); +/** + * Starts the API server on the configured port. + */ +export function startServer(port: number | string = process.env.PORT || 3001): Server { + return app.listen(port, () => { + console.log(`TalentTrust API listening on http://localhost:${port}`); + }); +} -app.get('/api/v1/contracts', (_req: Request, res: Response) => { - res.json({ contracts: [] }); -}); - -app.listen(PORT, () => { - console.log(`TalentTrust API listening on http://localhost:${PORT}`); -}); +/* istanbul ignore next */ +if (process.env.NODE_ENV !== 'test') { + startServer(); +}