From d40d343a0f0e5786bd8860b9d3481f28d324e8e8 Mon Sep 17 00:00:00 2001 From: Nekolandd Date: Sat, 27 Jun 2026 23:48:53 -0600 Subject: [PATCH] feat(reports): add pdf report export --- apps/backend/src/app.module.ts | 2 + apps/backend/tsconfig.app.json | 8 +- package-lock.json | 370 +++++++++++++++++- package.json | 2 + .../reports/pdf/pdf-report.service.spec.ts | 62 +++ src/modules/reports/pdf/pdf-report.service.ts | 24 ++ .../reports/pdf/pdf-report.template.spec.ts | 31 ++ .../reports/pdf/pdf-report.template.ts | 67 ++++ .../reports/reports.controller.spec.ts | 62 +++ src/modules/reports/reports.controller.ts | 40 ++ src/modules/reports/reports.module.ts | 12 + 11 files changed, 674 insertions(+), 6 deletions(-) create mode 100644 src/modules/reports/pdf/pdf-report.service.spec.ts create mode 100644 src/modules/reports/pdf/pdf-report.service.ts create mode 100644 src/modules/reports/pdf/pdf-report.template.spec.ts create mode 100644 src/modules/reports/pdf/pdf-report.template.ts create mode 100644 src/modules/reports/reports.controller.spec.ts create mode 100644 src/modules/reports/reports.controller.ts create mode 100644 src/modules/reports/reports.module.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index e2c6964..f062c7e 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { ChainsModule } from './modules/chains/chains.module'; import { RiskAnalyzerModule } from './modules/soroban/risk/risk-analyzer.module'; import { NotesModule } from './modules/cases/notes/notes.module'; import { AlertsModule } from './modules/alerts/alerts.module'; +import { ReportsModule } from '../../../src/modules/reports/reports.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { AlertsModule } from './modules/alerts/alerts.module'; HealthModule, NotificationsModule, ReportingModule, + ReportsModule, DependencyTrackerModule, GovernanceModule, SiemModule, diff --git a/apps/backend/tsconfig.app.json b/apps/backend/tsconfig.app.json index bc119e3..9ac8804 100644 --- a/apps/backend/tsconfig.app.json +++ b/apps/backend/tsconfig.app.json @@ -15,6 +15,10 @@ "@integrations/*": ["../../../apps/backend/src/integrations/*"] } }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.spec.ts"] + "include": [ + "src/**/*", + "../../src/modules/reports/**/*.ts", + "../../src/modules/reports/**/*.tsx" + ], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "../../src/modules/reports/**/*.spec.ts"] } diff --git a/package-lock.json b/package-lock.json index 5c48e08..f42f5d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/react": "^19.2.17", "axios": "^1.17.0", "ethers": "^6.17.0", + "pdfkit": "^0.19.1", "pg": "^8.21.0", "react": "^19.2.7", "typeorm": "^1.0.0", @@ -38,6 +39,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/node": "^26.0.0", + "@types/pdfkit": "^0.17.6", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.50.0", @@ -2405,6 +2407,18 @@ "typeorm": "^0.3.0 || ^1.0.0-dev" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -3449,6 +3463,15 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "devOptional": true }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3726,6 +3749,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4590,6 +4623,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.37", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", @@ -4632,6 +4685,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -4933,6 +5004,15 @@ "node": ">=20" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5345,6 +5425,12 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6004,8 +6090,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -6216,6 +6301,23 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -8281,6 +8383,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8512,6 +8620,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9372,6 +9499,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9502,6 +9635,32 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "devOptional": true }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, + "node_modules/pdfkit/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==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -9710,6 +9869,14 @@ "pathe": "^2.0.3" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -10360,6 +10527,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -11051,6 +11224,12 @@ "readable-stream": "3" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -11436,6 +11615,32 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -13498,6 +13703,11 @@ "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "requires": {} }, + "@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==" + }, "@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -14195,6 +14405,14 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "devOptional": true }, + "@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "requires": { + "tslib": "^2.8.0" + } + }, "@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -14407,6 +14625,15 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -14919,6 +15146,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "baseline-browser-mapping": { "version": "2.10.37", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", @@ -14949,6 +15181,22 @@ "fill-range": "^7.1.1" } }, + "brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "requires": { + "base64-js": "^1.1.2" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "requires": { + "pako": "~1.0.5" + } + }, "browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -15129,6 +15377,11 @@ "wrap-ansi": "^9.0.0" } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -15411,6 +15664,11 @@ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true }, + "dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -15850,8 +16108,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-diff": { "version": "1.3.0", @@ -15991,6 +16248,22 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" }, + "fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "requires": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -17398,6 +17671,11 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "devOptional": true }, + "js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -17548,6 +17826,22 @@ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true }, + "linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "requires": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + }, + "dependencies": { + "base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==" + } + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -18112,6 +18406,11 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18201,6 +18500,26 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "devOptional": true }, + "pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "requires": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + }, + "dependencies": { + "@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==" + } + } + }, "perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -18349,6 +18668,14 @@ "pathe": "^2.0.3" } }, + "png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "requires": { + "browserify-zlib": "^0.2.0" + } + }, "postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -18774,6 +19101,11 @@ } } }, + "restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -19238,6 +19570,11 @@ "readable-stream": "3" } }, + "tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -19423,6 +19760,31 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" }, + "unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "requires": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "requires": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + }, + "dependencies": { + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + } + } + }, "unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", diff --git a/package.json b/package.json index df33e3d..cce9a27 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@types/react": "^19.2.17", "axios": "^1.17.0", "ethers": "^6.17.0", + "pdfkit": "^0.19.1", "pg": "^8.21.0", "react": "^19.2.7", "typeorm": "^1.0.0", @@ -90,6 +91,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/node": "^26.0.0", + "@types/pdfkit": "^0.17.6", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.50.0", diff --git a/src/modules/reports/pdf/pdf-report.service.spec.ts b/src/modules/reports/pdf/pdf-report.service.spec.ts new file mode 100644 index 0000000..bfb8b4d --- /dev/null +++ b/src/modules/reports/pdf/pdf-report.service.spec.ts @@ -0,0 +1,62 @@ +import 'reflect-metadata'; +import { SecurityReport } from '../../../../apps/backend/src/modules/reporting/interfaces/reporting.interface'; +import { PdfReportService } from './pdf-report.service'; + +const mockReport: SecurityReport = { + generatedAt: '2026-06-15T10:00:00.000Z', + periodDays: 7, + totalAlerts: 27, + severityBreakdown: { + low: 12, + medium: 8, + high: 5, + critical: 2, + }, + topChains: [ + { chain: 'Ethereum', count: 11 }, + { chain: 'Soroban', count: 9 }, + ], + resolvedAlerts: 20, + unresolvedAlerts: 7, + criticalUnresolved: 2, +}; + +describe('PdfReportService', () => { + let service: PdfReportService; + + beforeEach(() => { + service = new PdfReportService(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateSecurityReport', () => { + it('returns a PDF buffer with a valid PDF header', async () => { + const pdf = await service.generateSecurityReport(mockReport); + + expect(Buffer.isBuffer(pdf)).toBe(true); + expect(pdf.length).toBeGreaterThan(0); + expect(pdf.subarray(0, 4).toString()).toBe('%PDF'); + }); + + it('generates a larger buffer for reports with more chain data', async () => { + const smallReport = { ...mockReport, topChains: [{ chain: 'Ethereum', count: 1 }] }; + const largeReport = { + ...mockReport, + topChains: [ + { chain: 'Ethereum', count: 11 }, + { chain: 'Soroban', count: 9 }, + { chain: 'Polygon', count: 7 }, + { chain: 'Stellar', count: 5 }, + ], + }; + + const smallPdf = await service.generateSecurityReport(smallReport); + const largePdf = await service.generateSecurityReport(largeReport); + + expect(largePdf.length).toBeGreaterThanOrEqual(smallPdf.length); + }); + }); +}); diff --git a/src/modules/reports/pdf/pdf-report.service.ts b/src/modules/reports/pdf/pdf-report.service.ts new file mode 100644 index 0000000..05c6875 --- /dev/null +++ b/src/modules/reports/pdf/pdf-report.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import PDFDocument from 'pdfkit'; +import { SecurityReport } from '../../../../apps/backend/src/modules/reporting/interfaces/reporting.interface'; +import { renderSecurityReportPdfTemplate } from './pdf-report.template'; + +@Injectable() +export class PdfReportService { + /** + * Generates a downloadable PDF buffer for the given security report. + */ + generateSecurityReport(report: SecurityReport): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ margin: 50, size: 'A4' }); + const chunks: Buffer[] = []; + + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + renderSecurityReportPdfTemplate(doc, report); + doc.end(); + }); + } +} diff --git a/src/modules/reports/pdf/pdf-report.template.spec.ts b/src/modules/reports/pdf/pdf-report.template.spec.ts new file mode 100644 index 0000000..bd87bdb --- /dev/null +++ b/src/modules/reports/pdf/pdf-report.template.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import PDFDocument from 'pdfkit'; +import { SecurityReport } from '../../../../apps/backend/src/modules/reporting/interfaces/reporting.interface'; +import { renderSecurityReportPdfTemplate } from './pdf-report.template'; + +const mockReport: SecurityReport = { + generatedAt: '2026-06-15T10:00:00.000Z', + periodDays: 30, + totalAlerts: 27, + severityBreakdown: { + low: 12, + medium: 8, + high: 5, + critical: 2, + }, + topChains: [{ chain: 'Ethereum', count: 11 }], + resolvedAlerts: 20, + unresolvedAlerts: 7, + criticalUnresolved: 2, +}; + +describe('renderSecurityReportPdfTemplate', () => { + it('renders without throwing', () => { + const doc = new PDFDocument({ margin: 50, size: 'A4' }); + + expect(() => { + renderSecurityReportPdfTemplate(doc, mockReport); + doc.end(); + }).not.toThrow(); + }); +}); diff --git a/src/modules/reports/pdf/pdf-report.template.ts b/src/modules/reports/pdf/pdf-report.template.ts new file mode 100644 index 0000000..6932c5d --- /dev/null +++ b/src/modules/reports/pdf/pdf-report.template.ts @@ -0,0 +1,67 @@ +import PDFDocument from 'pdfkit'; +import { SecurityReport } from '../../../../apps/backend/src/modules/reporting/interfaces/reporting.interface'; + +const BRAND_COLOR = '#8b5cf6'; +const TEXT_MUTED = '#64748b'; + +type PdfDocumentInstance = InstanceType; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }); +} + +/** + * Renders a security findings report into a PDF document using the standard template. + */ +export function renderSecurityReportPdfTemplate( + doc: PdfDocumentInstance, + report: SecurityReport, +): void { + doc + .fontSize(22) + .fillColor(BRAND_COLOR) + .text('Sentinel Security Report', { align: 'center' }) + .moveDown(0.5); + + doc + .fontSize(10) + .fillColor(TEXT_MUTED) + .text(`Generated: ${formatDate(report.generatedAt)}`, { align: 'center' }) + .text(`Reporting period: last ${report.periodDays} days`, { align: 'center' }) + .moveDown(1.5); + + doc.fontSize(14).fillColor('#0f172a').text('Executive Summary'); + doc.moveDown(0.5); + doc.fontSize(11).fillColor('#1e293b'); + doc.text(`Total alerts: ${report.totalAlerts}`); + doc.text(`Resolved: ${report.resolvedAlerts}`); + doc.text(`Unresolved: ${report.unresolvedAlerts}`); + doc.text(`Critical unresolved: ${report.criticalUnresolved}`); + doc.moveDown(1); + + doc.fontSize(14).fillColor('#0f172a').text('Severity Breakdown'); + doc.moveDown(0.5); + doc.fontSize(11).fillColor('#1e293b'); + const { severityBreakdown } = report; + doc.text(`Low: ${severityBreakdown.low}`); + doc.text(`Medium: ${severityBreakdown.medium}`); + doc.text(`High: ${severityBreakdown.high}`); + doc.text(`Critical: ${severityBreakdown.critical}`); + doc.moveDown(1); + + doc.fontSize(14).fillColor('#0f172a').text('Top Affected Chains'); + doc.moveDown(0.5); + doc.fontSize(11).fillColor('#1e293b'); + report.topChains.forEach(({ chain, count }) => { + doc.text(`${chain}: ${count} alert(s)`); + }); + + doc.moveDown(2); + doc + .fontSize(9) + .fillColor(TEXT_MUTED) + .text('Sentinel — Security findings export', { align: 'center' }); +} diff --git a/src/modules/reports/reports.controller.spec.ts b/src/modules/reports/reports.controller.spec.ts new file mode 100644 index 0000000..fb221a8 --- /dev/null +++ b/src/modules/reports/reports.controller.spec.ts @@ -0,0 +1,62 @@ +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { StreamableFile } from '@nestjs/common'; +import { ReportingService } from '../../../apps/backend/src/modules/reporting/reporting.service'; +import { SecurityReport } from '../../../apps/backend/src/modules/reporting/interfaces/reporting.interface'; +import { ReportsController } from './reports.controller'; +import { PdfReportService } from './pdf/pdf-report.service'; + +const mockReport: SecurityReport = { + generatedAt: '2026-06-15T10:00:00.000Z', + periodDays: 7, + totalAlerts: 27, + severityBreakdown: { + low: 12, + medium: 8, + high: 5, + critical: 2, + }, + topChains: [{ chain: 'Ethereum', count: 11 }], + resolvedAlerts: 20, + unresolvedAlerts: 7, + criticalUnresolved: 2, +}; + +describe('ReportsController', () => { + let controller: ReportsController; + let reportingService: { getSecurityReport: jest.Mock }; + let pdfReportService: { generateSecurityReport: jest.Mock }; + + beforeEach(async () => { + reportingService = { + getSecurityReport: jest.fn().mockReturnValue(mockReport), + }; + pdfReportService = { + generateSecurityReport: jest.fn().mockResolvedValue(Buffer.from('%PDF-1.4 mock')), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReportsController], + providers: [ + { provide: ReportingService, useValue: reportingService }, + { provide: PdfReportService, useValue: pdfReportService }, + ], + }).compile(); + + controller = module.get(ReportsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('downloadSecurityReportPdf', () => { + it('returns a StreamableFile built from report data', async () => { + const result = await controller.downloadSecurityReportPdf(7); + + expect(result).toBeInstanceOf(StreamableFile); + expect(reportingService.getSecurityReport).toHaveBeenCalledWith(7); + expect(pdfReportService.generateSecurityReport).toHaveBeenCalledWith(mockReport); + }); + }); +}); diff --git a/src/modules/reports/reports.controller.ts b/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..b1f668f --- /dev/null +++ b/src/modules/reports/reports.controller.ts @@ -0,0 +1,40 @@ +import { + Controller, + DefaultValuePipe, + Get, + Header, + ParseIntPipe, + Query, + StreamableFile, +} from '@nestjs/common'; +import { ReportingService } from '../../../apps/backend/src/modules/reporting/reporting.service'; +import { PdfReportService } from './pdf/pdf-report.service'; + +/** + * PDF report export endpoints. + * + * GET /reports/security/pdf — download report for the last 30 days (default) + * GET /reports/security/pdf?days=7 — download report for a custom window + */ +@Controller('reports') +export class ReportsController { + constructor( + private readonly reportingService: ReportingService, + private readonly pdfReportService: PdfReportService, + ) {} + + @Get('security/pdf') + @Header('Content-Type', 'application/pdf') + async downloadSecurityReportPdf( + @Query('days', new DefaultValuePipe(30), ParseIntPipe) days: number, + ): Promise { + const report = this.reportingService.getSecurityReport(days); + const pdf = await this.pdfReportService.generateSecurityReport(report); + const filename = `sentinel-security-report-${days}d.pdf`; + + return new StreamableFile(pdf, { + type: 'application/pdf', + disposition: `attachment; filename="${filename}"`, + }); + } +} diff --git a/src/modules/reports/reports.module.ts b/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..d40895c --- /dev/null +++ b/src/modules/reports/reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ReportingModule } from '../../../apps/backend/src/modules/reporting/reporting.module'; +import { ReportsController } from './reports.controller'; +import { PdfReportService } from './pdf/pdf-report.service'; + +@Module({ + imports: [ReportingModule], + controllers: [ReportsController], + providers: [PdfReportService], + exports: [PdfReportService], +}) +export class ReportsModule {}