From 34afc8879c5d6a740e67d588c5cd21c3fab14b37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 04:23:14 +0000 Subject: [PATCH 01/30] chore(deps-dev): bump vite Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- .../frontend/package.json | 2 +- .../frontend/pnpm-lock.yaml | 529 +++++++++--------- 2 files changed, 262 insertions(+), 269 deletions(-) diff --git a/PROJECTS/intermediate/api-security-scanner/frontend/package.json b/PROJECTS/intermediate/api-security-scanner/frontend/package.json index c4cefe48..e6cc263f 100644 --- a/PROJECTS/intermediate/api-security-scanner/frontend/package.json +++ b/PROJECTS/intermediate/api-security-scanner/frontend/package.json @@ -63,7 +63,7 @@ "stylelint-config-standard-scss": "^17.0.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.0", - "vite": "^7.3.1", + "vite": "^7.3.2", "vite-tsconfig-paths": "^6.1.1" } } diff --git a/PROJECTS/intermediate/api-security-scanner/frontend/pnpm-lock.yaml b/PROJECTS/intermediate/api-security-scanner/frontend/pnpm-lock.yaml index fbe39cc1..529b7a29 100644 --- a/PROJECTS/intermediate/api-security-scanner/frontend/pnpm-lock.yaml +++ b/PROJECTS/intermediate/api-security-scanner/frontend/pnpm-lock.yaml @@ -95,7 +95,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1) + version: 5.1.4(vite@7.3.2) eslint: specifier: ^9.39.2 version: 9.39.2 @@ -131,7 +131,7 @@ importers: version: 1.0.0(stylelint@17.3.0(typescript@5.9.3)) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)) + version: 17.0.0(postcss@8.5.9)(stylelint@17.3.0(typescript@5.9.3)) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -139,11 +139,11 @@ importers: specifier: ^8.56.0 version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) vite: - specifier: ^7.3.1 - version: 7.3.1 + specifier: ^7.3.2 + version: 7.3.2 vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1) + version: 6.1.1(typescript@5.9.3)(vite@7.3.2) packages: @@ -252,28 +252,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.2': resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.2': resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.2': resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.2': resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} @@ -332,158 +328,158 @@ packages: peerDependencies: postcss-selector-parser: ^7.1.1 - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -957,141 +953,128 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} cpu: [x64] os: [win32] @@ -1664,8 +1647,8 @@ packages: es-toolkit@1.44.0: resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -2463,12 +2446,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} possible-typed-array-names@1.1.0: @@ -2504,6 +2487,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2675,8 +2662,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2911,8 +2898,8 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} to-regex-range@5.0.1: @@ -3053,8 +3040,8 @@ packages: peerDependencies: vite: '*' - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3366,82 +3353,82 @@ snapshots: dependencies: postcss-selector-parser: 7.1.1 - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.27.7': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': @@ -3919,79 +3906,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.57.1': + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true - '@rollup/rollup-android-arm64@4.57.1': + '@rollup/rollup-android-arm64@4.60.1': optional: true - '@rollup/rollup-darwin-arm64@4.57.1': + '@rollup/rollup-darwin-arm64@4.60.1': optional: true - '@rollup/rollup-darwin-x64@4.57.1': + '@rollup/rollup-darwin-x64@4.60.1': optional: true - '@rollup/rollup-freebsd-arm64@4.57.1': + '@rollup/rollup-freebsd-arm64@4.60.1': optional: true - '@rollup/rollup-freebsd-x64@4.57.1': + '@rollup/rollup-freebsd-x64@4.60.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.57.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.57.1': + '@rollup/rollup-linux-arm64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.57.1': + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.57.1': + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.57.1': + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.57.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.57.1': + '@rollup/rollup-linux-ppc64-musl@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.57.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.57.1': + '@rollup/rollup-linux-riscv64-musl@4.60.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.57.1': + '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.57.1': + '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-musl@4.57.1': + '@rollup/rollup-linux-x64-musl@4.60.1': optional: true - '@rollup/rollup-openbsd-x64@4.57.1': + '@rollup/rollup-openbsd-x64@4.60.1': optional: true - '@rollup/rollup-openharmony-arm64@4.57.1': + '@rollup/rollup-openharmony-arm64@4.60.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.57.1': + '@rollup/rollup-win32-arm64-msvc@4.60.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.57.1': + '@rollup/rollup-win32-ia32-msvc@4.60.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.57.1': + '@rollup/rollup-win32-x64-gnu@4.60.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.57.1': + '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true '@sindresorhus/merge-streams@4.0.0': {} @@ -4167,7 +4154,7 @@ snapshots: debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -4191,7 +4178,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1)': + '@vitejs/plugin-react@5.1.4(vite@7.3.2)': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -4199,7 +4186,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1 + vite: 7.3.2 transitivePeerDependencies: - supports-color @@ -4674,34 +4661,34 @@ snapshots: es-toolkit@1.44.0: {} - esbuild@0.27.3: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -4863,9 +4850,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@11.1.2: dependencies: @@ -5545,7 +5532,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -5666,9 +5653,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} possible-typed-array-names@1.1.0: {} @@ -5680,9 +5667,9 @@ snapshots: dependencies: postcss: 8.5.6 - postcss-scss@4.0.9(postcss@8.5.6): + postcss-scss@4.0.9(postcss@8.5.9): dependencies: - postcss: 8.5.6 + postcss: 8.5.9 postcss-selector-parser@7.1.1: dependencies: @@ -5697,6 +5684,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -5886,35 +5879,35 @@ snapshots: reusify@1.1.0: {} - rollup@4.57.1: + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 run-parallel@1.2.0: @@ -6134,26 +6127,26 @@ snapshots: dependencies: stylelint: 17.3.0(typescript@5.9.3) - stylelint-config-recommended-scss@17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)): + stylelint-config-recommended-scss@17.0.0(postcss@8.5.9)(stylelint@17.3.0(typescript@5.9.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.6) + postcss-scss: 4.0.9(postcss@8.5.9) stylelint: 17.3.0(typescript@5.9.3) stylelint-config-recommended: 18.0.0(stylelint@17.3.0(typescript@5.9.3)) stylelint-scss: 7.0.0(stylelint@17.3.0(typescript@5.9.3)) optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.9 stylelint-config-recommended@18.0.0(stylelint@17.3.0(typescript@5.9.3)): dependencies: stylelint: 17.3.0(typescript@5.9.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.9)(stylelint@17.3.0(typescript@5.9.3)): dependencies: stylelint: 17.3.0(typescript@5.9.3) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.9)(stylelint@17.3.0(typescript@5.9.3)) stylelint-config-standard: 40.0.0(stylelint@17.3.0(typescript@5.9.3)) optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.9 stylelint-config-standard@40.0.0(stylelint@17.3.0(typescript@5.9.3)): dependencies: @@ -6242,10 +6235,10 @@ snapshots: tiny-invariant@1.3.3: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 to-regex-range@5.0.1: dependencies: @@ -6415,24 +6408,24 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.2): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.1 + vite: 7.3.2 transitivePeerDependencies: - supports-color - typescript - vite@7.3.1: + vite@7.3.2: dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 optionalDependencies: fsevents: 2.3.3 From 7c57aa4d2f2b1d2ac0e72bfc3ed44a86c49edb84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:02:55 +0000 Subject: [PATCH 02/30] chore(deps): bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp Bumps [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp](https://github.com/open-telemetry/opentelemetry-go) from 1.39.0 to 1.43.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.39.0...v1.43.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp dependency-version: 1.43.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../intermediate/docker-security-audit/go.mod | 14 ++-- .../intermediate/docker-security-audit/go.sum | 64 +++++++++---------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/PROJECTS/intermediate/docker-security-audit/go.mod b/PROJECTS/intermediate/docker-security-audit/go.mod index 7ab66b08..3fd2189a 100644 --- a/PROJECTS/intermediate/docker-security-audit/go.mod +++ b/PROJECTS/intermediate/docker-security-audit/go.mod @@ -7,7 +7,7 @@ require ( github.com/moby/buildkit v0.28.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -38,11 +38,13 @@ require ( github.com/spf13/pflag v1.0.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/sys v0.41.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/sys v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/PROJECTS/intermediate/docker-security-audit/go.sum b/PROJECTS/intermediate/docker-security-audit/go.sum index 216a5f31..a43df7b4 100644 --- a/PROJECTS/intermediate/docker-security-audit/go.sum +++ b/PROJECTS/intermediate/docker-security-audit/go.sum @@ -38,8 +38,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -87,22 +87,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -113,22 +113,22 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -139,12 +139,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 5b6457797cc9a7ab2d5407a63f757c0a70cd1044 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:16:02 +0000 Subject: [PATCH 03/30] chore(deps): bump cryptography Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.7. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.7 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../encrypted-p2p-chat/backend/uv.lock | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock b/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock index cefb292d..45e06df1 100644 --- a/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock +++ b/PROJECTS/advanced/encrypted-p2p-chat/backend/uv.lock @@ -353,55 +353,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] From 736d81e50e363159ef4555dee8f6fc4660130f64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:32:27 +0000 Subject: [PATCH 04/30] chore(deps): bump cryptography in /PROJECTS/advanced/api-rate-limiter Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.7. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- PROJECTS/advanced/api-rate-limiter/uv.lock | 88 +++++++++++----------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/PROJECTS/advanced/api-rate-limiter/uv.lock b/PROJECTS/advanced/api-rate-limiter/uv.lock index 10b9e6ab..5927d289 100644 --- a/PROJECTS/advanced/api-rate-limiter/uv.lock +++ b/PROJECTS/advanced/api-rate-limiter/uv.lock @@ -166,55 +166,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] From 8517b473bb9faf12d8dffe1ef68ee344232a80d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:37:17 +0000 Subject: [PATCH 05/30] chore(deps): bump cryptography Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.3 to 46.0.7. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../bug-bounty-platform/backend/uv.lock | 91 +++++++++---------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/PROJECTS/advanced/bug-bounty-platform/backend/uv.lock b/PROJECTS/advanced/bug-bounty-platform/backend/uv.lock index f38f4d8b..dfa09d1b 100644 --- a/PROJECTS/advanced/bug-bounty-platform/backend/uv.lock +++ b/PROJECTS/advanced/bug-bounty-platform/backend/uv.lock @@ -412,58 +412,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] From d277d50a93beeb4482e63c38582e9cb1220e9730 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 8 Apr 2026 23:53:40 -0400 Subject: [PATCH 06/30] feat: sbom generator & vulnerability matcher + docstrings across 6 projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bomber CLI tool (Go) — scans dependencies across Go/Node/Python ecosystems, generates SPDX 2.3 and CycloneDX 1.5 SBOMs, and matches against OSV/NVD vulnerability databases with policy engine for CI/CD. Add file-level docstrings to ai-threat-detection, firewall-rule-engine, hash-cracker, linux-cis-hardening-auditor, binary-analysis-tool, and credential-enumeration. --- .../ai-threat-detection/backend/alembic.ini | 10 + .../backend/alembic/env.py | 15 + .../backend/app/__main__.py | 9 + .../backend/app/api/__init__.py | 3 + .../backend/app/api/deps.py | 14 + .../backend/app/api/health.py | 15 + .../backend/app/api/ingest.py | 15 + .../backend/app/api/models_api.py | 22 + .../backend/app/api/stats.py | 13 + .../backend/app/api/threats.py | 17 + .../backend/app/api/websocket.py | 16 + .../ai-threat-detection/backend/app/config.py | 21 + .../backend/app/core/__init__.py | 3 + .../backend/app/core/alerts/__init__.py | 3 + .../backend/app/core/alerts/dispatcher.py | 20 + .../backend/app/core/detection/__init__.py | 3 + .../backend/app/core/detection/ensemble.py | 21 + .../backend/app/core/detection/inference.py | 21 + .../backend/app/core/detection/rules.py | 26 + .../backend/app/core/enrichment/__init__.py | 3 + .../backend/app/core/enrichment/geoip.py | 18 + .../backend/app/core/features/__init__.py | 3 + .../backend/app/core/features/aggregator.py | 21 + .../backend/app/core/features/encoder.py | 17 + .../backend/app/core/features/extractor.py | 25 + .../backend/app/core/features/mappings.py | 19 + .../backend/app/core/features/patterns.py | 26 + .../backend/app/core/features/signatures.py | 17 + .../backend/app/core/ingestion/__init__.py | 3 + .../backend/app/core/ingestion/parsers.py | 22 + .../backend/app/core/ingestion/pipeline.py | 28 + .../backend/app/core/ingestion/tailer.py | 20 + .../backend/app/core/redis_manager.py | 15 + .../backend/app/factory.py | 30 + .../ai-threat-detection/backend/app/main.py | 5 + .../backend/app/models/__init__.py | 3 + .../backend/app/models/base.py | 12 + .../backend/app/models/model_metadata.py | 15 + .../backend/app/models/threat_event.py | 19 + .../backend/app/schemas/__init__.py | 3 + .../backend/app/schemas/stats.py | 12 + .../backend/app/schemas/threats.py | 15 + .../backend/app/schemas/websocket.py | 12 + .../backend/app/services/__init__.py | 3 + .../backend/app/services/stats_service.py | 16 + .../backend/app/services/threat_service.py | 23 + .../backend/cli/__init__.py | 3 + .../ai-threat-detection/backend/cli/main.py | 23 + .../backend/ml/__init__.py | 3 + .../backend/ml/autoencoder.py | 16 + .../backend/ml/data_loader.py | 22 + .../backend/ml/download_csic.py | 16 + .../backend/ml/experiment.py | 13 + .../backend/ml/export_onnx.py | 16 + .../backend/ml/metadata.py | 16 + .../backend/ml/orchestrator.py | 26 + .../ai-threat-detection/backend/ml/scaler.py | 21 + .../backend/ml/splitting.py | 17 + .../backend/ml/synthetic.py | 25 + .../backend/ml/train_autoencoder.py | 21 + .../backend/ml/train_classifiers.py | 18 + .../backend/ml/validation.py | 22 + .../backend/pyproject.toml | 14 + .../backend/tests/__init__.py | 13 + .../backend/tests/conftest.py | 17 +- .../backend/tests/test_api.py | 18 +- .../backend/tests/test_autoencoder.py | 16 +- .../backend/tests/test_cli.py | 14 + .../backend/tests/test_config_ml.py | 12 +- .../backend/tests/test_data_loader.py | 17 + .../backend/tests/test_detection.py | 20 +- .../backend/tests/test_ensemble.py | 18 +- .../backend/tests/test_experiment.py | 14 +- .../backend/tests/test_export_onnx.py | 17 +- .../backend/tests/test_features.py | 26 +- .../backend/tests/test_geoip.py | 14 +- .../backend/tests/test_inference.py | 18 +- .../backend/tests/test_integration.py | 19 +- .../backend/tests/test_metadata.py | 18 + .../backend/tests/test_ml_integration.py | 16 + .../backend/tests/test_orchestrator.py | 14 + .../backend/tests/test_parsers.py | 14 +- .../backend/tests/test_pipeline.py | 16 +- .../backend/tests/test_scaler.py | 16 +- .../backend/tests/test_splitting.py | 14 + .../backend/tests/test_synthetic.py | 18 + .../backend/tests/test_training.py | 19 +- .../backend/tests/test_training_e2e.py | 18 + .../backend/tests/test_validation.py | 16 + .../advanced/ai-threat-detection/compose.yml | 17 +- .../ai-threat-detection/dev-log/Dockerfile | 7 + .../ai-threat-detection/dev-log/app.py | 19 + .../ai-threat-detection/dev-log/compose.yml | 10 + .../ai-threat-detection/dev-log/nginx.conf | 10 + .../ai-threat-detection/dev-log/simulate.py | 20 + .../ai-threat-detection/dev.compose.yml | 13 + .../ai-threat-detection/frontend/src/App.tsx | 8 + .../frontend/src/api/hooks/index.ts | 7 + .../frontend/src/api/hooks/useAlerts.ts | 15 + .../frontend/src/api/hooks/useModels.ts | 11 + .../frontend/src/api/hooks/useStats.ts | 10 + .../frontend/src/api/hooks/useThreats.ts | 12 + .../frontend/src/api/index.ts | 7 + .../frontend/src/api/types/index.ts | 9 + .../frontend/src/api/types/models.types.ts | 11 + .../frontend/src/api/types/stats.types.ts | 11 + .../frontend/src/api/types/threats.types.ts | 13 + .../frontend/src/api/types/websocket.types.ts | 11 + .../src/components/alert-feed.module.scss | 12 + .../frontend/src/components/alert-feed.tsx | 15 + .../frontend/src/components/index.tsx | 8 + .../src/components/method-badge.module.scss | 8 + .../frontend/src/components/method-badge.tsx | 8 + .../src/components/severity-badge.module.scss | 10 + .../src/components/severity-badge.tsx | 9 + .../src/components/stat-card.module.scss | 8 + .../frontend/src/components/stat-card.tsx | 8 + .../src/components/threat-detail.module.scss | 11 + .../frontend/src/components/threat-detail.tsx | 13 + .../frontend/src/config.ts | 11 + .../frontend/src/core/api/api.config.ts | 8 + .../frontend/src/core/api/errors.ts | 12 + .../frontend/src/core/api/index.ts | 6 + .../frontend/src/core/api/query.config.ts | 12 + .../frontend/src/core/app/routers.tsx | 8 + .../frontend/src/core/app/shell.module.scss | 14 + .../frontend/src/core/app/shell.tsx | 12 + .../frontend/src/core/app/toast.module.scss | 12 + .../ai-threat-detection/frontend/src/main.tsx | 6 + .../src/pages/dashboard/dashboard.module.scss | 12 + .../frontend/src/pages/dashboard/index.tsx | 15 + .../frontend/src/pages/models/index.tsx | 14 + .../src/pages/models/models.module.scss | 12 + .../frontend/src/pages/threats/index.tsx | 16 + .../src/pages/threats/threats.module.scss | 11 + .../frontend/src/styles.scss | 10 + .../frontend/src/styles/_fonts.scss | 8 + .../frontend/src/styles/_index.scss | 6 + .../frontend/src/styles/_mixins.scss | 13 + .../frontend/src/styles/_reset.scss | 15 + .../frontend/src/styles/_tokens.scss | 15 + .../frontend/vite.config.ts | 13 + .../infra/docker/entrypoint.sh | 12 + .../infra/docker/fastapi.dev | 10 + .../infra/docker/fastapi.prod | 11 + .../ai-threat-detection/infra/docker/vite.dev | 12 +- .../infra/docker/vite.prod | 12 +- .../infra/nginx/vigil.conf | 13 + .../infra/redis/redis.conf | 15 + .../src/analyzer/analyzer_test.v | 34 +- .../src/analyzer/conflict.v | 30 +- .../src/analyzer/optimizer.v | 30 +- .../firewall-rule-engine/src/config/config.v | 32 +- .../src/display/display.v | 31 +- .../src/generator/generator.v | 27 +- .../src/generator/generator_test.v | 25 +- .../beginner/firewall-rule-engine/src/main.v | 31 +- .../firewall-rule-engine/src/models/models.v | 44 +- .../firewall-rule-engine/src/parser/common.v | 33 +- .../src/parser/iptables.v | 28 +- .../src/parser/nftables.v | 28 +- .../src/parser/parser_test.v | 30 +- PROJECTS/beginner/hash-cracker/main.cpp | 32 +- .../src/attack/BruteForceAttack.cpp | 26 +- .../src/attack/BruteForceAttack.hpp | 13 +- .../src/attack/DictionaryAttack.cpp | 27 +- .../src/attack/DictionaryAttack.hpp | 15 +- .../hash-cracker/src/attack/RuleAttack.cpp | 27 +- .../hash-cracker/src/attack/RuleAttack.hpp | 18 +- .../hash-cracker/src/config/Config.hpp | 33 +- .../hash-cracker/src/core/Concepts.hpp | 32 +- .../beginner/hash-cracker/src/core/Engine.hpp | 32 +- .../hash-cracker/src/display/Progress.cpp | 31 +- .../hash-cracker/src/display/Progress.hpp | 13 +- .../hash-cracker/src/hash/EVPHasher.hpp | 33 +- .../hash-cracker/src/hash/HashDetector.cpp | 22 +- .../hash-cracker/src/hash/HashDetector.hpp | 13 +- .../hash-cracker/src/hash/MD5Hasher.hpp | 11 +- .../hash-cracker/src/hash/SHA1Hasher.hpp | 11 +- .../hash-cracker/src/hash/SHA256Hasher.hpp | 11 +- .../hash-cracker/src/hash/SHA512Hasher.hpp | 11 +- .../hash-cracker/src/io/MappedFile.cpp | 24 +- .../hash-cracker/src/io/MappedFile.hpp | 13 +- .../hash-cracker/src/rules/RuleSet.cpp | 34 +- .../hash-cracker/src/rules/RuleSet.hpp | 13 +- .../hash-cracker/src/threading/ThreadPool.cpp | 19 +- .../hash-cracker/src/threading/ThreadPool.hpp | 26 +- .../tests/test_bruteforce_attack.cpp | 16 +- .../tests/test_dictionary_attack.cpp | 17 +- .../hash-cracker/tests/test_engine.cpp | 19 +- .../hash-cracker/tests/test_hash_detector.cpp | 16 +- .../hash-cracker/tests/test_hashers.cpp | 19 +- .../hash-cracker/tests/test_rules.cpp | 20 +- .../src/checks/01_initial_setup.sh | 18 + .../src/checks/02_services.sh | 18 + .../src/checks/03_network.sh | 18 + .../src/checks/04_logging.sh | 19 + .../src/checks/05_access.sh | 20 + .../src/checks/05_access_password.sh | 15 + .../src/checks/06_maintenance.sh | 16 + .../src/cisaudit.sh | 22 + .../src/controls/registry_data.sh | 19 + .../src/lib/baseline.sh | 16 + .../src/lib/constants.sh | 13 + .../src/lib/engine.sh | 14 + .../src/lib/registry.sh | 17 + .../src/lib/report_html.sh | 18 + .../src/lib/report_json.sh | 19 + .../src/lib/report_terminal.sh | 18 + .../src/lib/utils.sh | 16 + .../tests/test_01_initial_setup.sh | 14 + .../tests/test_02_services.sh | 14 + .../tests/test_03_network.sh | 15 + .../tests/test_04_logging.sh | 15 + .../tests/test_05_access.sh | 15 + .../tests/test_05_access_password.sh | 15 + .../tests/test_06_maintenance.sh | 14 + .../tests/test_baseline.sh | 16 + .../tests/test_engine.sh | 14 + .../tests/test_helpers.sh | 17 + .../tests/test_report_json.sh | 14 + .../tests/test_runner.sh | 17 + .../crates/axumortem-engine/src/context.rs | 21 + .../crates/axumortem-engine/src/error.rs | 12 + .../axumortem-engine/src/formats/elf.rs | 24 + .../axumortem-engine/src/formats/macho.rs | 25 + .../axumortem-engine/src/formats/mod.rs | 26 + .../crates/axumortem-engine/src/formats/pe.rs | 24 + .../crates/axumortem-engine/src/lib.rs | 23 + .../crates/axumortem-engine/src/pass.rs | 19 + .../axumortem-engine/src/passes/disasm.rs | 40 ++ .../axumortem-engine/src/passes/entropy.rs | 35 + .../axumortem-engine/src/passes/format.rs | 17 + .../axumortem-engine/src/passes/imports.rs | 37 + .../crates/axumortem-engine/src/passes/mod.rs | 11 + .../axumortem-engine/src/passes/strings.rs | 40 ++ .../axumortem-engine/src/passes/threat.rs | 56 ++ .../crates/axumortem-engine/src/types.rs | 16 + .../crates/axumortem-engine/src/yara.rs | 21 + .../axumortem-engine/tests/integration.rs | 19 + .../backend/crates/axumortem/src/config.rs | 15 + .../backend/crates/axumortem/src/db/mod.rs | 11 + .../backend/crates/axumortem/src/db/models.rs | 16 + .../crates/axumortem/src/db/queries.rs | 18 + .../backend/crates/axumortem/src/error.rs | 15 + .../backend/crates/axumortem/src/main.rs | 19 + .../crates/axumortem/src/middleware/cors.rs | 12 + .../crates/axumortem/src/middleware/mod.rs | 5 + .../crates/axumortem/src/routes/analysis.rs | 14 + .../crates/axumortem/src/routes/health.rs | 10 + .../crates/axumortem/src/routes/mod.rs | 13 + .../crates/axumortem/src/routes/upload.rs | 23 + .../backend/crates/axumortem/src/state.rs | 12 + .../binary-analysis-tool/frontend/src/App.tsx | 9 + .../frontend/src/api/hooks/index.ts | 22 + .../frontend/src/api/index.ts | 3 + .../frontend/src/api/schemas.ts | 32 + .../frontend/src/api/types/index.ts | 30 + .../frontend/src/config.ts | 15 + .../frontend/src/core/api/api.config.ts | 7 + .../frontend/src/core/api/errors.ts | 14 + .../frontend/src/core/api/index.ts | 3 + .../frontend/src/core/api/query.config.ts | 11 + .../frontend/src/core/app/routers.tsx | 16 + .../frontend/src/core/app/shell.tsx | 18 + .../frontend/src/core/lib/format.ts | 16 + .../frontend/src/core/lib/index.ts | 3 + .../frontend/src/core/lib/shell.ui.store.ts | 17 + .../frontend/src/main.tsx | 8 + .../frontend/src/pages/analysis/index.tsx | 35 + .../src/pages/analysis/tab-disassembly.tsx | 32 + .../src/pages/analysis/tab-entropy.tsx | 26 + .../src/pages/analysis/tab-headers.tsx | 25 + .../src/pages/analysis/tab-imports.tsx | 24 + .../src/pages/analysis/tab-overview.tsx | 23 + .../src/pages/analysis/tab-strings.tsx | 26 + .../frontend/src/pages/landing/index.tsx | 25 + .../frontend/vite.config.ts | 22 +- .../src/collectors/apptoken.nim | 356 ++++++---- .../src/collectors/base.nim | 44 +- .../src/collectors/browser.nim | 50 +- .../src/collectors/cloud.nim | 112 +-- .../src/collectors/git.nim | 80 ++- .../src/collectors/history.nim | 113 +-- .../src/collectors/keyring.nim | 122 ++-- .../src/collectors/ssh.nim | 99 ++- .../credential-enumeration/src/config.nim | 91 ++- .../credential-enumeration/src/harvester.nim | 31 +- .../src/output/json.nim | 22 + .../src/output/terminal.nim | 74 +- .../credential-enumeration/src/runner.nim | 24 +- .../credential-enumeration/src/types.nim | 19 + .../tests/docker/validate.sh | 24 + .../credential-enumeration/tests/test_all.nim | 31 + .../.gitignore | 7 + .../.golangci.yml | 31 + .../Justfile | 107 +++ .../LICENSE | 661 ++++++++++++++++++ .../README.md | 147 ++++ .../cmd/bomber/main.go | 10 + .../go.mod | 29 + .../go.sum | 76 ++ .../install.sh | 188 +++++ .../internal/cli/check.go | 85 +++ .../internal/cli/generate.go | 86 +++ .../internal/cli/root.go | 97 +++ .../internal/cli/scan.go | 46 ++ .../internal/cli/vuln.go | 189 +++++ .../internal/config/config.go | 30 + .../internal/graph/graph.go | 111 +++ .../internal/graph/graph_test.go | 101 +++ .../internal/parser/gomod.go | 227 ++++++ .../internal/parser/gomod_test.go | 70 ++ .../internal/parser/node.go | 209 ++++++ .../internal/parser/node_test.go | 89 +++ .../internal/parser/parser.go | 12 + .../internal/parser/python.go | 194 +++++ .../internal/parser/python_test.go | 100 +++ .../internal/parser/registry.go | 36 + .../internal/parser/registry_test.go | 48 ++ .../internal/policy/engine.go | 70 ++ .../internal/policy/engine_test.go | 144 ++++ .../internal/policy/rules.go | 33 + .../internal/report/json.go | 34 + .../internal/report/terminal.go | 120 ++++ .../internal/sbom/cyclonedx.go | 130 ++++ .../internal/sbom/cyclonedx_test.go | 73 ++ .../internal/sbom/spdx.go | 167 +++++ .../internal/sbom/spdx_test.go | 102 +++ .../internal/scanner/integration_test.go | 101 +++ .../internal/scanner/scanner.go | 86 +++ .../internal/scanner/scanner_test.go | 81 +++ .../internal/ui/banner.go | 18 + .../internal/ui/color.go | 15 + .../internal/ui/spinner.go | 51 ++ .../internal/ui/symbol.go | 13 + .../internal/vuln/cache.go | 102 +++ .../internal/vuln/cache_test.go | 90 +++ .../internal/vuln/client.go | 15 + .../internal/vuln/cvss.go | 177 +++++ .../internal/vuln/cvss_test.go | 115 +++ .../internal/vuln/nvd.go | 220 ++++++ .../internal/vuln/nvd_test.go | 120 ++++ .../internal/vuln/osv.go | 264 +++++++ .../internal/vuln/osv_test.go | 123 ++++ .../pkg/types/types.go | 160 +++++ .../testdata/empty-project/.gitkeep | 0 .../testdata/go-project/go.mod | 13 + .../testdata/go-project/go.sum | 10 + .../testdata/monorepo/frontend/package.json | 7 + .../testdata/monorepo/frontend/pnpm-lock.yaml | 20 + .../testdata/monorepo/go.mod | 5 + .../testdata/monorepo/go.sum | 2 + .../testdata/node-project/package.json | 11 + .../testdata/node-project/pnpm-lock.yaml | 69 ++ .../testdata/python-project/pyproject.toml | 13 + .../testdata/python-project/uv.lock | 100 +++ .../testdata/vuln-responses/osv-batch.json | 72 ++ TEMPLATES/fullstack-template | 2 +- 359 files changed, 11382 insertions(+), 554 deletions(-) mode change 100644 => 100755 PROJECTS/intermediate/credential-enumeration/tests/docker/validate.sh create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.gitignore create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.golangci.yml create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/Justfile create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/LICENSE create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/cmd/bomber/main.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.mod create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.sum create mode 100755 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/install.sh create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/check.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/generate.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/root.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/scan.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/vuln.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/config/config.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/parser.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/rules.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/json.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/terminal.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/integration_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/banner.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/color.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/spinner.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/symbol.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/client.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv_test.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/pkg/types/types.go create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/empty-project/.gitkeep create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.mod create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.sum create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/package.json create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/pnpm-lock.yaml create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.mod create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.sum create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/package.json create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/pnpm-lock.yaml create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/pyproject.toml create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/uv.lock create mode 100644 PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/vuln-responses/osv-batch.json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/alembic.ini b/PROJECTS/advanced/ai-threat-detection/backend/alembic.ini index d89eff32..10cf41db 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/alembic.ini +++ b/PROJECTS/advanced/ai-threat-detection/backend/alembic.ini @@ -1,5 +1,15 @@ # ©AngelaMos | 2026 # alembic.ini +# +# Alembic database migration configuration +# +# Points script_location to the alembic/ directory and sets +# the default asyncpg connection URL (overridden at runtime +# by env.py from settings). Configures Python logging with +# WARN level for root and sqlalchemy.engine, INFO for +# alembic, all routed to a stderr console handler with +# generic format. Connects to alembic/env.py, +# alembic/versions/, app/config [alembic] script_location = alembic diff --git a/PROJECTS/advanced/ai-threat-detection/backend/alembic/env.py b/PROJECTS/advanced/ai-threat-detection/backend/alembic/env.py index a1ad240b..cb3caf4f 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/alembic/env.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/alembic/env.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 env.py + +Alembic migration environment with async PostgreSQL engine +support + +Configures SQLModel.metadata as the target for autogenerate, +imports model registrations (ModelMetadata, ThreatEvent) to +ensure table definitions are available. run_migrations_ +offline generates SQL scripts without a connection. run_ +migrations_online creates an async engine with NullPool and +executes migrations via run_sync. Mode is selected based on +context.is_offline_mode() + +Connects to: + app/config - settings.database_url + app/models - SQLModel table registrations """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/__main__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/__main__.py index 8c14c2fb..fea9ea9c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/__main__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/__main__.py @@ -1,6 +1,15 @@ """ ©AngelaMos | 2026 __main__.py + +Uvicorn entry point for the AngelusVigil API server + +Launches app.main:app via uvicorn using host, port, and +reload settings from app.config.settings + +Connects to: + config.py - settings.host, settings.port, settings.debug + main.py - ASGI application instance """ import uvicorn diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/__init__.py index e1add2a9..db76f7be 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +API package containing FastAPI route modules for health, +ingest, threats, stats, models, and websocket endpoints """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/deps.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/deps.py index ec1b6674..7858aa38 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/deps.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/deps.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 deps.py + +FastAPI dependency injection providers for API key +authentication and async database sessions + +require_api_key checks the X-API-Key header against +settings.api_key, returning 401 if mismatched (no-op +when api_key is unconfigured). get_session yields an +AsyncSession from the app-level session_factory stored +on app.state during lifespan initialization + +Connects to: + config.py - settings.api_key + factory.py - app.state.session_factory + api/ - injected via Depends() in route handlers """ from collections.abc import AsyncIterator diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/health.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/health.py index d333162a..d4085edd 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/health.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/health.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 health.py + +Health and readiness probe endpoints for container +orchestration + +GET /health returns liveness status with uptime_seconds +and pipeline_running flag. GET /ready checks database +connectivity (SELECT 1) and Redis ping, reports +models_loaded status, and returns 503 if any dependency +is down. Both endpoints read from app.state set during +lifespan + +Connects to: + factory.py - app.state.startup_time, + pipeline_running, db_engine + core/redis_manager - redis_manager.ping() """ import time diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/ingest.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/ingest.py index 283d2ab0..fbaf3677 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/ingest.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/ingest.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 ingest.py + +Batch log ingestion endpoint for pushing raw log lines +into the detection pipeline + +POST /ingest/batch accepts a BatchIngestRequest (list of +raw log line strings), pushes each into the pipeline's +raw_queue via put_nowait, stops on QueueFull, and returns +the count of successfully queued lines. Protected by +require_api_key dependency + +Connects to: + deps.py - require_api_key + core/ingestion/ + pipeline.py - pipeline.raw_queue + factory.py - app.state.pipeline """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py index 33c9b539..4730f207 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 models_api.py + +ML model status and retraining endpoints + +GET /models/status returns models_loaded flag, detection +_mode (hybrid or rules), and active model metadata from +the database. POST /models/retrain dispatches a +background retraining job that loads stored ThreatEvents, +labels them using review_label or score thresholds +(SCORE_ATTACK_THRESHOLD 0.5, SCORE_NORMAL_CEILING 0.3), +supplements with synthetic data if below MIN_TRAINING_ +SAMPLES (200), runs TrainingOrchestrator, and writes +model metadata. _fallback_synthetic spawns a subprocess +CLI train command when no real events exist + +Connects to: + config.py - settings.model_dir, ensemble + weights + models/model_metadata - ModelMetadata queries + models/threat_event - ThreatEvent training data + ml/orchestrator - TrainingOrchestrator + ml/synthetic - generate_mixed_dataset + cli/main - _write_metadata """ import logging diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/stats.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/stats.py index 8770c5e5..3f8fb612 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/stats.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/stats.py @@ -1,6 +1,19 @@ """ ©AngelaMos | 2026 stats.py + +Threat statistics endpoint returning aggregated metrics +for a configurable time window + +GET /stats accepts a range query parameter (default +"24h") and delegates to stats_service.get_stats for +database aggregation, returning a StatsResponse + +Connects to: + deps.py - get_session dependency + schemas/stats - StatsResponse model + services/stats_ + service - get_stats business logic """ from fastapi import APIRouter, Depends, Query diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/threats.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/threats.py index 9b8f2f69..64bac35c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/threats.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/threats.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 threats.py + +Threat event CRUD endpoints with filtering and +pagination + +GET /threats lists events with optional severity, +source_ip, since/until datetime filters, and limit/ +offset pagination (max 100). GET /threats/{threat_id} +fetches a single event by UUID, returning 404 if not +found. Both delegate to threat_service for database +queries + +Connects to: + deps.py - get_session dependency + schemas/threats - ThreatEventResponse, + ThreatListResponse + services/threat_ + service - get_threats, get_threat_by_id """ import uuid diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/websocket.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/websocket.py index 60fe4c2c..44273529 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/websocket.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/websocket.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 websocket.py + +WebSocket endpoint streaming real-time threat alerts via +Redis pub/sub relay + +WS /ws/alerts accepts a client connection, subscribes to +the ALERTS_CHANNEL via a per-client Redis pubsub instance, +and runs two concurrent tasks: _relay forwards published +messages as WebSocket text frames, _receive drains client +messages until disconnect. asyncio.wait with FIRST_ +COMPLETED cancels the other task on disconnect, then +unsubscribes and closes the pubsub. Per-client subscribers +ensure correct multi-worker behavior + +Connects to: + core/alerts - ALERTS_CHANNEL constant + core/redis_manager- redis_manager.client """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/config.py b/PROJECTS/advanced/ai-threat-detection/backend/app/config.py index df54f9da..93e4c49d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/config.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/config.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 config.py + +Pydantic-settings application configuration loaded from +environment variables and .env file + +Defines the Settings model with defaults for: server +(host 0.0.0.0, port 8000, debug, log_level), database +(postgresql+asyncpg URL), Redis URL, GeoIP MaxMind +database path, nginx log path, pipeline queue sizes +(raw 1000, parsed 500, feature 200, alert 100), batch +settings (size 32, timeout 50ms), and ML configuration +(model_dir, detection_mode, ensemble weights for +autoencoder/random-forest/isolation-forest at 0.40/0.40 +/0.20, ae_threshold_percentile 99.5, MLflow tracking +URI). Exports a module-level singleton settings instance + +Connects to: + factory.py - consumed in lifespan and create_app + __main__.py - server host/port/reload + core/ingestion/ - queue sizes, log path + core/detection/ - model_dir, ensemble weights + core/enrichment/ - geoip_db_path """ from pydantic_settings import BaseSettings, SettingsConfigDict diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/__init__.py index e1add2a9..ec199995 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Core package containing detection, ingestion, feature +engineering, enrichment, and alert subsystems """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/__init__.py index 84399ce3..eadfbffc 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/__init__.py @@ -1,6 +1,9 @@ """ ©AngelaMos | 2026 __init__.py + +Alerts package defining the ALERTS_CHANNEL constant for +Redis pub/sub real-time threat notification """ ALERTS_CHANNEL = "alerts" diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/dispatcher.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/dispatcher.py index 27d10f28..c2c67351 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/dispatcher.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/dispatcher.py @@ -1,6 +1,26 @@ """ ©AngelaMos | 2026 dispatcher.py + +Alert dispatcher routing scored threat events to storage, +Redis pub/sub, and structured logging + +AlertDispatcher.dispatch receives a ScoredRequest from the +pipeline, classifies severity via classify_severity, logs +every event, and for MEDIUM+ severity persists to +PostgreSQL via create_threat_event and publishes a +WebSocketAlert JSON payload to the ALERTS_CHANNEL for +real-time WebSocket relay + +Connects to: + core/alerts/__init__ - ALERTS_CHANNEL + core/detection/ + ensemble - classify_severity + core/ingestion/ + pipeline - ScoredRequest dataclass + schemas/websocket - WebSocketAlert model + services/threat_ + service - create_threat_event """ import logging diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/__init__.py index e1add2a9..c2c47817 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Detection package containing the rule engine, ONNX +inference engine, and ensemble scoring utilities """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/ensemble.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/ensemble.py index 7ee1c7ae..85046b4a 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/ensemble.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/ensemble.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 ensemble.py + +Score normalization, fusion, and severity classification +utilities for the ML ensemble + +normalize_ae_score maps autoencoder reconstruction error +to [0,1] using 2x threshold scaling. normalize_if_score +inverts sklearn isolation forest scores to [0,1]. +fuse_scores computes a weighted average across available +model scores. blend_scores combines ML ensemble and rule +engine scores with configurable ml_weight (default 0.7). +classify_severity maps unified score to HIGH (>=0.7), +MEDIUM (>=0.5), or LOW + +Connects to: + core/detection/ + inference - raw model scores passed to normalizers + core/detection/ + rules - classify_severity used for rule results + core/ingestion/ + pipeline - fuse_scores and blend_scores in + scoring stage """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py index 2aee512c..2111795b 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 inference.py + +ONNX-based inference engine for the 3-model ML ensemble + +InferenceEngine loads autoencoder (ae.onnx), random +forest (rf.onnx), and isolation forest (if.onnx) sessions +plus RobustScaler parameters (scaler.json) and anomaly +threshold (threshold.json) from a model directory. predict +runs all 3 models on a batch of feature vectors: applies +_scale_for_ae to normalize autoencoder input, computes +reconstruction MSE for ae scores, extracts attack +probability from skl2onnx RF output format via _extract_ +rf_proba, and returns raw IF decision scores. Returns +None when models are unavailable. Each ONNX session uses +single-threaded execution (inter/intra_op_num_threads=1) + +Connects to: + config.py - settings.model_dir + factory.py - _load_inference_engine at startup + core/ingestion/ + pipeline - batch inference in scoring stage + ml/export_onnx - produces the ONNX model files """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/rules.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/rules.py index 7b56f605..9a43184b 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/rules.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/rules.py @@ -1,6 +1,32 @@ """ ©AngelaMos | 2026 rules.py + +Cold-start rule-based detection engine inspired by +ModSecurity Core Rule Set + +RuleEngine.score_request evaluates requests against 7 +regex-based _PatternRules (LOG4SHELL 0.95, COMMAND_ +INJECTION 0.90, SQL_INJECTION 0.85, XSS 0.80, FILE_ +INCLUSION 0.75, SSRF 0.70, PATH_TRAVERSAL 0.60), +double-encoding detection (0.40), scanner user-agent +signature matching (0.35), and 2 _ThresholdRules +(RATE_ANOMALY >100 req/min 0.30, HIGH_ERROR_RATE >50% +0.25). Final score takes the highest match plus 0.05 +boost per additional rule, capped at 1.0. Returns a +RuleResult with threat_score, severity, matched_rules, +and component_scores + +Connects to: + core/features/ + patterns - compiled regex patterns (SQLI, + XSS, LOG4SHELL, etc.) + core/features/ + signatures - SCANNER_USER_AGENTS list + core/detection/ + ensemble - classify_severity + core/ingestion/ + parsers - ParsedLogEntry """ import re diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/__init__.py index e1add2a9..5db31840 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Enrichment package providing GeoIP lookup services for +IP-to-location resolution """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/geoip.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/geoip.py index 96328ae1..44a9cf95 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/geoip.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/enrichment/geoip.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 geoip.py + +Async GeoIP lookup service backed by MaxMind GeoLite2-City +database + +GeoIPService loads a .mmdb reader on init, returning None +for missing databases. lookup resolves an IP to a GeoResult +(country ISO code, city, lat, lon), skipping private/ +loopback addresses and unknown entries. swap_reader +atomically replaces the database reader for hot-reload +after .mmdb updates. All blocking geoip2 calls run in a +thread via asyncio.to_thread + +Connects to: + config.py - settings.geoip_db_path + factory.py - initialized and closed in + lifespan + core/ingestion/ + pipeline - lookup called in feature_worker """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/__init__.py index e1add2a9..0def6a4e 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Feature engineering package with per-request extraction, +windowed aggregation, encoding, patterns, and signatures """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/aggregator.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/aggregator.py index 9ddc540a..aea86550 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/aggregator.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/aggregator.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 aggregator.py + +Per-IP sliding window feature aggregator backed by Redis +sorted sets + +WindowAggregator.record_and_aggregate records each request +into 7 Redis sorted sets (requests, paths, statuses, UAs, +sizes, methods, depths) keyed by IP, trims entries older +than KEY_TTL (900s), and computes 12 windowed features in +a single pipelined round-trip: req_count at 1m/5m/10m +windows, error_rate_5m (4xx/5xx ratio), unique_paths_5m, +unique_uas_10m, method_entropy_5m (Shannon), avg_response +_size_5m, status_diversity_5m (distinct codes), path_depth +_variance_5m, and inter_request_time mean/std in ms. +Members are MD5-hashed for deduplication where needed + +Connects to: + core/ingestion/ + pipeline - called in feature_worker stage + core/features/ + mappings - WINDOWED_FEATURE_NAMES defines the + 12 windowed feature keys """ import hashlib diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/encoder.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/encoder.py index 0297db88..6ab2a534 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/encoder.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/encoder.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 encoder.py + +Feature vector encoder transforming a combined feature +dict into a 35-element float vector for ML inference + +encode_for_inference iterates FEATURE_ORDER, applying +boolean 0/1 encoding for 7 BOOLEAN_FEATURES, ordinal +lookup via CATEGORICAL_ENCODERS for http_method, status_ +class, and file_extension, deterministic country code +encoding via _encode_country (A-Z ordinal to 1-676), and +direct float cast for all numeric features + +Connects to: + core/features/ + mappings - FEATURE_ORDER, BOOLEAN_FEATURES, + CATEGORICAL_ENCODERS + core/ingestion/ + pipeline - called after feature merge """ from app.core.features.mappings import ( diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/extractor.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/extractor.py index 3b0f94e1..e006aad6 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/extractor.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/extractor.py @@ -1,6 +1,31 @@ """ ©AngelaMos | 2026 extractor.py + +Stateless per-request feature extraction producing 23 +features from a parsed log entry + +extract_request_features computes: http_method, path_depth, +path_entropy (Shannon), path_length, query_string_length, +query_param_count, has_encoded_chars, has_double_encoding, +status_code, status_class (Nxx), response_size, hour_of_ +day, day_of_week, is_weekend, ua_length, ua_entropy, +is_known_bot, is_known_scanner, has_attack_pattern, +special_char_ratio, file_extension, country_code, and +is_private_ip. Pattern detection uses compiled regexes +from patterns module, bot/scanner detection uses signature +sets + +Connects to: + core/features/ + patterns - ATTACK_COMBINED, DOUBLE_ENCODED, + ENCODED_CHARS + core/features/ + signatures - BOT_USER_AGENTS, SCANNER_USER_AGENTS + core/ingestion/ + parsers - ParsedLogEntry input + core/ingestion/ + pipeline - called in feature_worker stage """ import ipaddress diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py index 38e57798..a45c3d15 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 mappings.py + +Feature encoding mappings, canonical feature order, and +classification constants for the 35-feature ML input spec + +Defines METHOD_MAP (7 HTTP verbs), STATUS_CLASS_MAP (5 +response classes), EXTENSION_MAP (25 file extensions), +FEATURE_ORDER (35-element canonical list: 23 per-request ++ 12 windowed), CATEGORICAL_ENCODERS routing http_method/ +status_class/file_extension to their ordinal maps, +WINDOWED_FEATURE_NAMES (last 12 features), and BOOLEAN_ +FEATURES (7 binary flags). These mappings ensure training +and inference use identical encoding + +Connects to: + core/features/ + encoder - FEATURE_ORDER, BOOLEAN_FEATURES, + CATEGORICAL_ENCODERS + ml/data_loader - FEATURE_ORDER for column alignment + ml/synthetic - FEATURE_ORDER for sample generation """ METHOD_MAP: dict[str, int] = { diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/patterns.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/patterns.py index cd375ca5..b499fcf8 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/patterns.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/patterns.py @@ -1,6 +1,32 @@ """ ©AngelaMos | 2026 patterns.py + +Compiled regex patterns for web attack detection covering +7 OWASP categories plus encoding anomalies + +Defines case-insensitive compiled patterns for: SQLI +(union select, sleep, benchmark, information_schema, hex +literals, comment injection), XSS (script tags, event +handlers, javascript/vbscript URIs, DOM sinks like +document.cookie/write, eval/alert/prompt), PATH_TRAVERSAL +(../ sequences, %2e encoding, sensitive file paths like +etc/passwd, .git/config, .env), COMMAND_INJECTION +(semicolon/pipe chaining to shell commands, $() and +backtick substitution, ${} expansion), FILE_INCLUSION +(php://, file://, data://, phar:// wrapper schemes), SSRF +(cloud metadata IPs 169.254.169.254, localhost with paths, +dict:// and gopher://), LOG4SHELL (${jndi, ${lower, ${:- +patterns). Also provides ENCODED_CHARS, DOUBLE_ENCODED for +evasion detection, and ATTACK_COMBINED unioning all 7 +patterns + +Connects to: + core/features/ + extractor - ATTACK_COMBINED, DOUBLE_ENCODED, + ENCODED_CHARS + core/detection/ + rules - individual patterns for scored rules """ import re diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/signatures.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/signatures.py index 2c6a5bb1..ef3f9c84 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/signatures.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/signatures.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 signatures.py + +User-agent signature sets for bot and security scanner +detection + +BOT_USER_AGENTS contains 34 lowercase search engine and +crawler identifiers (googlebot, bingbot, gptbot, claudebot, +etc.) for benign bot classification. SCANNER_USER_AGENTS +contains 41 lowercase security tool signatures (nikto, +sqlmap, nmap, burp, nuclei, metasploit, hydra, etc.) for +hostile scanner detection. Both are frozensets matched via +substring search against lowercased user-agent strings + +Connects to: + core/features/ + extractor - is_known_bot, is_known_scanner features + core/detection/ + rules - SCANNER_USER_AGENTS for UA rule scoring """ BOT_USER_AGENTS: frozenset[str] = frozenset({ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/__init__.py index e1add2a9..3e6fbb10 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Ingestion package with log parsing, file tailing, and the +four-stage async processing pipeline """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/parsers.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/parsers.py index 0685d922..0bc30273 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/parsers.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/parsers.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 parsers.py + +Nginx combined-format log line parser with fast string- +split primary and compiled regex fallback + +ParsedLogEntry is a frozen slotted dataclass holding ip, +timestamp, method, path, query_string, status_code, +response_size, referer, user_agent, and raw_line. +parse_combined tries _parse_split first (splitting on +quote boundaries for speed), falling back to _parse_regex +with a compiled _COMBINED_RE pattern. Both extract the +request line, split URI into path and query_string, parse +timestamp via strptime with timezone, and handle dash +placeholders for size and referer + +Connects to: + core/ingestion/ + pipeline - parse_combined in parse_worker stage + core/detection/ + rules - ParsedLogEntry consumed by RuleEngine + core/features/ + extractor - ParsedLogEntry consumed by feature + extraction """ import re diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/pipeline.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/pipeline.py index ddf1cc48..56862e47 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/pipeline.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/pipeline.py @@ -1,6 +1,34 @@ """ ©AngelaMos | 2026 pipeline.py + +Four-stage async pipeline transforming raw nginx log lines +into scored threat candidates + +Stage 1 (_parse_worker): parses raw lines via parse_ +combined into ParsedLogEntry. Stage 2 (_feature_worker): +enriches with GeoIP lookup, extracts 23 per-request +features, aggregates 12 windowed features via Redis-backed +WindowAggregator, and encodes the merged 35-dim float +vector. Stage 3 (_detection_worker): scores via RuleEngine, +optionally runs ML ensemble inference (normalize AE/IF +scores, fuse with configurable weights, blend with rule +score at 0.7 ML weight). Stage 4 (_dispatch_worker): +forwards ScoredRequests via the on_result callback. Stages +are connected by sized asyncio.Queues with poison-pill +shutdown propagation. EnrichedRequest and ScoredRequest +dataclasses carry data between stages + +Connects to: + core/ingestion/parsers - parse_combined + core/enrichment/geoip - GeoIPService.lookup + core/features/extractor - extract_request_features + core/features/aggregator - WindowAggregator + core/features/encoder - encode_for_inference + core/detection/rules - RuleEngine.score_request + core/detection/inference - InferenceEngine.predict + core/detection/ensemble - normalize/fuse/blend scores + core/alerts/dispatcher - on_result callback """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/tailer.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/tailer.py index ebc0004b..38e56b25 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/tailer.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/ingestion/tailer.py @@ -1,6 +1,26 @@ """ ©AngelaMos | 2026 tailer.py + +Watchdog-based nginx log file tailer with rotation +detection pushing raw lines into an asyncio queue + +_LogHandler extends FileSystemEventHandler to tail a single +target file: _open_target seeks to EOF, on_modified reads +new lines via _read_new_lines and checks inode changes for +rotation, on_moved handles rename-based rotation (access +.log -> access.log.1), on_created handles new-file +rotation. Lines are pushed via call_soon_threadsafe into +the asyncio queue, with QueueFull drops logged. LogTailer +wraps _LogHandler with a PollingObserver (2s interval) +watching the target's parent directory, providing start/ +stop lifecycle and is_active property + +Connects to: + factory.py - started/stopped in lifespan + core/ingestion/ + pipeline - feeds pipeline.raw_queue + config.py - settings.nginx_log_path """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/redis_manager.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/redis_manager.py index 163c1a10..6095fb2e 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/redis_manager.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/redis_manager.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 redis_manager.py + +Async Redis connection lifecycle manager with module-level +singleton + +RedisManager wraps redis.asyncio connection creation +(from_url with decode_responses), graceful close, client +property access, and PING health check. The module +exports redis_manager as a singleton used by factory +lifespan, alert dispatcher, and websocket endpoint + +Connects to: + config.py - settings.redis_url + factory.py - connect/disconnect in lifespan + api/websocket - client for pub/sub + api/health - ping() for readiness probe """ import redis.asyncio as aioredis diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py b/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py index d689c65e..5b904601 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py @@ -1,6 +1,36 @@ """ ©AngelaMos | 2026 factory.py + +FastAPI application factory with async lifespan managing +database, Redis, pipeline, and ML model initialization + +lifespan creates the async SQLAlchemy engine and session +factory, runs SQLModel.metadata.create_all, connects +Redis, initializes GeoIPService, constructs the Alert +Dispatcher, attempts to load the ONNX InferenceEngine +(falling back to rules-only mode), builds the Pipeline +with configured queue sizes and ensemble weights, starts +the LogTailer if the nginx log directory exists, and +stores all components on app.state. On shutdown it stops +the tailer, pipeline, GeoIP, Redis, and disposes the DB +engine. _load_inference_engine lazily imports onnxruntime +-backed InferenceEngine, returning None if the dependency +is missing or no models exist. create_app assembles the +FastAPI instance and mounts all six API routers (health, +ingest, threats, stats, models, websocket) + +Connects to: + config.py - settings for all config values + core/ingestion/pipeline - Pipeline + core/ingestion/tailer - LogTailer + core/detection/rules - RuleEngine + core/detection/inference- InferenceEngine (optional) + core/alerts/dispatcher - AlertDispatcher + core/enrichment/geoip - GeoIPService + core/redis_manager - redis_manager + api/ - all route modules + models/ - SQLModel registration """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/main.py b/PROJECTS/advanced/ai-threat-detection/backend/app/main.py index 2ee26550..5b0b7a58 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/main.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/main.py @@ -1,6 +1,11 @@ """ ©AngelaMos | 2026 main.py + +ASGI application instance created by the factory + +Connects to: + factory.py - create_app builds the FastAPI instance """ from app.factory import create_app diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/models/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/models/__init__.py index 0b879feb..8ad98183 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/models/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/models/__init__.py @@ -1,6 +1,9 @@ """ ©AngelaMos | 2026 __init__.py + +Models package exporting SQLModel table classes for +ThreatEvent and ModelMetadata """ from app.models.model_metadata import ModelMetadata diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/models/base.py b/PROJECTS/advanced/ai-threat-detection/backend/app/models/base.py index 764cdc49..6d26d70d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/models/base.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/models/base.py @@ -1,6 +1,18 @@ """ ©AngelaMos | 2026 base.py + +Abstract SQLModel base class providing UUID primary key +and timezone-aware created_at timestamp + +TimestampedModel defines id as a uuid4 primary key and +created_at as a DateTime(timezone=True) column with +CURRENT_TIMESTAMP server default. All domain models +inherit from this base + +Connects to: + models/threat_event - ThreatEvent inherits + models/model_metadata - ModelMetadata inherits """ import uuid diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py b/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py index 6311239a..a5de5d90 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 model_metadata.py + +SQLModel table tracking ML model versions, training +metrics, and deployment status + +ModelMetadata stores model_type, version, training_samples, +metrics (JSON), artifact_path, is_active flag, optional +mlflow_run_id, threshold, and notes. A partial index on +model_type filtered by is_active=TRUE enables fast lookup +of the currently deployed model per type + +Connects to: + models/base - inherits TimestampedModel + api/models_api - queried for /models/status, + written after retrain + cli/main - _write_metadata inserts records """ from sqlalchemy import Column, Index, JSON, text diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/models/threat_event.py b/PROJECTS/advanced/ai-threat-detection/backend/app/models/threat_event.py index f273ec28..28ca3b37 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/models/threat_event.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/models/threat_event.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 threat_event.py + +SQLModel table for detected threat events with full +request context and ML metadata + +ThreatEvent stores source_ip, request_method, request_path, +status_code, response_size, user_agent, threat_score, +severity, component_scores (JSON), geo fields (country, +city, lat, lon), feature_vector (JSON float array), +matched_rules (JSON string array), model_version, +reviewed flag, and review_label for analyst feedback. +Indexed on created_at, source_ip, severity, threat_score, +and a partial index on reviewed=FALSE for triage queries + +Connects to: + models/base - inherits TimestampedModel + services/threat_service - CRUD operations + api/models_api - training data source for + retrain + core/alerts/dispatcher - persisted on MEDIUM+ severity """ from sqlalchemy import ( diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/__init__.py index e1add2a9..b37e7cb2 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Pydantic schemas package for API request/response +validation across stats, threats, and websocket endpoints """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/stats.py b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/stats.py index 5c74c46d..acb2e018 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/stats.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/stats.py @@ -1,6 +1,18 @@ """ ©AngelaMos | 2026 stats.py + +Pydantic response models for the /stats endpoint + +SeverityBreakdown holds high/medium/low threat counts. +IPStatEntry and PathStatEntry pair a source_ip or path +with a count. StatsResponse aggregates time_range, +threats_stored, threats_detected, severity_breakdown, +top_source_ips (top 10), and top_attacked_paths (top 10) + +Connects to: + api/stats - StatsResponse as response_model + services/stats_service - constructs StatsResponse """ from pydantic import BaseModel diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/threats.py b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/threats.py index b2d42fe9..dc9d76c5 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/threats.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/threats.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 threats.py + +Pydantic response models for the /threats endpoints + +GeoInfo holds optional country, city, lat, lon from GeoIP +lookups. ThreatEventResponse is the full event schema with +UUID id, timestamps, request details, threat_score, +severity (Literal HIGH/MEDIUM/LOW), component_scores, +geo info, matched_rules, model_version, and review status +(from_attributes enabled for ORM conversion). Threat +ListResponse wraps paginated items with total/limit/offset + +Connects to: + api/threats - response_model for list and + detail endpoints + services/threat_service - _to_response builds these """ import uuid diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/websocket.py b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/websocket.py index 3d83cbee..400dd80d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/websocket.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/schemas/websocket.py @@ -1,6 +1,18 @@ """ ©AngelaMos | 2026 websocket.py + +Pydantic model for real-time WebSocket threat alert +payloads + +WebSocketAlert carries event type (Literal "threat"), +timestamp, source_ip, request_method, request_path, +threat_score, severity, and component_scores. Serialized +via model_dump_json for Redis pub/sub broadcast + +Connects to: + core/alerts/dispatcher - constructs and publishes alerts + api/websocket - relayed to connected clients """ from datetime import datetime diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/services/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/app/services/__init__.py index e1add2a9..553c2042 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/services/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/services/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +Service layer package with threat event CRUD and +statistics aggregation business logic """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/services/stats_service.py b/PROJECTS/advanced/ai-threat-detection/backend/app/services/stats_service.py index 40255ae4..8f0ceb7e 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/services/stats_service.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/services/stats_service.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 stats_service.py + +Threat statistics aggregation service computing time- +windowed metrics from stored events + +get_stats accepts a time_range string (1h, 6h, 24h, 7d, +30d) mapped to timedeltas via _RANGE_MAP, queries threat +events since the cutoff, and returns a StatsResponse with +total count, severity breakdown (HIGH/MEDIUM/LOW counts +via GROUP BY), top 10 source IPs, and top 10 attacked +paths ordered by frequency + +Connects to: + models/threat_event - ThreatEvent queries + schemas/stats - StatsResponse, SeverityBreakdown, + IPStatEntry, PathStatEntry + api/stats - called from GET /stats endpoint """ from datetime import datetime, timedelta, UTC diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/services/threat_service.py b/PROJECTS/advanced/ai-threat-detection/backend/app/services/threat_service.py index d7971dd1..30396781 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/services/threat_service.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/services/threat_service.py @@ -1,6 +1,29 @@ """ ©AngelaMos | 2026 threat_service.py + +Threat event CRUD service for database persistence and +retrieval + +get_threats builds a filtered, paginated query with +optional severity, source_ip, since/until datetime +filters, ordered by created_at DESC. get_threat_by_id +fetches a single event by UUID. create_threat_event +persists a ScoredRequest as a ThreatEvent with full +request context, GeoIP data, feature vector, matched +rules, and severity classification. _to_response converts +ThreatEvent ORM models to ThreatEventResponse schemas +with nested GeoInfo + +Connects to: + models/threat_event - ThreatEvent table operations + schemas/threats - ThreatEventResponse, GeoInfo, + ThreatListResponse + core/detection/ensemble - classify_severity for create + core/ingestion/pipeline - ScoredRequest input type + api/threats - called from list/detail + endpoints + core/alerts/dispatcher - called on MEDIUM+ dispatch """ import uuid diff --git a/PROJECTS/advanced/ai-threat-detection/backend/cli/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/cli/__init__.py index e1add2a9..328a1b39 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/cli/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/cli/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +CLI package providing the Typer-based vigil command-line +interface for server, training, replay, and diagnostics """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py b/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py index 050d8db6..3657ee4c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py @@ -1,6 +1,29 @@ """ ©AngelaMos | 2026 main.py + +Typer CLI application with serve, train, replay, config, +and health commands + +serve launches uvicorn with configurable host/port/reload. +train loads CSIC 2010 dataset and/or synthetic data, runs +TrainingOrchestrator, exports ONNX models, and writes +metadata to the database via _write_metadata (creates an +async engine, calls save_model_metadata). replay sends +historical log lines in batches to a running server's +/ingest/batch endpoint via httpx. config prints all +settings with secrets redacted (_redact_url masks +credentials in database URLs). health pings /health and +displays status, uptime, and pipeline state + +Connects to: + app/config - settings for serve defaults + app/main - uvicorn target "app.main:app" + ml/orchestrator - TrainingOrchestrator for train + ml/data_loader - load_csic_dataset for CSIC data + ml/synthetic - generate_mixed_dataset + ml/metadata - save_model_metadata + api/ingest - /ingest/batch for replay """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/__init__.py index e1add2a9..de7dc0db 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/__init__.py @@ -1,4 +1,7 @@ """ ©AngelaMos | 2026 __init__.py + +ML package with autoencoder, classifier training, ONNX +export, data loading, experiment tracking, and validation """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py index 586468d3..cd33f3cc 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 autoencoder.py + +PyTorch symmetric autoencoder for HTTP request anomaly +detection + +ThreatAutoencoder has a 35->24->12->6 encoder and 6->12 +->24->35 decoder with BatchNorm1d, LeakyReLU(0.2), and +Dropout(0.2) between each linear layer. Trained on normal +traffic only so that high reconstruction error (compute_ +reconstruction_error via per-sample MSE) indicates +anomalous requests. encode/decode expose bottleneck access +for analysis + +Connects to: + ml/export_onnx - exported to ae.onnx + ml/orchestrator - trained in _train_autoencoder + ml/scaler - input normalized before training """ import torch diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py index 8feec3f5..e8e0891d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 data_loader.py + +CSIC 2010 HTTP dataset loader with feature extraction for +ML training + +parse_csic_file reads a CSIC dataset file, splits on HTTP +request line boundaries, and produces CSICRequest objects +(method, path, query_string, headers, body, label). +csic_to_parsed_entry converts CSICRequests to +ParsedLogEntrys with synthetic defaults (private IP, +random timestamp over 90 days, 200 status). load_csic_ +dataset loads normal (label=0) and attack (label=1) +files, extracts 23 per-request features, zeros 12 +windowed features, encodes to 35-dim vectors, and returns +(X, y) numpy arrays. load_csic_normal loads a single +normal-only file + +Connects to: + core/features/extractor - extract_request_features + core/features/encoder - encode_for_inference + core/features/mappings - WINDOWED_FEATURE_NAMES + core/ingestion/parsers - ParsedLogEntry + cli/main - loaded in train command """ import logging diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py index 75b1cb31..ad547c91 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 download_csic.py + +CSIC 2010 dataset downloader with progress display and +integrity checking + +download_csic fetches normalTrafficTraining.txt, normal +TrafficTest.txt, and anomalousTrafficTest.txt from the +Universidad de la Republica GitLab mirror via httpx +streaming, writing to data/datasets/csic2010/. Skips +files that already exist above MIN_FILE_BYTES (1MB). +Shows download progress (percentage or MB), computes +SHA-256 via _compute_sha256, and warns on suspiciously +small downloads + +Connects to: + ml/data_loader - downloaded files consumed by + parse_csic_file """ import hashlib diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/experiment.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/experiment.py index c1758ad2..ce5e7ccb 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/experiment.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/experiment.py @@ -1,6 +1,19 @@ """ ©AngelaMos | 2026 experiment.py + +MLflow experiment context manager with automatic system +metadata logging + +VigilExperiment wraps mlflow.start_run/end_run as a context +manager, recording Python version, platform, and git commit +hash on entry, and setting status/error tags on exit. +Provides log_params, log_metrics (with optional step), and +log_artifact convenience methods. _get_git_hash shells out +to git rev-parse --short HEAD + +Connects to: + ml/orchestrator - used to wrap the full training run """ import platform diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py index 593dba8f..4d13dc6c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 export_onnx.py + +ONNX model export functions for the 3-model ML ensemble + +export_autoencoder converts a PyTorch ThreatAutoencoder to +ONNX with dynamic batch dimension, opset 17, constant +folding, and named I/O (features/reconstructed). export_ +random_forest and export_isolation_forest convert sklearn +estimators to ONNX via skl2onnx with FloatTensorType input +and target opset {"": 17, "ai.onnx.ml": 3}. All functions +create parent directories and return the output Path + +Connects to: + ml/autoencoder - ThreatAutoencoder model class + ml/orchestrator - called after training completes + core/detection/ + inference - loads the exported ONNX files """ from pathlib import Path diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py index 9d3c4e22..f9291d38 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 metadata.py + +Model metadata persistence for tracking trained model +versions and deployment status + +compute_model_version produces a 12-char hex version from +the SHA-256 of an ONNX artifact file. save_model_metadata +iterates MODEL_TYPES (ae.onnx -> autoencoder, rf.onnx -> +random_forest, if.onnx -> isolation_forest), deactivates +any previously active version of each type, and inserts +new ModelMetadata rows with version, training_samples, +metrics, artifact_path, mlflow_run_id, and threshold + +Connects to: + models/model_metadata - ModelMetadata ORM model + cli/main - called from _write_metadata + api/models_api - called after retrain """ import hashlib diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py index 4396b46b..6a562d6a 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py @@ -1,6 +1,32 @@ """ ©AngelaMos | 2026 orchestrator.py + +End-to-end training pipeline orchestrator for the 3-model +ML ensemble + +TrainingOrchestrator.run accepts (X, y) arrays, calls +prepare_training_data for stratified splitting with SMOTE, +trains the autoencoder on normal-only data, random forest +on labeled data, and isolation forest on normal-only data, +exports all three to ONNX (ae.onnx, rf.onnx, if.onnx) +plus scaler.json and threshold.json, runs validate_ensemble +against the held-out test set with PR-AUC and F1 quality +gates, and logs all parameters, metrics, and artifacts to +MLflow via VigilExperiment. Returns a TrainingResult +dataclass aggregating per-model metrics, gate status, +output directory, and MLflow run ID + +Connects to: + ml/experiment - VigilExperiment context manager + ml/export_onnx - ONNX export functions + ml/splitting - prepare_training_data + ml/train_autoencoder - train_autoencoder + ml/train_classifiers - train_random_forest, + train_isolation_forest + ml/validation - validate_ensemble + cli/main - called from train command + api/models_api - called from retrain endpoint """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py index 3619161c..695d2812 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 scaler.py + +IQR-based feature scaler with JSON persistence for the +autoencoder preprocessing stage + +FeatureScaler wraps sklearn RobustScaler (median/IQR +normalization) to handle outlier-heavy HTTP traffic data. +Provides fit, transform, fit_transform, and +inverse_transform mirroring the sklearn API. save_json +serializes center and scale arrays to a human-readable +JSON file (avoiding pickle for security and portability), +and load_json reconstructs a fitted scaler from that file. +Only the autoencoder uses this scaler since tree-based +models (random forest, isolation forest) are +scale-invariant + +Connects to: + ml/train_autoencoder - fitted during AE training + ml/orchestrator - scaler.json saved alongside models + core/detection/ + inference - loaded at inference time for AE + input normalization """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/splitting.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/splitting.py index 51470681..c23b707d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/splitting.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/splitting.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 splitting.py + +Stratified train/val/test splitting with SMOTE +oversampling for imbalanced attack data + +prepare_training_data performs a 70/15/15 stratified split +preserving class ratios, extracts the normal-only subset +from training data for the autoencoder and isolation +forest, and conditionally applies SMOTE oversampling to +the training set when the minority class ratio falls below +the target strategy (default 0.3). SMOTE is skipped if the +minority class has fewer than k_neighbors+1 samples. +Returns a TrainingSplit dataclass with X_train, y_train, +X_val, y_val, X_test, y_test, and X_normal_train arrays + +Connects to: + ml/orchestrator - called at the start of the training + pipeline """ from dataclasses import dataclass diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py index c916c3b7..556d7fdf 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py @@ -1,6 +1,31 @@ """ ©AngelaMos | 2026 synthetic.py + +Synthetic HTTP traffic generator for ML training and +testing with realistic attack payloads + +Provides per-category generators for 6 attack types: +generate_sqli_requests (22 SQL injection payloads), +generate_xss_requests (21 XSS vectors), generate_ +traversal_requests (15 path traversal payloads), +generate_log4shell_requests (10 JNDI lookup variants), +generate_ssrf_requests (11 cloud metadata and internal +service targets), and generate_scanner_requests (11 +vulnerability scanner user-agents). generate_normal_ +requests produces benign traffic across 31 realistic +paths. generate_mixed_dataset orchestrates all generators, +converts ParsedLogEntry objects to 35-dim feature vectors +via extract_request_features and encode_for_inference with +zeroed windowed features, and returns (X, y) numpy arrays + +Connects to: + core/features/extractor - extract_request_features + core/features/encoder - encode_for_inference + core/features/mappings - WINDOWED_FEATURE_NAMES + core/ingestion/parsers - ParsedLogEntry + cli/main - used when no CSIC dataset is + available """ import logging diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py index 5d90338d..26f2ebb7 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 train_autoencoder.py + +PyTorch autoencoder training loop with early stopping and +anomaly threshold calibration + +train_autoencoder takes normal-only traffic vectors, splits +off a 15% validation set, fits a FeatureScaler (IQR-based) +on training data, builds DataLoaders, and trains a +ThreatAutoencoder (35->24->12->6->12->24->35) using MSE +loss with AdamW optimizer (weight decay 1e-5), +ReduceLROnPlateau scheduler (factor 0.5, patience 5), +gradient clipping at max_norm 1.0, and early stopping +(default patience 10). After training, computes per-sample +reconstruction error on the validation set and sets the +anomaly threshold at the 99.5th percentile. Returns the +trained model, fitted scaler, calibrated threshold, and +train/val loss history + +Connects to: + ml/autoencoder - ThreatAutoencoder model class + ml/scaler - FeatureScaler for input normalization + ml/orchestrator - called during pipeline execution """ from typing import Any diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/train_classifiers.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/train_classifiers.py index 6d2b3fed..7aa7a182 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/train_classifiers.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/train_classifiers.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 train_classifiers.py + +Sklearn classifier training for the random forest and +isolation forest ensemble members + +train_random_forest builds a 200-tree balanced-weight +RandomForestClassifier with max_depth 20, wraps it in +CalibratedClassifierCV with isotonic calibration (3-fold +CV) for well-calibrated probability outputs, evaluates on +a held-out 20% calibration split, and returns the +calibrated model with accuracy, precision, recall, F1, and +PR-AUC metrics. train_isolation_forest fits a 200-tree +IsolationForest on normal-only traffic with automatic +contamination estimation, returning the model and sample +count + +Connects to: + ml/orchestrator - called during pipeline execution + ml/export_onnx - models exported to ONNX after training """ from typing import Any diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/validation.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/validation.py index 52906908..35bb903e 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/validation.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/validation.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 validation.py + +Post-training ensemble validation with quality gates for +deployment readiness + +validate_ensemble loads all 3 ONNX models via +InferenceEngine, runs batch prediction on held-out test +data, normalizes per-model raw scores (AE reconstruction +error against threshold, IF anomaly scores), fuses them +via weighted average (default weights: AE 0.4, RF 0.4, +IF 0.2), applies a 0.5 binary threshold, and computes +precision, recall, F1, PR-AUC, and ROC-AUC. Quality +gates require PR-AUC >= 0.85 and F1 >= 0.80 for +passed_gates to be True. Returns a ValidationResult +dataclass with all metrics, confusion matrix, and +per-gate pass/fail details + +Connects to: + core/detection/ensemble - normalize_ae_score, + normalize_if_score, fuse_scores + core/detection/inference - InferenceEngine ONNX runtime + ml/orchestrator - called after training to gate + deployment """ import logging diff --git a/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml b/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml index b0ab9158..8834f5f3 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml +++ b/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml @@ -1,5 +1,19 @@ # ©AngelaMos | 2026 # pyproject.toml +# +# Python project metadata, dependencies, and tool +# configuration for AngelusVigil +# +# Declares the angelusvigil package (Python 3.14+) with +# core dependencies (FastAPI, uvicorn, SQLAlchemy, asyncpg, +# Redis, Pydantic, watchdog, geoip2, typer), dev extras +# (pytest, ruff, mypy, pylint, coverage, fakeredis), and ml +# extras (torch, scikit-learn, onnxruntime, mlflow, pandas, +# imbalanced-learn). Uses hatchling as the build backend +# with app, cli, and ml packages. Configures ruff (line 95, +# Python 3.14 target), mypy (strict mode), pylint (4 jobs +# with pydantic plugin), and pytest (asyncio auto mode). +# Connects to all backend source modules [project] name = "angelusvigil" diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/__init__.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/__init__.py index e1add2a9..034d2c77 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/__init__.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/__init__.py @@ -1,4 +1,17 @@ """ ©AngelaMos | 2026 __init__.py + +Test suite package for the ai-threat-detection backend + +Contains unit, integration, and end-to-end tests covering +the full stack: API endpoints, ingestion pipeline, feature +extraction, rule engine, ML training and inference, +ensemble scoring, ONNX export, model metadata persistence, +CLI commands, and GeoIP enrichment. Uses pytest-asyncio for +async tests, fakeredis for Redis isolation, and in-memory +SQLite via aiosqlite for database tests + +Connects to: + tests/conftest - shared fixtures for DB and HTTP client """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/conftest.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/conftest.py index 399062b9..9a306b2b 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/conftest.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/conftest.py @@ -2,7 +2,22 @@ ©AngelaMos | 2026 conftest.py -Shared pytest fixtures for in-memory SQLite database and HTTPX test client setup. +Shared pytest fixtures for in-memory SQLite database and +HTTPX async test client setup + +test_settings overrides Settings for the test environment +with an in-memory SQLite URL and dummy paths. db_engine +creates a StaticPool aiosqlite engine with all tables via +SQLModel.metadata.create_all. db_session yields an +AsyncSession bound to the shared engine. db_client builds +a full HTTPX AsyncClient with ASGITransport wrapping the +FastAPI app, overriding get_session to use the in-memory +database with auto-commit + +Connects to: + app/config - Settings override + app/factory - create_app for ASGI transport + app/api/deps - get_session dependency override """ from collections.abc import AsyncIterator diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_api.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_api.py index eb0e1f94..c5074157 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_api.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_api.py @@ -2,7 +2,23 @@ ©AngelaMos | 2026 test_api.py -Tests the FastAPI REST endpoints for threats, stats, health, readiness, and model management. +Tests the FastAPI REST endpoints for health, threats, stats, +and model management using an in-memory database + +Validates /health returns status, uptime, and pipeline flag. +Tests /threats CRUD: empty list returns zero total, random +UUID returns 404, seeded event is fetchable by ID with all +fields, and severity filter returns only matching items. +Tests /stats returns zeroed counts on empty window. +Tests /models/status returns detection_mode and +active_models list, and POST /models/retrain returns 202 +with a 32-char job ID + +Connects to: + api/health - liveness endpoint + api/threats - threat CRUD + api/stats - statistics endpoint + api/models_api - model status and retrain """ import uuid diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_autoencoder.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_autoencoder.py index f8d96a01..df5b950b 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_autoencoder.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_autoencoder.py @@ -2,7 +2,21 @@ ©AngelaMos | 2026 test_autoencoder.py -Tests the ThreatAutoencoder architecture: shapes, output range, reconstruction error, and training behavior. +Tests the ThreatAutoencoder PyTorch architecture for shape +correctness, output range, reconstruction error, and +training behavior + +Validates output shape matches input (batch, 35), encoder +bottleneck compresses to 6 dimensions, single-sample +forward pass succeeds in eval mode, decoder output is +unbounded (matching RobustScaler range), reconstruction +error returns one positive scalar per sample, trained model +reconstructs normal data better than anomalies after 50 +epochs, eval mode produces deterministic output (dropout +off), and variable batch sizes (1, 8, 32, 128) are handled + +Connects to: + ml/autoencoder - ThreatAutoencoder """ import pytest diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py index 1d2da7f6..fe92fe1f 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 test_cli.py + +Tests the Typer CLI command help output, argument +validation, and metadata persistence wiring + +TestCLICommands validates train --help shows csic-dir and +synthetic options, nonexistent csic-dir exits with error, +replay/serve/config/health --help exit cleanly with +expected content, and missing replay log file fails. +TestCLITrainMetadata mocks the orchestrator to verify that +train emits a warning when DB metadata write is unavailable + +Connects to: + cli/main - Typer app with serve, train, replay, config, + health commands """ import re diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_config_ml.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_config_ml.py index 17a2203e..e4ed01e2 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_config_ml.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_config_ml.py @@ -2,7 +2,17 @@ ©AngelaMos | 2026 test_config_ml.py -Tests ML-related settings defaults: detection mode, ensemble weights, model paths, and MLflow URI. +Tests ML-related settings defaults for detection mode, +ensemble weights, model paths, and MLflow tracking URI + +Validates that the default detection_mode is 'rules', +ensemble weights (AE + RF + IF) sum to exactly 1.0, +model_dir defaults to 'data/models', ae_threshold_ +percentile defaults to 99.5, and mlflow_tracking_uri +defaults to 'file:./mlruns' + +Connects to: + app/config - Settings pydantic-settings model """ from app.config import settings diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_data_loader.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_data_loader.py index 8b01ad6c..f35745bc 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_data_loader.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_data_loader.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 test_data_loader.py + +Tests CSIC 2010 dataset parsing, CSICRequest-to- +ParsedLogEntry conversion, and end-to-end dataset loading + +TestParseCSICFile validates HTTP request block splitting, +method/path/query/header extraction, POST body capture, +attack label assignment, malformed block skipping, and +empty file handling using inline CSIC-format fixtures. +TestCSICToParsedEntry verifies synthesized defaults (IP, +timestamp, status) and POST body query string merging. +TestLoadCSICDataset confirms 35-column X shape, dual-label +y arrays, correct per-file label counts, and finite feature +values + +Connects to: + ml/data_loader - parse_csic_file, csic_to_parsed_entry, + load_csic_dataset """ from pathlib import Path diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_detection.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_detection.py index 6666d03c..22b0bebc 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_detection.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_detection.py @@ -2,7 +2,25 @@ ©AngelaMos | 2026 test_detection.py -Tests the rule engine's threat scoring, severity classification, and attack pattern matching. +Tests the RuleEngine threat scoring, severity +classification, and OWASP attack pattern matching + +Validates normal requests score LOW below 0.5, SQL +injection in query strings scores HIGH with SQL_INJECTION +rule, XSS payloads trigger XSS rule, path traversal +triggers PATH_TRAVERSAL, command injection triggers +COMMAND_INJECTION at HIGH severity, scanner UAs fire +SCANNER_UA, high request rates fire RATE_ANOMALY, multiple +rules aggregate to higher scores, scores are clamped to +[0, 1], severity thresholds align with architecture +(LOW < 0.5, MEDIUM >= 0.5, HIGH >= 0.7), component_scores +match matched_rules, FILE_INCLUSION detects PHP stream +wrappers, and DOUBLE_ENCODING detects %25-prefixed +sequences + +Connects to: + core/detection/rules - RuleEngine + core/ingestion/parsers - ParsedLogEntry """ from datetime import datetime, UTC diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ensemble.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ensemble.py index bce084ec..999f1147 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ensemble.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ensemble.py @@ -2,7 +2,23 @@ ©AngelaMos | 2026 test_ensemble.py -Tests ensemble score normalization, weighted fusion, ML/rule blending, and severity classification. +Tests ensemble score normalization, weighted fusion, ML/rule +blending, and severity classification functions + +TestScoreNormalization validates AE error below threshold +maps below 0.5, 3x threshold caps at 1.0, zero error maps +to 0.0, negative IF score maps above 0.5, positive below +0.5, and zero maps to 0.5. TestEnsembleFusion validates +weighted average computation, all-zero scores fuse to 0.0, +all-one scores fuse to 1.0, and partial model support. +TestBlendScores validates ML/rule blending at various +weights and clamping. TestClassifySeverity validates HIGH +at >= 0.7, MEDIUM at [0.5, 0.7), LOW below 0.5 + +Connects to: + core/detection/ensemble - normalize_ae_score, + normalize_if_score, fuse_scores, + blend_scores, classify_severity """ from app.core.detection.ensemble import ( diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_experiment.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_experiment.py index 0bffa51d..8cd7cca5 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_experiment.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_experiment.py @@ -2,7 +2,19 @@ ©AngelaMos | 2026 test_experiment.py -Tests the VigilExperiment MLflow wrapper: run lifecycle, param/metric logging, and status tagging. +Tests the VigilExperiment MLflow context manager for run +lifecycle, parameter/metric logging, and status tagging + +Uses a tmp_path MLflow tracking URI for isolation. +Validates run ID is set on context entry and None before, +log_params writes string values, log_metrics stores floats, +log_artifact uploads files to the artifact list, +python_version and platform system metadata tags are auto- +logged, successful exit tags status='completed', and +exception exit tags status='failed' with the error message + +Connects to: + ml/experiment - VigilExperiment """ from pathlib import Path diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_export_onnx.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_export_onnx.py index 09c50569..9e9dce17 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_export_onnx.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_export_onnx.py @@ -2,7 +2,22 @@ ©AngelaMos | 2026 test_export_onnx.py -Tests ONNX export for the autoencoder, random forest, and isolation forest models. +Tests ONNX export and inference parity for the autoencoder, +random forest, and isolation forest models + +TestAutoencoderExport validates file creation, PyTorch-to- +ONNX output match within 1e-5 tolerance, and dynamic batch +dimension (1, 16, 64). TestRandomForestExport validates +file creation and ONNX inference returning class predictions +and probabilities. TestIsolationForestExport validates file +creation and ONNX anomaly scores matching sklearn +decision_function within 1e-4 tolerance + +Connects to: + ml/export_onnx - export_autoencoder, + export_random_forest, + export_isolation_forest + ml/autoencoder - ThreatAutoencoder for AE export """ from pathlib import Path diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_features.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_features.py index 34fee45e..9771ee33 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_features.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_features.py @@ -2,7 +2,31 @@ ©AngelaMos | 2026 test_features.py -Tests per-request feature extraction, Redis sliding-window aggregation, and feature encoding. +Tests the 23 per-request feature extractor, Redis sliding- +window aggregator (12 windowed features), and 35-dim +feature encoder + +Validates all 23 feature keys are returned, path_depth +counts segments, path_entropy distinguishes random vs +simple paths, query param count and length, percent- +encoding and double-encoding detection, status class +grouping, temporal features (hour, day, weekend), bot +and scanner UA detection, attack pattern detection (SQLi, +XSS, traversal), special char ratio, private IP, file +extension, and country code passthrough. WindowAggregator +tests use fakeredis to validate single/multi-request +counts, error rate calculation, unique paths/UAs, TTL +setting, and window boundary exclusion. Encoder tests +validate 35-element output, method/status ordinal mapping, +boolean-to-float, numerical passthrough, and unknown +categorical fallback + +Connects to: + core/features/extractor - extract_request_features + core/features/aggregator - WindowAggregator + core/features/encoder - encode_for_inference + core/features/mappings - FEATURE_ORDER, METHOD_MAP, + STATUS_CLASS_MAP """ import time diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_geoip.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_geoip.py index 30a6d080..df9cb7f3 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_geoip.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_geoip.py @@ -2,7 +2,19 @@ ©AngelaMos | 2026 test_geoip.py -Tests the GeoIP lookup service including private IP handling and missing database fallback. +Tests the GeoIPService MaxMind lookup including private IP +handling, error cases, and missing database fallback + +Validates GeoResult field storage, successful lookup +returning country/city/lat/lon, private and loopback IPs +returning None without hitting the reader, AddressNotFound +Error returning None, None reader returning None, missing +city name handled gracefully, non-existent .mmdb path sets +reader to None, and valid .mmdb path opens the reader via +mock + +Connects to: + core/enrichment/geoip - GeoIPService, GeoResult """ from unittest.mock import MagicMock, patch diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_inference.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_inference.py index 91baf558..a407dea9 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_inference.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_inference.py @@ -2,7 +2,23 @@ ©AngelaMos | 2026 test_inference.py -Tests the InferenceEngine: model loading, predict output shapes, score ranges, and missing-model handling. +Tests the ONNX InferenceEngine for model loading, batch +prediction, score ranges, and error handling + +Uses a model_dir fixture with all 3 exported ONNX models, +scaler.json, and threshold.json. Validates is_loaded=True +with all models, is_loaded=False for nonexistent and +partial directories, predict returns None when not loaded, +predict returns ae/rf/if score dicts, AE scores are non- +negative, RF probabilities are in [0, 1], single-sample +prediction works, threshold loads from JSON, and partial +model sets (AE only) report not loaded + +Connects to: + core/detection/inference - InferenceEngine + ml/export_onnx - model export for fixture + ml/scaler - FeatureScaler for fixture + ml/autoencoder - ThreatAutoencoder for fixture """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_integration.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_integration.py index 1fc612ce..b461a1b1 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_integration.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_integration.py @@ -2,7 +2,24 @@ ©AngelaMos | 2026 test_integration.py -End-to-end tests covering the full path from log file write through tailer, pipeline, and database storage. +End-to-end tests covering the full path from log file +write through tailer, pipeline, and database storage + +integration_env fixture creates a temp log file, in-memory +SQLite, fake Redis, AlertDispatcher, RuleEngine, Pipeline, +and LogTailer wired together. Tests write nginx-format log +lines (normal, SQLi, XSS, path traversal) to the file and +poll the database for stored ThreatEvent rows. Validates +that MEDIUM+ threats are persisted, LOW severity requests +are not stored, and stored events have correct severity, +score, matched_rules, feature_vector length, and source_ip + +Connects to: + core/ingestion/tailer - LogTailer + core/ingestion/pipeline - Pipeline + core/alerts/dispatcher - AlertDispatcher + core/detection/rules - RuleEngine + models/threat_event - ThreatEvent """ import asyncio diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py index d205436d..f513dace 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 test_metadata.py + +Tests SHA-256 model version hashing and async metadata +persistence to the database + +TestComputeModelVersion verifies 12-char hex output, +deterministic hashing (same file = same version), and +distinct versions for different files. TestSaveModel +Metadata uses an in-memory SQLite session and fake ONNX +artifacts to validate 3-row creation (one per model type), +is_active flag on new rows, correct model_type values +(autoencoder, random_forest, isolation_forest), previous +active row deactivation on re-save, and inactive row +preservation (6 total rows after two saves) + +Connects to: + ml/metadata - compute_model_version, + save_model_metadata + models/model_metadata - ModelMetadata ORM model """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ml_integration.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ml_integration.py index 3e4a2af8..dd80b464 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ml_integration.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_ml_integration.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 test_ml_integration.py + +Tests the ML inference engine wired into the ingestion +pipeline in hybrid detection mode + +Uses a trained_model_dir fixture with ONNX models to build +a pipeline with InferenceEngine. Validates hybrid detection +mode is set when ML models are present, final_score is in +[0, 1], rules-only mode falls back to rule score as final +score, attack lines score higher than benign in hybrid +mode, and rule_result is preserved alongside ML scores + +Connects to: + core/detection/inference - InferenceEngine + core/detection/rules - RuleEngine + core/ingestion/pipeline - Pipeline, ScoredRequest + ml/export_onnx - model export for fixture """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_orchestrator.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_orchestrator.py index 682d5367..39937891 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_orchestrator.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_orchestrator.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 test_orchestrator.py + +Tests the TrainingOrchestrator pipeline from data splitting +through model export, validation, and MLflow logging + +Verifies all 5 output files are produced (ae.onnx, rf.onnx, +if.onnx, scaler.json, threshold.json), TrainingResult +dataclass structure, scaler.json keys (center, scale, +n_features), threshold.json float value, per-model metrics +presence (ae_threshold, rf f1, if n_samples), ensemble +validation metrics, MLflow run ID capture (32-char hex), +and passed_gates boolean type + +Connects to: + ml/orchestrator - TrainingOrchestrator, TrainingResult """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_parsers.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_parsers.py index 01a2639b..542ac560 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_parsers.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_parsers.py @@ -2,7 +2,19 @@ ©AngelaMos | 2026 test_parsers.py -Tests nginx combined log line parsing via parse_combined. +Tests nginx combined-format log line parsing via the +parse_combined function + +Validates full field extraction (IP, timestamp, method, +path, query string, status code, response size, referer, +user agent, raw line), IPv4 and IPv6 address handling, +dash-referer normalization to empty string, multi-parameter +query strings with special characters, malformed and empty +line None returns, dash response size normalization to +zero, and full-length IPv6 address parsing + +Connects to: + core/ingestion/parsers - parse_combined, ParsedLogEntry """ from datetime import datetime, UTC diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_pipeline.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_pipeline.py index 5befebb3..3171494d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_pipeline.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_pipeline.py @@ -2,7 +2,21 @@ ©AngelaMos | 2026 test_pipeline.py -Tests the async ingestion pipeline: parsing, feature extraction, rule scoring, and shutdown. +Tests the async ingestion pipeline across all 4 stages: +parsing, feature extraction, rule scoring, and dispatch + +Uses a fakeredis-backed Pipeline with a results collector +callback. Validates that valid log lines flow end-to-end +producing a ScoredRequest with correct IP, method, 35-dim +feature vector, and LOW severity. Confirms malformed lines +are dropped without crashing, backpressure works with +maxsize=1 queues, stop() drains remaining items with all +tasks completing cleanly, and SQLi payloads score HIGH with +SQL_INJECTION rule match + +Connects to: + core/ingestion/pipeline - Pipeline, ScoredRequest + core/detection/rules - RuleEngine """ import fakeredis.aioredis diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_scaler.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_scaler.py index 2125455a..2631fc74 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_scaler.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_scaler.py @@ -2,7 +2,21 @@ ©AngelaMos | 2026 test_scaler.py -Tests the FeatureScaler: fitting, transform correctness, JSON serialization, and round-trip loading. +Tests the FeatureScaler IQR-based normalization for +fitting, transform correctness, JSON round-trip, and error +handling + +Validates n_features is stored after fit, transform +preserves shape and float32 dtype, median of scaled +features is near zero, inverse_transform recovers original +values within 1e-5, save_json creates a valid JSON file +with center/scale/n_features keys, load_json round-trip +produces identical transform output within 1e-6, transform +before fit raises RuntimeError, and fit_transform +convenience method works + +Connects to: + ml/scaler - FeatureScaler """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_splitting.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_splitting.py index 73d17644..097b80d0 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_splitting.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_splitting.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 test_splitting.py + +Tests stratified train/val/test splitting with SMOTE +oversampling for imbalanced datasets + +Validates TrainingSplit dataclass return, 70/15/15 split +proportions within tolerance, stratified class distribution +preservation in val/test sets, SMOTE minority ratio near +target strategy (0.3), val/test sizes unaffected by SMOTE, +X_normal_train containing only class-0 rows, small dataset +(50 samples) success, single-class ValueError, and SMOTE +skip when minority count is below k_neighbors threshold + +Connects to: + ml/splitting - prepare_training_data, TrainingSplit """ import numpy as np diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py index c4fc48c1..5973fbb9 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 test_synthetic.py + +Tests synthetic HTTP traffic generators and mixed dataset +assembly for ML training + +TestGenerators validates all 7 per-type generators (SQLi, +XSS, traversal, Log4Shell, SSRF, scanner, normal) return +correct counts, contain expected payload patterns (OR/UNION +for SQLi, script/alert for XSS, ../ for traversal), return +ParsedLogEntry instances, and pass through feature +extraction and encoding to 35-dim vectors. TestMixedDataset +verifies correct X shape (n, 35), dual-label y, matching +label counts, and finite feature values + +Connects to: + ml/synthetic - all generate_* functions, + generate_mixed_dataset + core/features/extractor - extract_request_features + core/features/encoder - encode_for_inference """ import numpy as np diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training.py index a5d32b14..ac8d4b1c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training.py @@ -2,7 +2,24 @@ ©AngelaMos | 2026 test_training.py -Tests training pipelines for the autoencoder, random forest, and isolation forest models. +Tests training functions for the autoencoder, random forest, +and isolation forest models + +TestAutoencoderTraining validates train_autoencoder returns +model/threshold/scaler/history, threshold is positive, +history has correct epoch count, higher percentile yields +higher threshold, and returned model is in eval mode. +TestRandomForestTraining validates model/metrics return, +predict_proba availability, required metric keys (f1, +pr_auc, accuracy, precision, recall), probability range, +and metric value range. TestIsolationForestTraining +validates model return, score_samples availability, +n_samples metric, and normal/outlier score separation + +Connects to: + ml/train_autoencoder - train_autoencoder + ml/train_classifiers - train_random_forest, + train_isolation_forest """ import numpy as np diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training_e2e.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training_e2e.py index 36465b41..c63b21f7 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training_e2e.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_training_e2e.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 test_training_e2e.py + +End-to-end training integration test from synthetic data +generation through ONNX inference and score fusion + +test_full_training_produces_loadable_models generates a +500-normal/200-attack synthetic dataset, runs the full +TrainingOrchestrator pipeline with 3 epochs, verifies all +5 output files (ae.onnx, rf.onnx, if.onnx, scaler.json, +threshold.json), loads models via InferenceEngine, runs +batch prediction, normalizes and fuses per-model scores, +blends with rule scores, and asserts all values are in +[0, 1]. Validates passed_gates is a boolean + +Connects to: + ml/orchestrator - TrainingOrchestrator + ml/synthetic - generate_mixed_dataset + core/detection/ensemble - normalize, fuse, blend + core/detection/inference - InferenceEngine """ from pathlib import Path diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_validation.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_validation.py index 9bff193f..0eaed56a 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_validation.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_validation.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 test_validation.py + +Tests post-training ensemble validation with quality gates + +Uses a trained_model_dir fixture with all 3 ONNX models, +scaler, and threshold, plus a separable_test_data fixture +with well-separated normal/attack clusters. Validates +ValidationResult structure, metric ranges (precision, +recall, f1, pr_auc, roc_auc all in [0, 1]), 2x2 confusion +matrix shape, gate_details keys (pr_auc, f1), gate pass +with low thresholds, gate fail with high thresholds, and +custom ensemble weight acceptance + +Connects to: + ml/validation - validate_ensemble, ValidationResult + ml/export_onnx - model export for fixture setup + ml/scaler - FeatureScaler for fixture setup """ import json diff --git a/PROJECTS/advanced/ai-threat-detection/compose.yml b/PROJECTS/advanced/ai-threat-detection/compose.yml index e114396d..86a1d593 100644 --- a/PROJECTS/advanced/ai-threat-detection/compose.yml +++ b/PROJECTS/advanced/ai-threat-detection/compose.yml @@ -1,5 +1,20 @@ # ©AngelaMos | 2026 -# Production Docker Compose +# compose.yml +# +# Production Docker Compose stack for AngelusVigil +# +# Orchestrates 5 services on the vigil_network bridge: +# postgres (18-alpine with healthcheck and persistent +# volume), redis (7.4-alpine with custom redis.conf), +# backend (FastAPI with asyncpg, Redis, nginx log tail, +# GeoIP, and model data volumes), frontend (Vite +# production build served via nginx on the host port), +# and geoip-updater (MaxMind weekly refresh). Joins the +# external certgames_net network and mounts the external +# certgames_nginx_logs volume for real-time log access. +# Connects to infra/docker/fastapi.prod, +# infra/docker/vite.prod, infra/redis/redis.conf, +# infra/nginx/vigil.conf services: postgres: diff --git a/PROJECTS/advanced/ai-threat-detection/dev-log/Dockerfile b/PROJECTS/advanced/ai-threat-detection/dev-log/Dockerfile index b7674904..96aab5d2 100644 --- a/PROJECTS/advanced/ai-threat-detection/dev-log/Dockerfile +++ b/PROJECTS/advanced/ai-threat-detection/dev-log/Dockerfile @@ -1,5 +1,12 @@ # ©AngelaMos | 2026 # Dockerfile +# +# Container image for the dev-log FastAPI target application +# +# Based on python:3.14-slim with uv copied from the official +# astral-sh image. Installs fastapi and uvicorn system-wide, +# copies app.py, and runs uvicorn on port 8000. Sits behind +# the nginx reverse proxy defined in compose.yml FROM python:3.14-slim diff --git a/PROJECTS/advanced/ai-threat-detection/dev-log/app.py b/PROJECTS/advanced/ai-threat-detection/dev-log/app.py index 3850b1f6..3ccf2e9e 100644 --- a/PROJECTS/advanced/ai-threat-detection/dev-log/app.py +++ b/PROJECTS/advanced/ai-threat-detection/dev-log/app.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 app.py + +Minimal FastAPI target application for generating nginx +access logs during development + +Exposes realistic REST endpoints that the simulate.py +traffic generator hits through the nginx reverse proxy: +/ (HTML landing), /health, /api/users (list and by ID), +/api/login (POST returning a fake JWT), /api/search with +query parameter, /api/products (list and by ID), +/api/checkout (POST), /admin and /admin/dashboard (403 +forbidden), and /static/{path} (404). Designed to produce +diverse nginx combined-format log lines for testing the +ingestion pipeline and rule engine + +Connects to: + dev-log/nginx.conf - proxied behind nginx + dev-log/simulate.py - traffic generator targets these + endpoints + dev-log/compose.yml - containerized as vigil-devlog-app """ from fastapi import FastAPI, Request diff --git a/PROJECTS/advanced/ai-threat-detection/dev-log/compose.yml b/PROJECTS/advanced/ai-threat-detection/dev-log/compose.yml index 98d26ea0..bd2c89af 100644 --- a/PROJECTS/advanced/ai-threat-detection/dev-log/compose.yml +++ b/PROJECTS/advanced/ai-threat-detection/dev-log/compose.yml @@ -1,5 +1,15 @@ # ©AngelaMos | 2026 # compose.yml +# +# Docker Compose stack for the dev-log traffic generation +# environment +# +# Runs two services on a bridge network: app (FastAPI target +# built from the local Dockerfile with a /health check) and +# nginx (alpine image reverse-proxying port 58319 to the +# app, writing combined-format access logs to a named volume +# vigil_dev_nginx_logs). The nginx container clears stale +# log files on startup for clean sessions services: app: diff --git a/PROJECTS/advanced/ai-threat-detection/dev-log/nginx.conf b/PROJECTS/advanced/ai-threat-detection/dev-log/nginx.conf index 7ac766c5..2dfd095e 100644 --- a/PROJECTS/advanced/ai-threat-detection/dev-log/nginx.conf +++ b/PROJECTS/advanced/ai-threat-detection/dev-log/nginx.conf @@ -1,5 +1,15 @@ # ©AngelaMos | 2026 # nginx.conf +# +# Nginx reverse proxy configuration for the dev-log traffic +# generation environment +# +# Listens on port 80, proxies all requests to the upstream +# FastAPI app on port 8000, and writes combined-format +# access logs to /var/log/nginx/access.log. Sets X-Real-IP, +# X-Forwarded-For, and X-Forwarded-Proto headers for the +# backend. The log output is mounted as a named volume in +# compose.yml for consumption by the ingestion pipeline events { worker_connections 64; diff --git a/PROJECTS/advanced/ai-threat-detection/dev-log/simulate.py b/PROJECTS/advanced/ai-threat-detection/dev-log/simulate.py index d054c5c1..db011e32 100755 --- a/PROJECTS/advanced/ai-threat-detection/dev-log/simulate.py +++ b/PROJECTS/advanced/ai-threat-detection/dev-log/simulate.py @@ -2,6 +2,26 @@ """ ©AngelaMos | 2026 simulate.py + +HTTP traffic simulator for generating realistic attack and +normal log patterns against the dev-log target application + +Provides 10 traffic modes via argparse: normal (benign +browsing with GET/POST mix), sqli (12 SQL injection +payloads), xss (10 script/event handler vectors), +traversal (10 dot-dot-slash and encoding variants), cmdi +(7 shell command injection payloads), log4shell (4 JNDI +lookup variants), ssrf (5 cloud metadata and internal +service targets), scanner (20 recon paths with 11 scanner +user-agents), flood (rapid-fire requests), and mixed +(50/10/40 normal/scanner/attack split). Uses urllib for +HTTP requests with configurable count, delay, and target +URL. Checks /health reachability before starting + +Connects to: + dev-log/app.py - target endpoints + dev-log/nginx.conf - requests proxied through nginx + to generate access.log entries """ import argparse diff --git a/PROJECTS/advanced/ai-threat-detection/dev.compose.yml b/PROJECTS/advanced/ai-threat-detection/dev.compose.yml index d51d23b4..3575a9aa 100644 --- a/PROJECTS/advanced/ai-threat-detection/dev.compose.yml +++ b/PROJECTS/advanced/ai-threat-detection/dev.compose.yml @@ -1,5 +1,18 @@ # ©AngelaMos | 2026 # dev.compose.yml +# +# Development Docker Compose stack with exposed ports and +# hot reload +# +# Orchestrates 4 services on the vigil_dev bridge: postgres +# (18-alpine on host port 16969 with default devpassword), +# redis (7.4-alpine on host port 26969 with appendonly), +# backend (FastAPI dev build on host port 36969 with debug +# enabled, quiet gitpython, and SKIP_AUTO_TRAIN toggle), +# and frontend (Vite dev server on host port 46969 with +# source bind-mount for HMR and API proxy to the backend). +# Connects to infra/docker/fastapi.dev, +# infra/docker/vite.dev services: postgres: diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/App.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/App.tsx index efbd5388..cfdc5a6b 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/App.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/App.tsx @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // App.tsx +// +// Root React component with providers and routing +// +// Wraps the application in QueryClientProvider (TanStack +// React Query), provides the browser router via +// RouterProvider, renders a dark-themed Sonner toast +// container at top-right, and includes ReactQueryDevtools +// in development mode // =================== import { QueryClientProvider } from '@tanstack/react-query' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/index.ts index 880b1482..06a72379 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/index.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/index.ts @@ -1,6 +1,13 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Barrel export for API query hooks +// +// Re-exports useAlerts (WebSocket alert stream), +// useModelStatus and useRetrain (model management), +// useStats (dashboard statistics), and useThreats and +// useThreat (threat event listing and detail) // =================== export * from './useAlerts' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useAlerts.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useAlerts.ts index b4a2eb38..392684bf 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useAlerts.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useAlerts.ts @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // useAlerts.ts +// +// WebSocket alert stream hook with Zustand state and +// exponential reconnect +// +// Maintains a Zustand AlertState store holding the alert +// ring buffer (capped at ALERTS.MAX_ITEMS), connection +// status, and error state. useAlerts opens a WebSocket to +// WS_ENDPOINTS.ALERTS, validates incoming JSON frames +// against WebSocketAlertSchema via safeParse, stamps each +// with a crypto.randomUUID id, and prepends to the store. +// On close the hook schedules reconnection with exponential +// backoff (RECONNECT_BASE_MS * 2^attempt, capped at +// RECONNECT_MAX_MS). Cleanup on unmount closes the socket +// and clears the retry timer. Connects to api/types/ +// websocket.types, config, components/alert-feed // =================== import { useEffect, useRef } from 'react' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useModels.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useModels.ts index 99796a90..12591e40 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useModels.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useModels.ts @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // useModels.ts +// +// TanStack Query hooks for model status and retraining +// +// useModelStatus fetches API_ENDPOINTS.MODELS.STATUS and +// validates the response through ModelStatusSchema, using +// the standard query strategy. useRetrain posts to +// API_ENDPOINTS.MODELS.RETRAIN, validates through +// RetrainResponseSchema, shows a Sonner success toast, and +// invalidates all QUERY_KEYS.MODELS queries to refresh the +// status display. Connects to api/types/models.types, +// core/api, config, pages/models // =================== import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useStats.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useStats.ts index 10ef089f..e5633aa0 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useStats.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useStats.ts @@ -1,6 +1,16 @@ // =================== // © AngelaMos | 2026 // useStats.ts +// +// TanStack Query hook for dashboard statistics +// +// useStats accepts an optional time range string (defaults +// to 24h) and queries API_ENDPOINTS.STATS with the range +// as a query parameter. The response is validated through +// StatsResponseSchema and the hook uses the frequent query +// strategy for short stale times and automatic refetch +// intervals. Connects to api/types/stats.types, core/api, +// config, pages/dashboard // =================== import { useQuery } from '@tanstack/react-query' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useThreats.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useThreats.ts index 047a6c3a..6ef969f0 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useThreats.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/hooks/useThreats.ts @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // useThreats.ts +// +// TanStack Query hooks for threat event listing and detail +// +// useThreats accepts optional ThreatParams (limit, offset, +// severity, source_ip, since, until) with PAGINATION +// defaults, queries API_ENDPOINTS.THREATS.LIST, and +// validates through ThreatListSchema using the frequent +// strategy. useThreat fetches a single threat by id from +// API_ENDPOINTS.THREATS.BY_ID, validates through +// ThreatEventSchema, and is disabled when id is null. +// Connects to api/types/threats.types, core/api, config, +// pages/threats // =================== import { useQuery } from '@tanstack/react-query' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/index.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/index.ts index 8eb1f51a..0da48c21 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/index.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/index.ts @@ -1,6 +1,13 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Barrel export for the API layer +// +// Re-exports all TanStack Query hooks (useAlerts, +// useModelStatus, useRetrain, useStats, useThreats, +// useThreat) and Zod-validated type definitions from the +// hooks and types sub-modules // =================== export * from './hooks' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/index.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/index.ts index 17e60acc..8854805f 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/index.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/index.ts @@ -1,6 +1,15 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Barrel export for Zod-validated API type definitions +// +// Re-exports all Zod schemas and inferred TypeScript types +// from models.types (ActiveModel, ModelStatus, +// RetrainResponse), stats.types (SeverityBreakdown, +// IPStatEntry, PathStatEntry, StatsResponse), threats.types +// (GeoInfo, ThreatEvent, ThreatList), and websocket.types +// (WebSocketAlert) // =================== export * from './models.types' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/models.types.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/models.types.ts index 2c0bc055..029974b3 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/models.types.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/models.types.ts @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // models.types.ts +// +// Zod schemas and types for ML model status and retraining +// +// Defines ActiveModelSchema with model_type, version, +// training_samples, a flexible metrics record, and nullable +// threshold. ModelStatusSchema wraps models_loaded flag, +// detection_mode string, and an array of ActiveModel +// entries. RetrainResponseSchema captures the status and +// job_id returned when a retrain is triggered. All types +// are inferred from their schemas via z.infer. Connects to +// api/hooks/useModels, pages/models // =================== import { z } from 'zod' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/stats.types.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/stats.types.ts index bb8b8985..a16c889e 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/stats.types.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/stats.types.ts @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // stats.types.ts +// +// Zod schemas and types for dashboard statistics +// +// Defines SeverityBreakdownSchema with high/medium/low +// integer counts, IPStatEntrySchema and PathStatEntrySchema +// for ranked lists with source_ip or path plus count, and +// StatsResponseSchema combining time_range, +// threats_stored, threats_detected, severity_breakdown, +// top_source_ips, and top_attacked_paths. All types are +// inferred from their schemas via z.infer. Connects to +// api/hooks/useStats, pages/dashboard // =================== import { z } from 'zod' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/threats.types.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/threats.types.ts index 01825d15..cc445c0c 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/threats.types.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/threats.types.ts @@ -1,6 +1,19 @@ // =================== // © AngelaMos | 2026 // threats.types.ts +// +// Zod schemas and types for threat event data +// +// Defines GeoInfoSchema with nullable country, city, lat, +// and lon fields. ThreatEventSchema captures the full +// threat record: uuid id, timestamps, source_ip, HTTP +// request details, threat_score, severity enum (HIGH, +// MEDIUM, LOW), per-model component_scores record, geo +// info, nullable matched_rules array, model_version, +// reviewed flag, and review_label. ThreatListSchema wraps +// paginated results with total, limit, offset, and items +// array. Connects to api/hooks/useThreats, pages/threats, +// components/threat-detail // =================== import { z } from 'zod' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/websocket.types.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/websocket.types.ts index d07f9564..8a70a6a4 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/websocket.types.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/api/types/websocket.types.ts @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // websocket.types.ts +// +// Zod schema and type for real-time WebSocket alert frames +// +// Defines WebSocketAlertSchema validating incoming JSON +// frames from the alert WebSocket: optional id (stamped +// client-side), literal 'threat' event discriminator, +// timestamp, source_ip, request_method (defaults to GET), +// request_path, threat_score, severity string, and +// per-model component_scores record. The WebSocketAlert +// type is inferred via z.infer. Connects to +// api/hooks/useAlerts, components/alert-feed // =================== import { z } from 'zod' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.module.scss index bc2739b4..09f904ed 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.module.scss @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // alert-feed.module.scss +// +// CSS module for the live alert feed component +// +// Styles the feed container with surface background and +// muted border, a header row with title and connection +// status dot (green connected, red disconnected), and a +// scrollable list of alert rows in a 6-column grid +// (time, IP, method, path, severity, score). Applies +// tabular-nums to time and score columns, monospace to +// IP, truncation to path, and per-method color classes +// (methodGet through methodOptions) using $method-* tokens. +// Connects to components/alert-feed, styles/_tokens // =================== @use '@/styles/tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.tsx index 4ef8b0c4..eef1904a 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/alert-feed.tsx @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // alert-feed.tsx +// +// Live WebSocket alert feed with auto-scroll and color-coded +// entries +// +// Renders a scrollable list of WebSocketAlert items with a +// connection status indicator dot (green connected, red +// disconnected). Each row displays formatted timestamp, +// source IP, HTTP method with color-coded SCSS class +// (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS), request path, +// SeverityBadge, and threat score. Auto-scrolls to the top +// on new alerts via a useEffect keyed on alert count. Shows +// an empty-state message when no alerts are present. +// Connects to api/types/websocket.types, +// components/severity-badge, api/hooks/useAlerts, +// pages/dashboard // =================== import { useEffect, useRef } from 'react' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/index.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/index.tsx index 07d23849..1bff3b4e 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/index.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/index.tsx @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // index.tsx +// +// Barrel export for shared UI components +// +// Re-exports AlertFeed (live WebSocket alert stream), +// MethodBadge (color-coded HTTP method label), +// SeverityBadge (HIGH/MEDIUM/LOW indicator), StatCard +// (metric display with label and sublabel), and +// ThreatDetail (modal dialog for full threat inspection) // =================== export { AlertFeed } from './alert-feed' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.module.scss index d0d6d48f..04f45201 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.module.scss @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // method-badge.module.scss +// +// CSS module for the HTTP method badge component +// +// Styles a monospace semibold 2xs text badge with wide +// letter spacing and a default $text-lighter color. Seven +// method variant classes (.get through .options) apply +// $method-* token colors. Connects to +// components/method-badge, styles/_tokens // =================== @use '@/styles/tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.tsx index 10be6ff8..febcb828 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/method-badge.tsx @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // method-badge.tsx +// +// Color-coded HTTP method badge component +// +// Renders a span with a base badge class and an +// additional SCSS module class mapped from the method +// string (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) +// via the METHOD_STYLES record. Unknown methods receive +// only the base style. Connects to pages/threats // =================== import styles from './method-badge.module.scss' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.module.scss index 338d2b01..d1cc952e 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.module.scss @@ -1,6 +1,16 @@ // =================== // © AngelaMos | 2026 // severity-badge.module.scss +// +// CSS module for the severity badge component +// +// Styles a compact inline-flex pill badge with full border +// radius, 2xs semibold uppercase text, and wider letter +// spacing. Three severity variants map to token pairs: +// .high ($severity-high text on $severity-high-bg), .medium +// ($severity-medium on $severity-medium-bg), and .low +// ($severity-low on $severity-low-bg). Connects to +// components/severity-badge, styles/_tokens // =================== @use '@/styles/tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.tsx index 2389db84..3b883ae5 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/severity-badge.tsx @@ -1,6 +1,15 @@ // =================== // © AngelaMos | 2026 // severity-badge.tsx +// +// Threat severity level badge component +// +// Renders a span with a base badge class and a +// severity-specific SCSS module class derived by +// lowercasing the severity prop (HIGH, MEDIUM, LOW). +// Used across the alert feed, threats table, and threat +// detail modal. Connects to components/alert-feed, +// components/threat-detail, pages/threats, pages/dashboard // =================== import styles from './severity-badge.module.scss' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.module.scss index 33b01cc7..e7557319 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.module.scss @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // stat-card.module.scss +// +// CSS module for the dashboard metric card component +// +// Styles a column flex card with surface background, muted +// border, and large border radius. Displays a 3xl semibold +// value with tight letter spacing, an sm lighter label, and +// an optional xs muted sublabel. Connects to +// components/stat-card, styles/_tokens // =================== @use '@/styles/tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.tsx index cfd5da7e..8b06ffa8 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/stat-card.tsx @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // stat-card.tsx +// +// Dashboard metric card component +// +// Renders a card displaying a prominent value (string or +// number), a descriptive label underneath, and an optional +// sublabel for secondary context. Used on the dashboard +// page to show threat counts, detection rates, and time +// range indicators. Connects to pages/dashboard // =================== import styles from './stat-card.module.scss' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.module.scss index d6145de2..c3f991ae 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.module.scss @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // threat-detail.module.scss +// +// CSS module for the threat detail slide-in panel +// +// Styles a full-height modal overlay (60% opacity black, +// z-modal) with a right-aligned 520px max-width panel. +// Contains a sticky header with close button, sectioned +// body with 2-column grids for overview and request fields, +// a component scores section with labeled progress bars +// ($accent fill on $bg-surface-300 track), and a matched +// rules section with monospace accent-tinted tags. Connects +// to components/threat-detail, styles/_tokens // =================== @use '@/styles/tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.tsx index 83c7bc5b..ef79d97b 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/components/threat-detail.tsx @@ -1,6 +1,19 @@ // =================== // © AngelaMos | 2026 // threat-detail.tsx +// +// Modal dialog for full threat event inspection +// +// Renders a click-to-dismiss overlay with a detail panel +// displaying four sections: Overview (severity badge, +// threat score to 4 decimals, detection timestamp, review +// status), Request (source IP, method, path, status code, +// response size, user agent), Component Scores (per-model +// score bars with percentage fill widths), and conditionally +// Geolocation (country, city) and Matched Rules (tag list). +// Returns null when threat prop is null. Connects to +// api/types/threats.types, components/severity-badge, +// pages/threats // =================== import { LuX } from 'react-icons/lu' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/config.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/config.ts index 5ed0f050..dc40e75d 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/config.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/config.ts @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // config.ts +// +// Application-wide constants and configuration values +// +// Centralizes all API endpoint paths (health, ready, +// threats CRUD, stats, model status/retrain), WebSocket +// endpoint (/ws/alerts), TanStack Query cache keys with +// hierarchical namespacing, client-side route paths +// (dashboard, threats, models), localStorage key for UI +// persistence, query timing config (stale, GC, retry), +// pagination defaults (50/100 limit), and alert feed +// settings (max 50 items, exponential reconnect 1s-30s) // =================== export const API_ENDPOINTS = { diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/api.config.ts index 162ae2db..1a98c999 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/api.config.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/api.config.ts @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // api.config.ts +// +// Axios HTTP client singleton with error interceptor +// +// Creates an axios instance with base URL from VITE_API_URL +// env var (defaulting to /api), 15-second timeout, and JSON +// content type. Response interceptor transforms AxiosError +// into typed ApiError via transformAxiosError for consistent +// error handling across all API hooks // =================== import axios, { type AxiosError, type AxiosInstance } from 'axios' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/errors.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/errors.ts index a838ea42..05a2e831 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/errors.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/errors.ts @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // errors.ts +// +// Typed API error handling with status code mapping and +// user-facing messages +// +// Defines ApiErrorCode literal union (NETWORK_ERROR, +// VALIDATION_ERROR, NOT_FOUND, CONFLICT, RATE_LIMITED, +// SERVER_ERROR, UNKNOWN_ERROR), ApiError class with code, +// statusCode, details, and getUserMessage() for toast +// display, and transformAxiosError which maps HTTP status +// codes to ApiErrorCode and extracts detail/message from +// FastAPI error responses. Registers ApiError as the +// TanStack React Query default error type // =================== import type { AxiosError } from 'axios' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/index.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/index.ts index f6dc3639..816c59f0 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/index.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/index.ts @@ -1,6 +1,12 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Barrel export for the core API module +// +// Re-exports apiClient from api.config, ApiError and +// transformAxiosError from errors, and queryClient with +// QUERY_STRATEGIES from query.config // =================== export * from './api.config' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/query.config.ts b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/query.config.ts index 9133f9f9..00469d6a 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/query.config.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/api/query.config.ts @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // query.config.ts +// +// TanStack React Query client configuration with retry +// logic and global error toasts +// +// Configures QueryClient with smart retry (skips NOT_FOUND +// and VALIDATION_ERROR, retries up to 3 times with +// exponential backoff capped at 30s), window focus refetch, +// and reconnect refetch. QueryCache shows toast errors only +// for background updates (stale data present). MutationCache +// shows toast errors only when no per-mutation onError is +// set. Exports QUERY_STRATEGIES (standard, frequent, static) +// with tuned stale/gc/refetch settings // =================== import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/routers.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/routers.tsx index bfadf9be..1103c7a0 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/routers.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/routers.tsx @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // routers.tsx +// +// Browser router with lazy-loaded page routes under the +// Shell layout +// +// Defines a createBrowserRouter with Shell as the root +// layout element containing 3 lazy-loaded child routes: +// dashboard (/), threats (/threats), and models (/models). +// Unknown paths redirect to the dashboard via Navigate // =================== import { createBrowserRouter, Navigate, type RouteObject } from 'react-router-dom' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.module.scss index 47f0f1f5..1fc56c64 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.module.scss @@ -1,6 +1,20 @@ // =================== // © AngelaMos | 2026 // shell.module.scss +// +// Application shell layout styles with collapsible sidebar +// and responsive mobile drawer +// +// Styles a fixed-position sidebar (240px expanded, 64px +// collapsed) with dot-grid background, NavLink items with +// hover and active states, red-tinted nav icons, a chevron +// collapse toggle hidden on mobile, and a slide-in mobile +// drawer with overlay dismiss. The main content area adjusts +// margin-left to match sidebar width, contains a sticky +// header with dot-grid background and mobile hamburger +// button, and a scrollable content region with radial-dot +// background pattern. Includes error and loading fallback +// states. Connects to core/app/shell, styles/_index // =================== @use '@/styles' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.tsx index 85c2f031..9820facb 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/shell.tsx @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // shell.tsx +// +// Root application shell with sidebar navigation, header, +// and content outlet +// +// Renders a collapsible sidebar with NavLink items +// (Dashboard, Threats, Models), a mobile hamburger menu +// toggle with overlay dismiss, a header showing the current +// page title, and a main content area wrapped in +// ErrorBoundary and Suspense. Sidebar collapsed state +// persists via the Zustand UIStore. ShellErrorFallback +// displays caught errors and ShellLoading shows a loading +// placeholder during lazy route resolution // =================== import { Suspense } from 'react' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/toast.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/toast.module.scss index 29b3f614..a61950a9 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/toast.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/core/app/toast.module.scss @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // toast.module.scss +// +// Sonner toast notification theme overrides +// +// Applies global dark theme CSS custom properties to +// [data-sonner-toaster] for normal, success, error, +// warning, and info variants using $bg-surface-100, +// $border-default, and $text-default tokens. Styles +// [data-sonner-toast] with $radius-md border radius, +// medium-weight titles, light-colored descriptions, and +// hover-brightened close buttons. Error toasts receive a +// $error-default border accent. Connects to App.tsx, +// styles/_index // =================== @use '@/styles' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/main.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/main.tsx index ac319510..3a23a518 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/main.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/main.tsx @@ -1,6 +1,12 @@ // =========================== // ©AngelaMos | 2026 // main.tsx +// +// React application entry point +// +// Mounts the App component inside React.StrictMode onto the +// #root DOM element via createRoot. Imports the global SCSS +// stylesheet for Tailwind-free custom theming // =========================== import { StrictMode } from 'react' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/dashboard.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/dashboard.module.scss index 60cf8d62..89e295f4 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/dashboard.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/dashboard.module.scss @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // dashboard.module.scss +// +// CSS module for the dashboard page layout +// +// Styles a padded column flex page with a 4-column +// responsive stat row (collapsing to 2 then 1 at lg/sm +// breakpoints), a severity section with a proportional +// colored bar (high/medium/low segments with animated +// widths) and dot legend, a 2-column bottom row +// (collapsing to 1 at lg) for the alert feed and ranked +// lists with monospace labels and tabular-nums counts, and +// a severity-high-themed WebSocket error banner. Connects +// to pages/dashboard, styles/_index // =================== @use '@/styles' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/index.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/index.tsx index a0d371e9..aaf6c333 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/index.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/dashboard/index.tsx @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // index.tsx +// +// Dashboard page with stats overview, severity distribution, +// alert feed, and ranked lists +// +// Exports a lazy-loaded Component (displayName +// DashboardPage) that fetches useStats, useModelStatus, and +// useAlerts. Renders a stat row (threats detected, stored, +// high severity count, detection mode), a proportional +// SeverityBar with colored segments and a SeverityLegend, +// a live AlertFeed capped at 360px, and two RankedList +// panels for top source IPs and most attacked paths. Shows +// a WebSocket connection error banner when present. Connects +// to api/hooks/useStats, api/hooks/useModels, +// api/hooks/useAlerts, components/alert-feed, +// components/stat-card // =================== import { useAlerts, useModelStatus, useStats } from '@/api/hooks' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/index.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/index.tsx index f0106c99..46c42bae 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/index.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/index.tsx @@ -1,6 +1,20 @@ // =================== // © AngelaMos | 2026 // index.tsx +// +// Models page with status banner, retrain button, and model +// cards +// +// Exports a lazy-loaded Component (displayName ModelsPage) +// that fetches useModelStatus and provides useRetrain. Shows +// a status banner indicating whether models are loaded and +// the current detection mode. A retrain button triggers the +// mutation with a spinning icon while pending. ModelCard +// renders each ActiveModel entry with model_type, version, +// training_samples, optional threshold to 4 decimals, and +// a metrics section listing all numeric metric key-value +// pairs. Empty state prompts retraining. Connects to +// api/hooks/useModels, api/types/models.types // =================== import { LuRefreshCw } from 'react-icons/lu' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/models.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/models.module.scss index a2e9eb4b..3f6d899b 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/models.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/models/models.module.scss @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // models.module.scss +// +// CSS module for the models management page +// +// Styles a status banner with loaded (green) and not-loaded +// (amber) severity-themed variants, a retrain button with +// accent-muted background and spinning icon animation during +// pending state, an empty-state placeholder, and a +// responsive 3-column grid (collapsing to 2/1) of model +// cards showing type, version, training samples, threshold, +// and a metrics section with tabular-nums monospace values +// separated by a muted border. Connects to pages/models, +// styles/_index // =================== @use '@/styles' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/index.tsx b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/index.tsx index baaeeff2..cc5ad6fa 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/index.tsx +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/index.tsx @@ -1,6 +1,22 @@ // =================== // © AngelaMos | 2026 // index.tsx +// +// Threats table page with severity and IP filters, +// pagination, and detail modal +// +// Exports a lazy-loaded Component (displayName ThreatsPage) +// that manages offset, severity filter (ALL/HIGH/MEDIUM/ +// LOW), and source IP text filter as local state. Passes +// these as ThreatParams to useThreats with PAGINATION +// defaults. Renders a responsive table with time, source +// IP, MethodBadge, path, score to 3 decimals, +// SeverityBadge, and status code columns. Rows are +// clickable to open ThreatDetail in a modal. Previous/Next +// pagination controls show the current range and total. +// Connects to api/hooks/useThreats, api/types/threats.types, +// components/method-badge, components/severity-badge, +// components/threat-detail, config // =================== import { useState } from 'react' diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/threats.module.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/threats.module.scss index 8f4a79a4..3c987b86 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/threats.module.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/pages/threats/threats.module.scss @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // threats.module.scss +// +// CSS module for the threats table page +// +// Styles a filter bar with select dropdown and text input +// (accent-bordered on focus), a scrollable table wrapper +// with uppercase column headers, clickable rows with hover +// highlight, monospace and truncated cell variants for IPs +// and paths, tabular-nums score cells, centered loading and +// empty states, and a pagination footer with previous/next +// buttons that dim when disabled. Connects to pages/threats, +// styles/_index // =================== @use '@/styles' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles.scss index e06571ba..69d6b8e5 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles.scss @@ -1,6 +1,16 @@ // =================== // © AngelaMos | 2026 // styles.scss +// +// Global stylesheet entry point with root element setup +// +// Forwards tokens, fonts, and mixins for downstream module +// consumption, applies the CSS reset, and defines #root as +// a full-viewport column flex container with $bg-default +// background. The .app class sets flex: 1, column layout, +// default background, text color, and sans font family. +// Connects to styles/_tokens, styles/_fonts, +// styles/_mixins, styles/_reset, main.tsx // =================== @forward 'styles/tokens'; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_fonts.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_fonts.scss index 10d59dca..3a211d40 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_fonts.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_fonts.scss @@ -1,6 +1,14 @@ // =================== // © AngelaMos | 2026 // _fonts.scss +// +// Font family stack definitions +// +// Defines $font-sans (system UI stack: -apple-system, +// BlinkMacSystemFont, Segoe UI, Inter, Roboto, Helvetica +// Neue, Arial) and $font-mono (ui-monospace, SFMono-Regular, +// SF Mono, Menlo, Consolas, Liberation Mono). Connects to +// styles/_tokens, styles/_index, styles/_reset // =================== @use 'tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_index.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_index.scss index 9d5d028c..506508ee 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_index.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_index.scss @@ -1,6 +1,12 @@ // =================== // © AngelaMos | 2026 // _index.scss +// +// SCSS module forwarding index for the styles package +// +// Forwards tokens, fonts, and mixins so that downstream +// SCSS modules can access the full design system with a +// single @use '@/styles' as * import // =================== @forward 'tokens'; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_mixins.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_mixins.scss index 288b4e67..ed25c9b5 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_mixins.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_mixins.scss @@ -1,6 +1,19 @@ // =================== // © AngelaMos | 2026 // _mixins.scss +// +// Reusable SCSS mixins for layout, typography, transitions, +// and responsiveness +// +// Defines a $breakpoints map (xs through 2xl) with +// breakpoint-up and breakpoint-down media query mixins, +// flex layout helpers (flex-center, flex-between, +// flex-column, flex-column-center), accessibility (sr-only), +// text overflow (truncate, line-clamp), transition presets +// (transition-fast, transition-normal), positioning +// (absolute-fill, absolute-center), hover media query +// guard, and hide-scrollbar. Connects to styles/_tokens, +// styles/_index, all *.module.scss files // =================== @use 'sass:map'; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_reset.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_reset.scss index fb9eca5b..cb8a1e0a 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_reset.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_reset.scss @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // _reset.scss +// +// Modern CSS reset with accessibility and scrollbar styling +// +// Applies universal box-sizing border-box, zeroes margins +// and padding, sets html font-size 16px with +// text-size-adjust, enables smooth scrolling when +// prefers-reduced-motion allows it, configures body with +// 100dvh min-height and optimized text rendering, resets +// headings with balanced text-wrap, strips list styles and +// link decorations, normalizes form elements, adds +// focus-visible outlines with $border-strong, disables +// animations for prefers-reduced-motion, applies safe-area +// insets, and styles 6px scrollbar thumbs with +// $border-default. Connects to styles/_tokens, +// styles/_fonts, styles.scss // =================== @use 'tokens' as *; diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_tokens.scss index fd804e01..efac199b 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_tokens.scss +++ b/PROJECTS/advanced/ai-threat-detection/frontend/src/styles/_tokens.scss @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // _tokens.scss +// +// Design token variables for the entire UI system +// +// Defines the full token set consumed by all SCSS modules: +// 8px-base spacing scale ($space-0 through $space-32), +// typography scale (3xs through 5xl), font weights (regular, +// medium, semibold), line heights, letter spacing, dark +// theme color palette (backgrounds, borders, text tiers, +// error, severity high/medium/low with backgrounds, accent, +// HTTP method colors GET through OPTIONS, success), border +// radius scale, z-index layers (hide through max), +// transition durations and easings, responsive breakpoints +// (xs 360px through 2xl 1536px), and container width +// constraints. Connects to styles/_index, styles/_reset, +// styles/_fonts, styles/_mixins, all *.module.scss files // =================== // ============================================================================ diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/vite.config.ts b/PROJECTS/advanced/ai-threat-detection/frontend/vite.config.ts index e18938b5..a8f55007 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/vite.config.ts +++ b/PROJECTS/advanced/ai-threat-detection/frontend/vite.config.ts @@ -1,6 +1,19 @@ /** * ©AngelaMos | 2026 * vite.config.ts + * + * Vite build configuration with dev proxy and manual chunk + * splitting + * + * Loads environment from the parent directory via loadEnv, + * resolves VITE_API_TARGET for the dev server proxy + * (/api rewrite and /ws WebSocket passthrough), sets @ path + * alias to src, enables SCSS preprocessing, builds to + * esnext with oxc minification and hidden sourcemaps in + * production, and splits vendor chunks into vendor-react + * (react-dom, react-router), vendor-query (TanStack), and + * vendor-state (zustand). Connects to src/main.tsx, + * src/App.tsx, src/config.ts */ import path from 'node:path' diff --git a/PROJECTS/advanced/ai-threat-detection/infra/docker/entrypoint.sh b/PROJECTS/advanced/ai-threat-detection/infra/docker/entrypoint.sh index 36765d01..0bba0d8e 100755 --- a/PROJECTS/advanced/ai-threat-detection/infra/docker/entrypoint.sh +++ b/PROJECTS/advanced/ai-threat-detection/infra/docker/entrypoint.sh @@ -1,6 +1,18 @@ #!/bin/sh # ©AngelaMos | 2026 # entrypoint.sh +# +# Container entrypoint with automatic ML model training +# +# Cleans up any symlinked nginx log files, then checks +# MODEL_DIR for the required ONNX artifacts (ae.onnx, +# rf.onnx, if.onnx, scaler.json, threshold.json). If all +# models exist, skips training. If SKIP_AUTO_TRAIN is true, +# starts in rules-only mode. Otherwise runs cli.main train +# with 2000 normal and 1000 attack synthetic samples, 100 +# epochs, batch size 256. Executes the CMD arguments via +# exec on completion. Connects to cli/main, compose.yml, +# dev.compose.yml MODEL_DIR="${MODEL_DIR:-data/models}" NGINX_LOG_PATH="${NGINX_LOG_PATH:-/var/log/nginx/access.log}" diff --git a/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.dev b/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.dev index 18bbf480..fac6fb6c 100644 --- a/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.dev +++ b/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.dev @@ -1,5 +1,15 @@ # ©AngelaMos | 2026 # fastapi.dev +# +# Development Dockerfile for the FastAPI backend +# +# Based on python:3.14-slim with uv from the astral-sh +# image. Installs build-essential, libpq-dev, and curl, +# then installs the project with dev and ml extras via uv +# pip install --system -e. Copies the backend source and +# entrypoint script, creates the model data directory, and +# runs uvicorn with --reload for live code changes. +# Connects to dev.compose.yml, entrypoint.sh FROM python:3.14-slim diff --git a/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.prod b/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.prod index 9f9ee4b8..9d324a68 100644 --- a/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.prod +++ b/PROJECTS/advanced/ai-threat-detection/infra/docker/fastapi.prod @@ -1,5 +1,16 @@ # ©AngelaMos | 2026 # fastapi.prod +# +# Multi-stage production Dockerfile for the FastAPI backend +# +# Builder stage: python:3.14-slim with uv, compiles +# requirements from pyproject.toml with ml extras and +# installs to /app/deps. Runtime stage: python:3.14-slim +# with libpq5 and curl, copies the dependency tree to a +# non-root appuser site-packages path, copies backend source +# and entrypoint, creates model directory, and runs uvicorn +# without --reload. Includes a HEALTHCHECK on /health. +# Connects to compose.yml, entrypoint.sh FROM python:3.14-slim AS builder diff --git a/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.dev b/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.dev index 1a39298b..37b05c47 100644 --- a/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.dev +++ b/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.dev @@ -1,6 +1,14 @@ # ©AngelaMos | 2026 -# Development Vite Dockerfile -# Hot reload with volume mounts for source code +# vite.dev +# +# Development Dockerfile for the Vite frontend with hot +# reload +# +# Based on node:24-alpine with corepack-activated pnpm. +# Copies package.json, runs pnpm install, copies the +# frontend source, and starts the Vite dev server on port +# 5173. Source code is bind-mounted from the host in +# dev.compose.yml for HMR. Connects to dev.compose.yml FROM node:24-alpine diff --git a/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.prod b/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.prod index 0400504b..4be580cb 100644 --- a/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.prod +++ b/PROJECTS/advanced/ai-threat-detection/infra/docker/vite.prod @@ -1,6 +1,14 @@ # ©AngelaMos | 2026 -# Production Vite Dockerfile -# Multi-stage build: pnpm build → nginx static serving +# vite.prod +# +# Multi-stage production Dockerfile for the Vite frontend +# +# Builder stage: node:24-alpine with corepack pnpm, runs +# frozen-lockfile install and pnpm build to produce the +# dist output. Runtime stage: nginx:alpine, removes the +# default config, copies vigil.conf and the built assets +# to the nginx html root. Includes a HEALTHCHECK on /health. +# Connects to compose.yml, infra/nginx/vigil.conf FROM node:24-alpine AS builder diff --git a/PROJECTS/advanced/ai-threat-detection/infra/nginx/vigil.conf b/PROJECTS/advanced/ai-threat-detection/infra/nginx/vigil.conf index f7f667f1..06338ded 100644 --- a/PROJECTS/advanced/ai-threat-detection/infra/nginx/vigil.conf +++ b/PROJECTS/advanced/ai-threat-detection/infra/nginx/vigil.conf @@ -1,5 +1,18 @@ # ©AngelaMos | 2026 # vigil.conf +# +# Production nginx reverse proxy and static file server +# +# Defines an upstream to the backend on port 8000. Serves +# the Vite-built SPA from /usr/share/nginx/html with +# try_files fallback to index.html. Proxies /api/ requests +# to the backend (stripping the prefix) with standard +# forwarded headers and 30s read timeout. Proxies /ws/ +# requests with HTTP 1.1 upgrade for WebSocket connections +# and 86400s read timeout. Exposes a /health endpoint +# returning 200. Enables gzip for common types and sets +# 1-year immutable cache headers on static assets. Connects +# to compose.yml, frontend Vite build output upstream vigil_backend { server backend:8000; diff --git a/PROJECTS/advanced/ai-threat-detection/infra/redis/redis.conf b/PROJECTS/advanced/ai-threat-detection/infra/redis/redis.conf index 4a9b4b68..08d4762b 100644 --- a/PROJECTS/advanced/ai-threat-detection/infra/redis/redis.conf +++ b/PROJECTS/advanced/ai-threat-detection/infra/redis/redis.conf @@ -1,5 +1,20 @@ # ©AngelaMos | 2026 # redis.conf +# +# Production Redis 7.4 server configuration with security +# hardening +# +# Binds to all interfaces in protected mode. Disables +# dangerous commands (FLUSHALL, FLUSHDB, CONFIG, SHUTDOWN, +# MONITOR, DEBUG, SLAVEOF, MIGRATE) via rename-command. +# Enables AOF persistence with everysec fsync and RDB +# snapshots at 900/1, 300/10, 60/10000 intervals. Sets +# 2GB maxmemory with allkeys-lru eviction, 10000 max +# clients, 4 I/O threads with read offloading, TCP backlog +# 2048, 300s keepalive, slowlog at 10ms, latency monitor +# at 100ms, active defrag (5-20% thresholds), and lazyfree +# for eviction, expiry, and server-del. Connects to +# compose.yml bind 0.0.0.0 diff --git a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v index 64363e3d..577122b5 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v @@ -1,5 +1,35 @@ -// ©AngelaMos | 2026 -// analyzer_test.v +/* +©AngelaMos | 2026 +analyzer_test.v + +Tests for conflict detection and optimization analysis + +Tests both conflict.v and optimizer.v functions. Conflict tests cover +shadowed rules (broad ACCEPT before narrow DROP), contradictions +(overlapping criteria with opposing actions), duplicates (identical +criteria and action), and redundant rules (strict subset with same +action). Also verifies that disjoint rules (different protocols, non- +overlapping ports) produce no false positives. Comparison helper tests +exercise matches_overlap, match_is_superset, criteria_equal, +actions_conflict, ports_overlap, ports_is_superset, addr_is_superset, +addrs_overlap, and opt_str_equal with various none/some combinations. +Optimizer tests cover mergeable ports, missing SSH rate limits, missing +conntrack, unreachable rules after catch-all drops, overly permissive +source-less rules on sensitive ports, redundant terminal drops against +chain policy, and CIDR /0 containment. + +Connects to: + analyzer/conflict.v - tests analyze_conflicts, find_shadowed_rules, + find_contradictions, find_duplicates, find_redundant_rules, + matches_overlap, match_is_superset, criteria_equal, + actions_conflict, ports_overlap, ports_is_superset, + addr_is_superset, addrs_overlap, opt_str_equal + analyzer/optimizer.v - tests find_mergeable_ports, find_missing_rate_limits, + find_missing_conntrack, find_unreachable_after_drop, + find_overly_permissive, find_redundant_terminal_drop + models/models.v - uses MatchCriteria, NetworkAddr, PortSpec, Rule, Ruleset, + tests cidr_contains directly +*/ module analyzer diff --git a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v index da9313d1..fe3e6902 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v @@ -1,5 +1,31 @@ -// ©AngelaMos | 2026 -// conflict.v +/* +©AngelaMos | 2026 +conflict.v + +Conflict detection engine for firewall rulesets + +Walks every chain pairwise comparing rules to find four classes of +problems: shadowed rules (a broader rule with a different action appears +earlier, making the narrower rule unreachable), contradictions (two +rules overlap in traffic but have opposing accept/deny actions), +duplicates (identical criteria and action), and redundant rules (a +strict subset of another rule with the same action). The comparison +logic uses match_is_superset for subset testing and matches_overlap for +partial intersection, both of which recurse through protocol, source +address, destination address, ports, interfaces, and conntrack states. +CIDR containment delegates to models.cidr_contains for the actual +prefix arithmetic. + +Key exports: + analyze_conflicts - Scans a Ruleset and returns all conflict Findings + +Connects to: + models/models.v - imports Rule, Ruleset, MatchCriteria, Finding, Action, + NetworkAddr, PortSpec, cidr_contains, port_range_contains + analyzer/optimizer.v - sibling module, both called from main.v cmd_analyze + main.v - called from cmd_analyze + display/display.v - Findings are rendered by print_findings +*/ module analyzer diff --git a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v index a5195b79..18f5ec3d 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v @@ -1,5 +1,31 @@ -// ©AngelaMos | 2026 -// optimizer.v +/* +©AngelaMos | 2026 +optimizer.v + +Optimization and hardening suggestions for firewall rulesets + +Produces advisory Findings that do not indicate bugs but highlight ways +to tighten or simplify a ruleset. find_mergeable_ports groups rules that +differ only in destination port and suggests combining them into a +single multiport rule. suggest_reordering flags high-traffic port rules +(HTTP, HTTPS, DNS) buried deep in a chain where they slow traversal. +find_missing_rate_limits warns when sensitive ports like SSH accept +traffic without rate limiting. find_missing_conntrack checks for an +ESTABLISHED/RELATED rule near the top of each chain. find_overly_permissive +flags sensitive ports (SSH, MySQL, PostgreSQL, Redis) open to any source. +find_redundant_terminal_drop catches explicit drop-all rules that duplicate +the chain default policy. + +Key exports: + suggest_optimizations - Scans a Ruleset and returns optimization Findings + +Connects to: + config/config.v - reads port constants, rate-limit defaults, multiport_max + models/models.v - imports Rule, Ruleset, Finding + analyzer/conflict.v - sibling module, both called from main.v cmd_analyze + main.v - called from cmd_analyze and cmd_optimize + display/display.v - Findings are rendered by print_findings +*/ module analyzer diff --git a/PROJECTS/beginner/firewall-rule-engine/src/config/config.v b/PROJECTS/beginner/firewall-rule-engine/src/config/config.v index a1cfacdf..054a1020 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/config/config.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/config/config.v @@ -1,5 +1,33 @@ -// ©AngelaMos | 2026 -// config.v +/* +©AngelaMos | 2026 +config.v + +Application-wide constants for ports, limits, display, and exit codes + +Centralizes every magic number and string the tool uses. Well-known +ports and service_ports drive the hardened ruleset generator so adding a +new service is a one-line map entry. Rate-limit defaults (ssh_rate_limit, +icmp_rate_limit) match common CIS and NIST hardening baselines. +private_ranges lists RFC 1918 CIDR blocks used for anti-spoofing rules. +Column widths and Unicode symbols control the terminal table layout in +the display module. + +Key exports: + version, app_name - Binary identity + exit_success .. exit_usage_error - Process exit codes + port_ssh .. port_ntp - Well-known port constants + private_ranges - RFC 1918 CIDR blocks for spoofing checks + ssh_rate_limit, icmp_rate_limit - Default rate-limit strings + service_ports - Service name to port number map + col_num .. col_action - Terminal table column widths + sym_check .. sym_bullet - Unicode glyphs for display + +Connects to: + analyzer/optimizer.v - reads port constants, rate-limit defaults, multiport_max + generator/generator.v - reads service_ports, private_ranges, rate-limit strings + display/display.v - reads column widths, Unicode symbols, version + main.v - reads app_name, version, exit codes +*/ module config diff --git a/PROJECTS/beginner/firewall-rule-engine/src/display/display.v b/PROJECTS/beginner/firewall-rule-engine/src/display/display.v index 695cb98c..626e4836 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/display/display.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/display/display.v @@ -1,5 +1,32 @@ -// ©AngelaMos | 2026 -// display.v +/* +©AngelaMos | 2026 +display.v + +Terminal output formatting for rulesets, findings, and diffs + +Handles all user-facing output so the rest of the codebase never calls +println directly for structured data. print_rule_table renders a +fixed-width ASCII table with columns for rule number, chain, protocol, +source, destination, ports, and action. Actions are color-coded green +for ACCEPT, red for DROP/REJECT, yellow for LOG. print_findings groups +analyzer results by severity with colored brackets and includes the +suggestion arrow for each finding. print_diff compares two Rulesets by +building a set of normalized rule strings and showing only-left / +only-right entries, similar to a unified diff. + +Key exports: + print_banner - Renders the boxed FWRULE header with version + print_rule_table - Renders a tabular view of all rules in a Ruleset + print_summary - Shows format, rule count, chains, and policies + print_findings - Renders a list of analyzer Findings with severity counts + print_finding - Renders a single Finding with colored severity tag + print_diff - Side-by-side comparison of two Rulesets + +Connects to: + config/config.v - reads column widths, Unicode symbols, version + models/models.v - imports Rule, Ruleset, Finding, Action, Severity + main.v - called from every cmd_* handler for display +*/ module display diff --git a/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v index 05265656..38b5a74a 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v @@ -1,5 +1,28 @@ -// ©AngelaMos | 2026 -// generator.v +/* +©AngelaMos | 2026 +generator.v + +Hardened ruleset generation and cross-format export + +generate_hardened builds a complete firewall ruleset from scratch using +CIS-aligned defaults: default-deny INPUT/FORWARD, loopback acceptance, +conntrack early in the chain, RFC 1918 anti-spoofing, rate-limited ICMP +and SSH, and a LOG rule before the final drop. Services are resolved +through config.service_ports so DNS gets dual tcp/udp rules and NTP gets +udp-only. export_ruleset converts an existing parsed Ruleset into the +opposite format by serializing each Rule through rule_to_iptables or +rule_to_nftables, preserving table and chain structure including +multi-table layouts (filter + nat). + +Key exports: + generate_hardened - Builds a hardened ruleset string for given services and format + export_ruleset - Converts a Ruleset to iptables or nftables string output + +Connects to: + config/config.v - reads service_ports, private_ranges, rate-limit strings, log prefixes + models/models.v - imports Rule, Ruleset, RuleSource + main.v - called from cmd_harden and cmd_export +*/ module generator diff --git a/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v index 225371c1..911d4615 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v @@ -1,5 +1,26 @@ -// ©AngelaMos | 2026 -// generator_test.v +/* +©AngelaMos | 2026 +generator_test.v + +Tests for hardened ruleset generation and cross-format export + +Hardened generation tests verify both iptables and nftables output +formats: default-deny policies, loopback acceptance, conntrack early +in the chain, SSH rate limiting, HTTP/HTTPS service rules, RFC 1918 +anti-spoofing drops, ICMP rate limiting, LOG before final drop, COMMIT +wrapping, DNS dual-protocol (tcp+udp), NTP udp-only, and custom +interface names. Serialization tests cover rule_to_iptables and +rule_to_nftables for TCP port rules, source/destination addresses with +negation, multiport sets, interface matching, and log prefix handling. +Export tests verify round-trip conversion of Rulesets including +multi-table layouts with filter and nat tables, correct chain nesting +inside their parent tables, and empty ruleset edge cases. + +Connects to: + generator/generator.v - tests generate_hardened, export_ruleset, rule_to_iptables, + rule_to_nftables + models/models.v - uses Rule, Ruleset, MatchCriteria, NetworkAddr, PortSpec, Action +*/ module generator diff --git a/PROJECTS/beginner/firewall-rule-engine/src/main.v b/PROJECTS/beginner/firewall-rule-engine/src/main.v index 7cc151a3..0dd83033 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/main.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/main.v @@ -1,5 +1,32 @@ -// ©AngelaMos | 2026 -// main.v +/* +©AngelaMos | 2026 +main.v + +CLI entry point with command dispatch and ruleset loading + +Parses the first positional argument as a subcommand and fans out to +the appropriate handler. load/analyze/optimize/diff read a ruleset file +through load_ruleset, which auto-detects iptables vs nftables format +via parser.detect_format before delegating to the correct parser. +harden and export use V's flag module for option parsing (--services, +--iface, --format). Every command prints through the display module so +output formatting is consistent. + +Key exports: + main - Entry point, dispatches to cmd_* handlers + load_ruleset - Reads a file, auto-detects format, returns a Ruleset + +Connects to: + config/config.v - exit codes, app_name, version, default_services, default_iface + models/models.v - RuleSource for format selection in harden/export + parser/common.v - detect_format for auto-detection + parser/iptables.v - parse_iptables for iptables input + parser/nftables.v - parse_nftables for nftables input + analyzer/conflict.v - analyze_conflicts for the analyze command + analyzer/optimizer.v - suggest_optimizations for analyze/optimize commands + generator/generator.v - generate_hardened, export_ruleset + display/display.v - print_banner, print_summary, print_rule_table, print_findings, print_diff +*/ module main diff --git a/PROJECTS/beginner/firewall-rule-engine/src/models/models.v b/PROJECTS/beginner/firewall-rule-engine/src/models/models.v index 857640a8..a9b1f446 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/models/models.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/models/models.v @@ -1,5 +1,45 @@ -// ©AngelaMos | 2026 -// models.v +/* +©AngelaMos | 2026 +models.v + +Domain types for firewall rule representation and matching + +Every rule parsed from iptables or nftables input lands in the same +unified Rule struct so the analyzer and generator never need to know +which format the original file used. Enums use explicit u8 backing for +compact storage when rulesets grow large. NetworkAddr and PortSpec carry +a negated flag so "! -s 10.0.0.0/8" round-trips cleanly through parse, +analyze, and export. ip_to_u32 and cidr_contains power the superset and +overlap checks in the conflict analyzer by converting dotted-quad +addresses to a single u32 for prefix comparison. + +Key exports: + Protocol - Network protocol enum (tcp, udp, icmp, icmpv6, all, sctp, gre) + Action - Firewall target action (accept, drop, reject, log, NAT variants) + Table - Netfilter table (filter, nat, mangle, raw, security) + ChainType - Built-in chain identifiers plus custom + RuleSource - Discriminates iptables from nftables origin + Severity - Finding severity for analyzer output (info, warning, critical) + ConnState - Bitflag set for conntrack states (new, established, related, invalid) + NetworkAddr - IP address with CIDR prefix length and negation + PortSpec - Single port or port range with negation + MatchCriteria - Full match tuple: protocol, addresses, ports, interfaces, conntrack + Rule - One firewall rule: table, chain, action, criteria, line number, raw text + Finding - Analyzer result: severity, title, description, affected rules, suggestion + Ruleset - Collection of rules with chain default policies + ip_to_u32 - Converts dotted-quad IPv4 string to a 32-bit integer + cidr_contains - Tests whether one CIDR prefix fully contains another + port_range_contains - Tests whether one port range fully contains another + +Connects to: + parser/common.v - imports all enums and structs for parsing + parser/iptables.v - imports Rule, Ruleset, MatchCriteria, NetworkAddr, Action, Table + parser/nftables.v - imports Rule, Ruleset, MatchCriteria, NetworkAddr, Action, Table + analyzer/conflict.v - imports Rule, Ruleset, MatchCriteria, Finding, Action, NetworkAddr, PortSpec + analyzer/optimizer.v - imports Rule, Ruleset, Finding + generator/generator.v - imports Rule, Ruleset, RuleSource + display/display.v - imports Rule, Ruleset, Finding, Action, Severity +*/ module models diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v index b9b71bd0..ba2631b9 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v @@ -1,5 +1,34 @@ -// ©AngelaMos | 2026 -// common.v +/* +©AngelaMos | 2026 +common.v + +Shared parsing primitives and format auto-detection + +Provides the low-level converters that both iptables.v and nftables.v +call: network addresses with optional CIDR and negation, single ports +and port lists, protocol names and numbers, actions, tables, chain +types, and conntrack state flags. detect_format examines the first +non-blank, non-comment line of a ruleset file to choose between the +iptables and nftables parsers. Protocol parsing accepts both names +("tcp") and IANA numbers ("6") so either style works in rule files. + +Key exports: + parse_network_addr - Parses "!10.0.0.0/8" into a NetworkAddr with negation and CIDR + parse_port_spec - Parses "!1024:65535" into a PortSpec with range and negation + parse_port_list - Splits "80,443,8080" into a []PortSpec + parse_protocol - Converts name or IANA number to Protocol enum + parse_action - Converts target string to Action enum + parse_table - Converts table name to Table enum + parse_chain_type - Maps chain name to ChainType, defaults to .custom + parse_conn_states - Splits "ESTABLISHED,RELATED" into a ConnState bitflag set + detect_format - Auto-detects whether input is iptables or nftables + +Connects to: + models/models.v - imports all enum and struct types + parser/iptables.v - calls every function here during rule parsing + parser/nftables.v - calls parse_network_addr, parse_port_spec, parse_protocol, parse_action, parse_table, parse_chain_type, parse_conn_states + main.v - calls detect_format for auto-detection +*/ module parser diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v index 2bf64c65..2484a7b1 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v @@ -1,5 +1,29 @@ -// ©AngelaMos | 2026 -// iptables.v +/* +©AngelaMos | 2026 +iptables.v + +Parser for iptables-save output format + +Reads the *table / :CHAIN POLICY / -A rule / COMMIT structure that +iptables-save produces. tokenize_iptables splits each rule line into +tokens while respecting single and double quotes so log prefixes like +"DROPPED: " stay intact. parse_iptables_rule walks the token stream +flag by flag (-p, -s, -d, --dport, --state, -j, etc.) building a Rule +struct. The ! negation token is tracked across flag boundaries so +"! -s 10.0.0.0/8" correctly sets NetworkAddr.negated. Chain default +policies (":INPUT DROP [0:0]") are stored in Ruleset.policies. + +Key exports: + parse_iptables - Parses a full iptables-save string into a Ruleset + parse_iptables_file - Convenience wrapper that reads a file first + +Connects to: + parser/common.v - calls parse_network_addr, parse_port_spec, parse_port_list, + parse_protocol, parse_action, parse_table, parse_chain_type, + parse_conn_states for every token type + models/models.v - imports Rule, Ruleset, MatchCriteria, NetworkAddr, Action, Table + main.v - called from load_ruleset when format is .iptables +*/ module parser diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v index 4fe52b42..b78eb231 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v @@ -1,5 +1,29 @@ -// ©AngelaMos | 2026 -// nftables.v +/* +©AngelaMos | 2026 +nftables.v + +Parser for nftables ruleset format + +Uses recursive descent to walk the nested brace structure: table blocks +contain chain blocks which contain rule lines. parse_nft_table extracts +the table name (skipping address family keywords like inet/ip/ip6), +parse_nft_chain reads the "type filter hook ... policy ..." preamble to +capture chain policies, and parse_nft_rule tokenizes individual rule +lines. Port matching handles both single ports ("dport 22") and set +syntax ("dport { 80, 443 }"). Supports IPv4/IPv6 address matching via +"ip saddr"/"ip6 saddr", conntrack via "ct state", rate limiting, NAT +actions (dnat/snat/masquerade), and log with prefix. + +Key exports: + parse_nftables - Parses a full nftables ruleset string into a Ruleset + parse_nftables_file - Convenience wrapper that reads a file first + +Connects to: + parser/common.v - calls parse_network_addr, parse_port_spec, parse_protocol, + parse_action, parse_table, parse_chain_type, parse_conn_states + models/models.v - imports Rule, Ruleset, MatchCriteria, NetworkAddr, Action, Table + main.v - called from load_ruleset when format is .nftables +*/ module parser diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v index 4e54a5ba..2ad52cca 100644 --- a/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v @@ -1,5 +1,31 @@ -// ©AngelaMos | 2026 -// parser_test.v +/* +©AngelaMos | 2026 +parser_test.v + +Tests for parsing primitives, format detection, and full ruleset parsing + +Covers every public function in common.v, iptables.v, and nftables.v. +Primitive tests verify network address CIDR and negation parsing, single +and ranged port specs, protocol name and number resolution, action and +table mapping, chain type classification, and conntrack state flag +combinations. Format detection tests confirm heuristic identification of +iptables table headers, chain policies, rule lines, and nftables table +blocks. Integration tests load fixture files from testdata/ to verify +rule counts, policy extraction, SSH port rules, conntrack rules, +multiport parsing, rate limits, NAT/masquerade actions, and IPv6 +address handling. Also tests tokenize_iptables for quoted strings, +ip_to_u32 for valid/invalid addresses, and goto (-g/--goto) handling. + +Connects to: + parser/common.v - tests parse_network_addr, parse_port_spec, parse_port_list, + parse_protocol, parse_action, parse_table, parse_chain_type, + parse_conn_states, detect_format + parser/iptables.v - tests parse_iptables, tokenize_iptables + parser/nftables.v - tests parse_nftables + models/models.v - tests ip_to_u32 directly + testdata/ - loads iptables_basic, iptables_complex, iptables_conflicts, + nftables_basic, nftables_complex, nftables_conflicts fixtures +*/ module parser diff --git a/PROJECTS/beginner/hash-cracker/main.cpp b/PROJECTS/beginner/hash-cracker/main.cpp index 4a380385..08e11112 100644 --- a/PROJECTS/beginner/hash-cracker/main.cpp +++ b/PROJECTS/beginner/hash-cracker/main.cpp @@ -1,5 +1,33 @@ -// ©AngelaMos | 2026 -// main.cpp +/* +©AngelaMos | 2026 +main.cpp + +CLI entry point with hash type dispatch and attack mode selection + +Parses command-line arguments via boost::program_options for hash target, +algorithm (md5/sha1/sha256/sha512/auto), attack mode (dictionary, brute- +force, or rule-based), charset selection, salt, thread count, and JSON +output. build_charset assembles a character set from comma-separated +tokens (lower, upper, digits, special). dispatch_hasher selects the +concrete EVPHasher instantiation at runtime via a switch on HashType, +then dispatch_attack picks the attack strategy (BruteForceAttack, +RuleAttack, or DictionaryAttack) based on config flags. When auto- +detection is requested, HashDetector identifies the algorithm from hex +digest length. + +Key exports: + main - Entry point, returns 0 on crack success, 1 on failure or exhaustion + +Connects to: + config/Config.hpp - CrackConfig, CrackResult, charset constants, defaults + core/Concepts.hpp - CrackError enum for error propagation + core/Engine.hpp - Engine::crack template drives the crack session + hash/HashDetector.hpp - HashDetector::detect for auto-detection + hash/MD5Hasher.hpp et al. - Concrete hasher types for dispatch + attack/BruteForceAttack.hpp - BruteForceAttack for exhaustive mode + attack/DictionaryAttack.hpp - DictionaryAttack for wordlist mode + attack/RuleAttack.hpp - RuleAttack for mutation mode +*/ #include #include diff --git a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp index b2c0532b..a04332e4 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp @@ -1,5 +1,27 @@ -// ©AngelaMos | 2026 -// BruteForceAttack.cpp +/* +©AngelaMos | 2026 +BruteForceAttack.cpp + +Keyspace generation from charset and max length with parallel partitioning + +compute_keyspace calculates the total number of candidates across all +lengths from 1 to max_length (sum of charset_size^len). The constructor +divides this space evenly among threads using index-based partitioning +with remainder distribution. index_to_candidate converts a flat index +into a string by first determining the target length (walking cumulative +powers) then extracting each character position via modular arithmetic, +similar to converting a number to a variable-base representation. + +Key exports: + BruteForceAttack::BruteForceAttack - Constructor with charset, max_length, thread partitioning + BruteForceAttack::next - Returns next candidate or AttackComplete + BruteForceAttack::total - Total keyspace size + BruteForceAttack::progress - Candidates generated so far by this partition + +Connects to: + attack/BruteForceAttack.hpp - class declaration + core/Concepts.hpp - AttackComplete sentinel +*/ #include "src/attack/BruteForceAttack.hpp" #include diff --git a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp index b04aee75..27144374 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp @@ -1,5 +1,14 @@ -// ©AngelaMos | 2026 -// BruteForceAttack.hpp +/* +©AngelaMos | 2026 +BruteForceAttack.hpp + +Exhaustive keyspace enumeration with thread-partitioned ranges + +Connects to: + attack/BruteForceAttack.cpp - implementation of next(), index_to_candidate + core/Concepts.hpp - satisfies AttackStrategy concept, uses AttackComplete + core/Engine.hpp - instantiated when cfg.bruteforce is true +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp index c419590e..a3930c7d 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp @@ -1,5 +1,28 @@ -// ©AngelaMos | 2026 -// DictionaryAttack.cpp +/* +©AngelaMos | 2026 +DictionaryAttack.cpp + +Wordlist reading over a memory-mapped file with thread-safe partitioning + +create() opens the wordlist via MappedFile, counts total lines, divides +them evenly among threads (with remainder distribution), then walks +forward through the mapped buffer to find each thread's start and end +byte offsets. next() scans forward from current_offset_ to the next +newline, strips trailing \r for Windows-format wordlists, and returns +the word. Skips blank lines. Returns AttackComplete when the thread's +partition is exhausted. + +Key exports: + DictionaryAttack::create - Factory that opens and partitions a wordlist file + DictionaryAttack::next - Returns next word or AttackComplete + DictionaryAttack::total - Total words in this thread's partition + DictionaryAttack::progress - Words read so far + +Connects to: + attack/DictionaryAttack.hpp - class declaration + io/MappedFile.hpp - MappedFile for zero-copy file access + core/Concepts.hpp - CrackError and AttackComplete types +*/ #include "src/attack/DictionaryAttack.hpp" #include diff --git a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp index 20d55341..11d6770a 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp @@ -1,5 +1,16 @@ -// ©AngelaMos | 2026 -// DictionaryAttack.hpp +/* +©AngelaMos | 2026 +DictionaryAttack.hpp + +Memory-mapped wordlist attack with line-based thread partitioning + +Connects to: + attack/DictionaryAttack.cpp - implementation of create(), next() + io/MappedFile.hpp - MappedFile for zero-copy file access + core/Concepts.hpp - satisfies AttackStrategy concept + core/Engine.hpp - default attack when no flags set + attack/RuleAttack.hpp - RuleAttack wraps DictionaryAttack internally +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp index 30110386..e737a969 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp @@ -1,5 +1,28 @@ -// ©AngelaMos | 2026 -// RuleAttack.cpp +/* +©AngelaMos | 2026 +RuleAttack.cpp + +Mutation-based attack combining dictionary words with transformation rules + +For each word from the underlying DictionaryAttack, load_next_word() +generates the full mutation set via RuleSet::apply_all (capitalize, +uppercase, leet speak, append/prepend digits 0-999, reverse, toggle +case). When chain_rules is true, it takes the first-pass mutations and +runs them through apply_all again, producing double-mutated candidates. +next() iterates through the mutations_ buffer for the current word, +calling load_next_word() when the buffer is exhausted. + +Key exports: + RuleAttack::create - Factory wrapping a DictionaryAttack with mutation config + RuleAttack::next - Returns next mutation or AttackComplete + RuleAttack::total - Underlying dictionary word count (mutations multiply this) + RuleAttack::progress - Total candidates yielded so far + +Connects to: + attack/RuleAttack.hpp - class declaration + attack/DictionaryAttack.hpp - DictionaryAttack for word iteration + rules/RuleSet.hpp - RuleSet::apply_all for generating mutations +*/ #include "src/attack/RuleAttack.hpp" #include "src/rules/RuleSet.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp index f9379b0e..34d3bc3c 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// RuleAttack.hpp +/* +©AngelaMos | 2026 +RuleAttack.hpp + +Dictionary attack augmented with password mutation rules + +Wraps a DictionaryAttack and applies RuleSet mutations to each word. +When chain_rules is enabled, mutations of mutations are also generated, +greatly expanding the candidate space. + +Connects to: + attack/RuleAttack.cpp - implementation of create(), next(), load_next_word() + attack/DictionaryAttack.hpp - DictionaryAttack used internally for word iteration + core/Concepts.hpp - satisfies AttackStrategy concept + core/Engine.hpp - instantiated when cfg.use_rules is true +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/config/Config.hpp b/PROJECTS/beginner/hash-cracker/src/config/Config.hpp index 87bd67ef..13c53142 100644 --- a/PROJECTS/beginner/hash-cracker/src/config/Config.hpp +++ b/PROJECTS/beginner/hash-cracker/src/config/Config.hpp @@ -1,5 +1,34 @@ -// ©AngelaMos | 2026 -// Config.hpp +/* +©AngelaMos | 2026 +Config.hpp + +Application-wide constants and configuration structs + +Defines every constant the tool references: character sets for brute- +force, hex digest lengths for hash detection, ANSI color codes for +terminal output, Unicode box-drawing and symbol characters for the +progress display, and numeric defaults (thread count, max brute-force +length, progress update interval). CrackConfig carries all user-facing +options from the CLI into the Engine. CrackResult holds the output of +a successful crack including plaintext, timing, and throughput stats. + +Key exports: + config::VERSION, APP_NAME - Binary identity + config::CHARSET_* - Character sets for brute-force generation + config::MD5_HEX_LENGTH et al. - Expected hex digest lengths per algorithm + config::color::* - ANSI escape sequences + config::box::* - Box-drawing Unicode characters + config::symbol::* - Status symbols (check, cross, arrow, etc.) + CrackConfig - All runtime options for a crack session + CrackResult - Output struct with plaintext and performance metrics + +Connects to: + main.cpp - CrackConfig populated from CLI args, CrackResult written to JSON + core/Engine.hpp - reads CrackConfig, produces CrackResult + hash/HashDetector.cpp - reads hex length constants for detection + display/Progress.cpp - reads color, box, symbol constants for rendering + rules/RuleSet.cpp - reads MAX_APPEND_DIGIT, MAX_PREPEND_DIGIT +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/core/Concepts.hpp b/PROJECTS/beginner/hash-cracker/src/core/Concepts.hpp index 7394dab3..59d1ee90 100644 --- a/PROJECTS/beginner/hash-cracker/src/core/Concepts.hpp +++ b/PROJECTS/beginner/hash-cracker/src/core/Concepts.hpp @@ -1,5 +1,33 @@ -// ©AngelaMos | 2026 -// Concepts.hpp +/* +©AngelaMos | 2026 +Concepts.hpp + +C++20 concepts, error types, and contract definitions for the crack pipeline + +Defines the two core concepts that Engine::crack is templated on: Hasher +(requires hash(string_view)->string, name()->string_view, digest_length() +->size_t) and AttackStrategy (requires next()->expected, total()->size_t, progress()->size_t). CrackError is the +unified error enum propagated via std::expected throughout the tool. +AttackComplete is a sentinel type returned by attack strategies when +their candidate space is exhausted. + +Key exports: + Hasher - Concept constraining hash algorithm implementations + AttackStrategy - Concept constraining candidate generators + CrackError - Error enum (FileNotFound, InvalidHash, Exhausted, etc.) + AttackComplete - Empty sentinel signaling end of candidate stream + crack_error_message - Maps CrackError to human-readable string_view + +Connects to: + core/Engine.hpp - Engine::crack constrained by both concepts + hash/EVPHasher.hpp - EVPHasher satisfies the Hasher concept + attack/BruteForceAttack.hpp - BruteForceAttack satisfies AttackStrategy + attack/DictionaryAttack.hpp - DictionaryAttack satisfies AttackStrategy + attack/RuleAttack.hpp - RuleAttack satisfies AttackStrategy + io/MappedFile.hpp - returns CrackError on failure + main.cpp - uses crack_error_message for error display +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/core/Engine.hpp b/PROJECTS/beginner/hash-cracker/src/core/Engine.hpp index 901d23a0..ba8c48bb 100644 --- a/PROJECTS/beginner/hash-cracker/src/core/Engine.hpp +++ b/PROJECTS/beginner/hash-cracker/src/core/Engine.hpp @@ -1,5 +1,33 @@ -// ©AngelaMos | 2026 -// Engine.hpp +/* +©AngelaMos | 2026 +Engine.hpp + +Template-driven crack engine orchestrating threads, attacks, and progress + +Engine::crack is the single function that runs an entire crack +session. It creates a ThreadPool, spawns one attack instance per thread +(each partitioned to a disjoint slice of the keyspace or wordlist), and +runs a background jthread for progress display updates. Each worker +thread hashes candidates through the Hasher H, prepending or appending +salt if configured, and checks against the target hash. The first match +sets SharedState::found atomically and stores the plaintext. Candidate +counts are flushed from thread-local accumulators to the shared atomic +counter every 1024 iterations to reduce contention. Returns a CrackResult +on success or CrackError::Exhausted when all candidates are spent. + +Key exports: + Engine::crack - Runs a full crack session for Hasher H and AttackStrategy A + +Connects to: + config/Config.hpp - reads CrackConfig options, produces CrackResult + core/Concepts.hpp - Hasher and AttackStrategy concept constraints + threading/ThreadPool.hpp - ThreadPool for parallel worker dispatch + display/Progress.hpp - Progress for live terminal feedback + attack/BruteForceAttack.hpp - instantiated when A = BruteForceAttack + attack/DictionaryAttack.hpp - instantiated when A = DictionaryAttack + attack/RuleAttack.hpp - instantiated when A = RuleAttack + main.cpp - called from dispatch_attack +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/display/Progress.cpp b/PROJECTS/beginner/hash-cracker/src/display/Progress.cpp index c2373464..7202ee69 100644 --- a/PROJECTS/beginner/hash-cracker/src/display/Progress.cpp +++ b/PROJECTS/beginner/hash-cracker/src/display/Progress.cpp @@ -1,5 +1,32 @@ -// ©AngelaMos | 2026 -// Progress.cpp +/* +©AngelaMos | 2026 +Progress.cpp + +ANSI terminal progress bar with speed, ETA, and result formatting + +print_banner draws a Unicode box with algorithm, attack mode, and thread +count. update() runs in a background jthread and uses ANSI escape +sequences (\033[3A) to overwrite the previous three lines with an +updated progress bar, hash speed, elapsed time, ETA, and candidate +count. render_bar builds a filled/empty block character bar scaled to +terminal width (queried via ioctl TIOCGWINSZ). format_count humanizes +large numbers (K/M/B suffixes). print_cracked clears the progress area +and shows the cracked password with green highlight. print_exhausted +shows the red failure state. All output is suppressed when stdout is not +a TTY, falling back to a single-line plaintext result. + +Key exports: + Progress::print_banner - Draw boxed header on session start + Progress::update - Refresh progress bar (called from jthread loop) + Progress::print_cracked - Display success result + Progress::print_exhausted - Display exhaustion result + Progress::is_tty - Check if stdout is a terminal + +Connects to: + display/Progress.hpp - class declaration + config/Config.hpp - color, box, symbol constants, PROGRESS_BAR_MIN_WIDTH + core/Engine.hpp - constructs Progress, spawns update jthread, calls print_* +*/ #include "src/display/Progress.hpp" #include "src/config/Config.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/display/Progress.hpp b/PROJECTS/beginner/hash-cracker/src/display/Progress.hpp index 9084b2a6..c9e7b304 100644 --- a/PROJECTS/beginner/hash-cracker/src/display/Progress.hpp +++ b/PROJECTS/beginner/hash-cracker/src/display/Progress.hpp @@ -1,5 +1,14 @@ -// ©AngelaMos | 2026 -// Progress.hpp +/* +©AngelaMos | 2026 +Progress.hpp + +Terminal progress display with live speed, ETA, and result rendering + +Connects to: + display/Progress.cpp - implementation of banner, update loop, and result display + config/Config.hpp - color, box, symbol constants and PROGRESS_* settings + core/Engine.hpp - Engine::crack creates Progress and calls update in a jthread +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/hash/EVPHasher.hpp b/PROJECTS/beginner/hash-cracker/src/hash/EVPHasher.hpp index 4223d077..e2ebd751 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/EVPHasher.hpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/EVPHasher.hpp @@ -1,5 +1,34 @@ -// ©AngelaMos | 2026 -// EVPHasher.hpp +/* +©AngelaMos | 2026 +EVPHasher.hpp + +OpenSSL EVP-based hash implementation with tag dispatch and compile-time hex table + +Uses the EVP high-level API so the same code path handles MD5, SHA-1, +SHA-256, and SHA-512 without per-algorithm boilerplate. Each algorithm is +identified by a tag struct (MD5Tag, SHA1Tag, SHA256Tag, SHA512Tag) that +provides the EVP_MD factory pointer, display name, and expected hex +length. EVPHasher satisfies the Hasher concept from Concepts.hpp. +The HEX_TABLE constexpr lookup array converts raw digest bytes to hex +characters in a single indexed load per byte, avoiding the overhead of +std::format or snprintf in the hot path. + +Key exports: + EVPHasher - Template class satisfying the Hasher concept via OpenSSL EVP + MD5Hasher - Type alias for EVPHasher + SHA1Hasher - Type alias for EVPHasher + SHA256Hasher - Type alias for EVPHasher + SHA512Hasher - Type alias for EVPHasher + HEX_TABLE - Compile-time byte-to-hex lookup array + +Connects to: + core/Concepts.hpp - satisfies the Hasher concept + core/Engine.hpp - Engine::crack instantiates EVPHasher per thread + hash/MD5Hasher.hpp - forwarding header that includes this file + hash/SHA1Hasher.hpp - forwarding header + hash/SHA256Hasher.hpp - forwarding header + hash/SHA512Hasher.hpp - forwarding header +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.cpp b/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.cpp index 22d7335b..579f3bcf 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.cpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.cpp @@ -1,5 +1,23 @@ -// ©AngelaMos | 2026 -// HashDetector.cpp +/* +©AngelaMos | 2026 +HashDetector.cpp + +Hash algorithm detection via hex character validation and length matching + +Validates that every character in the input is hexadecimal, then switches +on the string length to identify the algorithm: 32 chars for MD5, 40 for +SHA-1, 64 for SHA-256, 128 for SHA-512. Returns CrackError::InvalidHash +for non-hex input or unrecognized lengths. + +Key exports: + HashDetector::detect - Returns HashType or CrackError based on hex length + +Connects to: + hash/HashDetector.hpp - class declaration and HashType enum + config/Config.hpp - MD5_HEX_LENGTH, SHA1_HEX_LENGTH, SHA256_HEX_LENGTH, + SHA512_HEX_LENGTH constants + main.cpp - called when --type=auto +*/ #include "src/hash/HashDetector.hpp" #include "src/config/Config.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.hpp b/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.hpp index 0949b63e..4c339fe4 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.hpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/HashDetector.hpp @@ -1,5 +1,14 @@ -// ©AngelaMos | 2026 -// HashDetector.hpp +/* +©AngelaMos | 2026 +HashDetector.hpp + +Hash algorithm auto-detection by hex digest length + +Connects to: + hash/HashDetector.cpp - implementation of detect() + core/Concepts.hpp - CrackError for invalid/unsupported hash errors + main.cpp - called when --type=auto (the default) +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/hash/MD5Hasher.hpp b/PROJECTS/beginner/hash-cracker/src/hash/MD5Hasher.hpp index 725ece6b..39e7a0a7 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/MD5Hasher.hpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/MD5Hasher.hpp @@ -1,5 +1,12 @@ -// ©AngelaMos | 2026 -// MD5Hasher.hpp +/* +©AngelaMos | 2026 +MD5Hasher.hpp + +Forwarding header exposing MD5Hasher from EVPHasher.hpp + +Connects to: + hash/EVPHasher.hpp - defines MD5Hasher as EVPHasher +*/ #pragma once #include "src/hash/EVPHasher.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/hash/SHA1Hasher.hpp b/PROJECTS/beginner/hash-cracker/src/hash/SHA1Hasher.hpp index 884a3ee6..46bfac6a 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/SHA1Hasher.hpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/SHA1Hasher.hpp @@ -1,5 +1,12 @@ -// ©AngelaMos | 2026 -// SHA1Hasher.hpp +/* +©AngelaMos | 2026 +SHA1Hasher.hpp + +Forwarding header exposing SHA1Hasher from EVPHasher.hpp + +Connects to: + hash/EVPHasher.hpp - defines SHA1Hasher as EVPHasher +*/ #pragma once #include "src/hash/EVPHasher.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/hash/SHA256Hasher.hpp b/PROJECTS/beginner/hash-cracker/src/hash/SHA256Hasher.hpp index 3f109540..cea83e3c 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/SHA256Hasher.hpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/SHA256Hasher.hpp @@ -1,5 +1,12 @@ -// ©AngelaMos | 2026 -// SHA256Hasher.hpp +/* +©AngelaMos | 2026 +SHA256Hasher.hpp + +Forwarding header exposing SHA256Hasher from EVPHasher.hpp + +Connects to: + hash/EVPHasher.hpp - defines SHA256Hasher as EVPHasher +*/ #pragma once #include "src/hash/EVPHasher.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/hash/SHA512Hasher.hpp b/PROJECTS/beginner/hash-cracker/src/hash/SHA512Hasher.hpp index caf29fe2..4a102d0d 100644 --- a/PROJECTS/beginner/hash-cracker/src/hash/SHA512Hasher.hpp +++ b/PROJECTS/beginner/hash-cracker/src/hash/SHA512Hasher.hpp @@ -1,5 +1,12 @@ -// ©AngelaMos | 2026 -// SHA512Hasher.hpp +/* +©AngelaMos | 2026 +SHA512Hasher.hpp + +Forwarding header exposing SHA512Hasher from EVPHasher.hpp + +Connects to: + hash/EVPHasher.hpp - defines SHA512Hasher as EVPHasher +*/ #pragma once #include "src/hash/EVPHasher.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/io/MappedFile.cpp b/PROJECTS/beginner/hash-cracker/src/io/MappedFile.cpp index f8a4b067..e80eca1d 100644 --- a/PROJECTS/beginner/hash-cracker/src/io/MappedFile.cpp +++ b/PROJECTS/beginner/hash-cracker/src/io/MappedFile.cpp @@ -1,5 +1,25 @@ -// ©AngelaMos | 2026 -// MappedFile.cpp +/* +©AngelaMos | 2026 +MappedFile.cpp + +POSIX mmap-based file mapping with RAII cleanup and move semantics + +open() calls POSIX open(2), fstat for size, and mmap with PROT_READ | +MAP_PRIVATE. madvise(MADV_SEQUENTIAL) hints to the kernel that the file +will be read linearly, which improves readahead performance for large +wordlists. The destructor munmaps the region and closes the fd. Move +constructor and assignment transfer ownership by nulling the source +pointers, preventing double-unmap. Copy is deleted. + +Key exports: + MappedFile::open - Maps a file read-only, returns MappedFile or CrackError + MappedFile::data - Pointer to mapped memory + MappedFile::size - File size in bytes + +Connects to: + io/MappedFile.hpp - class declaration + attack/DictionaryAttack.cpp - open() called in create(), data() read in next() +*/ #include "src/io/MappedFile.hpp" #include diff --git a/PROJECTS/beginner/hash-cracker/src/io/MappedFile.hpp b/PROJECTS/beginner/hash-cracker/src/io/MappedFile.hpp index a31e8d35..35b594e9 100644 --- a/PROJECTS/beginner/hash-cracker/src/io/MappedFile.hpp +++ b/PROJECTS/beginner/hash-cracker/src/io/MappedFile.hpp @@ -1,5 +1,14 @@ -// ©AngelaMos | 2026 -// MappedFile.hpp +/* +©AngelaMos | 2026 +MappedFile.hpp + +RAII wrapper for read-only memory-mapped files + +Connects to: + io/MappedFile.cpp - implementation using mmap/munmap + attack/DictionaryAttack.hpp - DictionaryAttack holds a MappedFile + core/Concepts.hpp - CrackError for open failures +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp b/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp index c308b482..9e0d0318 100644 --- a/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp +++ b/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp @@ -1,5 +1,35 @@ -// ©AngelaMos | 2026 -// RuleSet.cpp +/* +©AngelaMos | 2026 +RuleSet.cpp + +Coroutine-based password mutation generators + +Each static method is a C++23 generator that co_yields transformed +candidates from a base word. capitalize_first uppercases the first +character. uppercase_all transforms every character. leet_speak applies +a fixed substitution map (a->@, e->3, i->1, o->0, s->$, t->7). +append_digits and prepend_digits yield the word with every integer from +0 to MAX_APPEND_DIGIT/MAX_PREPEND_DIGIT (999 by default, producing 1000 +candidates each). reverse yields the reversed string. toggle_case swaps +upper to lower and vice versa. apply_all chains all generators together +using co_yield std::ranges::elements_of, producing ~2005 mutations per +input word. + +Key exports: + RuleSet::capitalize_first - Uppercase first letter + RuleSet::uppercase_all - Uppercase all letters + RuleSet::leet_speak - Common character substitutions + RuleSet::append_digits - Word + 0..999 + RuleSet::prepend_digits - 0..999 + word + RuleSet::reverse - Reversed string + RuleSet::toggle_case - Swap upper/lower case + RuleSet::apply_all - Chains all above generators into one stream + +Connects to: + rules/RuleSet.hpp - class declaration + config/Config.hpp - MAX_APPEND_DIGIT, MAX_PREPEND_DIGIT + attack/RuleAttack.cpp - calls apply_all for each word +*/ #include "src/rules/RuleSet.hpp" #include "src/config/Config.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.hpp b/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.hpp index b5b8563e..bf291869 100644 --- a/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.hpp +++ b/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.hpp @@ -1,5 +1,14 @@ -// ©AngelaMos | 2026 -// RuleSet.hpp +/* +©AngelaMos | 2026 +RuleSet.hpp + +Password mutation rules using C++23 std::generator coroutines + +Connects to: + rules/RuleSet.cpp - implementation of all mutation generators + attack/RuleAttack.cpp - calls apply_all() for each dictionary word + config/Config.hpp - MAX_APPEND_DIGIT, MAX_PREPEND_DIGIT limits +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.cpp b/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.cpp index 2f45e9d4..27691aed 100644 --- a/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.cpp +++ b/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.cpp @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// ThreadPool.cpp +/* +©AngelaMos | 2026 +ThreadPool.cpp + +Thread pool and shared state implementation + +set_result uses a relaxed store on the found flag for speed (all readers +also use relaxed loads) and a mutex guard for the result string to +prevent races when multiple threads find the answer simultaneously; only +the first write wins. The constructor resolves thread_count 0 to +hardware_concurrency. run() spawns jthreads in a vector; they auto-join +on destruction when the vector goes out of scope. + +Connects to: + threading/ThreadPool.hpp - class declarations + core/Engine.hpp - Engine::crack calls run() with a lambda +*/ #include "src/threading/ThreadPool.hpp" diff --git a/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.hpp b/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.hpp index 3da56245..0145073d 100644 --- a/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.hpp +++ b/PROJECTS/beginner/hash-cracker/src/threading/ThreadPool.hpp @@ -1,5 +1,27 @@ -// ©AngelaMos | 2026 -// ThreadPool.hpp +/* +©AngelaMos | 2026 +ThreadPool.hpp + +Lightweight thread pool with shared atomic state for crack coordination + +SharedState holds the found flag (atomic), tested_count (atomic +size_t), and the cracked plaintext behind a mutex. The cache-line +aligned atomics (alignas(64)) prevent false sharing between the hot +found flag and the counter. ThreadPool spawns jthreads that each call +the user's WorkFn with their thread ID, total thread count, and a +reference to SharedState. + +Key exports: + SharedState - Shared atomic found flag, tested count, and result + SharedState::set_result - Thread-safe first-writer-wins plaintext storage + ThreadPool - Spawns jthreads and joins on destruction + ThreadPool::run - Launches work across all threads + ThreadPool::state - Access to SharedState for result checking + +Connects to: + threading/ThreadPool.cpp - implementation of set_result, constructor, run + core/Engine.hpp - Engine::crack creates and runs the pool +*/ #pragma once diff --git a/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp b/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp index 8bf83562..bcb8b413 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// test_bruteforce_attack.cpp +/* +©AngelaMos | 2026 +test_bruteforce_attack.cpp + +Tests for brute-force keyspace generation and thread partitioning + +Verifies single-char generation, multi-length enumeration up to +max_length, correct keyspace total (sum of charset^len), and that +splitting across two threads produces the same combined set without +duplicates or gaps. + +Connects to: + attack/BruteForceAttack.hpp - BruteForceAttack tested +*/ #include #include "src/attack/BruteForceAttack.hpp" diff --git a/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp b/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp index 64ef8c9a..c98bc12b 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp @@ -1,5 +1,18 @@ -// ©AngelaMos | 2026 -// test_dictionary_attack.cpp +/* +©AngelaMos | 2026 +test_dictionary_attack.cpp + +Tests for memory-mapped wordlist reading and thread partitioning + +Loads tests/data/small_wordlist.txt (10 words) and verifies all words +are read in order, first/last word content, correct total count, +two-thread partitioning that covers all words without overlap, and +graceful CrackError on missing files. + +Connects to: + attack/DictionaryAttack.hpp - DictionaryAttack tested + tests/data/small_wordlist.txt - fixture wordlist +*/ #include #include "src/attack/DictionaryAttack.hpp" diff --git a/PROJECTS/beginner/hash-cracker/tests/test_engine.cpp b/PROJECTS/beginner/hash-cracker/tests/test_engine.cpp index 5a6972ab..4fe08ffb 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_engine.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_engine.cpp @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// test_engine.cpp +/* +©AngelaMos | 2026 +test_engine.cpp + +End-to-end tests for the crack engine + +Verifies Engine::crack with SHA256Hasher + DictionaryAttack finds +"password" from the test wordlist. Confirms CrackError::Exhausted when +the target hash is not in the wordlist. Tests salt support by cracking +a prepend-salted hash. + +Connects to: + core/Engine.hpp - Engine::crack tested + hash/SHA256Hasher.hpp - SHA256Hasher used in all tests + attack/DictionaryAttack.hpp - DictionaryAttack as the attack strategy + tests/data/small_wordlist.txt - fixture wordlist +*/ #include #include "src/core/Engine.hpp" diff --git a/PROJECTS/beginner/hash-cracker/tests/test_hash_detector.cpp b/PROJECTS/beginner/hash-cracker/tests/test_hash_detector.cpp index 7b32891c..229d2e50 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_hash_detector.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_hash_detector.cpp @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// test_hash_detector.cpp +/* +©AngelaMos | 2026 +test_hash_detector.cpp + +Tests for hash algorithm auto-detection by digest length + +Verifies detection of MD5 (32 chars), SHA-1 (40), SHA-256 (64), and +SHA-512 (128) from real and synthetic hex strings. Confirms rejection +of invalid lengths and non-hex characters with CrackError::InvalidHash. + +Connects to: + hash/HashDetector.hpp - HashDetector::detect tested + core/Concepts.hpp - CrackError enum for error assertions +*/ #include #include "src/hash/HashDetector.hpp" diff --git a/PROJECTS/beginner/hash-cracker/tests/test_hashers.cpp b/PROJECTS/beginner/hash-cracker/tests/test_hashers.cpp index 5b735004..c9f75e2b 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_hashers.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_hashers.cpp @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// test_hashers.cpp +/* +©AngelaMos | 2026 +test_hashers.cpp + +Tests for all four hash algorithm implementations against known vectors + +Verifies MD5, SHA-1, SHA-256, and SHA-512 against NIST/RFC test vectors +for empty string and "password". Checks static name() and digest_length() +properties. Confirms deterministic output and non-empty results. + +Connects to: + hash/MD5Hasher.hpp - MD5Hasher tested + hash/SHA1Hasher.hpp - SHA1Hasher tested + hash/SHA256Hasher.hpp - SHA256Hasher tested + hash/SHA512Hasher.hpp - SHA512Hasher tested + hash/EVPHasher.hpp - underlying implementation +*/ #include #include "src/hash/MD5Hasher.hpp" diff --git a/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp b/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp index 017f4ff3..817d9768 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// test_rules.cpp +/* +©AngelaMos | 2026 +test_rules.cpp + +Tests for password mutation rules and rule-based attack integration + +Verifies each individual mutation generator: capitalize_first, uppercase_all, +leet_speak, append_digits (1000 candidates), prepend_digits, reverse, +toggle_case. Tests apply_all produces >2000 mutations and contains expected +entries. Integration tests confirm RuleAttack applies mutations to every +dictionary word and that chain_rules mode produces more candidates than +single-pass mode. + +Connects to: + rules/RuleSet.hpp - individual mutation functions tested + attack/RuleAttack.hpp - RuleAttack integration tested + tests/data/small_wordlist.txt - fixture wordlist +*/ #include #include "src/attack/RuleAttack.hpp" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh index e41879a7..2bab9514 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 01_initial_setup.sh +# +# CIS Section 1 checks: Initial Setup +# +# Implements controls 1.1.1-1.5.3 covering filesystem hardening and +# kernel protections. Checks kernel module disabling for eight legacy +# filesystems (cramfs, freevxfs, jffs2, hfs, hfsplus, squashfs, udf, +# vfat) via modprobe.d configs and lsmod. Verifies /tmp as a separate +# partition with noexec, nosuid, and nodev mount options through fstab +# and findmnt. Validates package repository configuration, GPG key +# presence, GRUB bootloader password, grub.cfg permissions and +# ownership, single-user mode authentication (sulogin), ASLR +# (kernel.randomize_va_space=2), core dump restrictions (limits.conf +# + fs.suid_dumpable), and prelink removal. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - get_sysctl, read_file, run_cmd, file_exists +# controls/registry_data.sh - defines CIS IDs 1.x.x _check_module_disabled() { local id="$1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/02_services.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/02_services.sh index cc8ef7f5..9c0fb4c0 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/02_services.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/02_services.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 02_services.sh +# +# CIS Section 2 checks: Services +# +# Implements controls 2.1.1-2.2.16 verifying that unnecessary network +# services are not installed on the system. Checks legacy super-servers +# (xinetd, openbsd-inetd), graphical environment (X Window System), +# and fourteen network daemons: Avahi, CUPS, ISC DHCP, OpenLDAP, NFS, +# BIND DNS, vsftpd, Apache/nginx HTTP, Dovecot IMAP/POP3, Samba, +# Squid proxy, SNMP, and NIS. Each check uses package_is_installed +# with file_exists fallback for binary/config detection. The MTA +# check (2.2.15) inspects Postfix inet_interfaces, Exim +# dc_local_interfaces, and ss port 25 listeners to verify local-only +# mail delivery. Also checks rsync daemon installation. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - package_is_installed, file_exists, read_file, +# service_is_enabled, run_cmd check_2_1_1() { local id="2.1.1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/03_network.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/03_network.sh index c4e86a98..89743b16 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/03_network.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/03_network.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 03_network.sh +# +# CIS Section 3 checks: Network Configuration +# +# Implements controls 3.1.1-3.4.5 auditing kernel network parameters, +# host firewall policy, and uncommon protocol modules. Sysctl checks +# cover IP forwarding, ICMP send_redirects, source routing acceptance, +# ICMP redirect acceptance, martian packet logging, broadcast ICMP +# ignore, bogus ICMP response ignore, reverse path filtering, TCP +# SYN cookies, and IPv6 router advertisement acceptance. Firewall +# checks verify iptables installation, default deny policies on +# INPUT/FORWARD/OUTPUT chains (from live iptables or rules.v4 file), +# and open-port-to-rule coverage via ss. Validates wireless interface +# disabling through rfkill and checks modprobe.d for four uncommon +# protocol modules: DCCP, SCTP, RDS, and TIPC. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - get_sysctl, package_is_installed, file_exists, run_cmd check_3_1_1() { local id="3.1.1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/04_logging.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/04_logging.sh index 9409fbc7..074fe665 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/04_logging.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/04_logging.sh @@ -1,6 +1,25 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 04_logging.sh +# +# CIS Section 4 checks: Logging and Auditing +# +# Implements controls 4.1.1-4.2.4 for the audit subsystem and syslog +# configuration. Verifies auditd is installed, enabled, and configured +# for boot-time auditing (audit=1 in GRUB_CMDLINE_LINUX) with a +# sufficient backlog limit (>=8192). Validates ten categories of audit +# rules by searching /etc/audit/rules.d/ and audit.rules: time changes +# (adjtimex, settimeofday, clock_settime), user/group modifications, +# network environment changes (sethostname, /etc/issue, /etc/hosts), +# MAC policy changes (SELinux/AppArmor paths), login/logout events, +# session initiation (utmp/wtmp/btmp), DAC permission changes (chmod, +# chown family), unauthorized access attempts (EACCES/EPERM), file +# system mounts, and file deletions (unlink/rename). Checks rsyslog +# installation, service status, FileCreateMode, and logging rules. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - package_is_installed, file_exists, service_is_enabled check_4_1_1() { local id="4.1.1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access.sh index a5c94241..a9828d0e 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access.sh @@ -1,6 +1,26 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 05_access.sh +# +# CIS Section 5 checks: Cron and SSH hardening +# +# Implements controls 5.1.1-5.2.14 covering cron daemon and SSH +# server configuration. Verifies cron is enabled and checks +# permissions/ownership on /etc/crontab, cron.hourly, and cron.daily +# (root:root, 0600/0700). SSH checks parse sshd_config for: file +# permissions (0600 root-owned), access restrictions (AllowUsers/ +# AllowGroups/DenyUsers/DenyGroups), host private key permissions, +# LogLevel (INFO or VERBOSE), X11Forwarding (no), MaxAuthTries (<=4), +# IgnoreRhosts (yes), PermitRootLogin (no), PermitEmptyPasswords (no), +# PermitUserEnvironment (no), weak cipher/MAC/KexAlgorithm rejection +# (CBC ciphers, MD5/SHA1-96 MACs, DH group1/14-sha1 KEX), and +# LoginGraceTime (<=60s). Uses _check_ssh_value and _check_ssh_max_int +# helpers for consistent sshd_config parsing. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - service_is_enabled, file_exists, run_cmd, +# get_config_value check_5_1_1() { local id="5.1.1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access_password.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access_password.sh index 0577e281..d6ed503a 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access_password.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/05_access_password.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 05_access_password.sh +# +# CIS Section 5.3-5.5 checks: Password policy and account lockout +# +# Implements controls 5.3.1, 5.4.1-5.4.3, and 5.5.1 for password +# strength and account security. Checks PAM password quality by +# verifying pam_pwquality or pam_cracklib is configured in +# /etc/pam.d/common-password. Validates /etc/login.defs parameters: +# PASS_MAX_DAYS (<=365), PASS_MIN_DAYS (>=1), and PASS_WARN_AGE (>=7) +# using the _check_login_defs_value helper with configurable comparison +# direction. Verifies account lockout is configured via pam_faillock +# or pam_tally2 in /etc/pam.d/common-auth. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - file_exists (SYSROOT-aware path resolution) check_5_3_1() { local id="5.3.1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/06_maintenance.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/06_maintenance.sh index 8d28b3e6..c3acc0f6 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/06_maintenance.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/checks/06_maintenance.sh @@ -1,6 +1,22 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # 06_maintenance.sh +# +# CIS Section 6 checks: System Maintenance +# +# Implements controls 6.1.1-6.2.5 for critical file integrity and +# account hygiene. Verifies permissions and ownership on five sensitive +# files (/etc/passwd 644 root:root, /etc/shadow 640 root:shadow, +# /etc/group 644 root:root, /etc/gshadow 640 root:shadow, /etc/passwd- +# 600 root:root) via the _check_file_permissions helper using octal +# comparison. Detects duplicate UIDs, duplicate GIDs, and duplicate +# usernames in /etc/passwd and /etc/group using awk/sort/uniq. Enforces +# that only root has UID 0 and detects legacy NIS '+' entries in +# passwd, shadow, and group files. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - run_cmd (stat wrapper) _check_file_permissions() { local id="$1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/cisaudit.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/cisaudit.sh index 93530350..e6796c3a 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/cisaudit.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/cisaudit.sh @@ -1,6 +1,28 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # cisaudit.sh +# +# Main entry point for the CIS benchmark compliance auditor +# +# Parses CLI arguments (level filtering, output format, categories, +# baseline compare/save, test-root override, threshold), sources all +# library modules and check files, then dispatches through run_checks +# to execute registered controls, compute_scores for scoring, and +# generate_report for terminal/JSON/HTML output. Supports --list-controls +# for a dry-run control inventory. Exits non-zero when the overall score +# falls below --threshold. +# +# Connects to: +# lib/constants.sh - version, exit codes, ANSI colors, section names +# lib/utils.sh - logging, progress, environment detection helpers +# lib/registry.sh - control registration and result tracking +# lib/engine.sh - score computation (overall, per-section, per-level) +# lib/report_terminal.sh - ANSI terminal report renderer +# lib/report_json.sh - JSON report emitter +# lib/report_html.sh - standalone HTML report generator +# lib/baseline.sh - baseline save/load/diff +# controls/registry_data.sh - CIS control definitions (70+ register_control calls) +# checks/*.sh - per-section check implementations set -euo pipefail diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/controls/registry_data.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/controls/registry_data.sh index 70eb0d0e..269c8dbe 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/controls/registry_data.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/controls/registry_data.sh @@ -1,6 +1,25 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # registry_data.sh +# +# CIS benchmark control definitions database +# +# Contains 70+ register_control calls that populate the control +# registry with every audited CIS benchmark item. Each entry +# specifies the control ID, parent section name, human-readable +# title, CIS level (1 or 2), scored flag, a rationale paragraph +# explaining the security impact, and a remediation command. Covers +# all six sections: Initial Setup (filesystem modules, /tmp hardening, +# package repos, bootloader, kernel protections), Services (xinetd +# through rsync), Network Configuration (sysctl parameters, firewall +# policies, wireless, uncommon protocols), Logging and Auditing +# (auditd, audit rules, rsyslog), Access/Authentication/Authorization +# (cron, SSH hardening, password policy, account lockout), and System +# Maintenance (file permissions, duplicate IDs, legacy entries). +# +# Connects to: +# lib/registry.sh - register_control function stores each definition +# checks/*.sh - check functions that implement each control ID register_control "1.1.1" \ "Initial Setup" \ diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/baseline.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/baseline.sh index 7b66193a..7799c0e7 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/baseline.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/baseline.sh @@ -1,6 +1,22 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # baseline.sh +# +# Baseline persistence for audit snapshot comparison +# +# Enables saving current audit results as a JSON baseline via +# save_baseline (delegates to emit_json_report), reloading a prior +# baseline with load_baseline (regex-parses control IDs and statuses +# from the JSON without a JSON library), and diffing current results +# against the baseline via diff_baseline which categorizes each +# control as improved, regressed, unchanged, new, or removed and +# prints a colored summary with regression warnings. +# +# Connects to: +# lib/report_json.sh - emit_json_report (called by save_baseline) +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, CTRL_TITLE +# lib/utils.sh - warn logging function +# lib/constants.sh - ANSI colors, STATUS_PASS/FAIL declare -gA BASELINE_STATUS diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/constants.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/constants.sh index 246411f1..7231f1a2 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/constants.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/constants.sh @@ -1,6 +1,19 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # constants.sh +# +# Global constants and configuration values +# +# Defines the tool version string, CIS benchmark identifier, minimum +# bash version requirement, process exit codes, ANSI terminal color +# escape sequences, audit status labels (PASS/FAIL/WARN/SKIP), the +# default SYSROOT path for live vs test-mode filesystem access, and +# the six CIS section names with their display ordering. +# +# Connects to: +# cisaudit.sh - sourced first, values used by all modules +# lib/utils.sh - ANSI colors used in logging functions +# lib/engine.sh - SECTION_ORDER drives per-section score iteration declare -gr VERSION="1.0.0" declare -gr CIS_BENCHMARK="CIS Debian Linux 12 Benchmark v1.1.0" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/engine.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/engine.sh index e2d513a7..48c4faa7 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/engine.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/engine.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # engine.sh +# +# Score computation engine for CIS audit results +# +# Iterates recorded check results to tally per-section pass/fail/warn/skip +# counts, then computes percentage scores at three levels: per-section +# (SCORE_BY_SECTION), per-CIS-level (SCORE_LEVEL1, SCORE_LEVEL2), and +# overall (SCORE_OVERALL). Scores are calculated as pass/(pass+fail)*100, +# ignoring warn and skip results. Sections with zero scored results +# receive "N/A" instead of a numeric score. +# +# Connects to: +# lib/constants.sh - SECTION_ORDER, STATUS_PASS/FAIL/WARN/SKIP +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, CTRL_SECTION, +# CTRL_LEVEL, CTRL_SCORED, TOTAL_PASS/FAIL declare -gA SCORE_BY_SECTION declare -gA SECTION_PASS diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/registry.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/registry.sh index 333ed148..d24794d4 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/registry.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/registry.sh @@ -1,6 +1,23 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # registry.sh +# +# Control registration and result tracking system +# +# Provides the in-memory database of CIS controls and their audit +# outcomes using bash associative arrays. register_control stores +# each control's metadata (title, section, level, scored, description, +# remediation) and derives the check function name (check_X_Y_Z). +# record_result captures per-control pass/fail/warn/skip status with +# evidence and maintains running totals. Query helpers retrieve +# controls by section or level. reset_results clears all state for +# test isolation. +# +# Connects to: +# lib/constants.sh - STATUS_PASS/FAIL/WARN/SKIP +# controls/registry_data.sh - populates registry via register_control +# checks/*.sh - check functions call record_result +# lib/engine.sh - reads RESULT_ORDER, RESULT_STATUS, CTRL_* declare -gA CTRL_TITLE declare -gA CTRL_SECTION diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_html.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_html.sh index 36f399ca..86604411 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_html.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_html.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # report_html.sh +# +# Standalone HTML report generator with Tokyo Night theme +# +# Produces a single-file HTML page via emit_html_report containing +# embedded CSS (dark Tokyo Night palette with print-friendly overrides), +# a responsive dashboard with score cards and level badges, a section +# breakdown table with colored progress bars, and collapsible +#
controls for each audited item showing status badges, +# control IDs, evidence blocks, and remediation hints. Failed +# controls auto-expand for immediate visibility. Responsive at +# 640px breakpoint with print media query support. +# +# Connects to: +# lib/constants.sh - VERSION, CIS_BENCHMARK, STATUS_* +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, RESULT_EVIDENCE, +# CTRL_TITLE, CTRL_SECTION, CTRL_LEVEL +# lib/engine.sh - SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2, +# SCORE_BY_SECTION, SECTION_PASS/FAIL/WARN/SKIP html_escape() { local s="$1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_json.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_json.sh index b533e547..5b55252d 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_json.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_json.sh @@ -1,6 +1,25 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # report_json.sh +# +# JSON report emitter for machine-readable audit output +# +# Serializes complete audit results into a structured JSON document +# via emit_json_report. Output includes version, CIS benchmark ID, +# ISO 8601 timestamp, hostname, OS detection, a summary block with +# totals and overall/level scores, a sections array with per-section +# pass/fail/warn/skip counts and scores, and a controls array with +# each control's ID, section, title, level, scored flag, status, +# evidence, and remediation. Provides json_escape for safe string +# encoding and null-coalescing for N/A scores. +# +# Connects to: +# lib/constants.sh - VERSION, CIS_BENCHMARK, STATUS_* +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, RESULT_EVIDENCE, +# CTRL_TITLE, CTRL_SECTION, CTRL_LEVEL, CTRL_SCORED +# lib/engine.sh - SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2, +# SCORE_BY_SECTION, SECTION_PASS/FAIL/WARN/SKIP +# lib/baseline.sh - save_baseline calls emit_json_report json_escape() { local s="$1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_terminal.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_terminal.sh index a4a907ed..d0652c3a 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_terminal.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/report_terminal.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # report_terminal.sh +# +# ANSI terminal report renderer +# +# Builds a rich terminal report through emit_terminal_report by +# composing four sections: an ASCII art banner with version/hostname/ +# timestamp, a bordered summary card showing overall score and +# pass/fail/warn/skip totals, a section breakdown table with inline +# progress bars and color-coded percentages, and detailed per-control +# results grouped by section with status symbols, evidence lines for +# failures, and remediation hints. All color output uses the ANSI +# escape sequences from constants.sh. +# +# Connects to: +# lib/constants.sh - ANSI colors, VERSION, CIS_BENCHMARK, STATUS_* +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, RESULT_EVIDENCE, +# CTRL_TITLE, CTRL_SECTION, CTRL_REMEDIATION +# lib/engine.sh - SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2, +# SCORE_BY_SECTION, SECTION_PASS/FAIL/WARN/SKIP _status_color() { local status="$1" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/utils.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/utils.sh index c2f88258..d2271e6e 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/utils.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/src/lib/utils.sh @@ -1,6 +1,22 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # utils.sh +# +# Shared utility functions for logging and system inspection +# +# Provides ANSI-colored log helpers (info, success, warn, fail) that +# respect the QUIET flag, inline progress display with carriage-return +# clearing, bash version enforcement, root privilege detection, and +# OS identification from /etc/os-release. Wraps all filesystem access +# through SYSROOT so checks work against both the live system and +# test fixture directories: file_exists, read_file, get_sysctl (proc +# tree then sysctl fallback), get_config_value (grep key from config), +# run_cmd (blocks in test mode), and dpkg/systemctl wrappers. +# +# Connects to: +# lib/constants.sh - ANSI color codes, EXIT_FAIL, MIN_BASH_VERSION +# checks/*.sh - check functions call file_exists, get_sysctl, +# package_is_installed, service_is_enabled, etc. info() { [[ "$QUIET" == "true" ]] || echo -e "${CYAN}[*]${RESET} $1" >&2; } success() { [[ "$QUIET" == "true" ]] || echo -e "${GREEN}[✔]${RESET} $1" >&2; } diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_01_initial_setup.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_01_initial_setup.sh index 1703ce86..f5c002d9 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_01_initial_setup.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_01_initial_setup.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_01_initial_setup.sh +# +# Tests for CIS Section 1 (Initial Setup) checks +# +# Uses pass and fail fixture directories to verify cramfs module +# detection (1.1.1), /tmp separate partition (1.2.1), /tmp mount +# options noexec/nosuid/nodev (1.2.2-1.2.4), ASLR enabled status +# (1.5.1), and core dump restrictions (1.5.2). Each test calls the +# corresponding check function against a fixture SYSROOT and asserts +# both the expected status and key evidence substrings. +# +# Connects to: +# checks/01_initial_setup.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_1_1_1_pass() { CURRENT_TEST="test_1_1_1_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_02_services.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_02_services.sh index cc0ee97b..f81a083e 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_02_services.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_02_services.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_02_services.sh +# +# Tests for CIS Section 2 (Services) checks +# +# Uses pass and fail fixture directories to verify xinetd removal +# (2.1.1), X Window System absence (2.2.1), HTTP server detection +# for apache2 (2.2.9), and MTA local-only configuration via Postfix +# inet_interfaces (2.2.15). Each test asserts the expected status +# and evidence substrings against both compliant and non-compliant +# fixture filesystems. +# +# Connects to: +# checks/02_services.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_2_1_1_pass() { CURRENT_TEST="test_2_1_1_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_03_network.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_03_network.sh index f021d2bf..b4f054cf 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_03_network.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_03_network.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_03_network.sh +# +# Tests for CIS Section 3 (Network Configuration) checks +# +# Uses pass and fail fixture directories to verify IP forwarding +# disabled (3.1.1), send_redirects disabled (3.1.2), martian packet +# logging enabled (3.2.1), TCP SYN cookies enabled (3.2.5), IPv6 +# router advertisement rejection (3.2.6), and DCCP protocol module +# disabling (3.4.2). Each test asserts the expected status and key +# evidence substrings from sysctl proc tree values and modprobe.d +# configs in the fixture filesystems. +# +# Connects to: +# checks/03_network.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_3_1_1_pass() { CURRENT_TEST="test_3_1_1_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_04_logging.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_04_logging.sh index ff6fc57a..3c968a44 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_04_logging.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_04_logging.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_04_logging.sh +# +# Tests for CIS Section 4 (Logging and Auditing) checks +# +# Uses pass and fail fixture directories to verify auditd installation +# (4.1.1), boot-time audit parameter in GRUB (4.1.3), audit backlog +# limit (4.1.4), time change audit rules (4.1.5), rsyslog installation +# (4.2.1), rsyslog FileCreateMode (4.2.3), and logging rule presence +# (4.2.4). Each test asserts the expected status and evidence substring +# matches against fixture /etc/default/grub, /etc/audit/rules.d/, and +# /etc/rsyslog.conf contents. +# +# Connects to: +# checks/04_logging.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_4_1_1_pass() { CURRENT_TEST="test_4_1_1_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access.sh index 344d782b..0fd77534 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_05_access.sh +# +# Tests for CIS Section 5 (Access) SSH hardening checks +# +# Uses pass and fail fixture directories to verify sshd_config +# parsing for LogLevel (5.2.4), MaxAuthTries (5.2.6), X11Forwarding +# (5.2.5), IgnoreRhosts (5.2.7), PermitRootLogin (5.2.8), +# PermitEmptyPasswords (5.2.9), and weak cipher rejection (5.2.11). +# Each test asserts the expected status and evidence substrings +# against fixture sshd_config files containing compliant and +# non-compliant directive values. +# +# Connects to: +# checks/05_access.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_5_2_4_pass() { CURRENT_TEST="test_5_2_4_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access_password.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access_password.sh index 7503a538..a1c5d586 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access_password.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_05_access_password.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_05_access_password.sh +# +# Tests for CIS Section 5.3-5.5 password policy and account lockout checks +# +# Uses pass and fail fixture directories to verify PAM password quality +# module presence (5.3.1 pam_pwquality), login.defs PASS_MAX_DAYS +# (5.4.1 <=365), PASS_MIN_DAYS (5.4.2 >=1), PASS_WARN_AGE (5.4.3 +# >=7), and account lockout via pam_faillock (5.5.1). Each test +# asserts the expected status and evidence substrings against fixture +# /etc/pam.d/common-password, /etc/login.defs, and /etc/pam.d/ +# common-auth contents. +# +# Connects to: +# checks/05_access_password.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_5_3_1_pass() { CURRENT_TEST="test_5_3_1_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_06_maintenance.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_06_maintenance.sh index 287815eb..5e968499 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_06_maintenance.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_06_maintenance.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_06_maintenance.sh +# +# Tests for CIS Section 6 (System Maintenance) checks +# +# Uses pass and fail fixture directories to verify duplicate UID +# detection (6.2.1), duplicate GID detection (6.2.2), duplicate +# username detection (6.2.3), UID 0 root-only enforcement (6.2.4), +# and legacy NIS '+' entry detection (6.2.5). Each test asserts +# the expected status and evidence substrings against fixture +# /etc/passwd and /etc/group contents. +# +# Connects to: +# checks/06_maintenance.sh - check functions under test +# tests/test_helpers.sh - setup_test, assert_status, +# assert_evidence_contains test_6_2_1_pass() { CURRENT_TEST="test_6_2_1_pass" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_baseline.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_baseline.sh index d9560aed..261d25af 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_baseline.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_baseline.sh @@ -1,6 +1,22 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_baseline.sh +# +# Tests for baseline save, load, and diff operations +# +# Verifies the full baseline lifecycle: save_baseline produces a JSON +# file that load_baseline can parse back into BASELINE_STATUS with +# correct per-control statuses. Tests diff_baseline with three +# scenarios: all controls unchanged (0 regressed), one regression +# introduced by switching SYSROOT mid-run, and a missing baseline +# file that produces a warning. Also validates that saved baseline +# files are well-formed JSON via assert_json_valid. +# +# Connects to: +# lib/baseline.sh - save_baseline, load_baseline, diff_baseline, +# BASELINE_STATUS +# lib/engine.sh - compute_scores (required before save) +# tests/test_helpers.sh - setup_test, assert_json_valid test_baseline_save_and_load() { CURRENT_TEST="test_baseline_save_and_load" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_engine.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_engine.sh index 2be416de..dcd598a5 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_engine.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_engine.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_engine.sh +# +# Tests for the scoring engine +# +# Verifies compute_scores produces correct results across five +# scenarios: overall score is non-zero when checks produce mixed +# results from fixtures, per-section scores are populated for Initial +# Setup and Network Configuration, all-pass checks yield exactly +# 100.0 overall, one pass plus one fail yields exactly 50.0, and +# reset_results clears all counters and result arrays to zero. +# +# Connects to: +# lib/engine.sh - compute_scores, SCORE_OVERALL, SCORE_BY_SECTION +# lib/registry.sh - reset_results, TOTAL_PASS/FAIL, RESULT_ORDER +# tests/test_helpers.sh - setup_test, TEST_TOTAL/PASS/FAIL counters test_engine_compute_scores_pass_fixtures() { CURRENT_TEST="test_engine_compute_scores_pass_fixtures" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_helpers.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_helpers.sh index a5fb76fd..59b7c40f 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_helpers.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_helpers.sh @@ -1,6 +1,23 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_helpers.sh +# +# Test assertion framework for the bash test harness +# +# Provides the core testing primitives: setup_test resets registry state +# and sets SYSROOT to a fixture directory for isolated filesystem +# checks. assert_status compares a control's recorded status against an +# expected value. assert_evidence_contains checks for a substring in the +# evidence string. assert_json_valid validates JSON via python3's +# json.tool (accepts both file paths and inline strings). print_results +# outputs the total/pass/fail summary and returns non-zero on failures. +# Tracks counts in TEST_PASS, TEST_FAIL, TEST_TOTAL globals. +# +# Connects to: +# lib/registry.sh - reset_results, RESULT_STATUS, RESULT_EVIDENCE +# tests/test_runner.sh - sources this file before running tests +# testdata/fixtures/ - pass-scenario fixture directory +# testdata/fixtures_fail/ - fail-scenario fixture directory declare -g TEST_PASS=0 declare -g TEST_FAIL=0 diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_report_json.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_report_json.sh index 703a6ace..0212d91f 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_report_json.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_report_json.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_report_json.sh +# +# Tests for JSON report output structure and validity +# +# Validates emit_json_report produces well-formed JSON via python3's +# json.tool, contains required top-level fields (version, cis_benchmark, +# summary with score_percent), a sections array with all six CIS +# sections, and a controls array with the correct count of audited +# items. Also verifies file-based JSON output by writing to a tmpfile +# and validating the result. +# +# Connects to: +# lib/report_json.sh - emit_json_report under test +# lib/engine.sh - compute_scores (required before report) +# tests/test_helpers.sh - setup_test, assert_json_valid test_json_valid_output() { CURRENT_TEST="test_json_valid_output" diff --git a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_runner.sh b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_runner.sh index f82cfba6..0f6476ac 100755 --- a/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_runner.sh +++ b/PROJECTS/beginner/linux-cis-hardening-auditor/tests/test_runner.sh @@ -1,6 +1,23 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # test_runner.sh +# +# Test harness for the CIS auditor test suite +# +# Sources all project modules (constants, utils, registry, engine, +# report_json, baseline, registry_data, and all check files) plus the +# test_helpers assertion framework. Discovers test_*.sh files (excluding +# itself and test_helpers.sh), sources each one, introspects all +# functions matching the test_ prefix via declare -F, executes them in +# order, then cleans up by unsetting each test function. Supports +# running specific test files as CLI arguments or auto-discovering all +# files in the tests directory. Prints the aggregated pass/fail summary +# via print_results and exits non-zero on any failures. +# +# Connects to: +# tests/test_helpers.sh - assertion framework (setup_test, assert_*) +# all src/ modules - sourced to provide testable functions +# tests/test_*.sh - individual test files discovered and run set -euo pipefail diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/context.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/context.rs index 23008dd6..f409b03d 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/context.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/context.rs @@ -1,5 +1,26 @@ // ©AngelaMos | 2026 // context.rs +// +// Analysis context holding binary data and accumulated pass results +// +// BinarySource is a two-variant enum: Mapped wraps a memmap2 +// Mmap for disk-backed files, Buffered wraps an Arc<[u8]> for +// in-memory data received over the network. Both implement +// AsRef<[u8]> so passes access the binary through a uniform +// data() method. AnalysisContext is constructed with a source, +// SHA-256 digest, filename, and file size. Each analysis pass +// populates its corresponding Option field (format_result, +// import_result, string_result, entropy_result, +// disassembly_result, threat_result) as it runs, building up +// the full analysis incrementally. +// +// Connects to: +// formats/mod.rs - FormatResult +// passes/disasm.rs - DisassemblyResult +// passes/entropy.rs - EntropyResult +// passes/imports.rs - ImportResult +// passes/strings.rs - StringResult +// passes/threat.rs - ThreatResult use std::sync::Arc; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/error.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/error.rs index 4c0e368c..9ed12141 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/error.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/error.rs @@ -1,5 +1,17 @@ // ©AngelaMos | 2026 // error.rs +// +// Engine error type hierarchy +// +// EngineError is a thiserror enum covering all failure modes +// in the analysis pipeline: InvalidBinary for unparseable +// input, UnsupportedFormat and UnsupportedArchitecture for +// recognized but unhandled binaries, MissingDependency when +// a pass requires results from an earlier pass that did not +// run, PassFailed wrapping the source error from any pass, +// Yara for rule compilation or scan failures, and Io for +// filesystem operations. The From impl +// enables transparent propagation with the ? operator. #[derive(thiserror::Error, Debug)] pub enum EngineError { diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/elf.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/elf.rs index 7a2fe6ce..eec371df 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/elf.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/elf.rs @@ -1,5 +1,29 @@ // ©AngelaMos | 2026 // elf.rs +// +// ELF binary format parser +// +// Parses ELF binaries via goblin::elf into a FormatResult. +// Extracts architecture (x86/x86_64/ARM/AArch64), bitness, +// endianness, entry point, and checks for symbol table +// presence (stripped detection), PT_INTERP (PIE detection), +// and .debug_ sections. build_sections iterates section +// headers, computing SHA-256 per section and mapping +// SHF_ALLOC/SHF_WRITE/SHF_EXECINSTR flags to +// SectionPermissions. build_segments maps program headers +// with PF_R/PF_W/PF_X flags and named segment types. +// build_elf_info extracts OS ABI, ELF type, interpreter +// path, GNU_RELRO, stack executability, BIND_NOW (via +// DT_BIND_NOW and DF_BIND_NOW), and needed libraries. +// collect_function_hints gathers STT_FUNC symbol addresses +// for disassembly seeding. +// +// Connects to: +// formats/mod.rs - FormatResult, SectionInfo, SegmentInfo, +// ElfInfo, detect_common_anomalies, +// compute_section_hash +// types.rs - Architecture, BinaryFormat, Endianness, +// SectionPermissions use goblin::elf::dynamic::DT_BIND_NOW; use goblin::elf::header::{ diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/macho.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/macho.rs index 59a33e14..17a4c224 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/macho.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/macho.rs @@ -1,5 +1,30 @@ // ©AngelaMos | 2026 // macho.rs +// +// Mach-O binary format parser +// +// Parses Mach-O binaries via goblin::mach into a +// FormatResult. Handles both single-architecture and +// universal (fat) binaries by selecting the first valid +// architecture slice. Extracts CPU type (x86/x86_64/ARM/ +// ARM64), bitness, endianness, entry point, symbol +// presence (stripped detection), __DWARF segment (debug +// info), and MH_PIE flag. build_sections walks segments +// and their sections, mapping VM_PROT_* initprot flags to +// SectionPermissions with per-section SHA-256 hashes. +// build_macho_info scans load commands for CodeSignature, +// FunctionStarts, VersionMinMacosx, VersionMinIphoneos, +// BuildVersion, and dylib references. cpu_subtype_name +// decodes x86, ARM, and ARM64 subtypes. Function hints are +// collected from non-stab N_SECT symbols for disassembly +// seeding. +// +// Connects to: +// formats/mod.rs - FormatResult, MachOInfo, SectionInfo, +// SegmentInfo, detect_common_anomalies, +// compute_section_hash +// types.rs - Architecture, BinaryFormat, Endianness, +// SectionPermissions use goblin::mach::cputype::{ CPU_TYPE_ARM, CPU_TYPE_ARM64, CPU_TYPE_X86, diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/mod.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/mod.rs index a31a65f3..610a1867 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/mod.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/mod.rs @@ -1,5 +1,31 @@ // ©AngelaMos | 2026 // mod.rs +// +// Binary format parsing dispatcher and shared format types +// +// Dispatches binary data to the appropriate format parser +// (ELF, PE, or Mach-O) via goblin::Object::parse and +// returns a unified FormatResult. Defines all shared format +// types: SectionInfo and SegmentInfo with permissions and +// SHA-256 hashes, FormatAnomaly enum (entry point outside +// text, RWX sections, suspicious section names, empty names, +// virtual/raw size mismatches, overlay data, TLS callbacks, +// missing import tables, suspicious timestamps), +// format-specific info structs (PeInfo with DLL +// characteristics, ElfInfo with RELRO/BIND_NOW/stack +// executable flags, MachOInfo with code signature and dylib +// list). SUSPICIOUS_SECTION_NAMES maps 15 packer section +// names to their tool names. detect_common_anomalies runs +// cross-format structural checks on entry point placement, +// RWX permissions, section naming, and size ratios. +// +// Connects to: +// formats/elf.rs - parse_elf +// formats/pe.rs - parse_pe +// formats/macho.rs - parse_macho +// types.rs - Architecture, BinaryFormat, Endianness, +// SectionPermissions +// error.rs - EngineError mod elf; mod macho; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/pe.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/pe.rs index 67d2c281..226a7a88 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/pe.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/formats/pe.rs @@ -1,5 +1,29 @@ // ©AngelaMos | 2026 // pe.rs +// +// PE (Portable Executable) binary format parser +// +// Parses PE binaries via goblin::pe into a FormatResult. +// Extracts COFF machine type (i386/AMD64/ARM/ARM64), +// bitness, entry point, and optional header fields including +// image base, subsystem name, linker version, and DLL +// characteristics (ASLR, DEP, CFG, SEH, force integrity). +// build_sections maps PE sections with IMAGE_SCN_MEM_* +// permission flags and per-section SHA-256 hashes. +// detect_pe_anomalies flags zeroed, pre-1990, or post-2100 +// timestamps, TLS callback presence, empty import tables, +// and overlay data beyond the last section. detect_rich_header +// scans for the "Rich" signature in the DOS stub. Function +// hints are collected from PE export RVAs for disassembly +// seeding. +// +// Connects to: +// formats/mod.rs - FormatResult, PeInfo, +// PeDllCharacteristics, FormatAnomaly, +// SectionInfo, detect_common_anomalies, +// compute_section_hash +// types.rs - Architecture, BinaryFormat, Endianness, +// SectionPermissions use goblin::pe::PE; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/lib.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/lib.rs index ee0e2f16..9b828638 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/lib.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/lib.rs @@ -1,5 +1,28 @@ // ©AngelaMos | 2026 // lib.rs +// +// Analysis engine entry point and binary pipeline coordinator +// +// Declares the AnalysisEngine struct which wires together six +// analysis passes (format, imports, strings, entropy, disasm, +// threat) via the PassManager's topological execution order. +// analyze() takes raw binary bytes and a filename, computes a +// SHA-256 digest, constructs an AnalysisContext backed by an +// Arc'd buffer, runs all passes sequentially, and returns the +// populated context alongside a PassReport of per-pass +// outcomes. sha256_hex is re-exported for callers that need +// hashing without a full analysis run. +// +// Connects to: +// context.rs - AnalysisContext, BinarySource +// error.rs - EngineError +// pass.rs - PassManager, PassReport, AnalysisPass +// passes/format.rs - FormatPass +// passes/imports.rs - ImportPass +// passes/strings.rs - StringPass +// passes/entropy.rs - EntropyPass +// passes/disasm.rs - DisasmPass +// passes/threat.rs - ThreatPass pub mod context; pub mod error; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/pass.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/pass.rs index 98627fb5..5594abf7 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/pass.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/pass.rs @@ -1,5 +1,24 @@ // ©AngelaMos | 2026 // pass.rs +// +// Analysis pass trait, pass manager, and topological execution +// +// Defines the AnalysisPass trait (sealed via a private module +// to prevent external implementations) with name(), +// dependencies(), and run() methods. PassManager accepts a +// Vec of boxed passes, computes a topological execution order +// via Kahn's algorithm (panics on cycles), and run_all() +// executes them in dependency order, continuing through +// failures and recording each PassOutcome with timing and +// error info. PassReport aggregates outcomes with +// all_succeeded() and failed_passes() query methods. +// Includes unit tests using MockPass to verify topological +// ordering, diamond dependencies, failure continuation, +// cycle detection, and duration tracking. +// +// Connects to: +// context.rs - AnalysisContext (passed to each pass) +// error.rs - EngineError (returned by pass::run) use std::collections::{HashMap, VecDeque}; use std::time::Instant; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/disasm.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/disasm.rs index 790f2050..4eb68087 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/disasm.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/disasm.rs @@ -1,5 +1,45 @@ // ©AngelaMos | 2026 // disasm.rs +// +// Recursive descent disassembly and CFG construction pass +// +// DisasmPass depends on format and performs recursive +// descent disassembly of x86 and x86_64 binaries using +// the iced-x86 decoder with Intel syntax formatting. +// Non-x86 architectures receive an empty result. +// disassemble seeds a function queue from the entry point +// and format-provided function hints, then iterates +// through function addresses with caps of 1000 functions +// and 50000 total instructions. disassemble_function +// performs worklist-driven linear sweep within a single +// function, decoding instructions and tracking block +// leaders from branch targets and fallthroughs. +// Conditional branches split into taken/fallthrough +// successors, unconditional branches follow the target, +// and returns/interrupts terminate the block. Call +// instructions discover new function entry points added +// to the outer queue. build_basic_blocks partitions +// decoded instructions by block leaders and terminators, +// computing successor and predecessor edges. +// finalize_block determines successors from branch targets +// and fallthroughs. build_cfg emits CfgNode and CfgEdge +// structs with ConditionalTrue/ConditionalFalse/ +// Unconditional/Fallthrough edge types, limited to +// functions with 500 or fewer instructions. +// vaddr_to_offset translates virtual addresses to file +// offsets via section mappings. disassemble_code provides +// a standalone linear disassembly API. Unit tests verify +// simple function disassembly, basic block splitting on +// conditional branches, CFG edge generation, non-x86 +// empty results, ELF disassembly, and context population. +// +// Connects to: +// pass.rs - AnalysisPass trait, Sealed +// context.rs - AnalysisContext +// formats/mod.rs - SectionInfo +// types.rs - Architecture, CfgEdgeType, +// FlowControlType +// error.rs - EngineError use std::collections::{ BTreeMap, HashMap, HashSet, VecDeque, diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/entropy.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/entropy.rs index 423d2069..69ae98f9 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/entropy.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/entropy.rs @@ -1,5 +1,40 @@ // ©AngelaMos | 2026 // entropy.rs +// +// Shannon entropy analysis and packing detection pass +// +// EntropyPass depends on format and computes Shannon +// entropy for the overall binary and each section +// individually. shannon_entropy calculates bits-per-byte +// over a 256-bucket frequency distribution. +// classify_entropy maps values to five bands: Plaintext +// (<3.5), NativeCode (<6.0), Compressed (<7.0), Packed +// (<7.2), and Encrypted (>=7.2). Per-section analysis +// flags anomalies via EntropyFlag: HighEntropy (>7.0), +// HighVirtualToRawRatio (>10x), EmptyRawData (raw=0 with +// virtual>0), Rwx (read+write+execute permissions), and +// PackerSectionName. PACKER_SECTION_NAMES maps 15 known +// section names to packers: UPX (UPX0/1/2), Themida, +// VMProtect (.vmp0/1/2), ASPack (.aspack/.adata), +// PECompact (PEC2TO/PEC2/pec1), MPRESS (.MPRESS1/2), +// and Enigma (.enigma1/2). Structural packing indicators +// track empty-raw-with-executable-virtual sections and +// high virtual-to-raw ratios; two or more structural +// indicators trigger packing_detected. find_ep_section +// locates the entry point section and checks for PUSHAD +// (0x60) as the first byte, a classic packer stub marker. +// Unit tests verify zero-entropy, uniform distribution +// (~8.0 bits), empty data, classification thresholds, +// packer section name detection, high entropy flagging, +// UPX packer detection, ELF entropy analysis, and context +// population. +// +// Connects to: +// pass.rs - AnalysisPass trait, Sealed +// context.rs - AnalysisContext +// formats/mod.rs - SectionInfo +// types.rs - EntropyClassification, EntropyFlag +// error.rs - EngineError use serde::{Deserialize, Serialize}; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/format.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/format.rs index 628d4bd7..273ff2f8 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/format.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/format.rs @@ -1,5 +1,22 @@ // ©AngelaMos | 2026 // format.rs +// +// Format analysis pass (binary header parsing) +// +// FormatPass is the first pass in the pipeline with no +// dependencies. It delegates to formats::parse_format which +// dispatches to the ELF, PE, or Mach-O parser and stores the +// resulting FormatResult in the context. All subsequent passes +// depend on this pass for section layout, architecture, and +// entry point information. Unit tests verify ELF metadata +// extraction, section and segment presence, stripped binary +// detection, ELF info population, section hash computation, +// invalid binary rejection, and context population. +// +// Connects to: +// formats/mod.rs - parse_format, FormatResult +// pass.rs - AnalysisPass trait, Sealed +// context.rs - AnalysisContext use crate::context::AnalysisContext; use crate::error::EngineError; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/imports.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/imports.rs index 1a35d25e..e5d87b2c 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/imports.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/imports.rs @@ -1,5 +1,42 @@ // ©AngelaMos | 2026 // imports.rs +// +// Import/export table analysis pass +// +// ImportPass depends on format and extracts import tables, +// export tables, and linked library lists from ELF, PE, +// and Mach-O binaries via goblin. SUSPICIOUS_APIS defines +// 22 APIs tagged with MITRE ATT&CK technique IDs covering +// injection (T1055), process hollowing (T1055.012), APC +// injection (T1055.004), anti-debug (T1622), token +// manipulation (T1134), persistence (T1547.001, +// T1543.003), download (T1105), network (T1071), +// deobfuscation (T1140), and Linux-specific APIs (ptrace, +// mprotect, dlopen, dlsym, execve, process_vm_readv/ +// writev). SUSPICIOUS_COMBINATIONS defines 15 multi-API +// chain detections including Process Injection Chain, +// Process Hollowing, Credential Theft, APC/DLL Injection, +// Download and Execute, Registry/Service Persistence, and +// Linux-specific chains (ptrace injection, RWX memory, C2 +// connection, network listener, dynamic loading, process +// injection). matches_api handles Windows A/W suffix +// variants. extract_elf, extract_pe, and extract_mach +// dispatch to format-specific importers that populate +// ImportEntry with library, function, address, ordinal, +// and threat tags. detect_combinations matches import +// function names against CombinationDef patterns with +// deduplication. collect_mitre_mappings emits per-API +// MITRE technique mappings. Unit tests verify ELF import +// extraction, suspicious API flagging, combination +// detection for injection chains and A/W suffixes, false +// positive rejection, MITRE mapping collection, and +// context population. +// +// Connects to: +// pass.rs - AnalysisPass trait, Sealed +// context.rs - AnalysisContext +// types.rs - Severity +// error.rs - EngineError use std::collections::HashSet; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/mod.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/mod.rs index cee27888..16356e85 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/mod.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/mod.rs @@ -1,5 +1,16 @@ // ©AngelaMos | 2026 // mod.rs +// +// Analysis pass module exports +// +// Re-exports the six analysis pass submodules: format +// (binary header parsing), imports (import/export table +// extraction and suspicious API detection), strings +// (ASCII/UTF-16LE extraction and categorization), entropy +// (Shannon entropy per section and packing detection), +// disasm (recursive descent disassembly with CFG +// construction), and threat (weighted scoring across all +// pass results plus YARA rule matching). pub mod disasm; pub mod entropy; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/strings.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/strings.rs index 71479d55..237a48ca 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/strings.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/strings.rs @@ -1,5 +1,45 @@ // ©AngelaMos | 2026 // strings.rs +// +// String extraction and categorization pass +// +// StringPass depends on format and extracts printable +// strings from raw binary data in both ASCII and UTF-16LE +// encodings with a minimum length of 4 characters. +// extract_ascii scans for contiguous runs of printable +// bytes (0x20-0x7E, tab, newline, CR), while +// extract_utf16le decodes little-endian wide character +// sequences terminated by null pairs. Each extracted +// string is classified into one of 14 StringCategory +// values by a priority-ordered classifier chain: Url +// (http/https/ftp prefixes), IpAddress (dotted quad +// validation), RegistryKey (HKEY_/HKLM/HKCU prefixes), +// ShellCommand (cmd.exe, powershell, /bin/sh indicators), +// PersistencePath (Run keys, cron, systemd, LaunchAgents), +// AntiAnalysis (VMware, VirtualBox, QEMU, debugger, Wine +// detection), PackerSignature (UPX!, MPRESS, Themida, +// VMProtect), SuspiciousApi (matched against the 22 +// SUSPICIOUS_APIS from imports.rs), DebugArtifact +// (/rustc/, .pdb, _ZN, DWARF), FilePath (Windows drive +// letters, UNC paths, Unix prefixes), CryptoWallet (BTC +// base58check and ETH 0x-prefixed addresses), Email +// (local@domain.tld validation), EncodedData (base64 +// character set with padding validation, minimum 20 +// chars), or Generic. Seven categories are flagged as +// suspicious. find_section attributes each string to its +// containing binary section by file offset. Statistics +// track totals by encoding and category. Unit tests +// verify minimum length filtering, UTF-16LE extraction, +// all 14 category classifiers, suspicious flag mapping, +// ELF string extraction, context population, and section +// attribution. +// +// Connects to: +// pass.rs - AnalysisPass trait, Sealed +// context.rs - AnalysisContext +// formats/mod.rs - SectionInfo +// passes/imports.rs - SUSPICIOUS_APIS +// types.rs - StringCategory, StringEncoding use std::collections::HashMap; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/threat.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/threat.rs index 531c7c94..7aea4450 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/threat.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/passes/threat.rs @@ -1,5 +1,61 @@ // ©AngelaMos | 2026 // threat.rs +// +// Weighted threat scoring and risk classification pass +// +// ThreatPass depends on all five preceding passes (format, +// imports, strings, entropy, disasm) and produces a +// composite threat score across eight capped scoring +// categories: Import/API Analysis (max 20), Entropy +// Analysis (max 15), Packing Indicators (max 15), String +// Analysis (max 10), Section Anomalies (max 10), Entry +// Point Anomalies (max 10), Anti-Analysis Indicators +// (max 10), and YARA Signature Matches (max 10). +// score_imports weights injection chains (15), hollowing +// chains (15), credential access (12), APC injection (8), +// anti-debug APIs (8), download/execute (6), persistence +// (7), very few imports (5), and Linux-specific chains +// (ptrace 8, RWX memory 5, C2 10, network listener 8, +// dynamic loading 5, process injection 15). +// score_entropy flags high-entropy sections (6 pts, cap 2) +// and very high overall entropy (3 pts). score_packing +// checks packer section names (5), signature matches (3), +// empty raw with virtual (4), high VR ratio (3), PUSHAD +// at EP (3), and modified UPX without magic string (5). +// score_strings checks C2 URL patterns with suspicious +// TLDs, shell commands, base64-encoded PE headers, registry +// persistence paths, and crypto wallet addresses. +// score_sections flags RWX sections (5), empty names (3), +// unusual section counts (2), and zero-size code (4). +// score_entry_point flags EP outside .text (5), EP in last +// section (5), EP outside all sections (7), and TLS +// callbacks (3). score_anti_analysis checks +// IsDebuggerPresent (3), NtQueryInformationProcess (5), VM +// detection strings (3), sandbox evasion (3), timing APIs +// (3), Linux ptrace checks (5), and /proc/self analysis +// (3). score_yara weights malware/critical rules (10), +// packer rules (3), and suspicious rules (5). classify_risk +// maps totals to five RiskLevel bands: Benign (0-15), Low +// (16-35), Medium (36-55), High (56-75), Critical (76+). +// MITRE technique mappings are deduplicated from import +// combinations and per-API mappings. generate_summary +// ranks the top 5 findings by points. Unit tests verify +// risk classification thresholds, category capping, empty +// scoring, summary generation, YARA malware/packer +// scoring, entropy scoring, RWX section scoring, and full +// context population through all predecessor passes. +// +// Connects to: +// pass.rs - AnalysisPass trait, Sealed +// context.rs - AnalysisContext +// formats/mod.rs - FormatResult, FormatAnomaly +// passes/imports.rs - ImportResult +// passes/strings.rs - StringResult +// passes/entropy.rs - EntropyResult +// yara.rs - YaraScanner, YaraMatch +// types.rs - RiskLevel, EntropyFlag, +// StringCategory +// error.rs - EngineError use std::collections::HashSet; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/types.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/types.rs index 61337cb1..1ce0ecde 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/types.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/types.rs @@ -1,5 +1,21 @@ // ©AngelaMos | 2026 // types.rs +// +// Core enum and struct definitions shared across all passes +// +// Defines the type vocabulary used throughout the engine: +// BinaryFormat (Elf/Pe/MachO), Architecture (x86 through +// AArch64 with an Other fallback), Endianness, RiskLevel +// (five tiers from Benign to Critical), Severity (four tiers +// for import threat tagging), StringEncoding (Ascii/Utf8/ +// Utf16Le), StringCategory (14 classifications from Url to +// Generic), EntropyClassification (five bands from Plaintext +// to Encrypted), EntropyFlag (five section anomaly markers), +// FlowControlType and CfgEdgeType for disassembly CFG +// representation, and SectionPermissions with an is_rwx() +// helper. All enums derive Serialize/Deserialize for JSON +// output and implement Display where needed for human- +// readable formatting. use serde::{Deserialize, Serialize}; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/yara.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/yara.rs index 39232cb2..469a6dce 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/yara.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/src/yara.rs @@ -1,5 +1,26 @@ // ©AngelaMos | 2026 // yara.rs +// +// YARA rule scanner with builtin detection rules +// +// Embeds 14 YARA rules as a compile-time constant covering +// UPX packing, anti-debugging (Windows and Linux), process +// injection, keylogger APIs, crypto mining, Windows and +// Linux persistence mechanisms, network backdoors, +// ransomware indicators, shellcode patterns (NOP sleds, egg +// hunters), obfuscation (XOR loops, base64 alphabet), C2 +// endpoint paths, and credential file access. YaraScanner +// wraps a compiled yara_x::Rules instance. new() compiles +// only the builtin ruleset; with_custom_rules() also loads +// .yar/.yara files from a directory. scan() executes against +// binary data and returns YaraMatch structs containing rule +// name, tags, metadata (description/category/severity), and +// matched string identifiers with counts. Unit tests verify +// compilation, UPX detection, process injection detection, +// and clean-data negative cases using fixture binaries. +// +// Connects to: +// error.rs - EngineError::Yara for compilation/scan failures use std::path::Path; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/integration.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/integration.rs index ab8d24f4..ed981bda 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/integration.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/integration.rs @@ -1,5 +1,24 @@ // ©AngelaMos | 2026 // integration.rs +// +// Full pipeline integration tests +// +// Exercises the AnalysisEngine end-to-end against test +// fixture binaries. full_pipeline_elf loads hello_elf and +// asserts all six passes succeed, format result is ELF +// with non-empty sections, all context slots are populated, +// disassembly finds functions and instructions, and threat +// score has 8 categories capped at 100. +// full_pipeline_stripped_elf verifies stripped binary +// detection and full pipeline completion. +// sha256_computed_correctly asserts the context SHA-256 is +// a valid 64-character hex string. invalid_binary_handled +// feeds 4 bytes of garbage and asserts the format pass +// fails while the engine does not panic. +// +// Connects to: +// lib.rs - AnalysisEngine +// types.rs - BinaryFormat use axumortem_engine::types::BinaryFormat; use axumortem_engine::AnalysisEngine; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/config.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/config.rs index 1b3c60df..c6288a89 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/config.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/config.rs @@ -1,5 +1,20 @@ // ©AngelaMos | 2026 // config.rs +// +// Application configuration via CLI arguments and +// environment variables +// +// AppConfig derives clap::Parser to accept database_url, +// host (default 0.0.0.0), port (default 3000), +// max_upload_size (default 50 MiB), and cors_origin +// (default wildcard). Each field maps to both a --long +// flag and an environment variable. bind_address formats +// the host:port string for the TCP listener. +// +// Connects to: +// main.rs - parsed at startup +// state.rs - stored as Arc +// middleware/cors.rs - cors_origin read by layer() use clap::Parser; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/mod.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/mod.rs index 3245184d..75be7c84 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/mod.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/mod.rs @@ -1,5 +1,16 @@ // ©AngelaMos | 2026 // mod.rs +// +// Database module exports and migration runner +// +// Re-exports the models and queries submodules. +// run_migrations executes embedded SQLx migrations from +// the ./migrations directory against the provided PgPool. +// +// Connects to: +// main.rs - called at startup +// db/models.rs - row and input structs +// db/queries.rs - SQL query functions pub mod models; pub mod queries; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/models.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/models.rs index 54483297..2cd314a8 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/models.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/models.rs @@ -1,5 +1,21 @@ // ©AngelaMos | 2026 // models.rs +// +// Database row and input structs +// +// AnalysisRow maps to the analyses table with UUID id, +// sha256 hash, file_name, file_size, format, architecture, +// entry_point, threat_score, risk_level, slug, and +// created_at timestamp. PassResultRow maps to the +// pass_results table with analysis_id foreign key, +// pass_name, JSON result blob, and duration_ms. +// NewAnalysis and NewPassResult are input structs for +// insert operations without server-generated fields. +// +// Connects to: +// db/queries.rs - used by insert and select queries +// routes/upload.rs - NewAnalysis built from engine output +// routes/analysis.rs - AnalysisRow returned to client use chrono::{DateTime, Utc}; use serde::Serialize; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/queries.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/queries.rs index 5f24d414..0d6714b0 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/queries.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/db/queries.rs @@ -1,5 +1,23 @@ // ©AngelaMos | 2026 // queries.rs +// +// PostgreSQL query functions +// +// find_slug_by_sha256 checks for an existing analysis by +// SHA-256 hash and returns the slug if cached. +// find_by_slug retrieves a full AnalysisRow by its +// URL-friendly slug. find_pass_results fetches all +// PassResultRow entries for an analysis_id ordered by +// pass_name. insert_analysis and insert_pass_result +// perform transactional inserts within a caller-provided +// Transaction, returning the created AnalysisRow and +// committing pass result rows respectively. +// +// Connects to: +// db/models.rs - AnalysisRow, PassResultRow, +// NewAnalysis, NewPassResult +// routes/upload.rs - insert_analysis, insert_pass_result +// routes/analysis.rs - find_by_slug, find_pass_results use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/error.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/error.rs index f2093514..b0246d7b 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/error.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/error.rs @@ -1,5 +1,20 @@ // ©AngelaMos | 2026 // error.rs +// +// API error types and HTTP response mapping +// +// ApiError enumerates six error variants: NoFile (400), +// FileTooLarge (400), InvalidBinary (400), +// AnalysisFailed (500), NotFound (404), and Internal +// (500). Each variant maps to a JSON response body with +// an error code string and human-readable message via the +// IntoResponse implementation. From impls convert +// sqlx::Error, serde_json::Error, and +// tokio::task::JoinError into Internal variants. +// +// Connects to: +// routes/upload.rs - returned from upload handler +// routes/analysis.rs - returned from analysis lookup use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/main.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/main.rs index 4eea8f91..99473535 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/main.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/main.rs @@ -1,5 +1,24 @@ // ©AngelaMos | 2026 // main.rs +// +// Axumortem web server entry point +// +// Bootstraps the Axum HTTP server with clap-driven CLI +// configuration, tracing subscriber initialization with +// EnvFilter, PostgreSQL connection pool (max 20 +// connections) via SQLx PgPoolOptions, automatic database +// migrations, and AnalysisEngine initialization. Assembles +// AppState from the pool, engine, and config, then applies +// tower layers for HTTP tracing, CORS, and body size +// limits before binding a TCP listener. Graceful shutdown +// is handled via ctrl_c signal. +// +// Connects to: +// config.rs - AppConfig (clap Parser) +// state.rs - AppState +// db/mod.rs - run_migrations +// middleware/ - cors::layer +// routes/mod.rs - api_router mod config; mod db; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/cors.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/cors.rs index e0dff4ff..63a41a8b 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/cors.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/cors.rs @@ -1,5 +1,17 @@ // ©AngelaMos | 2026 // cors.rs +// +// CORS middleware configuration +// +// layer() builds a tower-http CorsLayer allowing GET, +// POST, and OPTIONS methods with Content-Type and Accept +// headers. When cors_origin is "*" the layer permits any +// origin; otherwise it parses the configured origin string +// into a single allowed HeaderValue. +// +// Connects to: +// config.rs - AppConfig.cors_origin +// main.rs - applied as tower layer use axum::http::header::{HeaderName, ACCEPT, CONTENT_TYPE}; use axum::http::Method; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/mod.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/mod.rs index 9c449d6a..e443c20f 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/mod.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/middleware/mod.rs @@ -1,4 +1,9 @@ // ©AngelaMos | 2026 // mod.rs +// +// Middleware module exports +// +// Re-exports the cors submodule which provides the +// tower-http CORS layer configured from AppConfig. pub mod cors; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/analysis.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/analysis.rs index d5246276..9f853ae1 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/analysis.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/analysis.rs @@ -1,5 +1,19 @@ // ©AngelaMos | 2026 // analysis.rs +// +// Analysis result retrieval endpoint +// +// get_by_slug extracts the slug path parameter, queries +// the analysis row by slug, fetches all associated pass +// result rows, and assembles an AnalysisResponse with +// metadata fields and a passes HashMap mapping pass names +// to their JSON result blobs. Returns 404 via +// ApiError::NotFound if the slug does not exist. +// +// Connects to: +// state.rs - AppState +// db/queries.rs - find_by_slug, find_pass_results +// error.rs - ApiError use std::collections::HashMap; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/health.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/health.rs index 7d25fc89..dd308cb7 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/health.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/health.rs @@ -1,5 +1,15 @@ // ©AngelaMos | 2026 // health.rs +// +// Health check endpoint +// +// check executes a SELECT 1 probe against the PostgreSQL +// pool and returns a JSON HealthResponse with status "ok" +// and database connectivity as "connected" or +// "disconnected". +// +// Connects to: +// state.rs - AppState.db use axum::extract::State; use axum::Json; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/mod.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/mod.rs index 2ef0d4d2..a085ed3a 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/mod.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/mod.rs @@ -1,5 +1,18 @@ // ©AngelaMos | 2026 // mod.rs +// +// Route module exports and API router construction +// +// api_router assembles the Axum Router with three +// endpoints: GET /api/health (health check), POST +// /api/upload (binary upload and analysis), and GET +// /api/analysis/{slug} (analysis result retrieval). +// +// Connects to: +// routes/health.rs - check handler +// routes/upload.rs - handle handler +// routes/analysis.rs - get_by_slug handler +// state.rs - AppState mod analysis; mod health; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/upload.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/upload.rs index d1f8f634..cef05ec4 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/upload.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/routes/upload.rs @@ -1,5 +1,28 @@ // ©AngelaMos | 2026 // upload.rs +// +// Binary upload and analysis endpoint +// +// handle accepts a multipart file upload, computes +// SHA-256, and checks for a cached analysis by hash via +// find_slug_by_sha256. On cache miss it spawns +// AnalysisEngine::analyze on a blocking thread, builds a +// NewAnalysis from the format and threat results, generates +// a 12-character slug from the SHA-256 prefix, and +// transactionally inserts the analysis row and all six +// pass result rows. build_pass_results serializes each +// context field (format, imports, strings, entropy, +// disassembly, threat) to JSON with duration metadata. +// PASS_NAME_MAP renames "disasm" to "disassembly" for the +// API. extract_file iterates multipart fields looking for +// the "file" field name. +// +// Connects to: +// state.rs - AppState (engine, db, config) +// db/queries.rs - find_slug_by_sha256, insert_analysis, +// insert_pass_result +// db/models.rs - NewAnalysis, NewPassResult +// error.rs - ApiError use std::collections::HashMap; use std::sync::Arc; diff --git a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/state.rs b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/state.rs index 936f3f7f..289d12d9 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/state.rs +++ b/PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem/src/state.rs @@ -1,5 +1,17 @@ // ©AngelaMos | 2026 // state.rs +// +// Shared application state for Axum handlers +// +// AppState holds the SQLx PgPool for database access, an +// Arc-wrapped AnalysisEngine for binary analysis, and an +// Arc-wrapped AppConfig. Derives Clone for Axum's State +// extractor. +// +// Connects to: +// main.rs - constructed at startup +// config.rs - AppConfig +// routes/ - extracted via State use std::sync::Arc; diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/App.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/App.tsx index a7d82d6a..0773157d 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/App.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/App.tsx @@ -1,6 +1,15 @@ // =========================== // ©AngelaMos | 2026 // App.tsx +// +// Root application component that wraps the router with +// TanStack Query provider, Sonner toast notifications +// (dark theme, top-right, 2s duration), and React Query +// devtools +// +// Connects to: +// core/api - queryClient +// core/app - router // =========================== import { QueryClientProvider } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/hooks/index.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/hooks/index.ts index 383e6e60..05fd0cb7 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/hooks/index.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/hooks/index.ts @@ -1,6 +1,28 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// TanStack React Query hooks for binary upload and +// analysis retrieval +// +// useUpload returns a mutation that POSTs a File as +// multipart/form-data to API_ENDPOINTS.UPLOAD with +// UPLOAD_TIMEOUT_MS (120s), Zod-validates the response +// through UploadResponseSchema, and transforms Axios +// errors via transformAxiosError. useAnalysis returns a +// query keyed by QUERY_KEYS.ANALYSIS.BY_SLUG(slug) +// that GETs the full analysis result, Zod-validates +// through AnalysisResponseSchema, and is configured +// with staleTime: Infinity and no window-focus refetch +// since analysis results are immutable once computed +// +// Connects to: +// config.ts - API_ENDPOINTS, QUERY_KEYS, +// UPLOAD_TIMEOUT_MS +// core/api/api.config - apiClient instance +// core/api/errors - transformAxiosError +// api/schemas - parse() validation +// api/types - UploadResponse, ApiErrorBody // =================== import { useMutation, useQuery } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/index.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/index.ts index 8c2d64e5..784aafcd 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/index.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/index.ts @@ -1,6 +1,9 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// API layer barrel export for hooks, Zod schemas, and +// inferred TypeScript types // =================== export * from './hooks' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/schemas.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/schemas.ts index ad691b07..73984a5c 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/schemas.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/schemas.ts @@ -1,6 +1,38 @@ // =================== // © AngelaMos | 2026 // schemas.ts +// +// Zod runtime validation schemas mirroring every Rust +// engine result type +// +// Defines ~40 Zod schemas that map one-to-one with the +// backend serde output: enum schemas for BinaryFormat, +// Architecture, Endianness, RiskLevel, Severity, +// StringEncoding, StringCategory (14 variants), +// EntropyClassification (5 bands), EntropyFlag, +// FlowControlType, and CfgEdgeType; object schemas for +// SectionInfo, SegmentInfo, PeInfo, ElfInfo, MachOInfo +// (format pass), ImportEntry, ExportEntry, Suspicious +// Combination, ImportMitreMapping, ImportStatistics +// (import pass), ExtractedString, StringStatistics +// (string pass), SectionEntropy, PackingIndicator +// (entropy pass), InstructionInfo, BasicBlockInfo, +// CfgNode, CfgEdge, FunctionCfg, FunctionInfo +// (disassembly pass), ScoringDetail, ScoringCategory, +// ThreatMitreMapping, YaraMetadata, YaraStringMatch, +// YaraMatch (threat pass); and top-level composite +// schemas FormatResult, ImportResult, StringResult, +// EntropyResult, DisassemblyResult, ThreatResult, +// AnalysisPasses (all six optional), AnalysisResponse, +// UploadResponse, and ApiErrorBody. Every API response +// is parsed through these schemas before reaching +// components +// +// Connects to: +// api/types - z.infer exports for each schema +// api/hooks - AnalysisResponseSchema, UploadResponse +// Schema used in parse() calls +// Rust types - mirrors types.rs serde output exactly // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/types/index.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/types/index.ts index 37c7bd75..dc5876ea 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/types/index.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/api/types/index.ts @@ -1,6 +1,36 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Inferred TypeScript types derived from Zod schemas +// via z.infer +// +// Exports ~45 types covering every domain model in the +// analysis pipeline: enums (BinaryFormat, Architecture, +// Endianness, RiskLevel, Severity, StringEncoding, +// StringCategory, EntropyClassification, EntropyFlag, +// FlowControlType, CfgEdgeType), format structures +// (SectionPermissions, SectionInfo, SegmentInfo, +// FormatAnomaly, PeDllCharacteristics, PeInfo, ElfInfo, +// MachOInfo, FormatResult), import structures (Import +// Entry, ExportEntry, SuspiciousCombination, Import +// MitreMapping, ImportStatistics, ImportResult), string +// structures (ExtractedString, StringStatistics, +// StringResult), entropy structures (SectionEntropy, +// PackingIndicator, EntropyResult), disassembly +// structures (InstructionInfo, BasicBlockInfo, CfgNode, +// CfgEdge, FunctionCfg, FunctionInfo, Disassembly +// Result), threat structures (ScoringDetail, Scoring +// Category, ThreatMitreMapping, YaraMetadata, Yara +// StringMatch, YaraMatch, ThreatResult), and top-level +// composites (AnalysisPasses, AnalysisResponse, Upload +// Response, ApiErrorBody) +// +// Connects to: +// api/schemas - source schemas for z.infer +// api/hooks - UploadResponse, ApiErrorBody +// pages/ - all analysis result types consumed +// by tab components // =================== import type { z } from 'zod' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/config.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/config.ts index 58faf704..a38992a5 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/config.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/config.ts @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // config.ts +// +// Centralized application constants including API +// endpoint paths, TanStack Query cache keys, route +// definitions, localStorage keys, query timing +// strategies (stale/gc/retry), HTTP status codes, +// upload timeout (120s), and color maps for +// RiskLevel (5 levels) and EntropyClassification +// (5 bands) +// +// Connects to: +// api/hooks - API_ENDPOINTS, QUERY_KEYS +// core/app - ROUTES +// core/api - QUERY_CONFIG +// pages/ - RISK_LEVEL_COLORS, +// ENTROPY_CLASSIFICATION_COLORS // =================== export const API_ENDPOINTS = { diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/api.config.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/api.config.ts index f48a4d08..a4ed98ef 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/api.config.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/api.config.ts @@ -1,6 +1,13 @@ // =================== // © AngelaMos | 2026 // api.config.ts +// +// Axios HTTP client instance configured with base URL +// from VITE_API_URL (fallback /api), 15s timeout, JSON +// content type, and credentials enabled +// +// Connects to: +// api/hooks - used for upload and analysis requests // =================== import axios, { type AxiosInstance } from 'axios' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/errors.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/errors.ts index 492672f6..a929d746 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/errors.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/errors.ts @@ -1,6 +1,20 @@ /** * ©AngelaMos | 2026 * errors.ts + * + * API error classification and Axios error transformer + * + * ApiError extends Error with a typed code (9 variants + * from NETWORK_ERROR to UNKNOWN_ERROR), HTTP status code, + * optional validation details, and getUserMessage() for + * user-facing strings. transformAxiosError maps HTTP + * status codes to ApiErrorCode values and extracts + * detail/message from response bodies. Registers ApiError + * as the TanStack React Query default error type. + * + * Connects to: + * core/api/query.config.ts - error handling in caches + * api/hooks - onError transforms */ import type { AxiosError } from 'axios' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/index.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/index.ts index f6dc3639..f4b6f9d5 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/index.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/index.ts @@ -1,6 +1,9 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Core API barrel export for apiClient, error types, +// query client, and query strategies // =================== export * from './api.config' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/query.config.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/query.config.ts index 42084ec0..c80ee1ed 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/query.config.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/api/query.config.ts @@ -1,6 +1,17 @@ // =================== // © AngelaMos | 2026 // query.config.ts +// +// TanStack React Query client configuration with retry +// logic (exponential backoff, skip for auth/404/validation +// errors), Sonner toast integration for background query +// and mutation cache errors, and four pre-built query +// strategies (standard, frequent, static, auth) +// +// Connects to: +// config.ts - QUERY_CONFIG timing constants +// errors.ts - ApiError, ApiErrorCode +// App.tsx - QueryClientProvider // =================== import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/routers.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/routers.tsx index 89583966..f1b2a3f3 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/routers.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/routers.tsx @@ -1,6 +1,22 @@ // =================== // © AngelaMos | 2026 // routers.tsx +// +// Browser router definition with lazy-loaded routes +// wrapped in a Shell layout +// +// Declares three route entries under a single Shell +// parent: ROUTES.HOME loads the landing page, ROUTES +// .ANALYSIS loads the analysis results page, and a +// wildcard catch-all falls back to landing. Both page +// components use React.lazy via react-router-dom's +// lazy() convention for code-split chunk loading +// +// Connects to: +// config.ts - ROUTES path constants +// shell.tsx - Shell layout wrapper +// pages/landing - lazy-loaded upload page +// pages/analysis - lazy-loaded results page // =================== import { createBrowserRouter, type RouteObject } from 'react-router-dom' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/shell.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/shell.tsx index 6bfd8518..46160c56 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/shell.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/app/shell.tsx @@ -1,6 +1,24 @@ // =================== // © AngelaMos | 2026 // shell.tsx +// +// Root application shell with error boundary and +// suspense wrapper around the router outlet +// +// Shell renders a full-page layout container with an +// ErrorBoundary (ShellErrorFallback displays the error +// message) wrapping a Suspense boundary (ShellLoading +// shows a spinner placeholder) around the react-router +// Outlet. All lazy-loaded page components resolve +// through this boundary pair, ensuring both loading +// states and uncaught render errors are handled at the +// top level +// +// Connects to: +// routers.tsx - mounted as parent route element +// shell.module.scss - shell, content, error, loading +// layout styles +// pages/ - rendered via Outlet // =================== import { Suspense } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/format.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/format.ts index 3eb999d0..f99b7400 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/format.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/format.ts @@ -1,6 +1,22 @@ // =================== // © AngelaMos | 2026 // format.ts +// +// Display formatting utilities for binary analysis +// values +// +// formatBytes converts raw byte counts to human-readable +// strings (B/KB/MB/GB) using 1024-based units with two +// decimal places above bytes. formatHex renders numbers +// as zero-padded uppercase hex strings (default 8 chars). +// truncateHash shortens SHA-256 digests to a display +// length (default 16 chars) with an ellipsis. copyTo +// Clipboard wraps the Clipboard API with a boolean +// success/failure return +// +// Connects to: +// pages/analysis - hex formatting, hash display +// pages/landing - byte size display // =================== const BYTE_UNITS = ['B', 'KB', 'MB', 'GB'] as const diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/index.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/index.ts index 58f981ae..ab0d302d 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/index.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/index.ts @@ -1,6 +1,9 @@ // =================== // © AngelaMos | 2026 // index.ts +// +// Core library barrel export for formatting utilities +// and UI state store // =================== export * from './format' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/shell.ui.store.ts index e3005782..998641b5 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/shell.ui.store.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/core/lib/shell.ui.store.ts @@ -1,6 +1,23 @@ /** * ©AngelaMos | 2026 * shell.ui.store.ts + * + * Zustand UI state store with devtools and localStorage + * persistence + * + * Manages global theme (light/dark/system), sidebar open + * state, and sidebar collapsed state through a single + * Zustand store wrapped in devtools (named "UIStore" for + * Redux DevTools inspection) and persist middleware that + * serializes theme and sidebarCollapsed to localStorage + * under the "ui-storage" key. Exports three selector + * hooks (useTheme, useSidebarOpen, useSidebarCollapsed) + * for granular subscriptions without re-renders + * + * Connects to: + * config.ts - STORAGE_KEYS.UI matches persist key + * shell.tsx - consumes sidebar/theme state + * pages/ - theme-aware rendering */ import { create } from 'zustand' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/main.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/main.tsx index ac319510..258d0069 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/main.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/main.tsx @@ -1,6 +1,14 @@ // =========================== // ©AngelaMos | 2026 // main.tsx +// +// Application entry point that mounts the React root +// into the #root DOM element with StrictMode enabled +// and imports the global SCSS stylesheet +// +// Connects to: +// App.tsx - root component +// styles.scss - global styles // =========================== import { StrictMode } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/index.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/index.tsx index 1448819b..122d75c7 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/index.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/index.tsx @@ -1,6 +1,41 @@ // =================== // © AngelaMos | 2026 // index.tsx +// +// Analysis results page with threat score card, tab +// navigation, and six analysis tab panels +// +// Fetches the full analysis via useAnalysis(slug) from +// the URL params and renders three main sections: a +// header with file name, format/architecture/size +// badges, SHA-256 copy-to-clipboard button, and entry +// point hex display; a threat score card with numeric +// score colored by RISK_LEVEL_COLORS, risk label, and +// per-category ScoreBar components showing score/max +// fill percentages; and a six-tab navigation bar +// (overview, headers, imports, strings, entropy, +// disassembly) that switches between TabOverview, +// TabHeaders, TabImports, TabStrings, TabEntropy, and +// TabDisassembly via renderTab dispatch. Shows loading +// and 404 states with a back link to ROUTES.HOME. +// Lazy-loaded via react-router with displayName +// "Analysis" +// +// Connects to: +// api/hooks - useAnalysis query +// api/types - AnalysisResponse +// config.ts - RISK_LEVEL_COLORS, ROUTES +// core/lib - copyToClipboard, formatBytes, +// formatHex, truncateHash +// tab-overview.tsx - TabOverview component +// tab-headers.tsx - TabHeaders component +// tab-imports.tsx - TabImports component +// tab-strings.tsx - TabStrings component +// tab-entropy.tsx - TabEntropy component +// tab-disassembly.tsx - TabDisassembly component +// analysis.module +// .scss - all layout styles +// routers.tsx - lazy-loaded at ROUTES.ANALYSIS // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-disassembly.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-disassembly.tsx index 3f4eeba0..952e71f3 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-disassembly.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-disassembly.tsx @@ -1,6 +1,38 @@ // =================== // © AngelaMos | 2026 // tab-disassembly.tsx +// +// Disassembly tab with function sidebar, instruction +// table, and dagre-layouted control flow graph +// +// Renders a two-panel layout: a left sidebar listing +// all disassembled functions (address, name or sub_hex +// fallback, instruction count) with entry point +// highlighting and click-to-select via selectedAddr +// state; and a main panel showing the selected +// function's header (name, address, size, instruction +// count, block count), an InstructionTable with per- +// basic-block rows (address, hex bytes, mnemonic, +// operands) with block boundary markers, and a CfgGraph +// SVG visualization. layoutCfg uses @dagrejs/dagre for +// top-to-bottom hierarchical layout with CFG_NODE_WIDTH +// (160), CFG_NODE_HEIGHT (40), CFG_RANK_SEP (60), and +// CFG_NODE_SEP (30). CfgGraph renders nodes as labeled +// rectangles and edges as colored lines with arrowhead +// markers: Fallthrough gray, ConditionalTrue green, +// ConditionalFalse red, Unconditional blue, Call purple +// +// Connects to: +// api/types - AnalysisResponse, CfgEdge, +// CfgEdgeType, CfgNode, +// FunctionInfo +// core/lib - formatHex +// @dagrejs/dagre - Graph, layout for CFG +// positioning +// analysis/index - mounted in renderTab switch +// analysis.module +// .scss - disasmLayout, fnSidebar, +// cfgContainer, cfgSvg styles // =================== import { layout as dagreLayout, Graph } from '@dagrejs/dagre' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-entropy.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-entropy.tsx index bad93d50..9a05f364 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-entropy.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-entropy.tsx @@ -1,6 +1,32 @@ // =================== // © AngelaMos | 2026 // tab-entropy.tsx +// +// Entropy tab with per-section entropy bars, packing +// detection alert, and classification coloring +// +// Displays overall entropy as a prominent value out of +// MAX_ENTROPY (8.0). When packing is detected, renders +// a packing alert with packer name and indicator list +// (type and description for each). Per-section entropy +// is shown as horizontal bars where fill width is +// entropy/8 percentage and color is mapped from +// ENTROPY_CLASSIFICATION_COLORS (Plaintext green, +// NativeCode blue, Compressed yellow, Packed orange, +// Encrypted red). Each EntropyBar shows section name, +// classification label, bar visualization, numeric +// entropy value, size, virtual-to-raw ratio, and any +// EntropyFlag badges (HighEntropy, Rwx, Packer +// SectionName, etc.), with anomalous sections +// highlighted +// +// Connects to: +// api/types - AnalysisResponse, SectionEntropy +// config.ts - ENTROPY_CLASSIFICATION_COLORS +// analysis/index - mounted in renderTab switch +// analysis.module +// .scss - entropyRow, entropyBarFill, +// packingAlert styles // =================== import type { AnalysisResponse, SectionEntropy } from '@/api' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-headers.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-headers.tsx index 8409cbdf..913cfbc8 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-headers.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-headers.tsx @@ -1,6 +1,31 @@ // =================== // © AngelaMos | 2026 // tab-headers.tsx +// +// Headers tab displaying binary format metadata, +// format-specific info, section and segment tables +// +// Renders a format info grid (format, arch, bits, +// endianness, entry point, stripped/PIE/debug flags), +// then conditionally shows PE info (image base, +// subsystem, linker version, ASLR/DEP/CFG), ELF info +// (OS ABI, type, RELRO, bind-now, NX stack, needed +// libraries list), or Mach-O info (file type, universal, +// code signature). Below, a sections table shows name, +// virtual address (formatHex), virtual size, raw size, +// and R/W/X permissions via PermsBadge (with execute +// highlighted in a distinct style). Segments are in a +// collapsible section toggled by showSegments state, +// showing name, vaddr, vsize, fsize, and permissions +// +// Connects to: +// api/types - AnalysisResponse, SectionInfo, +// SegmentInfo +// core/lib - formatHex +// analysis/index - mounted in renderTab switch +// analysis.module +// .scss - metaGrid, dataTable, perm +// styles // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-imports.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-imports.tsx index 174a3012..b13d4697 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-imports.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-imports.tsx @@ -1,6 +1,30 @@ // =================== // © AngelaMos | 2026 // tab-imports.tsx +// +// Imports tab with library-grouped import tables, +// suspicious combination alerts, and export listing +// +// Groups imports by library into collapsible Library +// Group sections (tracked via openLibs Set state), +// each showing function name, hex address, ordinal, +// and threat tags with suspicious row highlighting via +// ImportRow. Above the import groups, suspicious API +// combinations render as alert cards with name, MITRE +// ID pill, severity badge (styled per level), description, +// and matched API tags. Below, an exports section shows +// name, address, ordinal, and forward target in a +// standard data table. All addresses formatted via +// formatHex, with PAGE_SIZE-less full rendering since +// import counts are typically manageable +// +// Connects to: +// api/types - AnalysisResponse, ImportEntry +// core/lib - formatHex +// analysis/index - mounted in renderTab switch +// analysis.module +// .scss - libraryGroup, alertCard, +// dataTable, severityBadge styles // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-overview.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-overview.tsx index 77ae4a28..b813683d 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-overview.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-overview.tsx @@ -1,6 +1,29 @@ // =================== // © AngelaMos | 2026 // tab-overview.tsx +// +// Overview tab showing summary cards for all six +// analysis passes, anomalies list, and MITRE ATT&CK +// technique links +// +// Renders a six-card summary grid: format (type, bits, +// section/segment counts), imports (total across +// libraries, suspicious count), strings (total +// extracted, suspicious count), entropy (overall value, +// packing detection status), disassembly (function and +// instruction totals), and YARA (rule match count with +// summary text). Below the grid, displays format +// anomalies as string or key-value entries, and MITRE +// ATT&CK techniques as clickable pill links that open +// attack.mitre.org technique pages (with sub-technique +// slash formatting via formatMitreUrl) +// +// Connects to: +// api/types - AnalysisResponse +// analysis/index - mounted in renderTab switch +// analysis.module +// .scss - summaryGrid, summaryCard, +// anomalyList, mitrePills styles // =================== import type { AnalysisResponse } from '@/api' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-strings.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-strings.tsx index f7a34385..41748dff 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-strings.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/analysis/tab-strings.tsx @@ -1,6 +1,32 @@ // =================== // © AngelaMos | 2026 // tab-strings.tsx +// +// Strings tab with search, encoding/category filters, +// pagination, and expandable string values +// +// Provides a filter bar with text search, encoding +// dropdown (All/Ascii/Utf8/Utf16Le), category dropdown +// (All plus 14 StringCategory values), and a suspicious +// -only toggle. Filters apply via useMemo over the full +// string array, with results paginated at PAGE_SIZE (50) +// and displayed in a table showing hex offset, value +// (truncated at 80 chars with expand toggle tracked via +// expandedRows Set), encoding, category badge, and +// section name. StringRow highlights suspicious entries +// and supports click-to-expand for long values. Prev/ +// Next pagination controls appear when totalPages +// exceeds one +// +// Connects to: +// api/types - AnalysisResponse, Extracted +// String, StringCategory, +// StringEncoding +// core/lib - formatHex +// analysis/index - mounted in renderTab switch +// analysis.module +// .scss - filterBar, searchInput, +// pagination, dataTable styles // =================== import { useMemo, useState } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/landing/index.tsx b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/landing/index.tsx index 030bd735..7dc12d35 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/landing/index.tsx +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/src/pages/landing/index.tsx @@ -1,6 +1,31 @@ // =================== // © AngelaMos | 2026 // index.tsx +// +// Binary upload landing page with drag-and-drop file +// intake and analysis pipeline visualization +// +// Renders the Axumortem specimen intake interface: an +// animated SVG grain background, hex offset margin +// decoration (16 addresses), corner brackets, meta +// strip header, and format support badges (ELF/PE/ +// Mach-O). The drop zone supports both drag-and-drop +// and click-to-browse file selection, displaying file +// name, size (via formatBytes), and MIME type once +// selected. On submit, useUpload posts the binary as +// multipart/form-data and navigates to /analysis/:slug +// on success. A six-step pipeline visualization shows +// the FORMAT through THREAT analysis passes. The +// Component is lazy-loaded via react-router and +// exported with displayName "Landing" +// +// Connects to: +// api/hooks - useUpload mutation +// core/lib - formatBytes +// config.ts - implicit via useUpload endpoints +// landing.module +// .scss - all layout and animation styles +// routers.tsx - lazy-loaded at ROUTES.HOME // =================== import { useCallback, useRef, useState } from 'react' diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/vite.config.ts b/PROJECTS/intermediate/binary-analysis-tool/frontend/vite.config.ts index a8d5d478..fa79b248 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/vite.config.ts +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/vite.config.ts @@ -1,6 +1,26 @@ /** - * ©AngelaMos | 2025 + * ©AngelaMos | 2026 * vite.config.ts + * + * Vite build configuration with React plugin, path + * aliases, dev proxy, SCSS preprocessing, and manual + * chunk splitting + * + * Configures @vitejs/plugin-react and vite-tsconfig-paths + * plugins, resolves @ alias to ./src, enables SCSS + * preprocessing, and sets up a dev server on port 5173 + * with /api proxy to VITE_API_TARGET (fallback localhost + * :8000) that strips the /api prefix. Production builds + * target esnext with oxc minification, hidden sourcemaps, + * and manual chunks splitting react-dom/react-router into + * vendor-react, @tanstack/react-query into vendor-query, + * and zustand into vendor-state. Environment variables are + * loaded from the parent directory via loadEnv + * + * Connects to: + * src/App.tsx - root application component + * src/config.ts - VITE_API_URL consumed at runtime + * tsconfig.json - path aliases resolved by plugin */ import path from 'node:path' diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/apptoken.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/apptoken.nim index 933d0ad4..8240af3e 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/apptoken.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/apptoken.nim @@ -1,5 +1,32 @@ # ©AngelaMos | 2026 # apptoken.nim +# +# Application token and database credential collector +# +# Scans for credential exposure across desktop apps, databases, +# package registries, and infrastructure tools. Checks application +# data directories (Slack, Discord, VS Code). scanDbCredFiles +# inspects database credential files: .pgpass (PostgreSQL entry +# count), .my.cnf (MySQL password presence), .rediscli_auth (Redis), +# and .mongorc.js (MongoDB auth). scanDockerConfig checks +# .docker/config.json for registry authentication tokens. scanNetrc +# parses .netrc for machine entries with passwords. scanDevTokenFiles +# checks .npmrc for _authToken, .pypirc for passwords, and +# .config/gh/hosts.yml for GitHub CLI OAuth tokens. +# scanInfraTokenFiles checks Terraform Cloud credentials, Vault +# tokens, Helm repository passwords, and Rclone cloud storage +# configs. Severity escalates for world-readable files and files +# containing plaintext credentials. +# +# Connects to: +# collectors/base.nim - expandHome, safeFileExists, safeDirExists, +# readFileContent, readFileLines, isWorldReadable, +# isGroupReadable, makeFinding, makeFindingWithCred, +# permissionSeverity +# config.nim - PgPass, MyCnf, RedisConf, MongoRc, DockerConfig, +# NetrcFile, NpmrcFile, PypircFile, GhCliHosts, +# TerraformCreds, VaultTokenFile, HelmRepos, +# RcloneConf, SlackDir, DiscordDir, VsCodeDir {.push raises: [].} @@ -8,39 +35,30 @@ import ../types import ../config import base -type - AppTarget = object - path: string - name: string - description: string - isDir: bool +type AppTarget = object + path: string + name: string + description: string + isDir: bool -proc scanAppDir( - config: HarvestConfig, - target: AppTarget, - result: var CollectorResult -) = +proc scanAppDir(config: HarvestConfig, target: AppTarget, result: var CollectorResult) = let fullPath = expandHome(config, target.path) if target.isDir: if not safeDirExists(fullPath): return let sev = permissionSeverity(fullPath, isDir = true) - result.findings.add(makeFinding( - fullPath, - target.description, - catApptoken, sev - )) + result.findings.add(makeFinding(fullPath, target.description, catApptoken, sev)) else: if not safeFileExists(fullPath): return - let sev = if isWorldReadable(fullPath): svCritical - elif isGroupReadable(fullPath): svHigh - else: svMedium - result.findings.add(makeFinding( - fullPath, - target.description, - catApptoken, sev - )) + let sev = + if isWorldReadable(fullPath): + svCritical + elif isGroupReadable(fullPath): + svHigh + else: + svMedium + result.findings.add(makeFinding(fullPath, target.description, catApptoken, sev)) proc scanDbCredFiles(config: HarvestConfig, result: var CollectorResult) = let pgpassPath = expandHome(config, PgPass) @@ -57,53 +75,69 @@ proc scanDbCredFiles(config: HarvestConfig, result: var CollectorResult) = source: pgpassPath, credType: "postgresql_credentials", preview: $entryCount & " database connection entries", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("entry_count", $entryCount) - result.findings.add(makeFindingWithCred( - pgpassPath, - "PostgreSQL password file with " & $entryCount & " entries", - catApptoken, sev, cred - )) + result.findings.add( + makeFindingWithCred( + pgpassPath, + "PostgreSQL password file with " & $entryCount & " entries", + catApptoken, + sev, + cred, + ) + ) let mycnfPath = expandHome(config, MyCnf) if safeFileExists(mycnfPath): let content = readFileContent(mycnfPath) let hasPassword = "password" in content.toLowerAscii() - let sev = if isWorldReadable(mycnfPath): svCritical - elif hasPassword: svHigh - else: svMedium + let sev = + if isWorldReadable(mycnfPath): + svCritical + elif hasPassword: + svHigh + else: + svMedium - result.findings.add(makeFinding( - mycnfPath, - "MySQL configuration" & (if hasPassword: " (contains password)" else: ""), - catApptoken, sev - )) + result.findings.add( + makeFinding( + mycnfPath, + "MySQL configuration" & (if hasPassword: " (contains password)" else: ""), + catApptoken, + sev, + ) + ) let redisPath = expandHome(config, RedisConf) if safeFileExists(redisPath): let sev = if isWorldReadable(redisPath): svCritical else: svHigh - result.findings.add(makeFinding( - redisPath, - "Redis CLI authentication file", - catApptoken, sev - )) + result.findings.add( + makeFinding(redisPath, "Redis CLI authentication file", catApptoken, sev) + ) let mongoPath = expandHome(config, MongoRc) if safeFileExists(mongoPath): let content = readFileContent(mongoPath) - let hasCreds = "password" in content.toLowerAscii() or - "auth" in content.toLowerAscii() - let sev = if isWorldReadable(mongoPath): svCritical - elif hasCreds: svHigh - else: svMedium + let hasCreds = + "password" in content.toLowerAscii() or "auth" in content.toLowerAscii() + let sev = + if isWorldReadable(mongoPath): + svCritical + elif hasCreds: + svHigh + else: + svMedium - result.findings.add(makeFinding( - mongoPath, - "MongoDB RC file" & (if hasCreds: " (may contain credentials)" else: ""), - catApptoken, sev - )) + result.findings.add( + makeFinding( + mongoPath, + "MongoDB RC file" & (if hasCreds: " (may contain credentials)" else: ""), + catApptoken, + sev, + ) + ) proc scanNetrc(config: HarvestConfig, result: var CollectorResult) = let path = expandHome(config, NetrcFile) @@ -122,125 +156,151 @@ proc scanNetrc(config: HarvestConfig, result: var CollectorResult) = if "password " in stripped.toLowerAscii(): hasPassword = true - let sev = if isWorldReadable(path): svCritical - elif hasPassword: svHigh - else: svMedium + let sev = + if isWorldReadable(path): + svCritical + elif hasPassword: + svHigh + else: + svMedium var cred = Credential( source: path, credType: "netrc_credentials", preview: $machineCount & " machine entries", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("machines", $machineCount) cred.setMeta("has_password", $hasPassword) - result.findings.add(makeFindingWithCred( - path, - "Netrc credential file with " & $machineCount & " entries", - catApptoken, sev, cred - )) + result.findings.add( + makeFindingWithCred( + path, + "Netrc credential file with " & $machineCount & " entries", + catApptoken, + sev, + cred, + ) + ) proc scanDevTokenFiles(config: HarvestConfig, result: var CollectorResult) = let npmrcPath = expandHome(config, NpmrcFile) if safeFileExists(npmrcPath): let content = readFileContent(npmrcPath) let hasToken = "_authToken" in content or "_auth" in content - let sev = if isWorldReadable(npmrcPath): svCritical - elif hasToken: svHigh - else: svInfo + let sev = + if isWorldReadable(npmrcPath): + svCritical + elif hasToken: + svHigh + else: + svInfo if hasToken: - result.findings.add(makeFinding( - npmrcPath, - "npm registry authentication token", - catApptoken, sev - )) + result.findings.add( + makeFinding(npmrcPath, "npm registry authentication token", catApptoken, sev) + ) let pypircPath = expandHome(config, PypircFile) if safeFileExists(pypircPath): let content = readFileContent(pypircPath) let hasPassword = "password" in content.toLowerAscii() - let sev = if isWorldReadable(pypircPath): svCritical - elif hasPassword: svHigh - else: svMedium + let sev = + if isWorldReadable(pypircPath): + svCritical + elif hasPassword: + svHigh + else: + svMedium - result.findings.add(makeFinding( - pypircPath, - "PyPI configuration" & (if hasPassword: " (contains credentials)" else: ""), - catApptoken, sev - )) + result.findings.add( + makeFinding( + pypircPath, + "PyPI configuration" & (if hasPassword: " (contains credentials)" else: ""), + catApptoken, + sev, + ) + ) let ghPath = expandHome(config, GhCliHosts) if safeFileExists(ghPath): let content = readFileContent(ghPath) let hasOauth = "oauth_token" in content.toLowerAscii() - let sev = if isWorldReadable(ghPath): svCritical - elif hasOauth: svHigh - else: svMedium + let sev = + if isWorldReadable(ghPath): + svCritical + elif hasOauth: + svHigh + else: + svMedium - result.findings.add(makeFinding( - ghPath, - "GitHub CLI OAuth token", - catApptoken, sev - )) + result.findings.add(makeFinding(ghPath, "GitHub CLI OAuth token", catApptoken, sev)) proc scanInfraTokenFiles(config: HarvestConfig, result: var CollectorResult) = let tfPath = expandHome(config, TerraformCreds) if safeFileExists(tfPath): let content = readFileContent(tfPath) let hasToken = "token" in content.toLowerAscii() - let sev = if isWorldReadable(tfPath): svCritical - elif hasToken: svHigh - else: svMedium + let sev = + if isWorldReadable(tfPath): + svCritical + elif hasToken: + svHigh + else: + svMedium - result.findings.add(makeFinding( - tfPath, - "Terraform Cloud API token", - catApptoken, sev - )) + result.findings.add( + makeFinding(tfPath, "Terraform Cloud API token", catApptoken, sev) + ) let vaultPath = expandHome(config, VaultTokenFile) if safeFileExists(vaultPath): let sev = if isWorldReadable(vaultPath): svCritical else: svHigh - result.findings.add(makeFinding( - vaultPath, - "HashiCorp Vault token", - catApptoken, sev - )) + result.findings.add( + makeFinding(vaultPath, "HashiCorp Vault token", catApptoken, sev) + ) let helmPath = expandHome(config, HelmRepos) if safeFileExists(helmPath): let content = readFileContent(helmPath) let hasPassword = "password" in content.toLowerAscii() - let sev = if isWorldReadable(helmPath): svCritical - elif hasPassword: svHigh - else: svInfo + let sev = + if isWorldReadable(helmPath): + svCritical + elif hasPassword: + svHigh + else: + svInfo if hasPassword: - result.findings.add(makeFinding( - helmPath, - "Helm repository credentials", - catApptoken, sev - )) + result.findings.add( + makeFinding(helmPath, "Helm repository credentials", catApptoken, sev) + ) let rclonePath = expandHome(config, RcloneConf) if safeFileExists(rclonePath): let content = readFileContent(rclonePath) - let hasCreds = "pass" in content.toLowerAscii() or - "token" in content.toLowerAscii() or - "key" in content.toLowerAscii() - let sev = if isWorldReadable(rclonePath): svCritical - elif hasCreds: svHigh - else: svMedium + let hasCreds = + "pass" in content.toLowerAscii() or "token" in content.toLowerAscii() or + "key" in content.toLowerAscii() + let sev = + if isWorldReadable(rclonePath): + svCritical + elif hasCreds: + svHigh + else: + svMedium - result.findings.add(makeFinding( - rclonePath, - "Rclone cloud storage configuration" & - (if hasCreds: " (contains credentials)" else: ""), - catApptoken, sev - )) + result.findings.add( + makeFinding( + rclonePath, + "Rclone cloud storage configuration" & + (if hasCreds: " (contains credentials)" else: ""), + catApptoken, + sev, + ) + ) proc scanDockerConfig(config: HarvestConfig, result: var CollectorResult) = let dockerPath = expandHome(config, DockerConfig) @@ -249,36 +309,60 @@ proc scanDockerConfig(config: HarvestConfig, result: var CollectorResult) = let content = readFileContent(dockerPath) let hasAuth = "\"auth\"" in content or "\"auths\"" in content - let sev = if isWorldReadable(dockerPath): svCritical - elif hasAuth: svHigh - else: svMedium + let sev = + if isWorldReadable(dockerPath): + svCritical + elif hasAuth: + svHigh + else: + svMedium var cred = Credential( source: dockerPath, credType: "docker_registry_auth", preview: if hasAuth: "Registry authentication tokens present" else: "No auth data", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) - result.findings.add(makeFindingWithCred( - dockerPath, - "Docker configuration" & (if hasAuth: " with registry auth tokens" else: ""), - catApptoken, sev, cred - )) + result.findings.add( + makeFindingWithCred( + dockerPath, + "Docker configuration" & (if hasAuth: " with registry auth tokens" else: ""), + catApptoken, + sev, + cred, + ) + ) proc collect*(config: HarvestConfig): CollectorResult = result = newCollectorResult("apptoken", catApptoken) let start = getMonoTime() let appTargets = [ - AppTarget(path: SlackDir, name: "Slack", - description: "Slack desktop session data", isDir: true), - AppTarget(path: DiscordDir, name: "Discord", - description: "Discord desktop session data", isDir: true), - AppTarget(path: VsCodeDir, name: "VS Code", - description: "VS Code configuration directory", isDir: true), - AppTarget(path: VsCodeUserSettings, name: "VS Code Settings", - description: "VS Code user settings (may contain tokens)", isDir: false) + AppTarget( + path: SlackDir, + name: "Slack", + description: "Slack desktop session data", + isDir: true, + ), + AppTarget( + path: DiscordDir, + name: "Discord", + description: "Discord desktop session data", + isDir: true, + ), + AppTarget( + path: VsCodeDir, + name: "VS Code", + description: "VS Code configuration directory", + isDir: true, + ), + AppTarget( + path: VsCodeUserSettings, + name: "VS Code Settings", + description: "VS Code user settings (may contain tokens)", + isDir: false, + ), ] for target in appTargets: diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/base.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/base.nim index f6e3f995..bd461524 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/base.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/base.nim @@ -1,5 +1,24 @@ # ©AngelaMos | 2026 # base.nim +# +# Shared collector utilities and Finding constructors +# +# Foundation layer used by all seven collector modules. Wraps POSIX +# stat for permission inspection (getPermsString, getNumericPerms, +# isWorldReadable, isGroupReadable), provides safe filesystem access +# (safeFileExists, safeDirExists, readFileContent, readFileLines with +# optional line cap), SYSROOT-aware path expansion via expandHome, +# and exclude-pattern matching. Constructs Finding objects through +# makeFinding and makeFindingWithCred (auto-populates permissions, +# modification time, and file size). permissionSeverity maps file +# modes to severity levels based on world/group read bits. Also +# provides redactValue for credential preview masking and setMeta +# for type-safe metadata insertion. +# +# Connects to: +# types.nim - Finding, Credential, Severity, Category, HarvestConfig +# config.nim - OwnerOnlyFilePerms, OwnerOnlyDirPerms, WorldReadBit, +# GroupReadBit {.push raises: [].} @@ -94,10 +113,7 @@ proc matchesExclude*(path: string, patterns: seq[string]): bool = return true proc makeFinding*( - path: string, - description: string, - category: Category, - severity: Severity + path: string, description: string, category: Category, severity: Severity ): Finding = Finding( path: path, @@ -107,15 +123,15 @@ proc makeFinding*( credential: none(Credential), permissions: getPermsString(path), modified: getModifiedTime(path), - size: getFileSizeBytes(path) + size: getFileSizeBytes(path), ) proc makeFindingWithCred*( - path: string, - description: string, - category: Category, - severity: Severity, - cred: Credential + path: string, + description: string, + category: Category, + severity: Severity, + cred: Credential, ): Finding = Finding( path: path, @@ -125,16 +141,12 @@ proc makeFindingWithCred*( credential: some(cred), permissions: getPermsString(path), modified: getModifiedTime(path), - size: getFileSizeBytes(path) + size: getFileSizeBytes(path), ) proc newCollectorResult*(name: string, category: Category): CollectorResult = CollectorResult( - name: name, - category: category, - findings: @[], - durationMs: 0, - errors: @[] + name: name, category: category, findings: @[], durationMs: 0, errors: @[] ) proc permissionSeverity*(path: string, isDir: bool = false): Severity = diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/browser.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/browser.nim index 0efe472b..7eb3a514 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/browser.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/browser.nim @@ -1,5 +1,24 @@ # ©AngelaMos | 2026 # browser.nim +# +# Browser credential store collector +# +# Detects Firefox and Chromium-family browser credential databases. +# scanFirefox parses profiles.ini to discover profile directories, +# then checks each for logins.json (stored passwords), cookies.sqlite, +# and key4.db (master key database). scanChromium iterates four +# browser paths (Chrome, Chromium, Brave, Vivaldi) and their Default/ +# Profile N subdirectories looking for Login Data, Cookies, and Web +# Data (autofill and payment methods) SQLite databases. Severity +# escalates based on file permissions (world-readable = critical, +# group-readable = high, owner-only = medium). +# +# Connects to: +# collectors/base.nim - expandHome, safeFileExists, safeDirExists, +# isWorldReadable, isGroupReadable, makeFinding +# config.nim - FirefoxDir, FirefoxProfilesIni, FirefoxLoginsFile, +# FirefoxCookiesDb, FirefoxKeyDb, ChromiumDirs, +# ChromiumLoginData, ChromiumCookies, ChromiumWebData {.push raises: [].} @@ -35,8 +54,11 @@ proc scanFirefox(config: HarvestConfig, result: var CollectorResult) = profiles.add(currentPath) for profile in profiles: - let profileDir = if profile.startsWith("/"): profile - else: firefoxPath / profile + let profileDir = + if profile.startsWith("/"): + profile + else: + firefoxPath / profile if not safeDirExists(profileDir): continue @@ -44,15 +66,19 @@ proc scanFirefox(config: HarvestConfig, result: var CollectorResult) = let credFiles = [ (FirefoxLoginsFile, "Firefox stored logins database"), (FirefoxCookiesDb, "Firefox cookies database"), - (FirefoxKeyDb, "Firefox key database") + (FirefoxKeyDb, "Firefox key database"), ] for (fileName, desc) in credFiles: let filePath = profileDir / fileName if safeFileExists(filePath): - let sev = if isWorldReadable(filePath): svCritical - elif isGroupReadable(filePath): svHigh - else: svMedium + let sev = + if isWorldReadable(filePath): + svCritical + elif isGroupReadable(filePath): + svHigh + else: + svMedium result.findings.add(makeFinding(filePath, desc, catBrowser, sev)) @@ -81,15 +107,19 @@ proc scanChromium(config: HarvestConfig, result: var CollectorResult) = let credFiles = [ (ChromiumLoginData, browserName & " stored login database"), (ChromiumCookies, browserName & " cookies database"), - (ChromiumWebData, browserName & " web data (autofill, payment methods)") + (ChromiumWebData, browserName & " web data (autofill, payment methods)"), ] for (fileName, desc) in credFiles: let filePath = profileDir / fileName if safeFileExists(filePath): - let sev = if isWorldReadable(filePath): svCritical - elif isGroupReadable(filePath): svHigh - else: svMedium + let sev = + if isWorldReadable(filePath): + svCritical + elif isGroupReadable(filePath): + svHigh + else: + svMedium result.findings.add(makeFinding(filePath, desc, catBrowser, sev)) diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/cloud.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/cloud.nim index 13efdd75..601892d6 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/cloud.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/cloud.nim @@ -1,5 +1,27 @@ # ©AngelaMos | 2026 # cloud.nim +# +# Cloud provider configuration collector +# +# Scans for credential exposure across four cloud platforms. scanAws +# parses ~/.aws/credentials for profile counts, static keys (AKIA +# prefix), and session keys (ASIA prefix), then checks ~/.aws/config +# for SSO and MFA configuration. scanGcp inspects application default +# credentials for service account vs user type and walks the gcloud +# config directory for additional service account key files. scanAzure +# checks for access token and MSAL token cache files. scanKubernetes +# parses ~/.kube/config to count contexts and users, detecting token- +# based and certificate-based authentication methods. Severity +# escalates for static keys, service accounts, token auth, and +# world-readable files. +# +# Connects to: +# collectors/base.nim - expandHome, safeFileExists, safeDirExists, +# readFileContent, readFileLines, isWorldReadable, +# makeFinding, makeFindingWithCred, permissionSeverity +# config.nim - AwsCredentials, AwsConfig, AwsStaticKeyPrefix, +# GcpConfigDir, GcpAppDefaultCreds, AzureDir, +# AzureAccessTokens, KubeConfig, KubeContextMarker {.push raises: [].} @@ -42,18 +64,22 @@ proc scanAws(config: HarvestConfig, result: var CollectorResult) = source: credPath, credType: "aws_credentials", preview: $profileCount & " profiles, " & $staticKeys & " static keys", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("profiles", $profileCount) cred.setMeta("static_keys", $staticKeys) cred.setMeta("session_keys", $sessionKeys) - result.findings.add(makeFindingWithCred( - credPath, - "AWS credentials file: " & $profileCount & " profiles, " & - $staticKeys & " static keys, " & $sessionKeys & " session keys", - catCloud, sev, cred - )) + result.findings.add( + makeFindingWithCred( + credPath, + "AWS credentials file: " & $profileCount & " profiles, " & $staticKeys & + " static keys, " & $sessionKeys & " session keys", + catCloud, + sev, + cred, + ) + ) if safeFileExists(configPath): let lines = readFileLines(configPath) @@ -91,16 +117,20 @@ proc scanGcp(config: HarvestConfig, result: var CollectorResult) = source: adcPath, credType: "gcp_credentials", preview: if isServiceAccount: "Service account key" else: "User credentials", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) let credTypeStr = if isServiceAccount: "service_account" else: "authorized_user" cred.setMeta("type", credTypeStr) - result.findings.add(makeFindingWithCred( - adcPath, - "GCP application default credentials (" & credTypeStr & ")", - catCloud, sev, cred - )) + result.findings.add( + makeFindingWithCred( + adcPath, + "GCP application default credentials (" & credTypeStr & ")", + catCloud, + sev, + cred, + ) + ) if safeDirExists(gcpDir): try: @@ -110,11 +140,9 @@ proc scanGcp(config: HarvestConfig, result: var CollectorResult) = if path.endsWith(".json") and path != adcPath: let content = readFileContent(path) if GcpServiceAccountPattern in content.toLowerAscii(): - result.findings.add(makeFinding( - path, - "GCP service account key file", - catCloud, svHigh - )) + result.findings.add( + makeFinding(path, "GCP service account key file", catCloud, svHigh) + ) except CatchableError as e: result.errors.add("Error scanning GCP directory: " & e.msg) @@ -123,28 +151,20 @@ proc scanAzure(config: HarvestConfig, result: var CollectorResult) = if not safeDirExists(azDir): return - let tokenPaths = [ - expandHome(config, AzureAccessTokens), - expandHome(config, AzureMsalTokenCache) - ] + let tokenPaths = + [expandHome(config, AzureAccessTokens), expandHome(config, AzureMsalTokenCache)] var foundTokens = false for path in tokenPaths: if safeFileExists(path): foundTokens = true let sev = if isWorldReadable(path): svCritical else: svMedium - result.findings.add(makeFinding( - path, - "Azure token cache", - catCloud, sev - )) + result.findings.add(makeFinding(path, "Azure token cache", catCloud, sev)) if not foundTokens: - result.findings.add(makeFinding( - azDir, - "Azure CLI configuration directory", - catCloud, svInfo - )) + result.findings.add( + makeFinding(azDir, "Azure CLI configuration directory", catCloud, svInfo) + ) proc scanKubernetes(config: HarvestConfig, result: var CollectorResult) = let kubePath = expandHome(config, KubeConfig) @@ -170,7 +190,7 @@ proc scanKubernetes(config: HarvestConfig, result: var CollectorResult) = inUsers = true inContexts = false elif stripped.len > 0 and not stripped.startsWith(" ") and - not stripped.startsWith("-"): + not stripped.startsWith("-"): inContexts = false inUsers = false @@ -183,26 +203,34 @@ proc scanKubernetes(config: HarvestConfig, result: var CollectorResult) = if "client-certificate-data:" in stripped: hasCertAuth = true - let sev = if isWorldReadable(kubePath): svCritical - elif hasTokenAuth: svHigh - else: svMedium + let sev = + if isWorldReadable(kubePath): + svCritical + elif hasTokenAuth: + svHigh + else: + svMedium var cred = Credential( source: kubePath, credType: "kubernetes_config", preview: $contextCount & " contexts, " & $userCount & " users", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("contexts", $contextCount) cred.setMeta("users", $userCount) cred.setMeta("token_auth", $hasTokenAuth) cred.setMeta("cert_auth", $hasCertAuth) - result.findings.add(makeFindingWithCred( - kubePath, - "Kubernetes config: " & $contextCount & " contexts, " & $userCount & " users", - catCloud, sev, cred - )) + result.findings.add( + makeFindingWithCred( + kubePath, + "Kubernetes config: " & $contextCount & " contexts, " & $userCount & " users", + catCloud, + sev, + cred, + ) + ) proc collect*(config: HarvestConfig): CollectorResult = result = newCollectorResult("cloud", catCloud) diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/git.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/git.nim index 12e9d7e4..f67be6e1 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/git.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/git.nim @@ -1,5 +1,25 @@ # ©AngelaMos | 2026 # git.nim +# +# Git credential store and token collector +# +# Scans for Git-related credential exposure in three areas. +# scanGitCredentials reads ~/.git-credentials for plaintext URL +# entries containing embedded passwords (severity high, critical if +# world-readable). scanGitConfig parses ~/.gitconfig and +# ~/.config/git/config for credential helper configuration, flagging +# the "store" helper as medium severity since it persists plaintext. +# scanTokenPatterns searches Git config files for GitHub personal +# access token prefixes (ghp_, gho_, ghu_, ghs_, ghr_) and GitLab +# token prefixes (glpat-), reporting detected tokens with redacted +# previews. +# +# Connects to: +# collectors/base.nim - expandHome, safeFileExists, readFileContent, +# readFileLines, isWorldReadable, getPermsString, +# makeFinding, makeFindingWithCred, redactValue +# config.nim - GitCredentials, GitConfig, GitConfigLocal, +# GitHubTokenPatterns, GitLabTokenPatterns {.push raises: [].} @@ -28,24 +48,25 @@ proc scanGitCredentials(config: HarvestConfig, result: var CollectorResult) = source: credPath, credType: "git_plaintext_credentials", preview: $credCount & " stored credentials", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("count", $credCount) cred.setMeta("permissions", getPermsString(credPath)) let sev = if isWorldReadable(credPath): svCritical else: svHigh - result.findings.add(makeFindingWithCred( - credPath, - "Plaintext Git credential store with " & $credCount & " entries", - catGit, sev, cred - )) + result.findings.add( + makeFindingWithCred( + credPath, + "Plaintext Git credential store with " & $credCount & " entries", + catGit, + sev, + cred, + ) + ) proc scanGitConfig(config: HarvestConfig, result: var CollectorResult) = - let paths = [ - expandHome(config, GitConfig), - expandHome(config, GitConfigLocal) - ] + let paths = [expandHome(config, GitConfig), expandHome(config, GitConfigLocal)] for path in paths: if not safeFileExists(path): @@ -71,17 +92,14 @@ proc scanGitConfig(config: HarvestConfig, result: var CollectorResult) = if helperValue.len > 0: let sev = if helperValue == "store": svMedium else: svInfo - result.findings.add(makeFinding( - path, - "Git credential helper configured: " & helperValue, - catGit, sev - )) + result.findings.add( + makeFinding( + path, "Git credential helper configured: " & helperValue, catGit, sev + ) + ) proc scanTokenPatterns(config: HarvestConfig, result: var CollectorResult) = - let configPaths = [ - expandHome(config, GitConfig), - expandHome(config, GitConfigLocal) - ] + let configPaths = [expandHome(config, GitConfig), expandHome(config, GitConfigLocal)] for path in configPaths: if not safeFileExists(path): @@ -99,13 +117,13 @@ proc scanTokenPatterns(config: HarvestConfig, result: var CollectorResult) = source: path, credType: "github_token", preview: redactValue(tokenStart, 8), - metadata: initTable[string, string]() + metadata: initTable[string, string](), + ) + result.findings.add( + makeFindingWithCred( + path, "GitHub personal access token detected", catGit, svHigh, cred + ) ) - result.findings.add(makeFindingWithCred( - path, - "GitHub personal access token detected", - catGit, svHigh, cred - )) break for pattern in GitLabTokenPatterns: @@ -116,13 +134,13 @@ proc scanTokenPatterns(config: HarvestConfig, result: var CollectorResult) = source: path, credType: "gitlab_token", preview: redactValue(tokenStart, 8), - metadata: initTable[string, string]() + metadata: initTable[string, string](), + ) + result.findings.add( + makeFindingWithCred( + path, "GitLab personal access token detected", catGit, svHigh, cred + ) ) - result.findings.add(makeFindingWithCred( - path, - "GitLab personal access token detected", - catGit, svHigh, cred - )) break proc collect*(config: HarvestConfig): CollectorResult = diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/history.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/history.nim index d6eacfdf..8ec7ad13 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/history.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/history.nim @@ -1,5 +1,27 @@ # ©AngelaMos | 2026 # history.nim +# +# Shell history and environment file collector +# +# Scans for secrets leaked through shell history and .env files. +# scanHistoryFile reads up to 50000 lines from each history file +# (.bash_history, .zsh_history, .fish_history, .sh_history, +# .python_history) and matches lines against two pattern sets: +# secret assignments (KEY=, SECRET=, TOKEN=, PASSWORD=, etc. with +# export prefix detection) capped at 20 reported findings, and +# sensitive commands (curl with auth headers, wget with passwords, +# mysql -p, psql password, sshpass) capped at 10. Redacts matched +# values using redactLine to avoid exposing actual secrets. +# scanEnvFiles recursively walks subdirectories up to depth 5 looking +# for .env, .env.local, .env.production, and .env.staging files, +# skipping hidden dirs and common vendored paths. +# +# Connects to: +# collectors/base.nim - expandHome, safeFileExists, readFileLines, +# isWorldReadable, isGroupReadable, makeFinding, +# makeFindingWithCred, matchesExclude, redactValue +# config.nim - HistoryFiles, SecretPatterns, +# HistoryCommandPatterns, EnvFilePatterns {.push raises: [].} @@ -21,11 +43,12 @@ proc redactLine*(line: string): string = if valStart >= line.len: return line let value = line[valStart .. ^1].strip() - let cleanValue = if (value.startsWith("\"") and value.endsWith("\"")) or - (value.startsWith("'") and value.endsWith("'")): - value[1 ..< ^1] - else: - value + let cleanValue = + if (value.startsWith("\"") and value.endsWith("\"")) or + (value.startsWith("'") and value.endsWith("'")): + value[1 ..< ^1] + else: + value result = key & "=" & redactValue(cleanValue, 4) proc matchesSecretPattern*(line: string): bool = @@ -33,7 +56,7 @@ proc matchesSecretPattern*(line: string): bool = for pattern in SecretPatterns: if pattern in upper: if "export " in line.toLowerAscii() or - line.strip().startsWith(pattern.split("=")[0]): + line.strip().startsWith(pattern.split("=")[0]): return true proc matchesCommandPattern*(line: string): bool = @@ -55,9 +78,7 @@ proc matchesCommandPattern*(line: string): bool = return true proc scanHistoryFile( - config: HarvestConfig, - fileName: string, - result: var CollectorResult + config: HarvestConfig, fileName: string, result: var CollectorResult ) = let path = expandHome(config, fileName) if not safeFileExists(path): @@ -79,40 +100,46 @@ proc scanHistoryFile( source: path, credType: "history_secret", preview: redactLine(stripped), - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("line_region", $(i + 1)) - result.findings.add(makeFindingWithCred( - path, - "Secret in shell history (line ~" & $(i + 1) & ")", - catHistory, svHigh, cred - )) - + result.findings.add( + makeFindingWithCred( + path, + "Secret in shell history (line ~" & $(i + 1) & ")", + catHistory, + svHigh, + cred, + ) + ) elif matchesCommandPattern(stripped): inc commandCount if commandCount <= 10: - let preview = if stripped.len > 60: stripped[0 ..< 60] & "..." - else: stripped + let preview = + if stripped.len > 60: + stripped[0 ..< 60] & "..." + else: + stripped - result.findings.add(makeFinding( - path, - "Sensitive command in history: " & preview, - catHistory, svMedium - )) + result.findings.add( + makeFinding( + path, "Sensitive command in history: " & preview, catHistory, svMedium + ) + ) if secretCount > 20: - result.findings.add(makeFinding( - path, - $secretCount & " total secret patterns found (showing first 20)", - catHistory, svInfo - )) + result.findings.add( + makeFinding( + path, + $secretCount & " total secret patterns found (showing first 20)", + catHistory, + svInfo, + ) + ) proc walkForEnv( - dir: string, - depth: int, - excludePatterns: seq[string], - result: var CollectorResult + dir: string, depth: int, excludePatterns: seq[string], result: var CollectorResult ) = if depth > MaxEnvDepth: return @@ -125,21 +152,23 @@ proc walkForEnv( let name = path.extractFilename() for envPattern in EnvFilePatterns: if name == envPattern: - let sev = if isWorldReadable(path): svCritical - elif isGroupReadable(path): svHigh - else: svMedium - result.findings.add(makeFinding( - path, - "Environment file: " & name, - catHistory, sev - )) + let sev = + if isWorldReadable(path): + svCritical + elif isGroupReadable(path): + svHigh + else: + svMedium + result.findings.add( + makeFinding(path, "Environment file: " & name, catHistory, sev) + ) break of pcDir: let dirName = path.extractFilename() if dirName.startsWith(".") and dirName notin [".config", ".local"]: continue - if dirName in ["node_modules", "vendor", ".git", "__pycache__", - ".venv", "venv", ".cache"]: + if dirName in + ["node_modules", "vendor", ".git", "__pycache__", ".venv", "venv", ".cache"]: continue walkForEnv(path, depth + 1, excludePatterns, result) else: diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/keyring.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/keyring.nim index 47d89bce..9c719f09 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/keyring.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/keyring.nim @@ -1,5 +1,25 @@ # ©AngelaMos | 2026 # keyring.nim +# +# Desktop keyring and password manager collector +# +# Detects credential stores from five sources. scanGnomeKeyring walks +# ~/.local/share/keyrings for .keyring database files. scanKdeWallet +# checks ~/.local/share/kwalletd for wallet files. scanKeePass +# recursively searches up to depth 5 for .kdbx database files, +# skipping hidden/vendored directories. scanPassStore checks +# ~/.password-store and counts GPG-encrypted entries. scanBitwarden +# checks for Bitwarden desktop and CLI local vault directories. +# Each finding's severity is based on file permissions (world-readable +# escalates to critical). +# +# Connects to: +# collectors/base.nim - expandHome, safeDirExists, safeFileExists, +# isWorldReadable, isGroupReadable, makeFinding, +# makeFindingWithCred, matchesExclude, +# permissionSeverity +# config.nim - GnomeKeyringDir, KdeWalletDir, KeePassExtension, +# PassStoreDir, BitwardenDir, BitwardenCliDir {.push raises: [].} @@ -20,22 +40,24 @@ proc scanGnomeKeyring(config: HarvestConfig, result: var CollectorResult) = continue if path.endsWith(".keyring"): inc dbCount - let sev = if isWorldReadable(path): svCritical - elif isGroupReadable(path): svHigh - else: svMedium + let sev = + if isWorldReadable(path): + svCritical + elif isGroupReadable(path): + svHigh + else: + svMedium - result.findings.add(makeFinding( - path, - "GNOME Keyring database", - catKeyring, sev - )) + result.findings.add( + makeFinding(path, "GNOME Keyring database", catKeyring, sev) + ) if dbCount == 0: - result.findings.add(makeFinding( - keyringDir, - "GNOME Keyring directory exists (empty)", - catKeyring, svInfo - )) + result.findings.add( + makeFinding( + keyringDir, "GNOME Keyring directory exists (empty)", catKeyring, svInfo + ) + ) except CatchableError as e: result.errors.add("Error scanning GNOME Keyring: " & e.msg) @@ -48,23 +70,20 @@ proc scanKdeWallet(config: HarvestConfig, result: var CollectorResult) = for kind, path in walkDir(walletDir): if kind != pcFile: continue - let sev = if isWorldReadable(path): svCritical - elif isGroupReadable(path): svHigh - else: svMedium + let sev = + if isWorldReadable(path): + svCritical + elif isGroupReadable(path): + svHigh + else: + svMedium - result.findings.add(makeFinding( - path, - "KDE Wallet database", - catKeyring, sev - )) + result.findings.add(makeFinding(path, "KDE Wallet database", catKeyring, sev)) except CatchableError as e: result.errors.add("Error scanning KDE Wallet: " & e.msg) proc walkForKdbx( - dir: string, - depth: int, - excludePatterns: seq[string], - result: var CollectorResult + dir: string, depth: int, excludePatterns: seq[string], result: var CollectorResult ) = if depth > 5: return @@ -75,22 +94,24 @@ proc walkForKdbx( case kind of pcFile: if path.endsWith(KeePassExtension): - let sev = if isWorldReadable(path): svCritical - elif isGroupReadable(path): svHigh - else: svMedium + let sev = + if isWorldReadable(path): + svCritical + elif isGroupReadable(path): + svHigh + else: + svMedium - result.findings.add(makeFinding( - path, - "KeePass database file", - catKeyring, sev - )) + result.findings.add( + makeFinding(path, "KeePass database file", catKeyring, sev) + ) of pcDir: let dirName = path.extractFilename() if dirName.startsWith(".") and - dirName notin [".config", ".local", ".keepass", ".keepassxc"]: + dirName notin [".config", ".local", ".keepass", ".keepassxc"]: continue - if dirName in ["node_modules", "vendor", ".git", "__pycache__", - ".venv", "venv", ".cache"]: + if dirName in + ["node_modules", "vendor", ".git", "__pycache__", ".venv", "venv", ".cache"]: continue walkForKdbx(path, depth + 1, excludePatterns, result) else: @@ -118,30 +139,29 @@ proc scanPassStore(config: HarvestConfig, result: var CollectorResult) = source: passDir, credType: "pass_store", preview: $entryCount & " encrypted entries", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("entry_count", $entryCount) - result.findings.add(makeFindingWithCred( - passDir, - "pass (password-store) with " & $entryCount & " entries", - catKeyring, svInfo, cred - )) + result.findings.add( + makeFindingWithCred( + passDir, + "pass (password-store) with " & $entryCount & " entries", + catKeyring, + svInfo, + cred, + ) + ) proc scanBitwarden(config: HarvestConfig, result: var CollectorResult) = - let dirs = [ - expandHome(config, BitwardenDir), - expandHome(config, BitwardenCliDir) - ] + let dirs = [expandHome(config, BitwardenDir), expandHome(config, BitwardenCliDir)] for dir in dirs: if safeDirExists(dir): let sev = permissionSeverity(dir, isDir = true) - result.findings.add(makeFinding( - dir, - "Bitwarden local vault data", - catKeyring, sev - )) + result.findings.add( + makeFinding(dir, "Bitwarden local vault data", catKeyring, sev) + ) proc collect*(config: HarvestConfig): CollectorResult = result = newCollectorResult("keyring", catKeyring) diff --git a/PROJECTS/intermediate/credential-enumeration/src/collectors/ssh.nim b/PROJECTS/intermediate/credential-enumeration/src/collectors/ssh.nim index 1fbb3979..c1fe03f3 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/collectors/ssh.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/collectors/ssh.nim @@ -1,5 +1,26 @@ # ©AngelaMos | 2026 # ssh.nim +# +# SSH key and configuration collector +# +# Scans the target's ~/.ssh directory for credential exposure across +# four areas. scanKeys walks all files looking for PEM/OpenSSH private +# key headers, classifies each as encrypted or unencrypted by checking +# for passphrase markers (ENCRYPTED, bcrypt, aes256-ctr), and escalates +# severity based on encryption status and file permissions (world/group +# readable). scanConfig parses ssh_config for host entry counts and +# weak settings (PasswordAuthentication yes, StrictHostKeyChecking no). +# scanAuthorizedKeys counts non-comment public key entries. +# scanKnownHosts counts known host entries. Also validates .ssh +# directory permissions against the expected 0700. +# +# Connects to: +# collectors/base.nim - expandHome, safeFileExists, safeDirExists, +# readFileContent, readFileLines, makeFinding, +# makeFindingWithCred, permissionSeverity +# config.nim - SshDir, SshKeyHeaders, SshEncryptedMarkers, +# SshSafeDirPerms, SshConfig, SshAuthorizedKeys, +# SshKnownHosts {.push raises: [].} @@ -26,12 +47,15 @@ proc scanKeys(config: HarvestConfig, result: var CollectorResult) = let dirPerms = getNumericPerms(sshPath) if dirPerms >= 0 and dirPerms != OwnerOnlyDirPerms: let sev = permissionSeverity(sshPath, isDir = true) - result.findings.add(makeFinding( - sshPath, - "SSH directory permissions " & getPermsString(sshPath) & - " (expected " & SshSafeDirPerms & ")", - catSsh, sev - )) + result.findings.add( + makeFinding( + sshPath, + "SSH directory permissions " & getPermsString(sshPath) & " (expected " & + SshSafeDirPerms & ")", + catSsh, + sev, + ) + ) try: for kind, path in walkDir(sshPath): @@ -62,22 +86,29 @@ proc scanKeys(config: HarvestConfig, result: var CollectorResult) = if sev < svHigh: sev = svHigh - let keyType = if content.startsWith(SshKeyHeaders[0]): "OpenSSH" - elif content.startsWith(SshKeyHeaders[1]): "RSA" - elif content.startsWith(SshKeyHeaders[2]): "ECDSA" - elif content.startsWith(SshKeyHeaders[3]): "DSA" - else: "Unknown" + let keyType = + if content.startsWith(SshKeyHeaders[0]): + "OpenSSH" + elif content.startsWith(SshKeyHeaders[1]): + "RSA" + elif content.startsWith(SshKeyHeaders[2]): + "ECDSA" + elif content.startsWith(SshKeyHeaders[3]): + "DSA" + else: + "Unknown" - let desc = if encrypted: - keyType & " private key (passphrase-protected)" - else: - keyType & " private key (no passphrase)" + let desc = + if encrypted: + keyType & " private key (passphrase-protected)" + else: + keyType & " private key (no passphrase)" var cred = Credential( source: path, credType: "ssh_private_key", preview: keyType & " key", - metadata: initTable[string, string]() + metadata: initTable[string, string](), ) cred.setMeta("encrypted", $encrypted) cred.setMeta("permissions", getPermsString(path)) @@ -98,7 +129,7 @@ proc scanConfig(config: HarvestConfig, result: var CollectorResult) = for line in lines: let stripped = line.strip() if stripped.toLowerAscii().startsWith("host ") and - not stripped.toLowerAscii().startsWith("host *"): + not stripped.toLowerAscii().startsWith("host *"): inc hostCount if stripped.toLowerAscii().startsWith("passwordauthentication yes"): @@ -108,18 +139,16 @@ proc scanConfig(config: HarvestConfig, result: var CollectorResult) = weakSettings.add("StrictHostKeyChecking disabled") if hostCount > 0: - result.findings.add(makeFinding( - configPath, - "SSH config with " & $hostCount & " host entries", - catSsh, svInfo - )) + result.findings.add( + makeFinding( + configPath, "SSH config with " & $hostCount & " host entries", catSsh, svInfo + ) + ) for setting in weakSettings: - result.findings.add(makeFinding( - configPath, - "Weak SSH setting: " & setting, - catSsh, svMedium - )) + result.findings.add( + makeFinding(configPath, "Weak SSH setting: " & setting, catSsh, svMedium) + ) proc scanAuthorizedKeys(config: HarvestConfig, result: var CollectorResult) = let akPath = expandHome(config, SshDir / SshAuthorizedKeys) @@ -133,11 +162,9 @@ proc scanAuthorizedKeys(config: HarvestConfig, result: var CollectorResult) = inc keyCount if keyCount > 0: - result.findings.add(makeFinding( - akPath, - $keyCount & " authorized public keys", - catSsh, svInfo - )) + result.findings.add( + makeFinding(akPath, $keyCount & " authorized public keys", catSsh, svInfo) + ) proc scanKnownHosts(config: HarvestConfig, result: var CollectorResult) = let khPath = expandHome(config, SshDir / SshKnownHosts) @@ -151,11 +178,9 @@ proc scanKnownHosts(config: HarvestConfig, result: var CollectorResult) = inc hostCount if hostCount > 0: - result.findings.add(makeFinding( - khPath, - $hostCount & " known hosts", - catSsh, svInfo - )) + result.findings.add( + makeFinding(khPath, $hostCount & " known hosts", catSsh, svInfo) + ) proc collect*(config: HarvestConfig): CollectorResult = result = newCollectorResult("ssh", catSsh) diff --git a/PROJECTS/intermediate/credential-enumeration/src/config.nim b/PROJECTS/intermediate/credential-enumeration/src/config.nim index 15f2619b..e53021df 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/config.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/config.nim @@ -1,5 +1,25 @@ # ©AngelaMos | 2026 # config.nim +# +# Application constants and default configuration +# +# Central repository for every configurable value in the tool. Defines +# the module category list with human-readable names and descriptions, +# filesystem paths for each collector target (Firefox/Chromium browser +# dirs, SSH paths and key headers/encryption markers, AWS/GCP/Azure/ +# Kubernetes config paths, shell history files, secret regex patterns, +# sensitive command patterns, .env file patterns, keyring/wallet/ +# password store dirs, Git credential files and token prefixes, and +# 20+ application token paths from Docker to Terraform to rclone). +# Also defines permission constants, ANSI color codes, severity color/ +# label mappings, box-drawing characters for terminal output, the +# ASCII banner, and the defaultConfig factory proc. +# +# Connects to: +# types.nim - Category, Severity, HarvestConfig, OutputFormat +# collectors/*.nim - all collectors reference path/pattern constants +# output/terminal.nim - banner, colors, box characters, severity labels +# harvester.nim - defaultConfig, ModuleNames, ModuleDescriptions {.push raises: [].} @@ -10,10 +30,8 @@ const AppVersion* = "0.1.0" BinaryName* = "credenum" - AllModules*: seq[Category] = @[ - catBrowser, catSsh, catCloud, - catHistory, catKeyring, catGit, catApptoken - ] + AllModules*: seq[Category] = + @[catBrowser, catSsh, catCloud, catHistory, catKeyring, catGit, catApptoken] ModuleNames*: array[Category, string] = [ catBrowser: "browser", @@ -22,7 +40,7 @@ const catHistory: "history", catKeyring: "keyring", catGit: "git", - catApptoken: "apptoken" + catApptoken: "apptoken", ] ModuleDescriptions*: array[Category, string] = [ @@ -32,7 +50,7 @@ const catHistory: "Shell history and environment files", catKeyring: "Keyrings and password stores", catGit: "Git credential stores", - catApptoken: "Application tokens and database configs" + catApptoken: "Application tokens and database configs", ] const @@ -42,12 +60,8 @@ const FirefoxCookiesDb* = "cookies.sqlite" FirefoxKeyDb* = "key4.db" - ChromiumDirs* = [ - ".config/google-chrome", - ".config/chromium", - ".config/brave", - ".config/vivaldi" - ] + ChromiumDirs* = + [".config/google-chrome", ".config/chromium", ".config/brave", ".config/vivaldi"] ChromiumLoginData* = "Login Data" ChromiumCookies* = "Cookies" ChromiumWebData* = "Web Data" @@ -59,20 +73,13 @@ const SshKnownHosts* = "known_hosts" SshKeyHeaders* = [ - "-----BEGIN OPENSSH PRIVATE KEY-----", - "-----BEGIN RSA PRIVATE KEY-----", - "-----BEGIN EC PRIVATE KEY-----", - "-----BEGIN DSA PRIVATE KEY-----", - "-----BEGIN PRIVATE KEY-----" + "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", ] - SshEncryptedMarkers* = [ - "ENCRYPTED", - "Proc-Type: 4,ENCRYPTED", - "aes256-ctr", - "aes128-ctr", - "bcrypt" - ] + SshEncryptedMarkers* = + ["ENCRYPTED", "Proc-Type: 4,ENCRYPTED", "aes256-ctr", "aes128-ctr", "bcrypt"] SshSafeKeyPerms* = "0600" SshSafeDirPerms* = "0700" @@ -96,35 +103,17 @@ const KubeUserMarker* = "users:" const - HistoryFiles* = [ - ".bash_history", - ".zsh_history", - ".fish_history", - ".sh_history", - ".python_history" - ] + HistoryFiles* = + [".bash_history", ".zsh_history", ".fish_history", ".sh_history", ".python_history"] SecretPatterns* = [ - "KEY=", - "SECRET=", - "TOKEN=", - "PASSWORD=", - "PASSWD=", - "API_KEY=", - "ACCESS_KEY=", - "PRIVATE_KEY=", - "AUTH_TOKEN=", - "CREDENTIALS=" + "KEY=", "SECRET=", "TOKEN=", "PASSWORD=", "PASSWD=", "API_KEY=", "ACCESS_KEY=", + "PRIVATE_KEY=", "AUTH_TOKEN=", "CREDENTIALS=", ] HistoryCommandPatterns* = [ - "curl.*-h.*authoriz", - "curl.*-u ", - "wget.*--header.*authoriz", - "wget.*--password", - "mysql.*-p", - "psql.*password", - "sshpass" + "curl.*-h.*authoriz", "curl.*-u ", "wget.*--header.*authoriz", "wget.*--password", + "mysql.*-p", "psql.*password", "sshpass", ] EnvFileName* = ".env" @@ -206,7 +195,7 @@ const svLow: ColorCyan, svMedium: ColorYellow, svHigh: ColorBoldMagenta, - svCritical: ColorBoldRed + svCritical: ColorBoldRed, ] SeverityLabels*: array[Severity, string] = [ @@ -214,7 +203,7 @@ const svLow: "LOW", svMedium: "MEDIUM", svHigh: "HIGH", - svCritical: "CRITICAL" + svCritical: "CRITICAL", ] const @@ -241,5 +230,5 @@ proc defaultConfig*(): HarvestConfig = outputPath: "", dryRun: false, quiet: false, - verbose: false + verbose: false, ) diff --git a/PROJECTS/intermediate/credential-enumeration/src/harvester.nim b/PROJECTS/intermediate/credential-enumeration/src/harvester.nim index 30cf52e2..6c0dfd59 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/harvester.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/harvester.nim @@ -1,5 +1,23 @@ # ©AngelaMos | 2026 # harvester.nim +# +# CLI entry point and argument parser +# +# Parses command-line flags via std/parseopt (--target, --modules, +# --exclude, --format, --output, --dry-run, --quiet, --verbose, +# --help, --version) into a HarvestConfig. Dispatches to renderDryRun +# for --dry-run preview, otherwise calls runCollectors to execute all +# enabled modules, stamps the report with a UTC ISO 8601 timestamp, +# and routes output to renderTerminal, renderJson, or both. Exits +# with code 1 if any critical or high severity findings are detected, +# 0 otherwise. +# +# Connects to: +# config.nim - defaultConfig, AppVersion, ModuleNames/Descriptions +# types.nim - HarvestConfig, OutputFormat, Severity, Report +# runner.nim - runCollectors orchestrates module execution +# output/terminal.nim - renderTerminal for ANSI output +# output/json.nim - renderJson for structured output {.push raises: [].} @@ -21,7 +39,10 @@ proc printHelp() = stdout.writeLine "" stdout.writeLine ColorBold & "FLAGS:" & ColorReset stdout.writeLine " --target Target home directory (default: current user)" - stdout.writeLine " --modules Comma-separated modules: " & ModuleNames[catBrowser] & "," & ModuleNames[catSsh] & "," & ModuleNames[catCloud] & "," & ModuleNames[catHistory] & "," & ModuleNames[catKeyring] & "," & ModuleNames[catGit] & "," & ModuleNames[catApptoken] + stdout.writeLine " --modules Comma-separated modules: " & + ModuleNames[catBrowser] & "," & ModuleNames[catSsh] & "," & ModuleNames[catCloud] & + "," & ModuleNames[catHistory] & "," & ModuleNames[catKeyring] & "," & + ModuleNames[catGit] & "," & ModuleNames[catApptoken] stdout.writeLine " --exclude Comma-separated path patterns to skip" stdout.writeLine " --format Output format: terminal, json, both (default: terminal)" stdout.writeLine " --output Write JSON output to file" @@ -34,7 +55,8 @@ proc printHelp() = stdout.writeLine ColorBold & "EXAMPLES:" & ColorReset stdout.writeLine " " & BinaryName & " Scan current user" stdout.writeLine " " & BinaryName & " --format json JSON output" - stdout.writeLine " " & BinaryName & " --modules ssh,git,cloud Scan specific modules" + stdout.writeLine " " & BinaryName & + " --modules ssh,git,cloud Scan specific modules" stdout.writeLine " " & BinaryName & " --target /home/victim Scan another user" stdout.writeLine " " & BinaryName & " --dry-run Preview scan paths" stdout.writeLine "" @@ -63,7 +85,7 @@ proc parseCli(): HarvestConfig = var parser = initOptParser( commandLineParams(), shortNoVal = {'d', 'q', 'v', 'h'}, - longNoVal = @["dry-run", "quiet", "verbose", "help", "version"] + longNoVal = @["dry-run", "quiet", "verbose", "help", "version"], ) while true: @@ -111,7 +133,8 @@ proc renderDryRun(conf: HarvestConfig) = stdout.writeLine ColorBold & "Dry run — scan targets:" & ColorReset stdout.writeLine "" for cat in conf.enabledModules: - stdout.writeLine " " & ColorCyan & ModuleNames[cat] & ColorReset & ": " & ModuleDescriptions[cat] + stdout.writeLine " " & ColorCyan & ModuleNames[cat] & ColorReset & ": " & + ModuleDescriptions[cat] stdout.writeLine "" stdout.writeLine ColorDim & " Target: " & conf.targetDir & ColorReset stdout.writeLine "" diff --git a/PROJECTS/intermediate/credential-enumeration/src/output/json.nim b/PROJECTS/intermediate/credential-enumeration/src/output/json.nim index 9a179341..ee8b0f45 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/output/json.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/output/json.nim @@ -1,5 +1,27 @@ # ©AngelaMos | 2026 # json.nim +# +# JSON report serializer +# +# Converts the Report object tree into a structured JSON document +# using std/json's JsonNode builders. credentialToJson serializes a +# Credential with source, type, preview, and a metadata key-value +# map. findingToJson serializes a Finding with path, category, +# severity, description, permissions, modified timestamp, file +# size, and an optional nested credential object. +# collectorResultToJson wraps a module's findings array alongside +# its name, category, duration, and error list. reportToJson +# assembles the top-level structure: metadata block (timestamp, +# target directory, version, duration, module list), a modules +# array of collector results, and a summary object mapping each +# Severity level to its finding count. renderJson pretty-prints the +# JSON tree to stdout and optionally writes to a file path. +# All builder procs use {.cast(raises: []).} to suppress exception +# tracking within the JSON construction blocks. +# +# Connects to: +# types.nim - Credential, Finding, CollectorResult, Report, +# Severity, ReportMetadata {.push raises: [].} diff --git a/PROJECTS/intermediate/credential-enumeration/src/output/terminal.nim b/PROJECTS/intermediate/credential-enumeration/src/output/terminal.nim index 630ba646..e12b0335 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/output/terminal.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/output/terminal.nim @@ -1,5 +1,37 @@ # ©AngelaMos | 2026 # terminal.nim +# +# ANSI terminal renderer with box-drawing output +# +# Renders the full credential enumeration report to the terminal +# using Unicode box-drawing characters for bordered sections. +# visualLen computes display width by skipping ANSI escape +# sequences and UTF-8 continuation bytes so padding aligns +# correctly despite embedded color codes. truncateVisual truncates +# strings at a visual-width boundary without splitting escape +# sequences. sevBadge produces colored severity labels using the +# SeverityColors and SeverityLabels maps from config. Three box +# helpers (boxLine, boxBottom, boxMid) draw top, bottom, and +# mid-section borders at a fixed 78-column width. +# renderModuleHeader prints a bordered header with the module name, +# category description, finding count, and duration. renderFinding +# shows a severity badge, truncated description, file path with +# permissions and modification date, and an optional credential +# preview. renderSummary totals findings across all modules and +# displays a severity badge breakdown. renderTerminal orchestrates +# the full output sequence: banner, target and module metadata, +# per-module sections (skipping empty modules unless verbose), and +# the summary footer. +# +# Connects to: +# types.nim - Severity, Finding, Report, CollectorResult, +# Credential, ReportMetadata +# config.nim - BoxVertical, BoxTopLeft, BoxTopRight, BoxBottomLeft, +# BoxBottomRight, BoxHorizontal, BoxTeeRight, BoxTeeLeft, +# SeverityColors, SeverityLabels, ColorBold, ColorReset, +# ColorDim, ColorCyan, ColorBoldRed, Banner, +# BannerTagline, AppVersion, Arrow, CrossMark, +# ModuleDescriptions {.push raises: [].} @@ -88,14 +120,16 @@ proc renderBanner*(quiet: bool) = except CatchableError: discard -proc renderModuleHeader(name: string, desc: string, findingCount: int, durationMs: int64) = +proc renderModuleHeader( + name: string, desc: string, findingCount: int, durationMs: int64 +) = try: stdout.writeLine boxLine(BoxWidth) - let label = BoxVertical & " " & ColorBold & ColorCyan & - name.toUpperAscii() & ColorReset & ColorDim & " " & Arrow & - " " & desc & ColorReset - let stats = $findingCount & " findings" & ColorDim & " (" & - $durationMs & "ms)" & ColorReset + let label = + BoxVertical & " " & ColorBold & ColorCyan & name.toUpperAscii() & ColorReset & + ColorDim & " " & Arrow & " " & desc & ColorReset + let stats = + $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset let usedWidth = 2 + name.len + 3 + desc.len let statsVisual = visualLen(stats) let gap = BoxWidth - usedWidth - statsVisual - 2 @@ -111,12 +145,12 @@ proc renderModuleHeader(name: string, desc: string, findingCount: int, durationM discard proc renderFinding(f: Finding) = - let descLine = BoxVertical & " " & sevBadge(f.severity) & " " & + let descLine = + BoxVertical & " " & sevBadge(f.severity) & " " & truncateVisual(f.description, InnerWidth - 14) writeBoxLine(descLine) - var detail = BoxVertical & " " & ColorDim & f.path & - " [" & f.permissions & "]" + var detail = BoxVertical & " " & ColorDim & f.path & " [" & f.permissions & "]" if f.modified != "unknown": detail &= " mod:" & f.modified detail &= ColorReset @@ -125,16 +159,17 @@ proc renderFinding(f: Finding) = if f.credential.isSome: let cred = f.credential.get() if cred.preview.len > 0: - let previewLine = BoxVertical & " " & ColorDim & Arrow & - " " & cred.preview & ColorReset + let previewLine = + BoxVertical & " " & ColorDim & Arrow & " " & cred.preview & ColorReset writeBoxLine(previewLine) proc renderModuleErrors(errors: seq[string]) = if errors.len == 0: return for err in errors: - let errLine = BoxVertical & " " & ColorBoldRed & CrossMark & - ColorReset & " " & ColorDim & err & ColorReset + let errLine = + BoxVertical & " " & ColorBoldRed & CrossMark & ColorReset & " " & ColorDim & err & + ColorReset writeBoxLine(errLine) proc renderSummary(report: Report) = @@ -148,10 +183,10 @@ proc renderSummary(report: Report) = for sev in Severity: totalFindings += report.summary[sev] - let countLine = BoxVertical & " " & ColorBold & $totalFindings & - ColorReset & " findings across " & ColorBold & - $report.results.len & ColorReset & " modules" & ColorDim & - " (" & $report.metadata.durationMs & "ms)" & ColorReset + let countLine = + BoxVertical & " " & ColorBold & $totalFindings & ColorReset & " findings across " & + ColorBold & $report.results.len & ColorReset & " modules" & ColorDim & " (" & + $report.metadata.durationMs & "ms)" & ColorReset writeBoxLine(countLine) var badgeLine = BoxVertical & " " @@ -184,10 +219,7 @@ proc renderTerminal*(report: Report, quiet: bool, verbose: bool) = continue renderModuleHeader( - res.name, - ModuleDescriptions[res.category], - res.findings.len, - res.durationMs + res.name, ModuleDescriptions[res.category], res.findings.len, res.durationMs ) for finding in res.findings: diff --git a/PROJECTS/intermediate/credential-enumeration/src/runner.nim b/PROJECTS/intermediate/credential-enumeration/src/runner.nim index 1b325cb4..2e4897cf 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/runner.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/runner.nim @@ -1,5 +1,25 @@ # ©AngelaMos | 2026 # runner.nim +# +# Module dispatcher and report assembler +# +# Maps each Category enum value to its collector proc via getCollector, +# then runCollectors iterates the enabled modules from HarvestConfig, +# invokes each collector, collects results with monotonic timing, and +# builds the final Report with aggregated severity counts across all +# findings. The metadata timestamp is left empty for the caller +# (harvester.nim) to fill with wall-clock time. +# +# Connects to: +# types.nim - HarvestConfig, Report, CollectorResult, Severity +# config.nim - AppVersion, ModuleNames +# collectors/ssh.nim - ssh.collect +# collectors/git.nim - git.collect +# collectors/cloud.nim - cloud.collect +# collectors/browser.nim - browser.collect +# collectors/history.nim - history.collect +# collectors/keyring.nim - keyring.collect +# collectors/apptoken.nim - apptoken.collect {.push raises: [].} @@ -49,8 +69,8 @@ proc runCollectors*(config: HarvestConfig): Report = target: config.targetDir, version: AppVersion, durationMs: elapsed.inMilliseconds, - modules: moduleNames + modules: moduleNames, ), results: results, - summary: summary + summary: summary, ) diff --git a/PROJECTS/intermediate/credential-enumeration/src/types.nim b/PROJECTS/intermediate/credential-enumeration/src/types.nim index 05b69acc..4b207871 100644 --- a/PROJECTS/intermediate/credential-enumeration/src/types.nim +++ b/PROJECTS/intermediate/credential-enumeration/src/types.nim @@ -1,5 +1,24 @@ # ©AngelaMos | 2026 # types.nim +# +# Domain types for the credential enumeration tool +# +# Defines the core type hierarchy: Severity (info through critical) and +# Category (browser, ssh, cloud, history, keyring, git, apptoken) as +# string-backed enums. Finding captures a discovered credential exposure +# with path, severity, description, optional Credential detail, +# permissions, modification time, and file size. CollectorResult groups +# findings from a single module with timing and error tracking. Report +# aggregates all collector results with metadata (timestamp, target, +# version, duration, module list) and a severity summary array. +# HarvestConfig holds CLI-parsed runtime options. CollectorProc defines +# the nimcall signature all collector modules implement. +# +# Connects to: +# config.nim - constructs HarvestConfig via defaultConfig +# collectors/base.nim - makeFinding/makeFindingWithCred build Findings +# output/json.nim - serializes Report/Finding/Credential to JSON +# output/terminal.nim - renders Report/Finding with severity badges {.push raises: [].} diff --git a/PROJECTS/intermediate/credential-enumeration/tests/docker/validate.sh b/PROJECTS/intermediate/credential-enumeration/tests/docker/validate.sh old mode 100644 new mode 100755 index 13aa54e7..87d1ed58 --- a/PROJECTS/intermediate/credential-enumeration/tests/docker/validate.sh +++ b/PROJECTS/intermediate/credential-enumeration/tests/docker/validate.sh @@ -1,6 +1,30 @@ #!/usr/bin/env bash # ©AngelaMos | 2026 # validate.sh +# +# Docker-based integration test for all 7 collector categories +# +# Runs the credenum binary against planted test fixtures under +# /home/testuser and validates that every expected finding appears +# in the output. Captures JSON-format output into OUTPUT, then +# runs the terminal renderer for visual inspection. The check() +# helper greps the captured output for a case-insensitive pattern +# and tallies pass/fail counts. +# +# Validates 30 findings across all categories: ssh (unprotected +# key, encrypted key, weak config, authorized keys, known hosts), +# cloud (AWS static keys, AWS config, GCP service account, +# Kubernetes config), browser (Firefox logins, cookies, key +# database, Chromium login data), history (secret pattern, curl +# auth, sshpass, environment file), keyring (GNOME Keyring, +# KeePass database, password store), git (plaintext credentials, +# credential helper, GitHub token), apptoken (PostgreSQL, MySQL, +# Docker auth, netrc, npm, PyPI, GitHub CLI, Vault). Exits with +# code 1 if any check fails. +# +# Connects to: +# credenum binary - all 7 collector modules +# tests/docker/Dockerfile - fixture layout in /home/testuser set -euo pipefail diff --git a/PROJECTS/intermediate/credential-enumeration/tests/test_all.nim b/PROJECTS/intermediate/credential-enumeration/tests/test_all.nim index 8fb5df05..8acf0c85 100644 --- a/PROJECTS/intermediate/credential-enumeration/tests/test_all.nim +++ b/PROJECTS/intermediate/credential-enumeration/tests/test_all.nim @@ -1,5 +1,36 @@ # ©AngelaMos | 2026 # test_all.nim +# +# Unit tests for core utility and parsing functions +# +# Exercises exported helpers from four modules across eight test +# suites. redactValue covers short, long, exact-length, and empty +# strings. isPrivateKey validates detection of five PEM header +# formats (OpenSSH, RSA, ECDSA, DSA, PKCS8) and rejection of +# public keys and non-key content. isEncrypted checks for +# ENCRYPTED, bcrypt, and aes256-ctr markers versus unencrypted +# keys. matchesSecretPattern verifies detection of export-prefixed +# and bare KEY=/SECRET=/TOKEN=/PASSWORD= assignments while +# rejecting PATH exports and ordinary commands. +# matchesCommandPattern tests curl with auth headers and -u flag, +# wget with authorization header and password, mysql -p, psql +# password, and sshpass detection, rejecting safe commands. +# matchesExclude validates exact filename and directory segment +# matching without false positives on partial or embedded +# substrings. permissionSeverity confirms svInfo for nonexistent +# paths. parseModules tests single, multiple, whitespace-padded, +# full-set, empty, and unknown module string parsing. redactLine +# checks export-prefixed quoted, unquoted, and single-quoted value +# redaction plus passthrough for lines without an equals sign. +# +# Connects to: +# types.nim - Category enum values for parseModules +# collectors/base.nim - redactValue, matchesExclude, +# permissionSeverity +# collectors/ssh.nim - isPrivateKey, isEncrypted +# collectors/history.nim - matchesSecretPattern, +# matchesCommandPattern, redactLine +# harvester.nim - parseModules import std/[unittest, strutils] import types diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.gitignore b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.gitignore new file mode 100644 index 00000000..faf22360 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.gitignore @@ -0,0 +1,7 @@ +bin/ +coverage.out +coverage.html +*.db +.bomber/ +dist/ +docs/ diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.golangci.yml b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.golangci.yml new file mode 100644 index 00000000..794b0097 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/.golangci.yml @@ -0,0 +1,31 @@ +# ©AngelaMos | 2026 +# .golangci.yml + +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - staticcheck + - unused + - gosimple + - ineffassign + - typecheck + - gofmt + - gocritic + - gosec + - misspell + - unconvert + - unparam + - prealloc + +linters-settings: + gocritic: + enabled-tags: + - diagnostic + - performance + gosec: + excludes: + - G304 diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/Justfile b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/Justfile new file mode 100644 index 00000000..1d70b942 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/Justfile @@ -0,0 +1,107 @@ +# ©AngelaMos | 2026 +# Justfile + +set export +set shell := ["bash", "-uc"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` + +default: + @just --list --unsorted + +[group('lint')] +lint *ARGS: + golangci-lint run --timeout=5m {{ARGS}} + +[group('lint')] +lint-fix: + golangci-lint run --timeout=5m --fix + +[group('lint')] +format: + golangci-lint fmt + +[group('lint')] +tidy: + go mod tidy + +[group('lint')] +vet: + go vet ./... + +[group('test')] +test *ARGS: + go test -race ./... {{ARGS}} + +[group('test')] +test-v *ARGS: + go test -race -v ./... {{ARGS}} + +[group('test')] +cover: + go test -race -cover ./... + +[group('test')] +cover-html: + go test -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +[group('ci')] +ci: lint test + @echo "All checks passed." + +[group('ci')] +check: lint vet + +[group('dev')] +run *ARGS: + go run ./cmd/bomber {{ARGS}} + +[group('dev')] +dev-scan: + go run ./cmd/bomber scan . + +[group('dev')] +dev-generate: + go run ./cmd/bomber generate . --format cyclonedx + +[group('dev')] +dev-vuln: + go run ./cmd/bomber vuln . + +[group('dev')] +dev-check: + go run ./cmd/bomber check . --policy policy.yaml + +[group('prod')] +build: + go build -ldflags="-s -w" -o bin/bomber ./cmd/bomber + @echo "Built: bin/bomber ($(du -h bin/bomber | cut -f1))" + +[group('prod')] +build-debug: + go build -o bin/bomber ./cmd/bomber + +[group('prod')] +install: + go install ./cmd/bomber + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "Go: $(go version | cut -d' ' -f3)" + @echo "OS: {{os()}} ({{arch()}})" + @echo "Module: $(head -1 go.mod | cut -d' ' -f2)" + +[group('util')] +update: + go get -u ./... + go mod tidy + +[group('util')] +clean: + -rm -rf bin/ coverage.out coverage.html + @echo "Cleaned build artifacts." diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/LICENSE b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md new file mode 100644 index 00000000..b421da58 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md @@ -0,0 +1,147 @@ +```regex +██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗██████╗ +██╔══██╗██╔═══██╗████╗ ████║██╔══██╗██╔════╝██╔══██╗ +██████╔╝██║ ██║██╔████╔██║██████╔╝█████╗ ██████╔╝ +██╔══██╗██║ ██║██║╚██╔╝██║██╔══██╗██╔══╝ ██╔══██╗ +██████╔╝╚██████╔╝██║ ╚═╝ ██║██████╔╝███████╗██║ ██║ +╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2340-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/sbom-generator-vulnerability-matcher) +[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) + +> SBOM generator and vulnerability matcher that scans Go, Node.js, and Python projects, produces SPDX 2.3 and CycloneDX 1.5 documents, and cross-references packages against OSV and NVD vulnerability databases. + +*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Multi-ecosystem dependency scanning (Go `go.mod`/`go.sum`, Node.js `package.json`/`pnpm-lock.yaml`, Python `pyproject.toml`/`uv.lock`) +- Dependency graph construction with cycle detection and depth tracking +- SBOM generation in SPDX 2.3 and CycloneDX 1.5 JSON formats +- Vulnerability matching via OSV batch API (primary) and NVD REST API (optional) +- SQLite-backed response cache with configurable TTL +- Policy engine for CI/CD gates with severity thresholds and dependency depth limits +- Monorepo support with recursive ecosystem detection + +## Quick Start + +```bash +go install github.com/CarterPerez-dev/bomber/cmd/bomber@latest +``` + +Or use the install script: + +```bash +curl -fsSL https://raw.githubusercontent.com/CarterPerez-dev/Cybersecurity-Projects/main/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/install.sh | bash +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +### Usage + +```bash +bomber scan ./my-project # scan dependencies +bomber generate ./my-project --format spdx # SPDX 2.3 SBOM +bomber generate ./my-project --format cyclonedx # CycloneDX 1.5 SBOM +bomber vuln ./my-project # vulnerability scan +bomber check ./my-project --policy policy.yaml # CI/CD policy gate +``` + +### Policy File + +```yaml +max_severity: medium +max_depth: 5 +``` + +`bomber check` exits with code 1 when violations are found — drop it into any CI pipeline. + +## Supported Ecosystems + +| Ecosystem | Manifest | Lockfile | +|-----------|----------|----------| +| Go | `go.mod` | `go.sum` | +| Node.js | `package.json` | `pnpm-lock.yaml` | +| Python | `pyproject.toml` | `uv.lock` | + +## Architecture + +``` +bomber scan ./project/ + +┌─────────────────────────────────────────────────┐ +│ CLI (cobra) │ +│ scan • generate • vuln • check │ +└──────────────────────┬──────────────────────────┘ + │ + ┌─────────────▼─────────────┐ + │ Scanner Engine │ + │ walks dir, detects │ + │ ecosystems, dispatches │ + └─────────────┬─────────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │Go Parser│ │Node │ │Python │ + │go.mod │ │pnpm-lock│ │uv.lock │ + │go.sum │ │pkg.json │ │pyproject│ + └────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + └──────────────┼──────────────┘ + ▼ + ┌─────────────────────────┐ + │ Dependency Graph │ + │ direct + transitive │ + │ cycle detection │ + └────────────┬────────────┘ + │ + ┌─────────┼─────────┐ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ SPDX 2.3 │ │ CycloneDX │ + │ Generator │ │ 1.5 Generator│ + └──────┬───────┘ └──────┬───────┘ + │ │ + └─────────┬─────────┘ + ▼ + ┌─────────────────────┐ + │ Vulnerability │ + │ Matcher │ + │ OSV API (primary) │ + │ NVD API (optional) │ + └─────────┬───────────┘ + ▼ + ┌─────────────────────┐ + │ Policy Engine │ + │ --check mode │ + │ exit code 0 or 1 │ + └─────────────────────┘ +``` + +## Stack + +**Language:** Go 1.25 + +**Dependencies:** cobra (CLI), fatih/color (terminal), go-toml/v2 (TOML), yaml.v3 (YAML), modernc.org/sqlite (cache), google/uuid (CycloneDX), testify (tests) + +## Learn + +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | SBOMs, supply chain security, and vulnerability databases | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | + +## License + +AGPL 3.0 diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/cmd/bomber/main.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/cmd/bomber/main.go new file mode 100644 index 00000000..43a543f0 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/cmd/bomber/main.go @@ -0,0 +1,10 @@ +// ©AngelaMos | 2026 +// main.go + +package main + +import "github.com/CarterPerez-dev/bomber/internal/cli" + +func main() { + cli.Execute() +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.mod b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.mod new file mode 100644 index 00000000..d93ced9a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.mod @@ -0,0 +1,29 @@ +module github.com/CarterPerez-dev/bomber + +go 1.25.0 + +require ( + github.com/fatih/color v1.19.0 + github.com/google/uuid v1.6.0 + github.com/pelletier/go-toml/v2 v2.3.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.48.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.sum b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.sum new file mode 100644 index 00000000..124a343a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/go.sum @@ -0,0 +1,76 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= +modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/install.sh b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/install.sh new file mode 100755 index 00000000..d7ee3690 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/install.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh + +set -euo pipefail + +REPO_OWNER="CarterPerez-dev" +REPO_NAME="bomber" +BINARY="bomber" +INSTALL_DIR="${BOMBER_INSTALL_DIR:-$HOME/.bomber/bin}" +VERSION="${BOMBER_VERSION:-}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +info() { echo -e " ${GREEN}+${NC} $1"; } +warn() { echo -e " ${YELLOW}!${NC} $1"; } +fail() { echo -e " ${RED}x${NC} $1"; exit 1; } +header() { echo -e "\n${BOLD}${CYAN}--- $1 ---${NC}\n"; } + +TMP_DIR="" +cleanup() { [[ -n "$TMP_DIR" ]] && rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +echo -e "${BOLD}" +echo -e " ${RED} ██▄ ▄▀▄ █▄ ▄█ ██▄ ██▀ █▀▄ ${NC}" +echo -e " ${CYAN} █▄█ ▀▄▀ █ ▀ █ █▄█ █▄▄ █▀▄ ${NC}" +echo -e "${NC}" +echo -e " ${DIM}SBOM generator & vulnerability matcher${NC}" + +header "Detecting system" + +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + MINGW*|MSYS*|CYGWIN*) fail "Windows is not supported. Use: go install github.com/${REPO_OWNER}/${REPO_NAME}/cmd/bomber@latest" ;; + *) fail "Unsupported OS: $OS" ;; +esac + +case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) fail "Unsupported architecture: $ARCH" ;; +esac + +info "System: ${OS}/${ARCH}" + +if [[ -z "$VERSION" ]]; then + header "Fetching latest release" + + if command -v curl &>/dev/null; then + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" 2>/dev/null \ + | grep '"tag_name":' \ + | sed -E 's/.*"([^"]+)".*/\1/') || true + elif command -v wget &>/dev/null; then + VERSION=$(wget -qO- "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" 2>/dev/null \ + | grep '"tag_name":' \ + | sed -E 's/.*"([^"]+)".*/\1/') || true + fi +fi + +INSTALLED=false + +if [[ -n "$VERSION" ]]; then + info "Version: ${VERSION}" + header "Downloading pre-built binary" + + ARCHIVE="${BINARY}_${VERSION#v}_${OS}_${ARCH}.tar.gz" + URL="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${VERSION}/${ARCHIVE}" + + TMP_DIR=$(mktemp -d) + + DOWNLOAD_OK=false + if command -v curl &>/dev/null; then + curl -fsSL "$URL" -o "$TMP_DIR/archive.tar.gz" 2>/dev/null && DOWNLOAD_OK=true + elif command -v wget &>/dev/null; then + wget -q "$URL" -O "$TMP_DIR/archive.tar.gz" 2>/dev/null && DOWNLOAD_OK=true + else + fail "Neither curl nor wget found" + fi + + if [[ "$DOWNLOAD_OK" == "true" ]]; then + tar -xzf "$TMP_DIR/archive.tar.gz" -C "$TMP_DIR" + mkdir -p "$INSTALL_DIR" + mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/" + chmod +x "$INSTALL_DIR/$BINARY" + INSTALLED=true + info "Installed to ${INSTALL_DIR}/${BINARY}" + else + warn "Pre-built binary not available for ${OS}/${ARCH}" + fi +fi + +if [[ "$INSTALLED" == "false" ]]; then + if command -v go &>/dev/null; then + GO_VER=$(go version | awk '{print $3}') + header "Building from source (${GO_VER})" + info "Running go install..." + GOBIN="$INSTALL_DIR" go install "github.com/${REPO_OWNER}/${REPO_NAME}/cmd/bomber@latest" + INSTALLED=true + info "Installed to ${INSTALL_DIR}/${BINARY}" + else + echo "" + fail "No pre-built binary and Go is not installed. + + Option 1 — Install Go, then: + go install github.com/${REPO_OWNER}/${REPO_NAME}/cmd/bomber@latest + + Option 2 — Install Go: + https://go.dev/dl/" + fi +fi + +header "Configuring PATH" + +PATH_UPDATED=false + +case ":$PATH:" in + *":${INSTALL_DIR}:"*) + info "${INSTALL_DIR} already in PATH" + PATH_UPDATED=true + ;; +esac + +if [[ "$PATH_UPDATED" == "false" ]]; then + CURRENT_SHELL="$(basename "${SHELL:-/bin/bash}")" + TARGET="" + + case "$CURRENT_SHELL" in + zsh) + [[ -f "$HOME/.zshrc" ]] && TARGET="$HOME/.zshrc" + ;; + bash) + if [[ -f "$HOME/.bashrc" ]]; then + TARGET="$HOME/.bashrc" + elif [[ -f "$HOME/.bash_profile" ]]; then + TARGET="$HOME/.bash_profile" + fi + ;; + fish) + mkdir -p "$HOME/.config/fish/conf.d" + echo "set -gx PATH \"$INSTALL_DIR\" \$PATH" > "$HOME/.config/fish/conf.d/bomber.fish" + info "Added to ~/.config/fish/conf.d/bomber.fish" + PATH_UPDATED=true + ;; + esac + + if [[ "$PATH_UPDATED" == "false" && -z "${TARGET:-}" ]]; then + [[ -f "$HOME/.profile" ]] && TARGET="$HOME/.profile" + fi + + if [[ "$PATH_UPDATED" == "false" && -n "${TARGET:-}" ]]; then + if ! grep -q "$INSTALL_DIR" "$TARGET" 2>/dev/null; then + printf '\nexport PATH="%s:$PATH"\n' "$INSTALL_DIR" >> "$TARGET" + info "Added to ${TARGET}" + else + info "Already configured in ${TARGET}" + fi + fi +fi + +echo "" +echo -e " ${GREEN}${BOLD}bomber installed successfully${NC}" +echo "" + +if ! command -v bomber &>/dev/null; then + warn "Restart your shell or run:" + echo -e " ${BOLD}export PATH=\"${INSTALL_DIR}:\$PATH\"${NC}" + echo "" +fi + +echo -e " ${DIM}Quick start:${NC}" +echo "" +echo -e " ${CYAN}bomber scan [path]${NC} Scan dependencies" +echo -e " ${CYAN}bomber generate [path] --format spdx${NC} Generate SPDX SBOM" +echo -e " ${CYAN}bomber vuln [path]${NC} Check for vulnerabilities" +echo -e " ${CYAN}bomber check [path] --policy p.yaml${NC} CI/CD policy gate" +echo "" +echo -e " ${DIM}Docs: https://github.com/${REPO_OWNER}/Cybersecurity-Projects${NC}" +echo "" diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/check.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/check.go new file mode 100644 index 00000000..089d19e2 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/check.go @@ -0,0 +1,85 @@ +// ©AngelaMos | 2026 +// check.go + +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/CarterPerez-dev/bomber/internal/policy" + "github.com/CarterPerez-dev/bomber/internal/report" + "github.com/CarterPerez-dev/bomber/internal/scanner" + "github.com/CarterPerez-dev/bomber/internal/ui" +) + +var policyFile string + +var checkCmd = &cobra.Command{ + Use: "check [path]", + Short: "Evaluate SBOM against a policy", + Args: cobra.MaximumNArgs(1), + RunE: runCheck, +} + +func init() { + checkCmd.Flags().StringVarP( + &policyFile, "policy", "p", "policy.yaml", + "policy file path", + ) +} + +func runCheck(cmd *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + pol, err := policy.LoadPolicy(policyFile) + if err != nil { + return fmt.Errorf("load policy: %w", err) + } + + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := scanner.New(reg) + result, err := s.Scan(path) + if err != nil { + return err + } + + sp := ui.NewSpinner("Querying vulnerability databases...") + if formatFlag != "json" { + sp.Start() + } + + vulnReport, err := queryVulns(cmd.Context(), result) + + if formatFlag != "json" { + sp.Stop() + } + if err != nil { + return err + } + + checkResult := policy.Evaluate(pol, vulnReport, result.Graphs) + + if formatFlag == "json" { + return report.WriteJSON(os.Stdout, result, vulnReport, checkResult) + } + + ui.PrintBanner() + report.PrintScanSummary(os.Stdout, result) + report.PrintVulnReport(os.Stdout, vulnReport) + report.PrintCheckResult(os.Stdout, checkResult) + + if !checkResult.Passed { + os.Exit(1) + } + + return nil +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/generate.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/generate.go new file mode 100644 index 00000000..e6b54e5d --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/generate.go @@ -0,0 +1,86 @@ +// ©AngelaMos | 2026 +// generate.go + +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/CarterPerez-dev/bomber/internal/sbom" + "github.com/CarterPerez-dev/bomber/internal/scanner" + "github.com/CarterPerez-dev/bomber/internal/ui" +) + +var ( + outputFile string + sbomFormatFlag string +) + +var generateCmd = &cobra.Command{ + Use: "generate [path]", + Short: "Generate an SBOM document", + Args: cobra.MaximumNArgs(1), + RunE: runGenerate, +} + +func init() { + generateCmd.Flags().StringVarP( + &outputFile, "output", "o", "", + "output file (default: stdout)", + ) + generateCmd.Flags().StringVar( + &sbomFormatFlag, "sbom-format", "cyclonedx", + "SBOM format: spdx, cyclonedx", + ) +} + +func runGenerate(_ *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := scanner.New(reg) + result, err := s.Scan(path) + if err != nil { + return err + } + + if len(result.Graphs) == 0 { + return fmt.Errorf("no ecosystems detected in %s", path) + } + + var data []byte + switch sbomFormatFlag { + case "spdx": + gen := sbom.NewSPDXGenerator() + data, err = gen.Generate(result.Graphs) + case "cyclonedx": + gen := sbom.NewCycloneDXGenerator() + data, err = gen.Generate(result.Graphs) + default: + return fmt.Errorf("unknown SBOM format: %s (use spdx or cyclonedx)", sbomFormatFlag) + } + if err != nil { + return err + } + + if outputFile != "" { + if err := os.WriteFile(outputFile, data, 0o644); err != nil { + return fmt.Errorf("write output: %w", err) + } + fmt.Fprintf(os.Stderr, " %s SBOM written to %s (%s)\n", + ui.Check, outputFile, sbomFormatFlag) + return nil + } + + _, err = os.Stdout.Write(data) + return err +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/root.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/root.go new file mode 100644 index 00000000..cca51a51 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/root.go @@ -0,0 +1,97 @@ +// ©AngelaMos | 2026 +// root.go + +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/bomber/internal/config" + "github.com/CarterPerez-dev/bomber/internal/ui" +) + +var ( + formatFlag string + verbose bool + noColor bool + noCache bool +) + +var rootCmd = &cobra.Command{ + Use: "bomber", + Short: "SBOM generator & vulnerability matcher", + Version: config.ToolVersion, + Long: `Bomber scans project directories for dependencies, generates SBOM +documents in SPDX 2.3 and CycloneDX 1.5 formats, and cross-references +packages against vulnerability databases.`, + SilenceUsage: true, + SilenceErrors: true, +} + +func Execute() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "%s %s\n", + ui.Cross, ui.Red(err.Error())) + os.Exit(1) + } +} + +func run() error { + ctx, cancel := signal.NotifyContext( + context.Background(), os.Interrupt, syscall.SIGTERM, + ) + defer cancel() + + return rootCmd.ExecuteContext(ctx) +} + +func init() { + cobra.OnInitialize(initGlobals) + + rootCmd.PersistentFlags().StringVarP( + &formatFlag, "format", "f", "terminal", + "output format: terminal, json", + ) + rootCmd.PersistentFlags().BoolVarP( + &verbose, "verbose", "v", false, + "verbose output", + ) + rootCmd.PersistentFlags().BoolVar( + &noColor, "no-color", false, + "disable colored output", + ) + rootCmd.PersistentFlags().BoolVar( + &noCache, "no-cache", false, + "disable vulnerability cache", + ) + + rootCmd.AddCommand(scanCmd) + rootCmd.AddCommand(generateCmd) + rootCmd.AddCommand(vulnCmd) + rootCmd.AddCommand(checkCmd) + + defaultHelp := rootCmd.HelpFunc() + rootCmd.SetHelpFunc( + func(cmd *cobra.Command, args []string) { + if cmd.Root() == cmd { + ui.PrintBannerWithArt() + } else { + ui.PrintBanner() + } + defaultHelp(cmd, args) + }, + ) +} + +func initGlobals() { + if noColor { + color.NoColor = true + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/scan.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/scan.go new file mode 100644 index 00000000..922f49dd --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/scan.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// scan.go + +package cli + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/CarterPerez-dev/bomber/internal/report" + "github.com/CarterPerez-dev/bomber/internal/scanner" + "github.com/CarterPerez-dev/bomber/internal/ui" +) + +var scanCmd = &cobra.Command{ + Use: "scan [path]", + Short: "Scan a directory for dependencies", + Args: cobra.MaximumNArgs(1), + RunE: runScan, +} + +func runScan(_ *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := scanner.New(reg) + result, err := s.Scan(path) + if err != nil { + return err + } + + if formatFlag == "json" { + return report.WriteJSON(os.Stdout, result, nil, nil) + } + + ui.PrintBanner() + report.PrintScanSummary(os.Stdout, result) + return nil +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/vuln.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/vuln.go new file mode 100644 index 00000000..b9490a78 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/cli/vuln.go @@ -0,0 +1,189 @@ +// ©AngelaMos | 2026 +// vuln.go + +package cli + +import ( + "context" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/bomber/internal/graph" + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/CarterPerez-dev/bomber/internal/report" + "github.com/CarterPerez-dev/bomber/internal/scanner" + "github.com/CarterPerez-dev/bomber/internal/ui" + "github.com/CarterPerez-dev/bomber/internal/vuln" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +var vulnCmd = &cobra.Command{ + Use: "vuln [path]", + Short: "Scan for known vulnerabilities", + Args: cobra.MaximumNArgs(1), + RunE: runVuln, +} + +func runVuln(cmd *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := scanner.New(reg) + result, err := s.Scan(path) + if err != nil { + return err + } + + if len(result.Graphs) == 0 { + if formatFlag == "json" { + return report.WriteJSON(os.Stdout, result, nil, nil) + } + ui.PrintBanner() + report.PrintScanSummary(os.Stdout, result) + return nil + } + + sp := ui.NewSpinner("Querying vulnerability databases...") + if formatFlag != "json" { + sp.Start() + } + + vulnReport, err := queryVulns(cmd.Context(), result) + + if formatFlag != "json" { + sp.Stop() + } + if err != nil { + return err + } + + if formatFlag == "json" { + return report.WriteJSON(os.Stdout, result, vulnReport, nil) + } + + ui.PrintBanner() + report.PrintScanSummary(os.Stdout, result) + report.PrintVulnReport(os.Stdout, vulnReport) + return nil +} + +func queryVulns(ctx context.Context, result *types.ScanResult) (*types.VulnReport, error) { + var allPkgs []types.Package + for _, g := range result.Graphs { + allPkgs = append(allPkgs, graph.AllPackages(g)...) + } + + var cache *vuln.Cache + if !noCache { + c, err := vuln.NewCache(vuln.DefaultCachePath(), 24*time.Hour) + if err == nil { + cache = c + defer cache.Close() + } + } + + osvClient := vuln.NewOSVClient() + var clients []vuln.Client + clients = append(clients, osvClient) + + nvdKey := os.Getenv("BOMBER_NVD_API_KEY") + if nvdKey != "" { + clients = append(clients, vuln.NewNVDClient(vuln.WithNVDAPIKey(nvdKey))) + } + + vulnReport := &types.VulnReport{ + TotalPkgs: result.TotalPkgs, + DirectPkgs: result.DirectPkgs, + BySeverity: make(map[types.Severity]int), + } + + for _, client := range clients { + var uncached []types.Package + for _, pkg := range allPkgs { + if cache != nil { + cached, ok, err := cache.Get(pkg.PURL, client.Source()) + if err == nil && ok { + vulnReport.Matches = append(vulnReport.Matches, cached...) + for _, m := range cached { + vulnReport.BySeverity[m.Vulnerability.Severity]++ + } + continue + } + } + uncached = append(uncached, pkg) + } + + if len(uncached) == 0 { + continue + } + + matches, err := client.Query(ctx, uncached) + if err != nil { + continue + } + + matchesByPURL := make(map[string][]types.VulnMatch) + for _, m := range matches { + vulnReport.Matches = append(vulnReport.Matches, m) + vulnReport.BySeverity[m.Vulnerability.Severity]++ + matchesByPURL[m.Package.PURL] = append(matchesByPURL[m.Package.PURL], m) + } + + if cache != nil { + for purl, pkgMatches := range matchesByPURL { + _ = cache.Put(purl, client.Source(), pkgMatches) + } + } + } + + vulnReport.Matches = deduplicateMatches(vulnReport.Matches) + vulnReport.BySeverity = make(map[types.Severity]int) + for _, m := range vulnReport.Matches { + vulnReport.BySeverity[m.Vulnerability.Severity]++ + } + + return vulnReport, nil +} + +func deduplicateMatches(matches []types.VulnMatch) []types.VulnMatch { + seen := make(map[string]int) + var deduped []types.VulnMatch + + for _, m := range matches { + ids := make([]string, 0, 1+len(m.Vulnerability.Aliases)) + ids = append(ids, m.Vulnerability.ID) + ids = append(ids, m.Vulnerability.Aliases...) + + existingIdx := -1 + for _, id := range ids { + if idx, ok := seen[id]; ok { + existingIdx = idx + break + } + } + + if existingIdx >= 0 { + existing := deduped[existingIdx] + if m.Vulnerability.Score > existing.Vulnerability.Score || + (m.Vulnerability.FixVersion != "" && existing.Vulnerability.FixVersion == "") { + deduped[existingIdx] = m + } + continue + } + + idx := len(deduped) + for _, id := range ids { + seen[id] = idx + } + deduped = append(deduped, m) + } + + return deduped +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/config/config.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/config/config.go new file mode 100644 index 00000000..cb93ebbc --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/config/config.go @@ -0,0 +1,30 @@ +// ©AngelaMos | 2026 +// config.go + +package config + +import "time" + +const ( + ToolName = "bomber" + ToolVersion = "0.1.0" + ToolVendor = "CarterPerez-dev" + + DefaultCacheTTLHours = 24 + DefaultCachePath = ".bomber/cache.db" + + OSVBaseURL = "https://api.osv.dev" + NVDBaseURL = "https://services.nvd.nist.gov/rest/json/cves/2.0" + HTTPTimeout = 30 * time.Second + + OSVBatchSize = 1000 + OSVSourceName = "osv" + NVDSourceName = "nvd" + NVDRateWithKey = 200 * time.Millisecond + NVDRateWithoutKey = 1700 * time.Millisecond + + SPDXVersion = "SPDX-2.3" + SPDXDataLicense = "CC0-1.0" + CycloneDXFormat = "CycloneDX" + CycloneDXVersion = "1.5" +) diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph.go new file mode 100644 index 00000000..16cba809 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph.go @@ -0,0 +1,111 @@ +// ©AngelaMos | 2026 +// graph.go + +package graph + +import "github.com/CarterPerez-dev/bomber/pkg/types" + +func AllPackages(g *types.DependencyGraph) []types.Package { + pkgs := make([]types.Package, 0, len(g.Nodes)) + for _, pkg := range g.Nodes { + pkgs = append(pkgs, pkg) + } + return pkgs +} + +func DirectPackages(g *types.DependencyGraph) []types.Package { + var pkgs []types.Package + for _, pkg := range g.Nodes { + if pkg.Direct && pkg.PURL != g.Root.PURL { + pkgs = append(pkgs, pkg) + } + } + return pkgs +} + +func TransitivePackages(g *types.DependencyGraph) []types.Package { + var pkgs []types.Package + for _, pkg := range g.Nodes { + if !pkg.Direct && pkg.PURL != g.Root.PURL { + pkgs = append(pkgs, pkg) + } + } + return pkgs +} + +func MaxDepth(g *types.DependencyGraph) int { + maxVal := 0 + for _, pkg := range g.Nodes { + if pkg.DepthLevel > maxVal { + maxVal = pkg.DepthLevel + } + } + return maxVal +} + +func DetectCycles(g *types.DependencyGraph) [][]string { + var cycles [][]string + visited := make(map[string]bool) + inStack := make(map[string]bool) + + var dfs func(purl string, path []string) + dfs = func(purl string, path []string) { + if inStack[purl] { + for i, p := range path { + if p == purl { + cycle := make([]string, len(path)-i) + copy(cycle, path[i:]) + cycles = append(cycles, cycle) + return + } + } + return + } + if visited[purl] { + return + } + + visited[purl] = true + inStack[purl] = true + path = append(path, purl) + + for _, child := range g.Edges[purl] { + dfs(child, path) + } + + inStack[purl] = false + } + + for purl := range g.Nodes { + if !visited[purl] { + dfs(purl, nil) + } + } + + return cycles +} + +func MergeGraphs(graphs []*types.DependencyGraph) *types.DependencyGraph { + if len(graphs) == 0 { + root := types.Package{Name: "merged", PURL: "pkg:merged/root"} + return types.NewDependencyGraph(root) + } + if len(graphs) == 1 { + return graphs[0] + } + + root := types.Package{Name: "merged", PURL: "pkg:merged/root"} + merged := types.NewDependencyGraph(root) + + for _, g := range graphs { + for purl, pkg := range g.Nodes { + merged.Nodes[purl] = pkg + } + for parent, children := range g.Edges { + merged.Edges[parent] = append(merged.Edges[parent], children...) + } + merged.AddEdge(root.PURL, g.Root.PURL) + } + + return merged +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph_test.go new file mode 100644 index 00000000..5af8e9bb --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/graph/graph_test.go @@ -0,0 +1,101 @@ +// ©AngelaMos | 2026 +// graph_test.go + +package graph + +import ( + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeTestGraph() *types.DependencyGraph { + root := types.Package{Name: "root", PURL: "pkg:test/root@1.0.0"} + g := types.NewDependencyGraph(root) + + a := types.Package{Name: "a", PURL: "pkg:test/a@1.0.0", Direct: true, DepthLevel: 1} + b := types.Package{Name: "b", PURL: "pkg:test/b@1.0.0", Direct: true, DepthLevel: 1} + c := types.Package{Name: "c", PURL: "pkg:test/c@1.0.0", Direct: false, DepthLevel: 2} + + g.AddPackage(a) + g.AddPackage(b) + g.AddPackage(c) + g.AddEdge(root.PURL, a.PURL) + g.AddEdge(root.PURL, b.PURL) + g.AddEdge(a.PURL, c.PURL) + g.AddEdge(b.PURL, c.PURL) + + return g +} + +func TestAllPackages(t *testing.T) { + g := makeTestGraph() + pkgs := AllPackages(g) + assert.Len(t, pkgs, 4) +} + +func TestDirectPackages(t *testing.T) { + g := makeTestGraph() + pkgs := DirectPackages(g) + assert.Len(t, pkgs, 2) +} + +func TestTransitivePackages(t *testing.T) { + g := makeTestGraph() + pkgs := TransitivePackages(g) + assert.Len(t, pkgs, 1) +} + +func TestMaxDepth(t *testing.T) { + g := makeTestGraph() + assert.Equal(t, 2, MaxDepth(g)) +} + +func TestDetectCyclesNone(t *testing.T) { + g := makeTestGraph() + cycles := DetectCycles(g) + assert.Empty(t, cycles) +} + +func TestDetectCyclesFound(t *testing.T) { + root := types.Package{Name: "root", PURL: "pkg:test/root@1.0.0"} + g := types.NewDependencyGraph(root) + + a := types.Package{Name: "a", PURL: "pkg:test/a@1.0.0"} + b := types.Package{Name: "b", PURL: "pkg:test/b@1.0.0"} + + g.AddPackage(a) + g.AddPackage(b) + g.AddEdge(root.PURL, a.PURL) + g.AddEdge(a.PURL, b.PURL) + g.AddEdge(b.PURL, a.PURL) + + cycles := DetectCycles(g) + require.NotEmpty(t, cycles) +} + +func TestMergeGraphs(t *testing.T) { + g1 := makeTestGraph() + + root2 := types.Package{Name: "root2", PURL: "pkg:test/root2@1.0.0"} + g2 := types.NewDependencyGraph(root2) + d := types.Package{Name: "d", PURL: "pkg:test/d@1.0.0"} + g2.AddPackage(d) + g2.AddEdge(root2.PURL, d.PURL) + + merged := MergeGraphs([]*types.DependencyGraph{g1, g2}) + assert.Len(t, AllPackages(merged), 7) +} + +func TestMergeGraphsEmpty(t *testing.T) { + merged := MergeGraphs(nil) + assert.Equal(t, "merged", merged.Root.Name) +} + +func TestMergeGraphsSingle(t *testing.T) { + g := makeTestGraph() + merged := MergeGraphs([]*types.DependencyGraph{g}) + assert.Equal(t, g, merged) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod.go new file mode 100644 index 00000000..584a7647 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod.go @@ -0,0 +1,227 @@ +// ©AngelaMos | 2026 +// gomod.go + +package parser + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type GoModParser struct{} + +func NewGoModParser() *GoModParser { + return &GoModParser{} +} + +func (p *GoModParser) Ecosystem() types.Ecosystem { + return types.EcosystemGo +} + +func (p *GoModParser) Detect(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "go.mod")) + return err == nil +} + +func (p *GoModParser) Parse(dir string) (*types.DependencyGraph, error) { + modPath := filepath.Join(dir, "go.mod") + modFile, err := os.Open(modPath) + if err != nil { + return nil, fmt.Errorf("open go.mod: %w", err) + } + defer modFile.Close() + + var moduleName string + directDeps := make(map[string]string) + indirectDeps := make(map[string]string) + + scanner := bufio.NewScanner(modFile) + inRequire := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "module ") { + moduleName = strings.TrimPrefix(line, "module ") + continue + } + + if line == "require (" { + inRequire = true + continue + } + if line == ")" { + inRequire = false + continue + } + + if inRequire { + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + name := parts[0] + version := parts[1] + if strings.Contains(line, "// indirect") { + indirectDeps[name] = version + } else { + directDeps[name] = version + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read go.mod: %w", err) + } + + root := types.Package{ + Name: moduleName, + Ecosystem: types.EcosystemGo, + PURL: fmt.Sprintf("pkg:golang/%s", moduleName), + Direct: true, + } + graph := types.NewDependencyGraph(root) + + checksums := parseGoSum(filepath.Join(dir, "go.sum")) + + for name, version := range directDeps { + purl := fmt.Sprintf("pkg:golang/%s@%s", name, version) + pkg := types.Package{ + Name: name, + Version: version, + Ecosystem: types.EcosystemGo, + PURL: purl, + Direct: true, + Checksums: checksums[name+"@"+version], + } + graph.AddPackage(pkg) + graph.AddEdge(root.PURL, purl) + } + + for name, version := range indirectDeps { + purl := fmt.Sprintf("pkg:golang/%s@%s", name, version) + pkg := types.Package{ + Name: name, + Version: version, + Ecosystem: types.EcosystemGo, + PURL: purl, + Direct: false, + Checksums: checksums[name+"@"+version], + } + graph.AddPackage(pkg) + } + + parseGoModGraph(dir, moduleName, graph) + computeDepthLevels(graph) + + return graph, nil +} + +func parseGoModGraph(dir, moduleName string, graph *types.DependencyGraph) { + cmd := exec.Command("go", "mod", "graph") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return + } + + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + parts := strings.Fields(scanner.Text()) + if len(parts) != 2 { + continue + } + parentPURL := goDepToPURL(parts[0], moduleName) + childPURL := goDepToPURL(parts[1], moduleName) + + if _, exists := graph.Nodes[childPURL]; !exists { + continue + } + if _, exists := graph.Nodes[parentPURL]; !exists { + continue + } + + graph.AddEdge(parentPURL, childPURL) + } +} + +func goDepToPURL(dep, moduleName string) string { + if !strings.Contains(dep, "@") { + return fmt.Sprintf("pkg:golang/%s", dep) + } + parts := strings.SplitN(dep, "@", 2) + return fmt.Sprintf("pkg:golang/%s@%s", parts[0], parts[1]) +} + +func computeDepthLevels(graph *types.DependencyGraph) { + depths := make(map[string]int) + depths[graph.Root.PURL] = 0 + queue := []string{graph.Root.PURL} + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + currentDepth := depths[current] + + for _, child := range graph.Edges[current] { + if _, visited := depths[child]; !visited { + depths[child] = currentDepth + 1 + queue = append(queue, child) + } + } + } + + for purl, depth := range depths { + if pkg, ok := graph.Nodes[purl]; ok { + pkg.DepthLevel = depth + graph.Nodes[purl] = pkg + } + } +} + +func parseGoSum(path string) map[string][]types.Checksum { + checksums := make(map[string][]types.Checksum) + + f, err := os.Open(path) + if err != nil { + return checksums + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + parts := strings.Fields(scanner.Text()) + if len(parts) != 3 { + continue + } + name := parts[0] + version := strings.TrimSuffix(parts[1], "/go.mod") + hash := parts[2] + + if !strings.HasPrefix(hash, "h1:") { + continue + } + raw := strings.TrimPrefix(hash, "h1:") + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + continue + } + + key := name + "@" + version + checksums[key] = append(checksums[key], types.Checksum{ + Algorithm: "SHA-256", + Value: hex.EncodeToString(decoded), + }) + } + + return checksums +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod_test.go new file mode 100644 index 00000000..a9663079 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/gomod_test.go @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// gomod_test.go + +package parser + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testdataDir(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Join(filepath.Dir(filename), "..", "..", "testdata") +} + +func TestGoModDetect(t *testing.T) { + p := NewGoModParser() + assert.True(t, p.Detect(filepath.Join(testdataDir(t), "go-project"))) + assert.False(t, p.Detect(filepath.Join(testdataDir(t), "node-project"))) + assert.False(t, p.Detect(filepath.Join(testdataDir(t), "empty-project"))) +} + +func TestGoModParse(t *testing.T) { + p := NewGoModParser() + dir := filepath.Join(testdataDir(t), "go-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + assert.Equal(t, "example.com/testproject", graph.Root.Name) + assert.Equal(t, types.EcosystemGo, graph.Root.Ecosystem) + + directCount := 0 + for _, pkg := range graph.Nodes { + if pkg.Direct && pkg.PURL != graph.Root.PURL { + directCount++ + } + } + assert.Equal(t, 2, directCount) + + _, hasCobra := graph.Nodes["pkg:golang/github.com/spf13/cobra@v1.10.2"] + assert.True(t, hasCobra) + + _, hasNet := graph.Nodes["pkg:golang/golang.org/x/net@v0.1.0"] + assert.True(t, hasNet) + + _, hasMousetrap := graph.Nodes["pkg:golang/github.com/inconshreveable/mousetrap@v1.1.0"] + assert.True(t, hasMousetrap) +} + +func TestGoModParseChecksums(t *testing.T) { + p := NewGoModParser() + dir := filepath.Join(testdataDir(t), "go-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + cobra := graph.Nodes["pkg:golang/github.com/spf13/cobra@v1.10.2"] + assert.NotEmpty(t, cobra.Checksums) +} + +func TestGoModEcosystem(t *testing.T) { + p := NewGoModParser() + assert.Equal(t, types.EcosystemGo, p.Ecosystem()) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node.go new file mode 100644 index 00000000..bb36aa34 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node.go @@ -0,0 +1,209 @@ +// ©AngelaMos | 2026 +// node.go + +package parser + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "gopkg.in/yaml.v3" +) + +var nodeSemverRe = regexp.MustCompile(`\d+\.\d+\.\d+`) + +type NodeParser struct{} + +func NewNodeParser() *NodeParser { + return &NodeParser{} +} + +func (p *NodeParser) Ecosystem() types.Ecosystem { + return types.EcosystemNode +} + +func (p *NodeParser) Detect(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "package.json")) + return err == nil +} + +func (p *NodeParser) Parse(dir string) (*types.DependencyGraph, error) { + pkgPath := filepath.Join(dir, "package.json") + data, err := os.ReadFile(pkgPath) + if err != nil { + return nil, fmt.Errorf("read package.json: %w", err) + } + + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, fmt.Errorf("parse package.json: %w", err) + } + + root := types.Package{ + Name: pkg.Name, + Version: pkg.Version, + Ecosystem: types.EcosystemNode, + PURL: fmt.Sprintf("pkg:npm/%s@%s", encodePURLName(pkg.Name), pkg.Version), + Direct: true, + } + graph := types.NewDependencyGraph(root) + + directNames := make(map[string]bool) + for name := range pkg.Dependencies { + directNames[name] = true + } + for name := range pkg.DevDependencies { + directNames[name] = true + } + + lockPath := filepath.Join(dir, "pnpm-lock.yaml") + lockData, err := os.ReadFile(lockPath) + if err == nil { + parsePnpmLock(lockData, graph, root.PURL, directNames) + } else { + parseFromPackageJSON(pkg, graph, root.PURL) + } + + return graph, nil +} + +type packageJSON struct { + Name string `json:"name"` + Version string `json:"version"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +type pnpmLockfile struct { + Packages map[string]pnpmPackage `yaml:"packages"` + Snapshots map[string]pnpmSnapshot `yaml:"snapshots"` +} + +type pnpmPackage struct { + Resolution struct { + Integrity string `yaml:"integrity"` + } `yaml:"resolution"` +} + +type pnpmSnapshot struct { + Dependencies map[string]string `yaml:"dependencies"` +} + +func parsePnpmLock( + data []byte, + graph *types.DependencyGraph, + rootPURL string, + directNames map[string]bool, +) { + var lock pnpmLockfile + if err := yaml.Unmarshal(data, &lock); err != nil { + return + } + + for key, pkgEntry := range lock.Packages { + name, version := splitPnpmKey(key) + if name == "" { + continue + } + + isDirect := directNames[name] + depth := 2 + if isDirect { + depth = 1 + } + + purl := fmt.Sprintf("pkg:npm/%s@%s", encodePURLName(name), version) + pkg := types.Package{ + Name: name, + Version: version, + Ecosystem: types.EcosystemNode, + PURL: purl, + Direct: isDirect, + DepthLevel: depth, + } + + if pkgEntry.Resolution.Integrity != "" { + pkg.Checksums = append(pkg.Checksums, types.Checksum{ + Algorithm: "SHA-512", + Value: pkgEntry.Resolution.Integrity, + }) + } + + graph.AddPackage(pkg) + if isDirect { + graph.AddEdge(rootPURL, purl) + } + } + + for key, snap := range lock.Snapshots { + parentName, parentVersion := splitPnpmKey(key) + parentPURL := fmt.Sprintf("pkg:npm/%s@%s", encodePURLName(parentName), parentVersion) + for depName, depVersion := range snap.Dependencies { + childPURL := fmt.Sprintf("pkg:npm/%s@%s", encodePURLName(depName), depVersion) + graph.AddEdge(parentPURL, childPURL) + } + } +} + +func parseFromPackageJSON( + pkg packageJSON, + graph *types.DependencyGraph, + rootPURL string, +) { + for name, version := range pkg.Dependencies { + cleanVersion := cleanNodeVersion(version) + purl := fmt.Sprintf("pkg:npm/%s@%s", encodePURLName(name), cleanVersion) + dep := types.Package{ + Name: name, + Version: cleanVersion, + Ecosystem: types.EcosystemNode, + PURL: purl, + Direct: true, + DepthLevel: 1, + } + graph.AddPackage(dep) + graph.AddEdge(rootPURL, purl) + } + for name, version := range pkg.DevDependencies { + cleanVersion := cleanNodeVersion(version) + purl := fmt.Sprintf("pkg:npm/%s@%s", encodePURLName(name), cleanVersion) + dep := types.Package{ + Name: name, + Version: cleanVersion, + Ecosystem: types.EcosystemNode, + PURL: purl, + Direct: true, + DepthLevel: 1, + } + graph.AddPackage(dep) + graph.AddEdge(rootPURL, purl) + } +} + +func splitPnpmKey(key string) (string, string) { + atIdx := strings.LastIndex(key, "@") + if atIdx <= 0 { + return "", "" + } + return key[:atIdx], key[atIdx+1:] +} + +func cleanNodeVersion(constraint string) string { + match := nodeSemverRe.FindString(constraint) + if match != "" { + return match + } + return strings.TrimLeft(constraint, "^~>=< ") +} + +func encodePURLName(name string) string { + if strings.HasPrefix(name, "@") { + return strings.Replace(name, "@", "%40", 1) + } + return name +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node_test.go new file mode 100644 index 00000000..3526b68a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/node_test.go @@ -0,0 +1,89 @@ +// ©AngelaMos | 2026 +// node_test.go + +package parser + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNodeDetect(t *testing.T) { + p := NewNodeParser() + assert.True(t, p.Detect(filepath.Join(testdataDir(t), "node-project"))) + assert.False(t, p.Detect(filepath.Join(testdataDir(t), "go-project"))) + assert.False(t, p.Detect(filepath.Join(testdataDir(t), "empty-project"))) +} + +func TestNodeParse(t *testing.T) { + p := NewNodeParser() + dir := filepath.Join(testdataDir(t), "node-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + assert.Equal(t, "test-project", graph.Root.Name) + assert.Equal(t, types.EcosystemNode, graph.Root.Ecosystem) + + directCount := 0 + for _, pkg := range graph.Nodes { + if pkg.Direct && pkg.PURL != graph.Root.PURL { + directCount++ + } + } + assert.Equal(t, 3, directCount) + + hasExpress := false + hasLodash := false + hasTS := false + for _, pkg := range graph.Nodes { + switch pkg.Name { + case "express": + hasExpress = true + assert.Equal(t, "4.18.2", pkg.Version) + case "lodash": + hasLodash = true + assert.Equal(t, "4.17.20", pkg.Version) + case "typescript": + hasTS = true + assert.Equal(t, "5.3.3", pkg.Version) + } + } + assert.True(t, hasExpress) + assert.True(t, hasLodash) + assert.True(t, hasTS) +} + +func TestNodeParseTransitive(t *testing.T) { + p := NewNodeParser() + dir := filepath.Join(testdataDir(t), "node-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + hasSemver := false + for _, pkg := range graph.Nodes { + if pkg.Name == "semver" { + hasSemver = true + assert.False(t, pkg.Direct) + } + } + assert.True(t, hasSemver) +} + +func TestNodeEcosystem(t *testing.T) { + p := NewNodeParser() + assert.Equal(t, types.EcosystemNode, p.Ecosystem()) +} + +func TestCleanNodeVersion(t *testing.T) { + assert.Equal(t, "4.18.2", cleanNodeVersion("^4.18.2")) + assert.Equal(t, "4.18.2", cleanNodeVersion("~4.18.2")) + assert.Equal(t, "1.2.3", cleanNodeVersion(">=1.2.3")) + assert.Equal(t, "2.0.0", cleanNodeVersion("<2.0.0")) + assert.Equal(t, "1.0.0", cleanNodeVersion(">=1.0.0 <2.0.0")) + assert.Equal(t, "4.18.2", cleanNodeVersion("4.18.2")) + assert.Equal(t, "1.0.0", cleanNodeVersion("=1.0.0")) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/parser.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/parser.go new file mode 100644 index 00000000..e0ae460a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/parser.go @@ -0,0 +1,12 @@ +// ©AngelaMos | 2026 +// parser.go + +package parser + +import "github.com/CarterPerez-dev/bomber/pkg/types" + +type DependencyParser interface { + Detect(dir string) bool + Parse(dir string) (*types.DependencyGraph, error) + Ecosystem() types.Ecosystem +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python.go new file mode 100644 index 00000000..3ec12721 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python.go @@ -0,0 +1,194 @@ +// ©AngelaMos | 2026 +// python.go + +package parser + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/pelletier/go-toml/v2" +) + +type PythonParser struct{} + +func NewPythonParser() *PythonParser { + return &PythonParser{} +} + +func (p *PythonParser) Ecosystem() types.Ecosystem { + return types.EcosystemPython +} + +func (p *PythonParser) Detect(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "pyproject.toml")) + return err == nil +} + +func (p *PythonParser) Parse(dir string) (*types.DependencyGraph, error) { + pyPath := filepath.Join(dir, "pyproject.toml") + data, err := os.ReadFile(pyPath) + if err != nil { + return nil, fmt.Errorf("read pyproject.toml: %w", err) + } + + var proj pyprojectTOML + if err := toml.Unmarshal(data, &proj); err != nil { + return nil, fmt.Errorf("parse pyproject.toml: %w", err) + } + + root := types.Package{ + Name: proj.Project.Name, + Version: proj.Project.Version, + Ecosystem: types.EcosystemPython, + PURL: fmt.Sprintf("pkg:pypi/%s@%s", proj.Project.Name, proj.Project.Version), + Direct: true, + } + graph := types.NewDependencyGraph(root) + + directNames := make(map[string]bool) + for _, dep := range proj.Project.Dependencies { + name := extractPyPkgName(dep) + directNames[strings.ToLower(name)] = true + } + for _, groups := range proj.DependencyGroups { + for _, dep := range groups { + name := extractPyPkgName(dep) + directNames[strings.ToLower(name)] = true + } + } + + lockPath := filepath.Join(dir, "uv.lock") + lockData, err := os.ReadFile(lockPath) + if err == nil { + parseUVLock(lockData, graph, root.PURL, directNames) + } else { + parseFromPyproject(proj, graph, root.PURL) + } + + return graph, nil +} + +type pyprojectTOML struct { + Project struct { + Name string `toml:"name"` + Version string `toml:"version"` + Dependencies []string `toml:"dependencies"` + } `toml:"project"` + DependencyGroups map[string][]string `toml:"dependency-groups"` +} + +type uvLockfile struct { + Packages []uvPackage `toml:"package"` +} + +type uvPackage struct { + Name string `toml:"name"` + Version string `toml:"version"` + Source uvSource `toml:"source"` + Dependencies []uvDependency `toml:"dependencies"` +} + +type uvSource struct { + Registry string `toml:"registry"` + Virtual string `toml:"virtual"` +} + +type uvDependency struct { + Name string `toml:"name"` +} + +func parseUVLock( + data []byte, + graph *types.DependencyGraph, + rootPURL string, + directNames map[string]bool, +) { + var lock uvLockfile + if err := toml.Unmarshal(data, &lock); err != nil { + return + } + + purlMap := make(map[string]string) + + for _, pkg := range lock.Packages { + if pkg.Source.Virtual != "" { + continue + } + + normalizedName := strings.ToLower(pkg.Name) + isDirect := directNames[normalizedName] + depth := 2 + if isDirect { + depth = 1 + } + + purl := fmt.Sprintf("pkg:pypi/%s@%s", normalizedName, pkg.Version) + purlMap[normalizedName] = purl + + dep := types.Package{ + Name: pkg.Name, + Version: pkg.Version, + Ecosystem: types.EcosystemPython, + PURL: purl, + Direct: isDirect, + DepthLevel: depth, + } + graph.AddPackage(dep) + + if isDirect { + graph.AddEdge(rootPURL, purl) + } + } + + for _, pkg := range lock.Packages { + if pkg.Source.Virtual != "" { + continue + } + parentName := strings.ToLower(pkg.Name) + parentPURL := purlMap[parentName] + if parentPURL == "" { + continue + } + for _, dep := range pkg.Dependencies { + childPURL := purlMap[strings.ToLower(dep.Name)] + if childPURL != "" { + graph.AddEdge(parentPURL, childPURL) + } + } + } +} + +func parseFromPyproject( + proj pyprojectTOML, + graph *types.DependencyGraph, + rootPURL string, +) { + for _, dep := range proj.Project.Dependencies { + name := extractPyPkgName(dep) + purl := fmt.Sprintf("pkg:pypi/%s", strings.ToLower(name)) + pkg := types.Package{ + Name: name, + Ecosystem: types.EcosystemPython, + PURL: purl, + Direct: true, + DepthLevel: 1, + } + graph.AddPackage(pkg) + graph.AddEdge(rootPURL, purl) + } +} + +var pyVersionRe = regexp.MustCompile(`[><=!~;]`) + +func extractPyPkgName(spec string) string { + loc := pyVersionRe.FindStringIndex(spec) + if loc != nil { + return strings.TrimSpace(spec[:loc[0]]) + } + return strings.TrimSpace(spec) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python_test.go new file mode 100644 index 00000000..b5c900b8 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/python_test.go @@ -0,0 +1,100 @@ +// ©AngelaMos | 2026 +// python_test.go + +package parser + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPythonDetect(t *testing.T) { + p := NewPythonParser() + assert.True(t, p.Detect(filepath.Join(testdataDir(t), "python-project"))) + assert.False(t, p.Detect(filepath.Join(testdataDir(t), "go-project"))) + assert.False(t, p.Detect(filepath.Join(testdataDir(t), "empty-project"))) +} + +func TestPythonParse(t *testing.T) { + p := NewPythonParser() + dir := filepath.Join(testdataDir(t), "python-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + assert.Equal(t, "test-project", graph.Root.Name) + assert.Equal(t, types.EcosystemPython, graph.Root.Ecosystem) + + directCount := 0 + for _, pkg := range graph.Nodes { + if pkg.Direct && pkg.PURL != graph.Root.PURL { + directCount++ + } + } + assert.GreaterOrEqual(t, directCount, 2) + + hasRequests := false + hasPydantic := false + for _, pkg := range graph.Nodes { + switch pkg.Name { + case "requests": + hasRequests = true + assert.Equal(t, "2.31.0", pkg.Version) + assert.True(t, pkg.Direct) + case "pydantic": + hasPydantic = true + assert.Equal(t, "2.6.1", pkg.Version) + assert.True(t, pkg.Direct) + } + } + assert.True(t, hasRequests) + assert.True(t, hasPydantic) +} + +func TestPythonParseTransitive(t *testing.T) { + p := NewPythonParser() + dir := filepath.Join(testdataDir(t), "python-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + hasUrllib3 := false + hasCertifi := false + for _, pkg := range graph.Nodes { + switch pkg.Name { + case "urllib3": + hasUrllib3 = true + assert.False(t, pkg.Direct) + case "certifi": + hasCertifi = true + assert.False(t, pkg.Direct) + } + } + assert.True(t, hasUrllib3) + assert.True(t, hasCertifi) +} + +func TestPythonParseEdges(t *testing.T) { + p := NewPythonParser() + dir := filepath.Join(testdataDir(t), "python-project") + graph, err := p.Parse(dir) + require.NoError(t, err) + + requestsPURL := "pkg:pypi/requests@2.31.0" + children := graph.Edges[requestsPURL] + assert.NotEmpty(t, children) +} + +func TestPythonEcosystem(t *testing.T) { + p := NewPythonParser() + assert.Equal(t, types.EcosystemPython, p.Ecosystem()) +} + +func TestExtractPyPkgName(t *testing.T) { + assert.Equal(t, "requests", extractPyPkgName("requests>=2.31.0")) + assert.Equal(t, "pydantic", extractPyPkgName("pydantic>=2.5.0")) + assert.Equal(t, "pytest", extractPyPkgName("pytest>=8.0.0")) + assert.Equal(t, "simple", extractPyPkgName("simple")) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry.go new file mode 100644 index 00000000..356f11b2 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry.go @@ -0,0 +1,36 @@ +// ©AngelaMos | 2026 +// registry.go + +package parser + +type Registry struct { + parsers []DependencyParser +} + +func NewRegistry() *Registry { + return &Registry{} +} + +func (r *Registry) Register(p DependencyParser) { + r.parsers = append(r.parsers, p) +} + +func (r *Registry) Detect(dir string) []DependencyParser { + var matched []DependencyParser + for _, p := range r.parsers { + if p.Detect(dir) { + matched = append(matched, p) + } + } + return matched +} + +func (r *Registry) All() []DependencyParser { + return r.parsers +} + +func RegisterAll(reg *Registry) { + reg.Register(NewGoModParser()) + reg.Register(NewNodeParser()) + reg.Register(NewPythonParser()) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry_test.go new file mode 100644 index 00000000..a5ae1c45 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/parser/registry_test.go @@ -0,0 +1,48 @@ +// ©AngelaMos | 2026 +// registry_test.go + +package parser + +import ( + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubParser struct { + ecosystem types.Ecosystem + detected bool +} + +func (s *stubParser) Detect(string) bool { return s.detected } +func (s *stubParser) Parse(string) (*types.DependencyGraph, error) { return nil, nil } +func (s *stubParser) Ecosystem() types.Ecosystem { return s.ecosystem } + +func TestRegistryDetect(t *testing.T) { + reg := NewRegistry() + goParser := &stubParser{ecosystem: types.EcosystemGo, detected: true} + nodeParser := &stubParser{ecosystem: types.EcosystemNode, detected: false} + + reg.Register(goParser) + reg.Register(nodeParser) + + detected := reg.Detect("some/dir") + require.Len(t, detected, 1) + assert.Equal(t, types.EcosystemGo, detected[0].Ecosystem()) +} + +func TestRegistryDetectEmpty(t *testing.T) { + reg := NewRegistry() + detected := reg.Detect("some/dir") + assert.Empty(t, detected) +} + +func TestRegistryAll(t *testing.T) { + reg := NewRegistry() + reg.Register(&stubParser{ecosystem: types.EcosystemGo}) + reg.Register(&stubParser{ecosystem: types.EcosystemNode}) + + assert.Len(t, reg.All(), 2) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine.go new file mode 100644 index 00000000..b2c38fa8 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine.go @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// engine.go + +package policy + +import ( + "fmt" + "time" + + "github.com/CarterPerez-dev/bomber/internal/graph" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +func Evaluate( + p *Policy, + report *types.VulnReport, + graphs []*types.DependencyGraph, +) *types.CheckResult { + result := &types.CheckResult{Passed: true} + + if p.MaxSeverity != "" { + threshold := types.ParseSeverity(p.MaxSeverity) + for _, m := range report.Matches { + if m.Vulnerability.Severity.Rank() > threshold.Rank() { + v := m.Vulnerability + result.Violations = append(result.Violations, types.PolicyViolation{ + Rule: "max_severity", + Message: fmt.Sprintf("%s has severity %s (max allowed: %s)", v.ID, v.Severity, p.MaxSeverity), + Package: m.Package, + Vuln: &v, + }) + result.Passed = false + } + } + } + + if p.MaxAgeDays > 0 { + cutoff := time.Now().AddDate(0, 0, -p.MaxAgeDays) + for _, m := range report.Matches { + if !m.Vulnerability.Published.IsZero() && m.Vulnerability.Published.Before(cutoff) { + v := m.Vulnerability + result.Violations = append(result.Violations, types.PolicyViolation{ + Rule: "max_age_days", + Message: fmt.Sprintf( + "%s published %s (older than %d days)", + v.ID, v.Published.Format("2006-01-02"), p.MaxAgeDays, + ), + Package: m.Package, + Vuln: &v, + }) + result.Passed = false + } + } + } + + if p.MaxDepth > 0 { + for _, g := range graphs { + depth := graph.MaxDepth(g) + if depth > p.MaxDepth { + result.Violations = append(result.Violations, types.PolicyViolation{ + Rule: "max_depth", + Message: fmt.Sprintf("dependency depth %d exceeds maximum %d", depth, p.MaxDepth), + }) + result.Passed = false + } + } + } + + return result +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine_test.go new file mode 100644 index 00000000..b34fa671 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/engine_test.go @@ -0,0 +1,144 @@ +// ©AngelaMos | 2026 +// engine_test.go + +package policy + +import ( + "testing" + "time" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvaluatePassesClean(t *testing.T) { + p := &Policy{MaxSeverity: "high"} + report := &types.VulnReport{ + Matches: []types.VulnMatch{ + {Vulnerability: types.Vulnerability{Severity: types.SeverityMedium, ID: "CVE-TEST"}}, + }, + } + + result := Evaluate(p, report, nil) + assert.True(t, result.Passed) + assert.Empty(t, result.Violations) +} + +func TestEvaluateFailsCritical(t *testing.T) { + p := &Policy{MaxSeverity: "medium"} + report := &types.VulnReport{ + Matches: []types.VulnMatch{ + {Vulnerability: types.Vulnerability{Severity: types.SeverityCritical, ID: "CVE-2024-0001"}}, + }, + } + + result := Evaluate(p, report, nil) + assert.False(t, result.Passed) + require.NotEmpty(t, result.Violations) + assert.Equal(t, "max_severity", result.Violations[0].Rule) +} + +func TestEvaluateMaxDepth(t *testing.T) { + p := &Policy{MaxDepth: 3} + + root := types.Package{Name: "root", PURL: "pkg:test/root@1.0.0"} + g := types.NewDependencyGraph(root) + deep := types.Package{Name: "deep", PURL: "pkg:test/deep@1.0.0", DepthLevel: 5} + g.AddPackage(deep) + + result := Evaluate(p, &types.VulnReport{}, []*types.DependencyGraph{g}) + assert.False(t, result.Passed) + require.NotEmpty(t, result.Violations) + assert.Equal(t, "max_depth", result.Violations[0].Rule) +} + +func TestEvaluateMaxDepthPasses(t *testing.T) { + p := &Policy{MaxDepth: 10} + + root := types.Package{Name: "root", PURL: "pkg:test/root@1.0.0"} + g := types.NewDependencyGraph(root) + shallow := types.Package{Name: "shallow", PURL: "pkg:test/shallow@1.0.0", DepthLevel: 2} + g.AddPackage(shallow) + + result := Evaluate(p, &types.VulnReport{}, []*types.DependencyGraph{g}) + assert.True(t, result.Passed) +} + +func TestLoadPolicyYAML(t *testing.T) { + yml := ` +max_severity: medium +max_depth: 5 +max_age_days: 365 +` + p, err := ParsePolicy([]byte(yml)) + require.NoError(t, err) + assert.Equal(t, "medium", p.MaxSeverity) + assert.Equal(t, 5, p.MaxDepth) + assert.Equal(t, 365, p.MaxAgeDays) +} + +func TestEvaluateMaxAgeFails(t *testing.T) { + p := &Policy{MaxAgeDays: 90} + report := &types.VulnReport{ + Matches: []types.VulnMatch{ + { + Vulnerability: types.Vulnerability{ + ID: "CVE-2023-0001", + Published: time.Now().AddDate(0, 0, -180), + }, + }, + }, + } + + result := Evaluate(p, report, nil) + assert.False(t, result.Passed) + require.NotEmpty(t, result.Violations) + assert.Equal(t, "max_age_days", result.Violations[0].Rule) +} + +func TestEvaluateMaxAgePasses(t *testing.T) { + p := &Policy{MaxAgeDays: 90} + report := &types.VulnReport{ + Matches: []types.VulnMatch{ + { + Vulnerability: types.Vulnerability{ + ID: "CVE-2024-0001", + Published: time.Now().AddDate(0, 0, -30), + }, + }, + }, + } + + result := Evaluate(p, report, nil) + assert.True(t, result.Passed) + assert.Empty(t, result.Violations) +} + +func TestEvaluateMaxAgeSkipsZeroPublished(t *testing.T) { + p := &Policy{MaxAgeDays: 90} + report := &types.VulnReport{ + Matches: []types.VulnMatch{ + { + Vulnerability: types.Vulnerability{ + ID: "CVE-2024-0001", + }, + }, + }, + } + + result := Evaluate(p, report, nil) + assert.True(t, result.Passed) +} + +func TestEvaluateEmptyPolicy(t *testing.T) { + p := &Policy{} + report := &types.VulnReport{ + Matches: []types.VulnMatch{ + {Vulnerability: types.Vulnerability{Severity: types.SeverityCritical}}, + }, + } + + result := Evaluate(p, report, nil) + assert.True(t, result.Passed) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/rules.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/rules.go new file mode 100644 index 00000000..3365ad34 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/policy/rules.go @@ -0,0 +1,33 @@ +// ©AngelaMos | 2026 +// rules.go + +package policy + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Policy struct { + MaxSeverity string `yaml:"max_severity"` + MaxAgeDays int `yaml:"max_age_days"` + MaxDepth int `yaml:"max_depth"` +} + +func ParsePolicy(data []byte) (*Policy, error) { + var p Policy + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("parse policy: %w", err) + } + return &p, nil +} + +func LoadPolicy(path string) (*Policy, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read policy file: %w", err) + } + return ParsePolicy(data) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/json.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/json.go new file mode 100644 index 00000000..09daec16 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/json.go @@ -0,0 +1,34 @@ +// ©AngelaMos | 2026 +// json.go + +package report + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type JSONReport struct { + Scan *types.ScanResult `json:"scan,omitempty"` + Vulns *types.VulnReport `json:"vulnerabilities,omitempty"` + Policy *types.CheckResult `json:"policy,omitempty"` +} + +func WriteJSON(w io.Writer, scan *types.ScanResult, vulns *types.VulnReport, policy *types.CheckResult) error { + report := JSONReport{ + Scan: scan, + Vulns: vulns, + Policy: policy, + } + + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("marshal json report: %w", err) + } + + _, err = w.Write(data) + return err +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/terminal.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/terminal.go new file mode 100644 index 00000000..2b10351a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/report/terminal.go @@ -0,0 +1,120 @@ +// ©AngelaMos | 2026 +// terminal.go + +package report + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/CarterPerez-dev/bomber/internal/graph" + "github.com/CarterPerez-dev/bomber/internal/ui" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +func PrintScanSummary(w io.Writer, result *types.ScanResult) { + fmt.Fprintf(w, " %s Scanned %d packages (%d direct", + ui.Check, result.TotalPkgs, result.DirectPkgs) + + transitive := result.TotalPkgs - result.DirectPkgs + if transitive > 0 { + fmt.Fprintf(w, ", %d transitive", transitive) + } + fmt.Fprintln(w, ")") + + if len(result.Ecosystems) > 0 { + names := make([]string, len(result.Ecosystems)) + for i, e := range result.Ecosystems { + names[i] = e.String() + } + fmt.Fprintf(w, " %s Ecosystems: %s\n", ui.Bullet, strings.Join(names, ", ")) + } + + for _, g := range result.Graphs { + cycles := graph.DetectCycles(g) + if len(cycles) > 0 { + fmt.Fprintf(w, " %s Circular dependencies detected in %s\n", + ui.Warning, g.Root.Name) + } + } + + fmt.Fprintln(w) +} + +func PrintVulnReport(w io.Writer, report *types.VulnReport) { + if len(report.Matches) == 0 { + fmt.Fprintf(w, " %s No vulnerabilities found\n\n", ui.Green(ui.Check)) + return + } + + bySev := map[types.Severity][]types.VulnMatch{} + for _, m := range report.Matches { + bySev[m.Vulnerability.Severity] = append(bySev[m.Vulnerability.Severity], m) + } + + order := []types.Severity{ + types.SeverityCritical, + types.SeverityHigh, + types.SeverityMedium, + types.SeverityLow, + } + + for _, sev := range order { + matches := bySev[sev] + if len(matches) == 0 { + continue + } + + sort.Slice(matches, func(i, j int) bool { + return matches[i].Vulnerability.Score > matches[j].Vulnerability.Score + }) + + header := fmt.Sprintf("%s (%d)", sev, len(matches)) + switch sev { + case types.SeverityCritical: + fmt.Fprintf(w, " %s\n", ui.Red(header)) + case types.SeverityHigh: + fmt.Fprintf(w, " %s\n", ui.Yellow(header)) + case types.SeverityMedium: + fmt.Fprintf(w, " %s\n", ui.Cyan(header)) + default: + fmt.Fprintf(w, " %s\n", ui.Dim(header)) + } + + for _, m := range matches { + v := m.Vulnerability + fmt.Fprintf(w, " %s\n", m.Package.PURL) + fmt.Fprintf(w, " %s", v.ID) + if v.Summary != "" { + summary := v.Summary + if len(summary) > 60 { + summary = summary[:57] + "..." + } + fmt.Fprintf(w, " %s %s", ui.Arrow, summary) + } + if v.Score > 0 { + fmt.Fprintf(w, " (CVSS %.1f)", v.Score) + } + fmt.Fprintln(w) + if v.FixVersion != "" { + fmt.Fprintf(w, " Fix: upgrade to %s\n", v.FixVersion) + } + fmt.Fprintln(w) + } + } +} + +func PrintCheckResult(w io.Writer, result *types.CheckResult) { + if result.Passed { + fmt.Fprintf(w, " %s Policy: %s\n\n", ui.Check, ui.Green("PASS")) + return + } + + fmt.Fprintf(w, " %s Policy: %s\n\n", ui.Cross, ui.Red("FAIL")) + for _, v := range result.Violations { + fmt.Fprintf(w, " %s [%s] %s\n", ui.Cross, v.Rule, v.Message) + } + fmt.Fprintln(w) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx.go new file mode 100644 index 00000000..b347e7e0 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx.go @@ -0,0 +1,130 @@ +// ©AngelaMos | 2026 +// cyclonedx.go + +package sbom + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/CarterPerez-dev/bomber/internal/config" + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/google/uuid" +) + +type CycloneDXGenerator struct{} + +func NewCycloneDXGenerator() *CycloneDXGenerator { + return &CycloneDXGenerator{} +} + +func (g *CycloneDXGenerator) Generate(graphs []*types.DependencyGraph) ([]byte, error) { + doc := cdxDocument{ + BOMFormat: config.CycloneDXFormat, + SpecVersion: config.CycloneDXVersion, + Version: 1, + SerialNum: fmt.Sprintf("urn:uuid:%s", uuid.New().String()), + Metadata: cdxMetadata{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Tools: []cdxTool{ + { + Vendor: config.ToolVendor, + Name: config.ToolName, + Version: config.ToolVersion, + }, + }, + }, + Components: []cdxComponent{}, + Dependencies: []cdxDependency{}, + } + + for _, graph := range graphs { + for _, pkg := range graph.Nodes { + if pkg.PURL == graph.Root.PURL { + continue + } + + comp := cdxComponent{ + Type: "library", + Name: pkg.Name, + Version: pkg.Version, + PURL: pkg.PURL, + BOMRef: pkg.PURL, + } + + for _, cs := range pkg.Checksums { + comp.Hashes = append(comp.Hashes, cdxHash{ + Alg: mapCDXAlgo(cs.Algorithm), + Content: cs.Value, + }) + } + + doc.Components = append(doc.Components, comp) + } + + for parentPURL, children := range graph.Edges { + dep := cdxDependency{ + Ref: parentPURL, + DependsOn: make([]string, len(children)), + } + copy(dep.DependsOn, children) + doc.Dependencies = append(doc.Dependencies, dep) + } + } + + return json.MarshalIndent(doc, "", " ") +} + +type cdxDocument struct { + BOMFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + Version int `json:"version"` + SerialNum string `json:"serialNumber"` + Metadata cdxMetadata `json:"metadata"` + Components []cdxComponent `json:"components"` + Dependencies []cdxDependency `json:"dependencies"` +} + +type cdxMetadata struct { + Timestamp string `json:"timestamp"` + Tools []cdxTool `json:"tools"` +} + +type cdxTool struct { + Vendor string `json:"vendor"` + Name string `json:"name"` + Version string `json:"version"` +} + +type cdxComponent struct { + Type string `json:"type"` + Name string `json:"name"` + Version string `json:"version"` + PURL string `json:"purl"` + BOMRef string `json:"bom-ref"` + Hashes []cdxHash `json:"hashes,omitempty"` +} + +type cdxHash struct { + Alg string `json:"alg"` + Content string `json:"content"` +} + +type cdxDependency struct { + Ref string `json:"ref"` + DependsOn []string `json:"dependsOn"` +} + +func mapCDXAlgo(algo string) string { + switch algo { + case "SHA-256", "SHA256": + return "SHA-256" + case "SHA-512", "SHA512": + return "SHA-512" + case "SHA-1", "SHA1": + return "SHA-1" + default: + return algo + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx_test.go new file mode 100644 index 00000000..53fc7ba6 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/cyclonedx_test.go @@ -0,0 +1,73 @@ +// ©AngelaMos | 2026 +// cyclonedx_test.go + +package sbom + +import ( + "encoding/json" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCycloneDXGenerate(t *testing.T) { + g := makeTestGraph() + gen := NewCycloneDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + + var doc map[string]any + require.NoError(t, json.Unmarshal(data, &doc)) + + assert.Equal(t, "CycloneDX", doc["bomFormat"]) + assert.Equal(t, "1.5", doc["specVersion"]) + + components, ok := doc["components"].([]any) + require.True(t, ok) + assert.NotEmpty(t, components) + + deps, ok := doc["dependencies"].([]any) + require.True(t, ok) + assert.NotEmpty(t, deps) +} + +func TestCycloneDXValidJSON(t *testing.T) { + g := makeTestGraph() + gen := NewCycloneDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + assert.True(t, json.Valid(data)) +} + +func TestCycloneDXHasSerialNumber(t *testing.T) { + g := makeTestGraph() + gen := NewCycloneDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + + var doc cdxDocument + require.NoError(t, json.Unmarshal(data, &doc)) + + assert.Contains(t, doc.SerialNum, "urn:uuid:") +} + +func TestCycloneDXHasPURL(t *testing.T) { + g := makeTestGraph() + gen := NewCycloneDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + + var doc cdxDocument + require.NoError(t, json.Unmarshal(data, &doc)) + + hasPURL := false + for _, comp := range doc.Components { + if comp.PURL != "" { + hasPURL = true + break + } + } + assert.True(t, hasPURL) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx.go new file mode 100644 index 00000000..c13e3b10 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx.go @@ -0,0 +1,167 @@ +// ©AngelaMos | 2026 +// spdx.go + +package sbom + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/CarterPerez-dev/bomber/internal/config" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type SPDXGenerator struct{} + +func NewSPDXGenerator() *SPDXGenerator { + return &SPDXGenerator{} +} + +func (g *SPDXGenerator) Generate(graphs []*types.DependencyGraph) ([]byte, error) { + now := time.Now().UTC().Format(time.RFC3339) + docName := "bomber-sbom" + if len(graphs) > 0 { + docName = graphs[0].Root.Name + } + + nsHash := fmt.Sprintf("%x", sha256.Sum256([]byte(docName+now))) + namespace := fmt.Sprintf("https://spdx.org/spdxdocs/%s-%s", docName, nsHash[:16]) + + doc := spdxDocument{ + SPDXVersion: config.SPDXVersion, + DataLicense: config.SPDXDataLicense, + SPDXID: "SPDXRef-DOCUMENT", + Name: docName, + DocumentNamespace: namespace, + CreationInfo: spdxCreationInfo{ + Created: now, + Creators: []string{ + fmt.Sprintf("Tool: %s-%s", config.ToolName, config.ToolVersion), + }, + }, + Packages: []spdxPackage{}, + Relationships: []spdxRelationship{}, + } + + for _, graph := range graphs { + rootRef := sanitizeSPDXID(graph.Root.PURL) + + doc.Relationships = append(doc.Relationships, spdxRelationship{ + Element: "SPDXRef-DOCUMENT", + Type: "DESCRIBES", + Related: rootRef, + }) + + for _, pkg := range graph.Nodes { + spdxPkg := spdxPackage{ + SPDXID: sanitizeSPDXID(pkg.PURL), + Name: pkg.Name, + VersionInfo: pkg.Version, + DownloadLocation: "NOASSERTION", + FilesAnalyzed: false, + Supplier: "NOASSERTION", + ExternalRefs: []spdxExternalRef{ + { + Category: "PACKAGE-MANAGER", + Type: "purl", + Locator: pkg.PURL, + }, + }, + } + + for _, cs := range pkg.Checksums { + spdxPkg.Checksums = append(spdxPkg.Checksums, spdxChecksum{ + Algorithm: mapChecksumAlgo(cs.Algorithm), + Value: cs.Value, + }) + } + + doc.Packages = append(doc.Packages, spdxPkg) + } + + for parentPURL, children := range graph.Edges { + parentRef := sanitizeSPDXID(parentPURL) + for _, childPURL := range children { + childRef := sanitizeSPDXID(childPURL) + doc.Relationships = append(doc.Relationships, spdxRelationship{ + Element: parentRef, + Type: "DEPENDS_ON", + Related: childRef, + }) + } + } + } + + return json.MarshalIndent(doc, "", " ") +} + +type spdxDocument struct { + SPDXVersion string `json:"spdxVersion"` + DataLicense string `json:"dataLicense"` + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + DocumentNamespace string `json:"documentNamespace"` + CreationInfo spdxCreationInfo `json:"creationInfo"` + Packages []spdxPackage `json:"packages"` + Relationships []spdxRelationship `json:"relationships"` +} + +type spdxCreationInfo struct { + Created string `json:"created"` + Creators []string `json:"creators"` +} + +type spdxPackage struct { + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + VersionInfo string `json:"versionInfo"` + DownloadLocation string `json:"downloadLocation"` + FilesAnalyzed bool `json:"filesAnalyzed"` + Supplier string `json:"supplier"` + Checksums []spdxChecksum `json:"checksums,omitempty"` + ExternalRefs []spdxExternalRef `json:"externalRefs"` +} + +type spdxChecksum struct { + Algorithm string `json:"algorithm"` + Value string `json:"checksumValue"` +} + +type spdxExternalRef struct { + Category string `json:"referenceCategory"` + Type string `json:"referenceType"` + Locator string `json:"referenceLocator"` +} + +type spdxRelationship struct { + Element string `json:"spdxElementId"` + Type string `json:"relationshipType"` + Related string `json:"relatedSpdxElement"` +} + +func sanitizeSPDXID(purl string) string { + r := strings.NewReplacer( + "/", "-", + "@", "-", + ":", "-", + ".", "-", + "%", "-", + ) + return "SPDXRef-" + r.Replace(purl) +} + +func mapChecksumAlgo(algo string) string { + switch strings.ToUpper(algo) { + case "SHA-256", "SHA256": + return "SHA256" + case "SHA-512", "SHA512": + return "SHA512" + case "SHA-1", "SHA1": + return "SHA1" + default: + return algo + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx_test.go new file mode 100644 index 00000000..70d474c3 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/sbom/spdx_test.go @@ -0,0 +1,102 @@ +// ©AngelaMos | 2026 +// spdx_test.go + +package sbom + +import ( + "encoding/json" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeTestGraph() *types.DependencyGraph { + root := types.Package{ + Name: "test-project", Version: "1.0.0", + Ecosystem: types.EcosystemGo, + PURL: "pkg:golang/test-project@1.0.0", + Direct: true, + } + g := types.NewDependencyGraph(root) + + dep := types.Package{ + Name: "github.com/example/lib", Version: "v2.0.0", + Ecosystem: types.EcosystemGo, + PURL: "pkg:golang/github.com/example/lib@v2.0.0", + Direct: true, DepthLevel: 1, + Checksums: []types.Checksum{{Algorithm: "SHA-256", Value: "abc123"}}, + } + g.AddPackage(dep) + g.AddEdge(root.PURL, dep.PURL) + + return g +} + +func TestSPDXGenerate(t *testing.T) { + g := makeTestGraph() + gen := NewSPDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + + var doc map[string]any + require.NoError(t, json.Unmarshal(data, &doc)) + + assert.Equal(t, "SPDX-2.3", doc["spdxVersion"]) + assert.Equal(t, "CC0-1.0", doc["dataLicense"]) + + packages, ok := doc["packages"].([]any) + require.True(t, ok) + assert.GreaterOrEqual(t, len(packages), 2) + + relationships, ok := doc["relationships"].([]any) + require.True(t, ok) + assert.NotEmpty(t, relationships) +} + +func TestSPDXValidJSON(t *testing.T) { + g := makeTestGraph() + gen := NewSPDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + assert.True(t, json.Valid(data)) +} + +func TestSPDXHasDescribes(t *testing.T) { + g := makeTestGraph() + gen := NewSPDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + + var doc spdxDocument + require.NoError(t, json.Unmarshal(data, &doc)) + + hasDescribes := false + for _, rel := range doc.Relationships { + if rel.Type == "DESCRIBES" { + hasDescribes = true + break + } + } + assert.True(t, hasDescribes) +} + +func TestSPDXHasDependsOn(t *testing.T) { + g := makeTestGraph() + gen := NewSPDXGenerator() + data, err := gen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + + var doc spdxDocument + require.NoError(t, json.Unmarshal(data, &doc)) + + hasDependsOn := false + for _, rel := range doc.Relationships { + if rel.Type == "DEPENDS_ON" { + hasDependsOn = true + break + } + } + assert.True(t, hasDependsOn) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/integration_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/integration_test.go new file mode 100644 index 00000000..af728d3b --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/integration_test.go @@ -0,0 +1,101 @@ +// ©AngelaMos | 2026 +// integration_test.go + +package scanner + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/bomber/internal/graph" + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/CarterPerez-dev/bomber/internal/sbom" + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFullPipelineGoProject(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "go-project")) + require.NoError(t, err) + require.Len(t, result.Graphs, 1) + + g := result.Graphs[0] + assert.Greater(t, len(graph.AllPackages(g)), 1) + assert.Empty(t, graph.DetectCycles(g)) + + spdxGen := sbom.NewSPDXGenerator() + spdxData, err := spdxGen.Generate(result.Graphs) + require.NoError(t, err) + assert.True(t, json.Valid(spdxData)) + + cdxGen := sbom.NewCycloneDXGenerator() + cdxData, err := cdxGen.Generate(result.Graphs) + require.NoError(t, err) + assert.True(t, json.Valid(cdxData)) +} + +func TestFullPipelineMonorepo(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "monorepo")) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(result.Graphs), 2) + assert.GreaterOrEqual(t, len(result.Ecosystems), 2) +} + +func TestFullPipelinePythonProject(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "python-project")) + require.NoError(t, err) + require.Len(t, result.Graphs, 1) + + g := result.Graphs[0] + all := graph.AllPackages(g) + assert.Greater(t, len(all), 1) + + spdxGen := sbom.NewSPDXGenerator() + spdxData, err := spdxGen.Generate(result.Graphs) + require.NoError(t, err) + assert.True(t, json.Valid(spdxData)) +} + +func TestFullPipelineAllEcosystems(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + ecosystems := []string{"go-project", "node-project", "python-project"} + + for _, eco := range ecosystems { + t.Run(eco, func(t *testing.T) { + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), eco)) + require.NoError(t, err) + require.NotEmpty(t, result.Graphs) + assert.Greater(t, result.TotalPkgs, 0) + assert.Greater(t, result.DirectPkgs, 0) + + for _, g := range result.Graphs { + spdxGen := sbom.NewSPDXGenerator() + spdxData, err := spdxGen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + assert.True(t, json.Valid(spdxData)) + + cdxGen := sbom.NewCycloneDXGenerator() + cdxData, err := cdxGen.Generate([]*types.DependencyGraph{g}) + require.NoError(t, err) + assert.True(t, json.Valid(cdxData)) + } + }) + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner.go new file mode 100644 index 00000000..a89e4467 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner.go @@ -0,0 +1,86 @@ +// ©AngelaMos | 2026 +// scanner.go + +package scanner + +import ( + "os" + "path/filepath" + + "github.com/CarterPerez-dev/bomber/internal/graph" + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +var skipDirs = map[string]bool{ + "node_modules": true, + ".git": true, + "vendor": true, + "__pycache__": true, + ".venv": true, + "dist": true, + "build": true, + ".tox": true, + "target": true, +} + +type Scanner struct { + registry *parser.Registry +} + +func New(registry *parser.Registry) *Scanner { + return &Scanner{registry: registry} +} + +func (s *Scanner) Scan(dir string) (*types.ScanResult, error) { + result := &types.ScanResult{} + ecosystemSet := make(map[types.Ecosystem]bool) + + dirs := discoverDirs(dir) + + for _, d := range dirs { + matched := s.registry.Detect(d) + for _, p := range matched { + g, err := p.Parse(d) + if err != nil { + continue + } + result.Graphs = append(result.Graphs, g) + ecosystemSet[p.Ecosystem()] = true + } + } + + for _, g := range result.Graphs { + all := graph.AllPackages(g) + result.TotalPkgs += len(all) + result.DirectPkgs += len(graph.DirectPackages(g)) + } + + for eco := range ecosystemSet { + result.Ecosystems = append(result.Ecosystems, eco) + } + + return result, nil +} + +func discoverDirs(root string) []string { + dirs := []string{root} + + entries, err := os.ReadDir(root) + if err != nil { + return dirs + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if skipDirs[entry.Name()] { + continue + } + subDir := filepath.Join(root, entry.Name()) + dirs = append(dirs, discoverDirs(subDir)...) + } + + return dirs +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner_test.go new file mode 100644 index 00000000..f4c52489 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/scanner/scanner_test.go @@ -0,0 +1,81 @@ +// ©AngelaMos | 2026 +// scanner_test.go + +package scanner + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/CarterPerez-dev/bomber/internal/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testdataDir(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Join(filepath.Dir(filename), "..", "..", "testdata") +} + +func TestScanGoProject(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "go-project")) + require.NoError(t, err) + + assert.Len(t, result.Graphs, 1) + assert.Greater(t, result.TotalPkgs, 0) +} + +func TestScanNodeProject(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "node-project")) + require.NoError(t, err) + + assert.Len(t, result.Graphs, 1) + assert.Greater(t, result.TotalPkgs, 0) +} + +func TestScanPythonProject(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "python-project")) + require.NoError(t, err) + + assert.Len(t, result.Graphs, 1) + assert.Greater(t, result.TotalPkgs, 0) +} + +func TestScanMonorepo(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "monorepo")) + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(result.Graphs), 2) + assert.GreaterOrEqual(t, len(result.Ecosystems), 2) +} + +func TestScanEmptyProject(t *testing.T) { + reg := parser.NewRegistry() + parser.RegisterAll(reg) + + s := New(reg) + result, err := s.Scan(filepath.Join(testdataDir(t), "empty-project")) + require.NoError(t, err) + + assert.Empty(t, result.Graphs) + assert.Equal(t, 0, result.TotalPkgs) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/banner.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/banner.go new file mode 100644 index 00000000..8605b429 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/banner.go @@ -0,0 +1,18 @@ +// ©AngelaMos | 2026 +// banner.go + +package ui + +import "fmt" + +func PrintBanner() { + fmt.Printf("\n %s %s\n\n", Bold("bomber"), Dim("SBOM generator & vulnerability matcher")) +} + +func PrintBannerWithArt() { + fmt.Println() + fmt.Printf(" %s\n", Red(" ██▄ ▄▀▄ █▄ ▄█ ██▄ ██▀ █▀▄")) + fmt.Printf(" %s\n", Cyan(" █▄█ ▀▄▀ █ ▀ █ █▄█ █▄▄ █▀▄")) + fmt.Println() + fmt.Printf(" %s\n\n", Dim("SBOM generator & vulnerability matcher")) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/color.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/color.go new file mode 100644 index 00000000..452e0d82 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/color.go @@ -0,0 +1,15 @@ +// ©AngelaMos | 2026 +// color.go + +package ui + +import "github.com/fatih/color" + +var ( + Red = color.New(color.FgRed).SprintFunc() + Green = color.New(color.FgGreen).SprintFunc() + Yellow = color.New(color.FgYellow).SprintFunc() + Cyan = color.New(color.FgCyan).SprintFunc() + Bold = color.New(color.Bold).SprintFunc() + Dim = color.New(color.Faint).SprintFunc() +) diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/spinner.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/spinner.go new file mode 100644 index 00000000..9cada031 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/spinner.go @@ -0,0 +1,51 @@ +// ©AngelaMos | 2026 +// spinner.go + +package ui + +import ( + "fmt" + "sync" + "time" +) + +var spinChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type Spinner struct { + message string + stop chan struct{} + done sync.WaitGroup +} + +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + stop: make(chan struct{}), + } +} + +func (s *Spinner) Start() { + s.done.Add(1) + go func() { + defer s.done.Done() + i := 0 + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.stop: + fmt.Print("\r\033[K") + return + case <-ticker.C: + fmt.Printf("\r %s %s", Cyan(spinChars[i%len(spinChars)]), s.message) + i++ + } + } + }() +} + +func (s *Spinner) Stop() { + close(s.stop) + s.done.Wait() +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/symbol.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/symbol.go new file mode 100644 index 00000000..0a7461e0 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/ui/symbol.go @@ -0,0 +1,13 @@ +// ©AngelaMos | 2026 +// symbol.go + +package ui + +const ( + Check = "✓" + Cross = "✗" + Warning = "⚠" + Arrow = "→" + Bullet = "●" + Shield = "🛡" +) diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache.go new file mode 100644 index 00000000..6a38a8b6 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache.go @@ -0,0 +1,102 @@ +// ©AngelaMos | 2026 +// cache.go + +package vuln + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" + + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type Cache struct { + db *sql.DB + ttl time.Duration +} + +func NewCache(dbPath string, ttl time.Duration) (*Cache, error) { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create cache dir: %w", err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open cache db: %w", err) + } + + createSQL := `CREATE TABLE IF NOT EXISTS vuln_cache ( + purl TEXT NOT NULL, + source TEXT NOT NULL, + data BLOB NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (purl, source) + )` + if _, err := db.Exec(createSQL); err != nil { + db.Close() + return nil, fmt.Errorf("create cache table: %w", err) + } + + return &Cache{db: db, ttl: ttl}, nil +} + +func (c *Cache) Put(purl, source string, matches []types.VulnMatch) error { + data, err := json.Marshal(matches) + if err != nil { + return fmt.Errorf("marshal cache data: %w", err) + } + + upsertSQL := `INSERT OR REPLACE INTO vuln_cache (purl, source, data, created_at) + VALUES (?, ?, ?, ?)` + _, err = c.db.Exec(upsertSQL, purl, source, data, time.Now().UnixMilli()) + if err != nil { + return fmt.Errorf("insert cache: %w", err) + } + + return nil +} + +func (c *Cache) Get(purl, source string) ([]types.VulnMatch, bool, error) { + querySQL := `SELECT data, created_at FROM vuln_cache WHERE purl = ? AND source = ?` + row := c.db.QueryRow(querySQL, purl, source) + + var data []byte + var createdAt int64 + if err := row.Scan(&data, &createdAt); err != nil { + if err == sql.ErrNoRows { + return nil, false, nil + } + return nil, false, fmt.Errorf("query cache: %w", err) + } + + created := time.UnixMilli(createdAt) + if time.Since(created) > c.ttl { + return nil, false, nil + } + + var matches []types.VulnMatch + if err := json.Unmarshal(data, &matches); err != nil { + return nil, false, fmt.Errorf("unmarshal cache data: %w", err) + } + + return matches, true, nil +} + +func (c *Cache) Close() error { + return c.db.Close() +} + +func DefaultCachePath() string { + home, err := os.UserHomeDir() + if err != nil { + return ".bomber/cache.db" + } + return filepath.Join(home, ".bomber", "cache.db") +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache_test.go new file mode 100644 index 00000000..154e298a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cache_test.go @@ -0,0 +1,90 @@ +// ©AngelaMos | 2026 +// cache_test.go + +package vuln + +import ( + "path/filepath" + "testing" + "time" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCachePutGet(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + cache, err := NewCache(dbPath, 24*time.Hour) + require.NoError(t, err) + defer cache.Close() + + matches := []types.VulnMatch{ + { + Package: types.Package{PURL: "pkg:golang/example@v1.0.0"}, + Vulnerability: types.Vulnerability{ID: "CVE-2024-1234", Source: "osv"}, + }, + } + + require.NoError(t, cache.Put("pkg:golang/example@v1.0.0", "osv", matches)) + + got, ok, err := cache.Get("pkg:golang/example@v1.0.0", "osv") + require.NoError(t, err) + assert.True(t, ok) + assert.Len(t, got, 1) + assert.Equal(t, "CVE-2024-1234", got[0].Vulnerability.ID) +} + +func TestCacheExpiry(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + cache, err := NewCache(dbPath, 1*time.Millisecond) + require.NoError(t, err) + defer cache.Close() + + matches := []types.VulnMatch{ + { + Package: types.Package{PURL: "pkg:golang/example@v1.0.0"}, + Vulnerability: types.Vulnerability{ID: "CVE-2024-1234", Source: "osv"}, + }, + } + + require.NoError(t, cache.Put("pkg:golang/example@v1.0.0", "osv", matches)) + time.Sleep(5 * time.Millisecond) + + _, ok, err := cache.Get("pkg:golang/example@v1.0.0", "osv") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestCacheMiss(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + cache, err := NewCache(dbPath, 24*time.Hour) + require.NoError(t, err) + defer cache.Close() + + _, ok, err := cache.Get("pkg:golang/nonexistent@v1.0.0", "osv") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestCacheOverwrite(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + cache, err := NewCache(dbPath, 24*time.Hour) + require.NoError(t, err) + defer cache.Close() + + old := []types.VulnMatch{ + {Vulnerability: types.Vulnerability{ID: "CVE-OLD"}}, + } + require.NoError(t, cache.Put("pkg:test@1.0.0", "osv", old)) + + updated := []types.VulnMatch{ + {Vulnerability: types.Vulnerability{ID: "CVE-NEW"}}, + } + require.NoError(t, cache.Put("pkg:test@1.0.0", "osv", updated)) + + got, ok, err := cache.Get("pkg:test@1.0.0", "osv") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "CVE-NEW", got[0].Vulnerability.ID) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/client.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/client.go new file mode 100644 index 00000000..acf7d0eb --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/client.go @@ -0,0 +1,15 @@ +// ©AngelaMos | 2026 +// client.go + +package vuln + +import ( + "context" + + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type Client interface { + Query(ctx context.Context, packages []types.Package) ([]types.VulnMatch, error) + Source() string +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss.go new file mode 100644 index 00000000..ea229a6f --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss.go @@ -0,0 +1,177 @@ +// ©AngelaMos | 2026 +// cvss.go + +package vuln + +import ( + "math" + "strings" +) + +const ( + scopeUnchanged = 0.0 + scopeChanged = 1.0 +) + +type cvssMetrics struct { + AV float64 + AC float64 + PR float64 + UI float64 + S float64 + C float64 + I float64 + A float64 +} + +var avWeights = map[string]float64{ + "N": 0.85, + "A": 0.62, + "L": 0.55, + "P": 0.20, +} + +var acWeights = map[string]float64{ + "L": 0.77, + "H": 0.44, +} + +var prWeightsUnchanged = map[string]float64{ + "N": 0.85, + "L": 0.62, + "H": 0.27, +} + +var prWeightsChanged = map[string]float64{ + "N": 0.85, + "L": 0.68, + "H": 0.50, +} + +var uiWeights = map[string]float64{ + "N": 0.85, + "R": 0.62, +} + +var ciaWeights = map[string]float64{ + "H": 0.56, + "L": 0.22, + "N": 0.0, +} + +func parseCVSSVector(vector string) *cvssMetrics { + if !strings.HasPrefix(vector, "CVSS:3") { + return nil + } + + parts := strings.Split(vector, "/") + if len(parts) < 9 { + return nil + } + + vals := make(map[string]string, 8) + for _, part := range parts[1:] { + kv := strings.SplitN(part, ":", 2) + if len(kv) == 2 { + vals[kv[0]] = kv[1] + } + } + + required := []string{"AV", "AC", "PR", "UI", "S", "C", "I", "A"} + for _, key := range required { + if _, ok := vals[key]; !ok { + return nil + } + } + + av, ok := avWeights[vals["AV"]] + if !ok { + return nil + } + ac, ok := acWeights[vals["AC"]] + if !ok { + return nil + } + ui, ok := uiWeights[vals["UI"]] + if !ok { + return nil + } + c, ok := ciaWeights[vals["C"]] + if !ok { + return nil + } + i, ok := ciaWeights[vals["I"]] + if !ok { + return nil + } + a, ok := ciaWeights[vals["A"]] + if !ok { + return nil + } + + var s float64 + switch vals["S"] { + case "U": + s = scopeUnchanged + case "C": + s = scopeChanged + default: + return nil + } + + prMap := prWeightsUnchanged + if s == scopeChanged { + prMap = prWeightsChanged + } + pr, ok := prMap[vals["PR"]] + if !ok { + return nil + } + + return &cvssMetrics{ + AV: av, + AC: ac, + PR: pr, + UI: ui, + S: s, + C: c, + I: i, + A: a, + } +} + +func parseCVSSScore(vector string) float64 { + metrics := parseCVSSVector(vector) + if metrics == nil { + return 0 + } + + iss := 1 - ((1 - metrics.C) * (1 - metrics.I) * (1 - metrics.A)) + + var impact float64 + if metrics.S == scopeUnchanged { + impact = 6.42 * iss + } else { + impact = 7.52*(iss-0.029) - 3.25*math.Pow(iss-0.02, 15) + } + + if impact <= 0 { + return 0 + } + + exploitability := 8.22 * metrics.AV * metrics.AC * metrics.PR * metrics.UI + + var score float64 + if metrics.S == scopeUnchanged { + score = math.Min(impact+exploitability, 10) + } else { + score = math.Min(1.08*(impact+exploitability), 10) + } + + return cvssRoundUp(score) +} + +func cvssRoundUp(val float64) float64 { + shifted := math.Round(val*100000) / 100000 + return math.Ceil(shifted*10) / 10 +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss_test.go new file mode 100644 index 00000000..110cdbe8 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/cvss_test.go @@ -0,0 +1,115 @@ +// ©AngelaMos | 2026 +// cvss_test.go + +package vuln + +import ( + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestCVSSKnownVectors(t *testing.T) { + tests := []struct { + name string + vector string + score float64 + }{ + { + name: "CVE-2023-44487 HTTP/2 rapid reset", + vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + score: 7.5, + }, + { + name: "max severity all high", + vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + score: 10.0, + }, + { + name: "log4shell CVE-2021-44228", + vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + score: 10.0, + }, + { + name: "local low impact", + vector: "CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N", + score: 1.8, + }, + { + name: "network medium complexity", + vector: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H", + score: 8.1, + }, + { + name: "scope changed low priv", + vector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N", + score: 6.4, + }, + { + name: "physical access required", + vector: "CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + score: 6.8, + }, + { + name: "adjacent network", + vector: "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + score: 8.8, + }, + { + name: "scope changed high priv", + vector: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H", + score: 8.8, + }, + { + name: "all none impact", + vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N", + score: 0.0, + }, + { + name: "CVE-2022-41723 net/http2 resource", + vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + score: 7.5, + }, + { + name: "CVSS 3.0 prefix also supported", + vector: "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + score: 9.8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCVSSScore(tt.vector) + assert.InDelta(t, tt.score, got, 0.1, + "vector=%s expected=%.1f got=%.1f", tt.vector, tt.score, got) + }) + } +} + +func TestCVSSInvalidVectors(t *testing.T) { + assert.Equal(t, 0.0, parseCVSSScore("")) + assert.Equal(t, 0.0, parseCVSSScore("not-a-vector")) + assert.Equal(t, 0.0, parseCVSSScore("CVSS:2.0/AV:N")) + assert.Equal(t, 0.0, parseCVSSScore("CVSS:3.1/AV:N")) + assert.Equal(t, 0.0, parseCVSSScore("CVSS:3.1/AV:X/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")) +} + +func TestCVSSRoundUp(t *testing.T) { + assert.Equal(t, 4.1, cvssRoundUp(4.02)) + assert.Equal(t, 4.0, cvssRoundUp(4.0)) + assert.Equal(t, 0.0, cvssRoundUp(0.0)) + assert.Equal(t, 10.0, cvssRoundUp(10.0)) +} + +func TestScoreToSeverity(t *testing.T) { + assert.Equal(t, types.SeverityCritical, scoreToSeverity(9.8)) + assert.Equal(t, types.SeverityCritical, scoreToSeverity(9.0)) + assert.Equal(t, types.SeverityHigh, scoreToSeverity(8.9)) + assert.Equal(t, types.SeverityHigh, scoreToSeverity(7.0)) + assert.Equal(t, types.SeverityMedium, scoreToSeverity(6.9)) + assert.Equal(t, types.SeverityMedium, scoreToSeverity(4.0)) + assert.Equal(t, types.SeverityLow, scoreToSeverity(3.9)) + assert.Equal(t, types.SeverityLow, scoreToSeverity(0.1)) + assert.Equal(t, types.SeverityNone, scoreToSeverity(0.0)) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd.go new file mode 100644 index 00000000..519da0ee --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd.go @@ -0,0 +1,220 @@ +// ©AngelaMos | 2026 +// nvd.go + +package vuln + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/CarterPerez-dev/bomber/internal/config" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type NVDClient struct { + baseURL string + apiKey string + httpClient *http.Client + mu sync.Mutex + lastReq time.Time + rateDelay time.Duration +} + +type nvdOption func(*NVDClient) + +func WithNVDBaseURL(url string) nvdOption { + return func(c *NVDClient) { + c.baseURL = url + } +} + +func WithNVDAPIKey(key string) nvdOption { + return func(c *NVDClient) { + c.apiKey = key + c.rateDelay = config.NVDRateWithKey + } +} + +func NewNVDClient(opts ...nvdOption) *NVDClient { + c := &NVDClient{ + baseURL: config.NVDBaseURL, + rateDelay: config.NVDRateWithoutKey, + httpClient: &http.Client{ + Timeout: config.HTTPTimeout, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *NVDClient) Source() string { + return config.NVDSourceName +} + +func (c *NVDClient) Query(ctx context.Context, packages []types.Package) ([]types.VulnMatch, error) { + if len(packages) == 0 { + return nil, nil + } + + var allMatches []types.VulnMatch + + for _, pkg := range packages { + if err := ctx.Err(); err != nil { + return allMatches, err + } + + c.rateLimit(ctx) + + matches, err := c.queryPackage(ctx, pkg) + if err != nil { + continue + } + allMatches = append(allMatches, matches...) + } + + return allMatches, nil +} + +func (c *NVDClient) queryPackage(ctx context.Context, pkg types.Package) ([]types.VulnMatch, error) { + params := url.Values{} + params.Set("virtualMatchString", buildCPEString(pkg)) + + reqURL := c.baseURL + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create nvd request: %w", err) + } + + if c.apiKey != "" { + req.Header.Set("apiKey", c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("nvd http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("nvd api error %d: %s", resp.StatusCode, string(body)) + } + + var nvdResp nvdResponse + if err := json.NewDecoder(resp.Body).Decode(&nvdResp); err != nil { + return nil, fmt.Errorf("decode nvd response: %w", err) + } + + var matches []types.VulnMatch + for _, item := range nvdResp.Vulnerabilities { + cve := item.CVE + published, _ := time.Parse(time.RFC3339, cve.Published) + match := types.VulnMatch{ + Package: pkg, + Vulnerability: types.Vulnerability{ + ID: cve.ID, + Summary: extractDescription(cve.Descriptions), + Source: config.NVDSourceName, + Published: published, + }, + } + + if len(cve.Metrics.CVSSV31) > 0 { + metric := cve.Metrics.CVSSV31[0] + match.Vulnerability.Score = metric.Data.BaseScore + match.Vulnerability.Severity = types.ParseSeverity(metric.Data.BaseSeverity) + } + + matches = append(matches, match) + } + + return matches, nil +} + +func buildCPEString(pkg types.Package) string { + product := pkg.Name + if idx := strings.LastIndex(product, "/"); idx >= 0 { + product = product[idx+1:] + } + product = strings.ToLower(product) + + version := strings.TrimPrefix(pkg.Version, "v") + if version == "" { + version = "*" + } + + return fmt.Sprintf("cpe:2.3:a:*:%s:%s:*:*:*:*:*:*:*", product, version) +} + +func (c *NVDClient) rateLimit(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + + elapsed := time.Since(c.lastReq) + if elapsed < c.rateDelay { + wait := c.rateDelay - elapsed + timer := time.NewTimer(wait) + defer timer.Stop() + + select { + case <-ctx.Done(): + return + case <-timer.C: + } + } + c.lastReq = time.Now() +} + +type nvdResponse struct { + Vulnerabilities []nvdVulnItem `json:"vulnerabilities"` +} + +type nvdVulnItem struct { + CVE nvdCVE `json:"cve"` +} + +type nvdCVE struct { + ID string `json:"id"` + Published string `json:"published"` + Descriptions []nvdDescription `json:"descriptions"` + Metrics nvdMetrics `json:"metrics"` +} + +type nvdDescription struct { + Lang string `json:"lang"` + Value string `json:"value"` +} + +type nvdMetrics struct { + CVSSV31 []nvdCVSSV31 `json:"cvssMetricV31"` +} + +type nvdCVSSV31 struct { + Data nvdCVSSData `json:"cvssData"` +} + +type nvdCVSSData struct { + BaseScore float64 `json:"baseScore"` + BaseSeverity string `json:"baseSeverity"` +} + +func extractDescription(descs []nvdDescription) string { + for _, d := range descs { + if d.Lang == "en" { + return d.Value + } + } + if len(descs) > 0 { + return descs[0].Value + } + return "" +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd_test.go new file mode 100644 index 00000000..1adcc7b1 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/nvd_test.go @@ -0,0 +1,120 @@ +// ©AngelaMos | 2026 +// nvd_test.go + +package vuln + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNVDQuery(t *testing.T) { + nvdFixture := `{ + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2023-44487", + "published": "2023-10-10T14:15:00.000", + "descriptions": [ + {"lang": "en", "value": "HTTP/2 rapid reset attack"} + ], + "metrics": { + "cvssMetricV31": [ + { + "cvssData": { + "baseScore": 7.5, + "baseSeverity": "HIGH" + } + } + ] + } + } + } + ] + }` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.RawQuery, "virtualMatchString") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(nvdFixture)) + })) + defer server.Close() + + client := NewNVDClient(WithNVDBaseURL(server.URL)) + packages := []types.Package{ + {Name: "golang.org/x/net", Version: "v0.1.0", Ecosystem: types.EcosystemGo, PURL: "pkg:golang/golang.org/x/net@v0.1.0"}, + } + + matches, err := client.Query(context.Background(), packages) + require.NoError(t, err) + require.NotEmpty(t, matches) + assert.Equal(t, "CVE-2023-44487", matches[0].Vulnerability.ID) + assert.Equal(t, "nvd", matches[0].Vulnerability.Source) + assert.Equal(t, 7.5, matches[0].Vulnerability.Score) + assert.Equal(t, types.SeverityHigh, matches[0].Vulnerability.Severity) +} + +func TestNVDAPIKeyHeader(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "test-key-123", r.Header.Get("apiKey")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"vulnerabilities": []}`)) + })) + defer server.Close() + + client := NewNVDClient(WithNVDBaseURL(server.URL), WithNVDAPIKey("test-key-123")) + packages := []types.Package{ + {Name: "test-pkg", Ecosystem: types.EcosystemNode, PURL: "pkg:npm/test-pkg@1.0.0"}, + } + _, err := client.Query(context.Background(), packages) + require.NoError(t, err) +} + +func TestNVDEmptyPackages(t *testing.T) { + client := NewNVDClient() + matches, err := client.Query(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, matches) +} + +func TestNVDSource(t *testing.T) { + client := NewNVDClient() + assert.Equal(t, "nvd", client.Source()) +} + +func TestNVDBuildCPEString(t *testing.T) { + tests := []struct { + name string + pkg types.Package + contains string + }{ + { + name: "go module", + pkg: types.Package{Name: "golang.org/x/net", Version: "v0.1.0", Ecosystem: types.EcosystemGo}, + contains: "cpe:2.3:a:*:net:0.1.0", + }, + { + name: "npm package", + pkg: types.Package{Name: "express", Version: "4.18.2", Ecosystem: types.EcosystemNode}, + contains: "cpe:2.3:a:*:express:4.18.2", + }, + { + name: "pypi package", + pkg: types.Package{Name: "requests", Version: "2.31.0", Ecosystem: types.EcosystemPython}, + contains: "cpe:2.3:a:*:requests:2.31.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cpe := buildCPEString(tt.pkg) + assert.Contains(t, cpe, tt.contains) + }) + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv.go new file mode 100644 index 00000000..3c4aef2a --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv.go @@ -0,0 +1,264 @@ +// ©AngelaMos | 2026 +// osv.go + +package vuln + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/CarterPerez-dev/bomber/internal/config" + "github.com/CarterPerez-dev/bomber/pkg/types" +) + +type OSVClient struct { + baseURL string + httpClient *http.Client +} + +type osvOption func(*OSVClient) + +func WithOSVBaseURL(url string) osvOption { + return func(c *OSVClient) { + c.baseURL = url + } +} + +func NewOSVClient(opts ...osvOption) *OSVClient { + c := &OSVClient{ + baseURL: config.OSVBaseURL, + httpClient: &http.Client{ + Timeout: config.HTTPTimeout, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *OSVClient) Source() string { + return config.OSVSourceName +} + +func (c *OSVClient) Query(ctx context.Context, packages []types.Package) ([]types.VulnMatch, error) { + if len(packages) == 0 { + return nil, nil + } + + var allMatches []types.VulnMatch + + for i := 0; i < len(packages); i += config.OSVBatchSize { + if err := ctx.Err(); err != nil { + return allMatches, err + } + + end := i + config.OSVBatchSize + if end > len(packages) { + end = len(packages) + } + batch := packages[i:end] + + matches, err := c.queryBatch(ctx, batch) + if err != nil { + return allMatches, fmt.Errorf("osv batch query: %w", err) + } + allMatches = append(allMatches, matches...) + } + + return allMatches, nil +} + +func (c *OSVClient) queryBatch(ctx context.Context, packages []types.Package) ([]types.VulnMatch, error) { + queries := make([]osvQuery, len(packages)) + for i, pkg := range packages { + queries[i] = osvQuery{ + Package: osvQueryPackage{ + PURL: pkg.PURL, + }, + } + } + + reqBody := osvBatchRequest{Queries: queries} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + url := c.baseURL + "/v1/querybatch" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("osv api error %d: %s", resp.StatusCode, string(respBody)) + } + + var batchResp osvBatchResponse + if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + var matches []types.VulnMatch + for i, result := range batchResp.Results { + if i >= len(packages) { + break + } + pkg := packages[i] + for _, v := range result.Vulns { + published, _ := time.Parse(time.RFC3339, v.Published) + match := types.VulnMatch{ + Package: pkg, + Vulnerability: types.Vulnerability{ + ID: v.ID, + Aliases: v.Aliases, + Summary: v.Summary, + Source: config.OSVSourceName, + Severity: parseSeverityFromOSV(v), + Score: parseScoreFromOSV(v), + Published: published, + }, + } + + if len(v.Affected) > 0 && len(v.Affected[0].Ranges) > 0 { + r := v.Affected[0].Ranges[0] + match.Vulnerability.AffectedRange = formatRange(r.Events) + match.Vulnerability.FixVersion = extractFixVersion(r.Events) + } + + matches = append(matches, match) + } + } + + return matches, nil +} + +type osvBatchRequest struct { + Queries []osvQuery `json:"queries"` +} + +type osvQuery struct { + Package osvQueryPackage `json:"package"` +} + +type osvQueryPackage struct { + PURL string `json:"purl"` +} + +type osvBatchResponse struct { + Results []osvResult `json:"results"` +} + +type osvResult struct { + Vulns []osvVuln `json:"vulns"` +} + +type osvVuln struct { + ID string `json:"id"` + Summary string `json:"summary"` + Published string `json:"published"` + Aliases []string `json:"aliases"` + Severity []osvSeverity `json:"severity"` + Affected []osvAffected `json:"affected"` + DBSpec struct { + Severity string `json:"severity"` + } `json:"database_specific"` +} + +type osvSeverity struct { + Type string `json:"type"` + Score string `json:"score"` +} + +type osvAffected struct { + Package struct { + PURL string `json:"purl"` + } `json:"package"` + Ranges []osvRange `json:"ranges"` +} + +type osvRange struct { + Type string `json:"type"` + Events []osvEvent `json:"events"` +} + +type osvEvent struct { + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` +} + +func parseSeverityFromOSV(v osvVuln) types.Severity { + if v.DBSpec.Severity != "" { + return types.ParseSeverity(v.DBSpec.Severity) + } + + for _, s := range v.Severity { + if s.Type == "CVSS_V3" { + score := parseCVSSScore(s.Score) + return scoreToSeverity(score) + } + } + + return types.SeverityNone +} + +func parseScoreFromOSV(v osvVuln) float64 { + for _, s := range v.Severity { + if s.Type == "CVSS_V3" { + return parseCVSSScore(s.Score) + } + } + return 0 +} + +func scoreToSeverity(score float64) types.Severity { + switch { + case score >= 9.0: + return types.SeverityCritical + case score >= 7.0: + return types.SeverityHigh + case score >= 4.0: + return types.SeverityMedium + case score > 0: + return types.SeverityLow + default: + return types.SeverityNone + } +} + +func formatRange(events []osvEvent) string { + var parts []string + for _, e := range events { + if e.Introduced != "" { + parts = append(parts, ">= "+e.Introduced) + } + if e.Fixed != "" { + parts = append(parts, "< "+e.Fixed) + } + } + return strings.Join(parts, ", ") +} + +func extractFixVersion(events []osvEvent) string { + for _, e := range events { + if e.Fixed != "" { + return e.Fixed + } + } + return "" +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv_test.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv_test.go new file mode 100644 index 00000000..b0f4c289 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/internal/vuln/osv_test.go @@ -0,0 +1,123 @@ +// ©AngelaMos | 2026 +// osv_test.go + +package vuln + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/CarterPerez-dev/bomber/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fixtureDir(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Join(filepath.Dir(filename), "..", "..", "testdata", "vuln-responses") +} + +func TestOSVQueryBatch(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join(fixtureDir(t), "osv-batch.json")) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/querybatch", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewOSVClient(WithOSVBaseURL(server.URL)) + + packages := []types.Package{ + {Name: "golang.org/x/net", Version: "v0.1.0", PURL: "pkg:golang/golang.org/x/net@v0.1.0"}, + } + + matches, err := client.Query(context.Background(), packages) + require.NoError(t, err) + require.NotEmpty(t, matches) + assert.Equal(t, "osv", matches[0].Vulnerability.Source) + assert.Equal(t, "GO-2023-2102", matches[0].Vulnerability.ID) +} + +func TestOSVMatchHasFixVersion(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join(fixtureDir(t), "osv-batch.json")) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewOSVClient(WithOSVBaseURL(server.URL)) + packages := []types.Package{ + {Name: "golang.org/x/net", Version: "v0.1.0", PURL: "pkg:golang/golang.org/x/net@v0.1.0"}, + } + + matches, err := client.Query(context.Background(), packages) + require.NoError(t, err) + require.NotEmpty(t, matches) + assert.Equal(t, "0.17.0", matches[0].Vulnerability.FixVersion) +} + +func TestOSVMatchHasCVSSScore(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join(fixtureDir(t), "osv-batch.json")) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewOSVClient(WithOSVBaseURL(server.URL)) + packages := []types.Package{ + {Name: "golang.org/x/net", Version: "v0.1.0", PURL: "pkg:golang/golang.org/x/net@v0.1.0"}, + } + + matches, err := client.Query(context.Background(), packages) + require.NoError(t, err) + require.NotEmpty(t, matches) + assert.Equal(t, 7.5, matches[0].Vulnerability.Score) +} + +func TestOSVEmptyPackages(t *testing.T) { + client := NewOSVClient() + matches, err := client.Query(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, matches) +} + +func TestOSVSource(t *testing.T) { + client := NewOSVClient() + assert.Equal(t, "osv", client.Source()) +} + +func TestOSVCancelledContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results": []}`)) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + client := NewOSVClient(WithOSVBaseURL(server.URL)) + packages := []types.Package{ + {Name: "test", PURL: "pkg:test/test@1.0.0"}, + } + + _, err := client.Query(ctx, packages) + assert.Error(t, err) +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/pkg/types/types.go b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/pkg/types/types.go new file mode 100644 index 00000000..ea81e3f2 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/pkg/types/types.go @@ -0,0 +1,160 @@ +// ©AngelaMos | 2026 +// types.go + +package types + +import ( + "strings" + "time" +) + +type Ecosystem int + +const ( + EcosystemGo Ecosystem = iota + EcosystemNode + EcosystemPython +) + +func (e Ecosystem) String() string { + switch e { + case EcosystemGo: + return "go" + case EcosystemNode: + return "node" + case EcosystemPython: + return "python" + default: + return "unknown" + } +} + +type Severity int + +const ( + SeverityNone Severity = iota + SeverityLow + SeverityMedium + SeverityHigh + SeverityCritical +) + +func (s Severity) String() string { + switch s { + case SeverityCritical: + return "CRITICAL" + case SeverityHigh: + return "HIGH" + case SeverityMedium: + return "MEDIUM" + case SeverityLow: + return "LOW" + case SeverityNone: + return "NONE" + default: + return "UNKNOWN" + } +} + +func ParseSeverity(s string) Severity { + switch strings.ToUpper(s) { + case "CRITICAL": + return SeverityCritical + case "HIGH": + return SeverityHigh + case "MEDIUM": + return SeverityMedium + case "LOW": + return SeverityLow + case "NONE": + return SeverityNone + default: + return SeverityNone + } +} + +func (s Severity) Rank() int { + return int(s) +} + +type Checksum struct { + Algorithm string + Value string +} + +type Package struct { + Name string + Version string + Ecosystem Ecosystem + PURL string + Checksums []Checksum + Direct bool + DepthLevel int +} + +type DependencyGraph struct { + Root Package + Nodes map[string]Package + Edges map[string][]string +} + +func NewDependencyGraph(root Package) *DependencyGraph { + g := &DependencyGraph{ + Root: root, + Nodes: make(map[string]Package), + Edges: make(map[string][]string), + } + g.Nodes[root.PURL] = root + return g +} + +func (g *DependencyGraph) AddPackage(pkg Package) { + g.Nodes[pkg.PURL] = pkg +} + +func (g *DependencyGraph) AddEdge(parentPURL, childPURL string) { + g.Edges[parentPURL] = append(g.Edges[parentPURL], childPURL) +} + +type Vulnerability struct { + ID string + Aliases []string + Severity Severity + Score float64 + AffectedRange string + FixVersion string + Summary string + Source string + Published time.Time +} + +type VulnMatch struct { + Package Package + Vulnerability Vulnerability +} + +type PolicyViolation struct { + Rule string + Message string + Package Package + Vuln *Vulnerability +} + +type ScanResult struct { + Graphs []*DependencyGraph + TotalPkgs int + DirectPkgs int + Ecosystems []Ecosystem +} + +type VulnReport struct { + Matches []VulnMatch + TotalPkgs int + DirectPkgs int + BySeverity map[Severity]int +} + +type CheckResult struct { + Passed bool + Violations []PolicyViolation +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/empty-project/.gitkeep b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/empty-project/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.mod b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.mod new file mode 100644 index 00000000..380051cb --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.mod @@ -0,0 +1,13 @@ +module example.com/testproject + +go 1.24.4 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/net v0.1.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.sum b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.sum new file mode 100644 index 00000000..b44c26e6 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/go-project/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUQYwXIPsb6RGxoLOEECLDgLA6tE0= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jGbOGsM= +github.com/spf13/cobra v1.10.2 h1:2CIUWEyHTv2WNHK+WwTGTEk0FfMJJGf1sP9JL8G8bQ= +github.com/spf13/cobra v1.10.2/go.mod h1:PfRYMMFHRAxnQGpE+WVPF+cSfnkmCnXanEqJbMvEB+4= +github.com/spf13/pflag v1.0.10 h1:iy+VFUOCP1a+8yFto/drg2CJ5YMXRVfIwvu3nHamp6g= +github.com/spf13/pflag v1.0.10/go.mod h1:n1GLbMFMk0PZ9JbCktcMRbaTMMWf9MMS1XjlCPiAb4Y= +golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7e0vtdQ034QjbX2ELhuB+T7Rn/HBdew= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/package.json b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/package.json new file mode 100644 index 00000000..5cc541e4 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/package.json @@ -0,0 +1,7 @@ +{ + "name": "monorepo-frontend", + "version": "1.0.0", + "dependencies": { + "react": "19.2.1" + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/pnpm-lock.yaml b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..ca221d98 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/frontend/pnpm-lock.yaml @@ -0,0 +1,20 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + react: + specifier: 19.2.1 + version: 19.2.1 + +packages: + react@19.2.1: + resolution: {integrity: sha512-T0bR1fHknGmLUFZLDmAXMmkiRx5s6V+1HJfOwkaXPgo3WieNQ3vY4BmPOcAlKm0O0GsrGP0icGo0N0a/4QcKg==} + engines: {node: '>=0.10.0'} + +snapshots: + react@19.2.1: {} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.mod b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.mod new file mode 100644 index 00000000..ec3ae217 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.mod @@ -0,0 +1,5 @@ +module example.com/monorepo + +go 1.24.4 + +require golang.org/x/sync v0.19.0 diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.sum b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.sum new file mode 100644 index 00000000..82c3b010 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/monorepo/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sync v0.19.0 h1:ENBSLSj5SicIJoAUhWOkkBTCBbqMm72MzO4BnMnTHMo= +golang.org/x/sync v0.19.0/go.mod h1:JkUqWJITmSJNOFnwMNHY1bJMEMx74se/cGD0+dii0to= diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/package.json b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/package.json new file mode 100644 index 00000000..0156e354 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/package.json @@ -0,0 +1,11 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "express": "4.18.2", + "lodash": "4.17.20" + }, + "devDependencies": { + "typescript": "5.3.3" + } +} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/pnpm-lock.yaml b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/pnpm-lock.yaml new file mode 100644 index 00000000..1d584fc1 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/node-project/pnpm-lock.yaml @@ -0,0 +1,69 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + express: + specifier: 4.18.2 + version: 4.18.2 + lodash: + specifier: 4.17.20 + version: 4.17.20 + devDependencies: + typescript: + specifier: 5.3.3 + version: 5.3.3 + +packages: + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + body-parser@1.20.1: + resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + cookie@0.5.0: + resolution: {integrity: sha512-YGG3ejvBNHRqu0559dc2l7RJhAt10gLfA5/UQqa5LoT6CrWnB3VEFIiDmYKKJvPqbDSIIqNhjR8GWtkJ8QNYRg==} + engines: {node: '>= 0.6'} + + express@4.18.2: + resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} + engines: {node: '>= 0.10.0'} + + lodash@4.17.20: + resolution: {integrity: sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRG5XhaY1B6l4zc7HjDtY9BV6YBN48URNR0500Q==} + + semver@7.3.7: + resolution: {integrity: sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==} + engines: {node: '>=10'} + hasBin: true + + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + accepts@1.3.8: {} + + body-parser@1.20.1: {} + + cookie@0.5.0: {} + + express@4.18.2: + dependencies: + accepts: 1.3.8 + body-parser: 1.20.1 + cookie: 0.5.0 + semver: 7.3.7 + + lodash@4.17.20: {} + + semver@7.3.7: {} + + typescript@5.3.3: {} diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/pyproject.toml b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/pyproject.toml new file mode 100644 index 00000000..a9b45c11 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "test-project" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [ + "requests>=2.31.0", + "pydantic>=2.5.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", +] diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/uv.lock b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/uv.lock new file mode 100644 index 00000000..c6dd1ce3 --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/python-project/uv.lock @@ -0,0 +1,100 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "certifi" +version = "2024.2.2" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "idna" +version = "3.6" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "packaging" +version = "24.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "pluggy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "pydantic" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] + +[[package]] +name = "pydantic-core" +version = "2.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] + +[[package]] +name = "pytest" +version = "8.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] + +[[package]] +name = "test-project" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[[package]] +name = "typing-extensions" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "urllib3" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/vuln-responses/osv-batch.json b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/vuln-responses/osv-batch.json new file mode 100644 index 00000000..a5b1924b --- /dev/null +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/testdata/vuln-responses/osv-batch.json @@ -0,0 +1,72 @@ +{ + "results": [ + { + "vulns": [ + { + "id": "GO-2023-2102", + "summary": "HTTP/2 rapid reset can cause excessive work in net/http", + "aliases": ["CVE-2023-44487", "GHSA-qppj-fm5r-hxr3"], + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "affected": [ + { + "package": { + "ecosystem": "Go", + "name": "golang.org/x/net", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + {"introduced": "0"}, + {"fixed": "0.17.0"} + ] + } + ] + } + ], + "database_specific": { + "severity": "HIGH" + } + }, + { + "id": "GO-2023-1571", + "summary": "Excessive resource consumption in net/http", + "aliases": ["CVE-2022-41723"], + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "affected": [ + { + "package": { + "ecosystem": "Go", + "name": "golang.org/x/net", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + {"introduced": "0"}, + {"fixed": "0.7.0"} + ] + } + ] + } + ], + "database_specific": { + "severity": "HIGH" + } + } + ] + } + ] +} diff --git a/TEMPLATES/fullstack-template b/TEMPLATES/fullstack-template index daa74d16..ecbb534e 160000 --- a/TEMPLATES/fullstack-template +++ b/TEMPLATES/fullstack-template @@ -1 +1 @@ -Subproject commit daa74d165fa60faaa41aac493f2ba1a3400a88c4 +Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172 From 4ec156ecf80bf7cc74bc4bf6ea19e8ef5a9faf16 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 8 Apr 2026 23:56:03 -0400 Subject: [PATCH 07/30] delete --- ...4-01-credential-enumeration-audit-fixes.md | 591 ------------------ ...026-04-01-credential-enumeration-design.md | 257 -------- 2 files changed, 848 deletions(-) delete mode 100644 docs/plans/2026-04-01-credential-enumeration-audit-fixes.md delete mode 100644 docs/superpowers/specs/2026-04-01-credential-enumeration-design.md diff --git a/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md b/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md deleted file mode 100644 index e4545b73..00000000 --- a/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md +++ /dev/null @@ -1,591 +0,0 @@ -# Credential Enumeration Audit - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans -> to implement this plan task-by-task. - -**Goal:** Address all gaps identified in the audit. - -**Architecture:** All changes are modifications to existing files unless noted. - -**Tech Stack:** Nim 2.2+, Docker, Bash (Justfile) - ---- - -## Impression - -Solid architecture for a Nim CLI tool — clean type hierarchy, consistent -`{.push raises: [].}` discipline, well-structured collector pattern. The -bones are genuinely good. But two of the command-detection patterns silently -match nothing, the terminal box renderer computes stats it never prints, -and the only test mechanism (Docker) can't actually build because the -Justfile passes the wrong build context. The tool scans 7 credential -categories competently but misses several high-value targets (.netrc, -npm/pip tokens, Terraform, Vault) that a real post-access operator would -check first. - -## Project Assessment - -**Type:** Rule-based credential detection CLI tool (post-access) -**Primary Axis:** Completeness — weighted 65/35 over code quality -**Why:** A scanner's value is directly proportional to what it catches. -Missing a credential category is a harder failure than a rendering bug. - -## Findings - -### Finding 1: Docker test build context is wrong — entire test pipeline broken -**Severity:** CRITICAL -**Axis:** Code Quality -**Files:** Justfile:88-89, tests/docker/Dockerfile:1-12 - -**Issue:** The Justfile recipe `docker-build` runs -`docker build -t credenum-test tests/docker`, setting the build context to -`tests/docker/`. But the Dockerfile's first stage copies `src/`, `config.nims`, -and `credential-enumeration.nimble` from the build context root — none of which -exist under `tests/docker/`. The build fails immediately with -"COPY failed: file not found in build context." - -**Proof:** The Dockerfile contains: -```dockerfile -COPY src/ src/ -COPY config.nims . -COPY credential-enumeration.nimble . -``` -With context `tests/docker/`, Docker looks for `tests/docker/src/`, -`tests/docker/config.nims`, `tests/docker/credential-enumeration.nimble`. -None exist — `find tests/docker/ -name "config.nims"` returns nothing. -The only test mechanism for this project has never run successfully with -this Justfile recipe. - -**Proof Check:** Confidence: HIGH — Docker build context semantics are deterministic; -this is not a maybe. - -**Fix:** -`Justfile:88-89` — change the docker-build recipe to use the project root as context: -```just -[group('test')] -docker-build: - docker build -t credenum-test -f tests/docker/Dockerfile . -``` -And update `docker-test` accordingly (it depends on docker-build, so no change needed -there since it just `docker run`s the image). - -**Test:** -```bash -just docker-build -``` - ---- - -### Finding 2: matchesCommandPattern has case mismatch — 2/7 patterns are dead code -**Severity:** CRITICAL -**Axis:** Code Quality -**Files:** src/collectors/history.nim:38-54, src/config.nim:120-128 - -**Issue:** `matchesCommandPattern` lowercases the input line (`line.toLowerAscii()`) -then searches for pattern fragments that contain uppercase characters. Two patterns -are affected: - -- `"curl.*-H.*[Aa]uthoriz"` splits into `["curl", "-H", "[Aa]uthoriz"]` — - `-H` (uppercase) will never be found in a lowercased string, and - `[Aa]uthoriz` is treated as a literal (not a character class) -- `"wget.*--header.*[Aa]uthoriz"` splits into `["wget", "--header", "[Aa]uthoriz"]` — - `[Aa]uthoriz` is literal and will never appear in real history - -This means `curl -H "Authorization: Bearer ..."` commands in shell history -are silently missed — one of the most common credential-leaking patterns. - -**Proof:** Trace through `matchesCommandPattern` with input -`curl -H "Authorization: Bearer token" https://api.example.com`: -1. `lower` = `curl -h "authorization: bearer token" https://api.example.com` -2. Pattern `"curl.*-H.*[Aa]uthoriz"` → parts = `["curl", "-H", "[Aa]uthoriz"]` -3. `lower.find("curl")` → found at 0 -4. `lower.find("-H")` → NOT FOUND (lowercase string has `-h`, not `-H`) -5. `allFound = false` → returns false - -The pattern never matches. The planted test data in `.bash_history` line 4 -has `curl -H "Authorization: ..."` which should trigger this pattern but -the validate.sh check labeled "Sensitive command" passes only because -OTHER patterns (like `sshpass`, `mysql.*-p`) produce matches. - -**Proof Check:** Confidence: HIGH — Nim's `find` is case-sensitive by default; -this is deterministic. - -**Fix:** -`src/config.nim:120-128` — lowercase all pattern fragments: -```nim -HistoryCommandPatterns* = [ - "curl.*-h.*authoriz", - "curl.*-u ", - "wget.*--header.*authoriz", - "wget.*--password", - "mysql.*-p", - "psql.*password", - "sshpass" -] -``` - -**Test:** -Add a Docker test assertion that specifically validates curl -H Authorization -detection. After fix, run `just docker-test`. - ---- - -### Finding 3: Module header stats computed but never rendered -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** src/output/terminal.nim:40-57 - -**Issue:** `renderModuleHeader` computes a `stats` string containing -the finding count and duration, but the padding calculation -`padLen - stats.len + stats.len` simplifies to just `padLen` — then -writes padding spaces without ever writing `stats` to stdout. -The finding count and per-module duration are silently dropped from output. - -**Proof:** The arithmetic: -```nim -let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset -let padLen = 76 - name.len - desc.len - 5 -if padLen > 0: - stdout.write " ".repeat(padLen - stats.len + stats.len) # = " ".repeat(padLen) -stdout.writeLine " " & BoxVertical -``` -`stats` is never passed to `stdout.write`. The line is equivalent to -`stdout.write " ".repeat(padLen)` followed by the box border — no stats -anywhere. - -**Proof Check:** Confidence: HIGH — the variable is computed and never -appears in any write call in the function. - -**Fix:** -`src/output/terminal.nim:51-55` — compute visual width (excluding ANSI codes), -pad to fill the box, then write stats: -```nim -proc visualLen(s: string): int = - var i = 0 - while i < s.len: - if s[i] == '\e': - while i < s.len and s[i] != 'm': - inc i - inc i - else: - inc result - inc i - -proc renderModuleHeader(name: string, desc: string, findingCount: int, durationMs: int64) = - try: - stdout.writeLine boxLine(78) - stdout.write BoxVertical & " " - stdout.write ColorBold & ColorCyan - stdout.write name.toUpperAscii() - stdout.write ColorReset - stdout.write ColorDim - stdout.write " " & Arrow & " " & desc - stdout.write ColorReset - - let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset - let usedWidth = 2 + name.len + 3 + desc.len - let statsVisual = visualLen(stats) - let padLen = 78 - usedWidth - statsVisual - 2 - if padLen > 0: - stdout.write " ".repeat(padLen) - stdout.write stats - stdout.writeLine " " & BoxVertical - stdout.writeLine boxMid(78) - except CatchableError: - discard -``` - -**Test:** -```bash -just run --target /tmp | head -20 -``` -Verify module headers show "N findings (Xms)" right-aligned. - ---- - -### Finding 4: Terminal box right-border alignment broken for variable content -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** src/output/terminal.nim:60-84, 98-126 - -**Issue:** `renderFinding` writes descriptions and paths of arbitrary length -then appends `" " & BoxVertical` with no padding to reach column 78. Long -descriptions push past the box. Short ones leave the right border floating -at different positions. Same issue in `renderSummary` — hardcoded -`" ".repeat(69)` and `" ".repeat(20)` assume fixed content widths that -vary with finding counts, module counts, and durations. - -**Proof:** A finding with path `/home/user/.config/google-chrome/Default/Login Data` -(49 chars) plus permissions `[0644]` plus modified timestamp is ~90+ chars of -content in a 78-char box. The right `BoxVertical` gets pushed to column ~95. -A finding with path `/home/user/.pgpass` (18 chars) leaves the right border -at ~column 50. - -**Proof Check:** Confidence: HIGH — the code has zero width calculation before -writing the trailing BoxVertical. - -**Fix:** -Create a `padWrite` helper that calculates visual width of content written so -far and pads to fill the 78-char box before writing the closing border. -Apply it to `renderFinding`, `renderSummary`, and `renderModuleErrors`. -Truncate content that would exceed box width. - -In `src/output/terminal.nim`, add the `visualLen` proc from Finding 3 -(shared), then refactor each line that writes content + BoxVertical: -```nim -proc padToBox(content: string, boxWidth: int = 78) = - let vLen = visualLen(content) - let pad = boxWidth - vLen - 1 - if pad > 0: - stdout.write " ".repeat(pad) - stdout.writeLine BoxVertical -``` - -Then each finding line becomes: -```nim -var line = BoxVertical & " " & sevBadge(f.severity) & " " & f.description -stdout.write line -padToBox(line) -``` - -Apply this pattern consistently to all content rows in the terminal renderer. - -**Test:** -```bash -just docker-test -``` -Visual inspection of terminal output — all right borders should align at column 78. - ---- - -### Finding 5: scanGitCredentials reports svHigh for empty credential files -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** src/collectors/git.nim:11-39 - -**Issue:** If `.git-credentials` exists but is empty or contains no valid URLs, -`credCount` stays at 0 but the function still creates a finding with -"Plaintext Git credential store with 0 entries" at severity svHigh -(or svCritical if world-readable). An empty file is not a high-severity -credential exposure. - -**Proof:** Trace through `scanGitCredentials` with an empty `.git-credentials`: -1. `safeFileExists` returns true -2. `readFileLines` returns `@[]` -3. Loop runs zero iterations, `credCount = 0` -4. Code falls through to create credential and finding with `svHigh` -5. Report shows "Plaintext Git credential store with 0 entries" as HIGH - -**Proof Check:** Confidence: HIGH — there is no guard checking `credCount > 0` -before creating the finding. - -**Fix:** -`src/collectors/git.nim` — add early return after counting: -```nim -if credCount == 0: - return -``` -Insert after the for-loop that counts credentials (after line 22), before -the credential/finding construction. - -**Test:** -Create an empty `.git-credentials` file, run scanner, verify no git finding -appears. - ---- - -### Finding 6: `just test` references non-existent test_all.nim -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** Justfile:84-85 - -**Issue:** The Justfile `test` recipe runs `nim c -r tests/test_all.nim`, -but this file does not exist. There are no unit tests in the project. -The only testing is Docker-based integration testing (validate.sh), which -itself is broken (Finding 1). - -**Proof:** `test -f tests/test_all.nim` returns non-zero. The `tests/` -directory contains only `docker/`. - -**Proof Check:** Confidence: HIGH — file does not exist. - -**Fix:** -Create `tests/test_all.nim` with unit tests for each collector's core logic. -At minimum, test: -- `isPrivateKey` with various key headers -- `isEncrypted` with encrypted/unencrypted markers -- `matchesSecretPattern` with positive and negative cases -- `matchesCommandPattern` (after fixing Finding 2) with all 7 patterns -- `redactValue` edge cases -- `permissionSeverity` logic -- `parseModules` from CLI parsing - -These should be fast, in-process tests that don't require Docker or -real credential files. - -**Test:** -```bash -just test -``` - ---- - -### Finding 7: Missing credential categories — .netrc, npm/pip tokens, Terraform, Vault, GitHub CLI -**Severity:** MAJOR -**Axis:** Completeness -**Files:** src/config.nim, src/collectors/apptoken.nim - -**Issue:** The tool covers 7 categories but misses several high-value -credential stores that a post-access operator would check: - -| Missing Target | Path | Why It Matters | -|---|---|---| -| `.netrc` | `~/.netrc` | Universal HTTP auth store; Heroku, Artifactory, many tools | -| `.npmrc` | `~/.npmrc` | npm registry auth tokens (`_authToken=`) | -| `.pypirc` | `~/.pypirc` | PyPI upload tokens | -| GitHub CLI | `~/.config/gh/hosts.yml` | GitHub OAuth tokens | -| Terraform | `~/.terraform.d/credentials.tfrc.json` | Terraform Cloud API tokens | -| Vault | `~/.vault-token` | HashiCorp Vault root/user tokens | -| `~/.config/helm/repositories.yaml` | Helm chart repo credentials | -| `~/.config/rclone/rclone.conf` | Cloud storage credentials (S3, GCS, etc.) | - -Industry comparison: LaZagne (closest post-access tool) covers 20+ -credential categories on Linux alone. `truffleHog` detects 700+ secret -patterns. This tool's 7 categories leave real coverage gaps. - -**Proof:** `grep -r "netrc\|npmrc\|pypirc\|vault-token\|terraform\|gh/hosts" src/` -returns zero matches. - -**Proof Check:** Confidence: HIGH — the files are either scanned or they're not. - -**Fix:** -Add constants to `src/config.nim`: -```nim -const - NetrcFile* = ".netrc" - NpmrcFile* = ".npmrc" - PypircFile* = ".pypirc" - GhCliHosts* = ".config/gh/hosts.yml" - TerraformCreds* = ".terraform.d/credentials.tfrc.json" - VaultTokenFile* = ".vault-token" - HelmRepos* = ".config/helm/repositories.yaml" - RcloneConf* = ".config/rclone/rclone.conf" -``` - -Add scanning logic to `src/collectors/apptoken.nim` — each is a simple -file-exists-and-check-contents pattern, consistent with existing -`scanDbCredFiles` approach. `.netrc` deserves content parsing (look for -`password` or `login` tokens). `.npmrc` should check for `_authToken=`. -`.pypirc` should check for `password` under `[pypi]` section. - -**Test:** -Add planted files to `tests/docker/planted/` and assertions to `validate.sh`. - ---- - -### Finding 8: matchesExclude uses substring matching, not glob patterns -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/base.nim:90-94 - -**Issue:** `matchesExclude` checks `if pattern in path` — plain substring. -An exclude pattern of `"env"` would exclude `/home/user/.venv/something`, -`/home/user/environment/data`, and the intended `.env` file. The CLI help -says `--exclude ` suggesting glob behavior, but the implementation -is substring containment. - -**Proof:** `matchesExclude("/home/user/.venv/lib/site.py", @["env"])` -returns `true`, excluding a Python virtualenv file that has nothing to do -with environment secrets. - -**Proof Check:** Confidence: HIGH — `in` is Nim's substring containment -operator for strings. - -**Fix:** -`src/collectors/base.nim:90-94` — use `std/os.extractFilename` and simple -glob matching, or at minimum document that patterns are substrings. Better -fix: use Nim's `std/strutils.contains` with path-segment awareness: -```nim -proc matchesExclude*(path: string, patterns: seq[string]): bool = - let name = path.extractFilename() - for pattern in patterns: - if pattern in name or pattern in path.splitPath().head: - return true -``` - -Or implement basic glob support with `*` matching. - -**Test:** -Unit test that `.venv/lib/site.py` is NOT excluded by pattern `".env"`. - ---- - -### Finding 9: JSON renderJson silently discards file-write errors -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/output/json.nim:72-85 - -**Issue:** When `--output ` specifies an invalid path (read-only dir, -nonexistent parent), `writeFile` throws, the exception is caught and -discarded. The JSON is then also written to stdout, but if stdout is -redirected and also fails, both errors are silently swallowed. The user -gets zero indication that their requested output file was not created. - -**Proof:** Run `credenum --format json --output /root/nope.json` as -non-root — the file write fails silently, output goes only to stdout. -If stdout is piped to a broken pipe, both writes fail and the user -sees nothing. - -**Proof Check:** Confidence: MEDIUM — the stdout fallback usually works, -so the practical impact is limited to the file path case. - -**Fix:** -`src/output/json.nim:77-80` — write a warning to stderr on file write failure: -```nim -except CatchableError as e: - try: - stderr.writeLine "Warning: could not write to " & outputPath & ": " & e.msg - except CatchableError: - discard -``` - -**Test:** -```bash -just run --format json --output /dev/full 2>&1 | grep "Warning" -``` - ---- - -### Finding 10: redactLine strips leading quote but keeps trailing quote -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/history.nim:15-28 - -**Issue:** `redactLine` strips a leading `"` or `'` from the value via -`value[1 .. ^1]`, but `^1` is the last index in Nim (inclusive), so -this removes only the first character. Input `"secret"` becomes -`secret"` — the trailing quote survives into the redacted preview. - -**Proof:** Input line `export API_KEY="mysecret"`: -1. `eqIdx` = 14 (position of `=`) -2. `value` = `"mysecret"` (after strip) -3. `value.startsWith("\"")` → true -4. `cleanValue` = `value[1 .. ^1]` = `mysecret"` (trailing quote kept) -5. `redactValue("mysecret\"", 4)` = `myse****"` - -**Proof Check:** Confidence: HIGH — `^1` is the last character in Nim slice -notation; this is deterministic. - -**Fix:** -`src/collectors/history.nim:24-26`: -```nim -let cleanValue = if (value.startsWith("\"") and value.endsWith("\"")) or - (value.startsWith("'") and value.endsWith("'")): - value[1 ..< ^1] -else: - value -``` - -Note: `^1` in `[1 ..< ^1]` excludes the last character (half-open range). - -**Test:** -Unit test: `redactLine("export KEY=\"secret\"")` should produce `KEY=secr**` -with no trailing quote. - ---- - -### Finding 11: isRelative computed but unused in Firefox profile parsing -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/browser.nim:11-48 - -**Issue:** The `scanFirefox` proc parses `IsRelative=0` from profiles.ini -and stores it in `isRelative`, but this variable is never read. Profile -path resolution uses `profile.startsWith("/")` instead. The variable is -dead code from an abandoned design path. - -**Proof:** `isRelative` is set on lines 23 and 37, but never appears in -any conditional or expression after the parsing loop. - -**Proof Check:** Confidence: HIGH — grep for `isRelative` in browser.nim -shows only assignments, zero reads. - -**Fix:** -`src/collectors/browser.nim` — remove the `isRelative` variable entirely -(lines 23, 37). The `startsWith("/")` check on line 43 is sufficient for -Linux path detection. - -**Test:** -```bash -just check -``` -Verify compilation succeeds with no warnings about unused variable. - ---- - -### Finding 12: Azure scanner adds directory finding unconditionally -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/cloud.nim:140-144 - -**Issue:** `scanAzure` always adds an svInfo finding for the Azure CLI -directory after checking for specific token files. If token cache findings -were already added, this creates redundant noise. If no tokens were found, -a bare directory finding at svInfo adds very little value. - -**Proof:** If `~/.azure/` exists with `accessTokens.json`, the output shows: -1. "Azure token cache" at svMedium — useful -2. "Azure CLI configuration directory" at svInfo — noise, adds nothing - -**Proof Check:** Confidence: MEDIUM — it's noise, not incorrect data. Could -argue the directory finding is useful as a "this user has Azure CLI installed" -signal, but only if no token files were found. - -**Fix:** -`src/collectors/cloud.nim:140-144` — only add the directory finding if no -token files were found: -```nim -if result.findings.len == 0 or - result.findings[^1].category != catCloud: - result.findings.add(makeFinding( - azDir, - "Azure CLI configuration directory", - catCloud, svInfo - )) -``` - -Better: track whether any Azure-specific findings were added and only emit -the directory finding as a fallback. - -**Test:** -Docker test — verify Azure directory finding only appears when no token -findings exist. - ---- - -## Self-Interrogation - -Looking at these 12 findings as a whole: - -- **Did I miss a dimension?** The tool has no rate-limiting or size-limiting on - file reads. `readFileContent` reads entire files into memory. A malicious - (or just large) `.bash_history` of several GB would cause OOM. But the - history scanner has `MaxHistoryLines = 50000` via `readFileLines`, which - mitigates this for its use case. Other collectors reading full files - (git config, kubeconfig) are typically small. Not worth a finding. - -- **Are any findings weak?** Finding 12 (Azure directory) is the weakest — - it's a UX preference, not a bug. Keeping it as MINOR is appropriate. - Finding 11 (dead variable) is real but trivial. Everything MAJOR and above - is solid. - -- **Completeness check:** The tool has 7 modules covering the major - categories but Finding 7 lists 8 specific credential stores that any - practitioner would expect. The `.netrc` omission alone is notable since - it's been the standard Unix credential store since the 1980s. - -## Summary - -**Total Findings:** 12 (2 critical, 5 major, 5 minor) -**Code Quality Findings:** 11 -**Completeness Findings:** 1 diff --git a/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md b/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md deleted file mode 100644 index 959bca97..00000000 --- a/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md +++ /dev/null @@ -1,257 +0,0 @@ -# Credential Enumeration Tool — Design Spec - -## Overview - -A post-access credential enumeration tool written in Nim that scans Linux systems for exposed secrets across 7 categories. Compiles to a single static binary with zero dependencies — drop on target, run, get a structured report of every credential file, its exposure level, and severity rating. - -**Language:** Nim 2.2.x -**Binary name:** `credenum` -**Architecture:** Modular collector pattern — one module per credential category, common interface, central runner - ---- - -## Core Types (`src/types.nim`) - -- **Severity** — enum: `info`, `low`, `medium`, `high`, `critical` -- **Category** — enum: `browser`, `ssh`, `cloud`, `history`, `keyring`, `git`, `apptoken` -- **Credential** — discovered credential data (source, credential type, value or redacted preview, metadata) -- **Finding** — a single discovery (path, category, severity, description, optional Credential, file permissions, timestamps) -- **CollectorResult** — `seq[Finding]` + collector metadata (name, duration, errors encountered) -- **HarvestConfig** — runtime configuration (target home dir, enabled modules, exclude patterns, output format, flags) -- **Report** — all collector results + summary stats + timestamp + target info - -**Severity assignment rules:** -- Critical: plaintext credentials in world-readable files -- High: unprotected private keys, plaintext credential stores -- Medium: overly permissive file permissions on credential files -- Low: credential files exist but properly permissioned -- Info: enumeration data (host lists, profile counts, existence checks) - ---- - -## Collector Modules - -Each module exports `proc collect(config: HarvestConfig): CollectorResult`. The runner calls each in sequence. No inheritance needed — just a common return type and a seq of collector procs populated at init. - -### 1. Browser Credential Store Scanner (`src/collectors/browser.nim`) -- Firefox: locate profiles via `profiles.ini`, check `logins.json`, `cookies.sqlite`, `key4.db` -- Chromium: locate `Login Data`, `Cookies`, `Web Data` SQLite databases -- Report: file locations, permissions, entry counts, last-modified timestamps -- Flag world-readable/group-readable databases as critical -- Detection + metadata level (no decryption) - -### 2. SSH Key & Config Auditor (`src/collectors/ssh.nim`) -- Scan `~/.ssh/` for private keys (RSA, Ed25519, ECDSA, non-standard filenames) -- Read key headers to determine passphrase protection (encrypted PEM vs unencrypted) -- Flag unprotected keys as high severity -- Check permissions (keys=600, directory=700) -- Parse `~/.ssh/config` — enumerate hosts, identify weak settings -- Read `authorized_keys` and `known_hosts` for enumeration - -### 3. Cloud Provider Config Scanner (`src/collectors/cloud.nim`) -- AWS: `~/.aws/credentials`, `~/.aws/config` — count profiles, identify static vs session keys -- GCP: `~/.config/gcloud/` — application default credentials, service account keys -- Azure: `~/.azure/` — access tokens, profile info -- Kubernetes: `~/.kube/config` — enumerate contexts, clusters, auth methods -- Permission checks, flag anything broader than owner-only - -### 4. Shell History & Environment Scanner (`src/collectors/history.nim`) -- Read `.bash_history`, `.zsh_history`, `.fish_history` -- Pattern match for inline secrets: KEY=, SECRET=, TOKEN=, PASSWORD= exports, DB connection strings, curl/wget with auth headers -- Scan for `.env` files in home directory tree -- Report: file, line region, redacted preview - -### 5. Keyring & Password Store Scanner (`src/collectors/keyring.nim`) -- GNOME Keyring: `~/.local/share/keyrings/` -- KDE Wallet: `~/.local/share/kwalletd/` -- KeePass/KeePassXC: search for `.kdbx` files -- pass (password-store): `~/.password-store/` -- Bitwarden: `~/.config/Bitwarden/` local vault data -- Report locations, file sizes, permissions, last modified - -### 6. Git Credential Store Scanner (`src/collectors/git.nim`) -- `~/.git-credentials` — plaintext storage (high severity) -- `~/.gitconfig` — check `credential.helper` setting -- Search for credential cache socket files -- Check for GitHub/GitLab PATs in config files - -### 7. Application Token Scanner (`src/collectors/apptoken.nim`) -- Slack: `~/.config/Slack/` session/cookie storage -- Discord: `~/.config/discord/` token storage -- VS Code: `~/.config/Code/` stored secrets -- Database configs: `~/.pgpass`, `~/.my.cnf`, Redis configs -- MQTT broker configs, common application credential files - ---- - -## CLI Interface - -``` -credenum [flags] - -Flags: - --target Target user home directory (default: current user) - --modules Comma-separated module list (default: all) - --exclude Glob patterns for paths to skip - --format Output format: terminal, json, both (default: terminal) - --output Write JSON output to file - --dry-run List paths that would be scanned without reading - --quiet Suppress banner and progress, output findings only - --verbose Show all scanned paths, not just findings -``` - -**CLI parsing:** `std/parseopt` (stdlib, no dependencies) - ---- - -## Terminal Output Design - -Hacker-aesthetic terminal output: -- ASCII art banner with tool name and version -- Box-drawing characters for section borders -- Color-coded severity badges (critical=red, high=magenta, medium=yellow, low=cyan, info=dim) -- Clean table formatting for findings -- Summary footer with totals by severity, modules scanned, duration -- Progress indicators showing which module is currently scanning - ---- - -## Output Formats - -### Terminal (ANSI) -Colored, formatted output designed for interactive use. Banner, per-module sections, severity badges, summary. - -### JSON -Structured report: -```json -{ - "metadata": { "timestamp": "...", "target": "...", "version": "...", "duration_ms": 0 }, - "modules": [ - { - "name": "ssh", - "findings": [ - { - "category": "ssh", - "severity": "high", - "path": "/home/user/.ssh/id_rsa", - "description": "Unprotected private key (no passphrase)", - "permissions": "0644", - "modified": "2026-01-15T10:30:00Z" - } - ], - "duration_ms": 12, - "errors": [] - } - ], - "summary": { "critical": 2, "high": 5, "medium": 8, "low": 3, "info": 12 } -} -``` - ---- - -## Build & Distribution - -### Static binary via musl -- `config.nims` configures musl-gcc for fully static Linux binaries -- Zero runtime dependencies - -### Cross-compilation -- x86_64-linux (primary) -- aarch64-linux (ARM64) -- Uses zig cc for cross-compilation -- Justfile tasks: `just build-x86`, `just build-arm64` - -### Build modes -- `just build` — debug build with all checks -- `just release` — optimized static binary (`-d:release -d:lto --opt:size`) -- `just release-small` — stripped + UPX compressed - -### Justfile tasks -- `just build` / `just release` / `just release-small` -- `just test` — run unit tests -- `just docker-test` — build + run in Docker test environment -- `just fmt` — format with nph -- `just clean` - ---- - -## Docker Test Environment - -**`tests/docker/Dockerfile`** — Ubuntu-based container planting fake credentials across all 7 categories: - -- SSH: test key pairs (some protected, some not), various permissions -- Browser: mock Firefox profile with dummy `logins.json`, mock Chromium dirs -- Cloud: fake AWS credentials, dummy GCP service account JSON, mock kubeconfig -- History: seeded `.bash_history`/`.zsh_history` with fake tokens -- Keyrings: mock `.kdbx`, mock `pass` store -- Git: `.git-credentials` with dummy entries -- App tokens: mock Slack/Discord/VS Code configs, `.pgpass`, `.my.cnf` - -All values are obviously fake (`AKIA_FAKE_ACCESS_KEY_12345`). - -`just docker-test` builds, runs credenum inside, validates all findings discovered with correct severity. - ---- - -## Project Structure - -``` -credential-enumeration/ -├── src/ -│ ├── harvester.nim # Entry point, CLI parsing -│ ├── config.nim # Constants, paths, patterns, severities -│ ├── types.nim # Core types -│ ├── runner.nim # Execute collectors, aggregate results -│ ├── output/ -│ │ ├── terminal.nim # ANSI terminal output with hacker aesthetic -│ │ └── json.nim # JSON serialization -│ └── collectors/ -│ ├── base.nim # Collector registration -│ ├── browser.nim -│ ├── ssh.nim -│ ├── cloud.nim -│ ├── history.nim -│ ├── keyring.nim -│ ├── git.nim -│ └── apptoken.nim -├── tests/ -│ └── docker/ -│ ├── Dockerfile -│ └── planted/ # Mock credential files -├── learn/ -│ ├── 00-OVERVIEW.md -│ ├── 01-CONCEPTS.md -│ ├── 02-ARCHITECTURE.md -│ ├── 03-IMPLEMENTATION.md -│ └── 04-CHALLENGES.md -├── config.nims # Build config (static linking, cross-compile) -├── credential-enumeration.nimble # Package manifest -├── Justfile -├── install.sh -├── README.md -├── LICENSE -└── .gitignore -``` - ---- - -## Learn Folder - -- **00-OVERVIEW.md** — What credential enumeration is, why it matters, prerequisites, quick start -- **01-CONCEPTS.md** — Linux credential storage locations, file permission model, where apps store secrets and why defaults are insecure. Real-world breach references. -- **02-ARCHITECTURE.md** — Modular collector design, data flow, why Nim for security tooling -- **03-IMPLEMENTATION.md** — Code walkthrough: core types, collector pattern, CLI parsing, output formatting, Nim type system and modules -- **04-CHALLENGES.md** — Extensions: new collectors, encrypted output, network enumeration, framework integration - ---- - -## What This Project Teaches - -- Linux credential storage locations across browsers, SSH, cloud tools, shells, keyrings, Git, and applications -- File permission models and their security implications -- Nim programming: static compilation, module system, type system, FFI potential -- Why Nim is adopted in the security assessment community (small static binaries, C-level performance) -- Modular tool architecture with common interfaces -- Building visually polished CLI tools -- Docker-based testing for security tools -- Cross-compilation and static linking for portable binaries From b58fed96d8830c5037d11075d87bf57bd81a58bc Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 10 Apr 2026 00:21:59 +0000 Subject: [PATCH 08/30] chore: update fullstack-template submodule --- TEMPLATES/fullstack-template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TEMPLATES/fullstack-template b/TEMPLATES/fullstack-template index ecbb534e..daa74d16 160000 --- a/TEMPLATES/fullstack-template +++ b/TEMPLATES/fullstack-template @@ -1 +1 @@ -Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172 +Subproject commit daa74d165fa60faaa41aac493f2ba1a3400a88c4 From 8d5a177bca922adbb3f2b96f4651929fa2ae6282 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 10 Apr 2026 17:14:30 -0400 Subject: [PATCH 09/30] =?UTF-8?q?feat:=20dlp=20scanner=20=E2=80=94=20multi?= =?UTF-8?q?-format=20data=20loss=20prevention=20scanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans files, databases, and network captures for sensitive data (PII, credentials, financial, health) with 30+ detection rules, protocol-aware network analysis, DNS exfiltration detection, compliance mapping (HIPAA, PCI-DSS, GDPR, CCPA), and multi-format reporting (console, JSON, SARIF, CSV). --- .../intermediate/dlp-scanner/.dlp-scanner.yml | 86 + .../intermediate/dlp-scanner/.env.example | 24 + PROJECTS/intermediate/dlp-scanner/.gitignore | 11 + PROJECTS/intermediate/dlp-scanner/.style.yapf | 46 + PROJECTS/intermediate/dlp-scanner/README.md | 112 ++ PROJECTS/intermediate/dlp-scanner/install.sh | 26 + .../dlp-scanner/learn/00-OVERVIEW.md | 76 + .../dlp-scanner/learn/01-CONCEPTS.md | 133 ++ .../dlp-scanner/learn/02-ARCHITECTURE.md | 539 +++++++ .../dlp-scanner/learn/03-IMPLEMENTATION.md | 801 ++++++++++ .../dlp-scanner/learn/04-CHALLENGES.md | 422 +++++ .../intermediate/dlp-scanner/pyproject.toml | 181 +++ .../dlp-scanner/src/dlp_scanner/__init__.py | 6 + .../dlp-scanner/src/dlp_scanner/cli.py | 73 + .../src/dlp_scanner/commands/__init__.py | 4 + .../src/dlp_scanner/commands/report.py | 187 +++ .../src/dlp_scanner/commands/scan.py | 195 +++ .../dlp-scanner/src/dlp_scanner/compliance.py | 245 +++ .../dlp-scanner/src/dlp_scanner/config.py | 173 +++ .../dlp-scanner/src/dlp_scanner/constants.py | 171 ++ .../src/dlp_scanner/detectors/__init__.py | 4 + .../src/dlp_scanner/detectors/base.py | 51 + .../src/dlp_scanner/detectors/context.py | 142 ++ .../src/dlp_scanner/detectors/entropy.py | 126 ++ .../src/dlp_scanner/detectors/pattern.py | 68 + .../src/dlp_scanner/detectors/registry.py | 112 ++ .../dlp_scanner/detectors/rules/__init__.py | 4 + .../detectors/rules/credentials.py | 157 ++ .../dlp_scanner/detectors/rules/financial.py | 184 +++ .../src/dlp_scanner/detectors/rules/health.py | 140 ++ .../src/dlp_scanner/detectors/rules/pii.py | 209 +++ .../dlp-scanner/src/dlp_scanner/engine.py | 146 ++ .../src/dlp_scanner/extractors/__init__.py | 4 + .../src/dlp_scanner/extractors/archive.py | 191 +++ .../src/dlp_scanner/extractors/base.py | 27 + .../src/dlp_scanner/extractors/email.py | 123 ++ .../src/dlp_scanner/extractors/office.py | 178 +++ .../src/dlp_scanner/extractors/pdf.py | 56 + .../src/dlp_scanner/extractors/plaintext.py | 112 ++ .../src/dlp_scanner/extractors/structured.py | 327 ++++ .../dlp-scanner/src/dlp_scanner/log.py | 80 + .../dlp-scanner/src/dlp_scanner/models.py | 112 ++ .../src/dlp_scanner/network/__init__.py | 4 + .../src/dlp_scanner/network/exfiltration.py | 279 ++++ .../src/dlp_scanner/network/flow_tracker.py | 126 ++ .../src/dlp_scanner/network/pcap.py | 115 ++ .../src/dlp_scanner/network/protocols.py | 250 +++ .../dlp-scanner/src/dlp_scanner/redaction.py | 84 + .../src/dlp_scanner/reporters/__init__.py | 4 + .../src/dlp_scanner/reporters/base.py | 20 + .../src/dlp_scanner/reporters/console.py | 162 ++ .../src/dlp_scanner/reporters/csv_report.py | 64 + .../src/dlp_scanner/reporters/json_report.py | 110 ++ .../src/dlp_scanner/reporters/sarif.py | 171 ++ .../src/dlp_scanner/scanners/__init__.py | 4 + .../src/dlp_scanner/scanners/base.py | 20 + .../src/dlp_scanner/scanners/db_scanner.py | 530 +++++++ .../src/dlp_scanner/scanners/file_scanner.py | 224 +++ .../dlp_scanner/scanners/network_scanner.py | 338 ++++ .../dlp-scanner/src/dlp_scanner/scoring.py | 52 + .../dlp-scanner/tests/__init__.py | 4 + .../dlp-scanner/tests/conftest.py | 87 ++ .../dlp-scanner/tests/test_cli.py | 312 ++++ .../dlp-scanner/tests/test_compliance.py | 71 + .../dlp-scanner/tests/test_config.py | 81 + .../tests/test_detectors/__init__.py | 4 + .../tests/test_detectors/test_context.py | 120 ++ .../tests/test_detectors/test_entropy.py | 83 + .../tests/test_detectors/test_pattern.py | 58 + .../test_detectors/test_rules/__init__.py | 4 + .../test_rules/test_credentials.py | 123 ++ .../test_rules/test_financial.py | 125 ++ .../test_detectors/test_rules/test_health.py | 112 ++ .../test_detectors/test_rules/test_pii.py | 138 ++ .../dlp-scanner/tests/test_engine.py | 220 +++ .../tests/test_extractors/__init__.py | 4 + .../tests/test_network/__init__.py | 4 + .../tests/test_network/test_exfiltration.py | 160 ++ .../tests/test_network/test_flow_tracker.py | 195 +++ .../tests/test_network/test_pcap.py | 60 + .../tests/test_network/test_protocols.py | 158 ++ .../tests/test_reporters/__init__.py | 4 + .../tests/test_reporters/test_csv_report.py | 104 ++ .../tests/test_reporters/test_json_report.py | 159 ++ .../tests/test_reporters/test_sarif.py | 174 +++ .../tests/test_scanners/__init__.py | 4 + .../tests/test_scanners/test_db_scanner.py | 278 ++++ .../tests/test_scanners/test_file_scanner.py | 210 +++ PROJECTS/intermediate/dlp-scanner/uv.lock | 1377 +++++++++++++++++ 89 files changed, 13054 insertions(+) create mode 100644 PROJECTS/intermediate/dlp-scanner/.dlp-scanner.yml create mode 100644 PROJECTS/intermediate/dlp-scanner/.env.example create mode 100644 PROJECTS/intermediate/dlp-scanner/.gitignore create mode 100644 PROJECTS/intermediate/dlp-scanner/.style.yapf create mode 100644 PROJECTS/intermediate/dlp-scanner/README.md create mode 100755 PROJECTS/intermediate/dlp-scanner/install.sh create mode 100644 PROJECTS/intermediate/dlp-scanner/learn/00-OVERVIEW.md create mode 100644 PROJECTS/intermediate/dlp-scanner/learn/01-CONCEPTS.md create mode 100644 PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md create mode 100644 PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md create mode 100644 PROJECTS/intermediate/dlp-scanner/learn/04-CHALLENGES.md create mode 100644 PROJECTS/intermediate/dlp-scanner/pyproject.toml create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/cli.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/report.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/scan.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/compliance.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/config.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/constants.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/base.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/context.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/entropy.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/pattern.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/registry.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/health.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/engine.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/archive.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/base.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/email.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/office.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/pdf.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/plaintext.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/structured.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/log.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/models.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/exfiltration.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/flow_tracker.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/pcap.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/protocols.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/redaction.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/base.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/console.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/csv_report.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/json_report.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/sarif.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/base.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py create mode 100644 PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scoring.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/conftest.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_cli.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_compliance.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_config.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_context.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_entropy.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_pattern.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_credentials.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_financial.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_health.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_pii.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_engine.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_extractors/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_network/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_network/test_exfiltration.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_network/test_flow_tracker.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_network/test_pcap.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_network/test_protocols.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_reporters/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_csv_report.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_json_report.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_sarif.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_scanners/__init__.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_db_scanner.py create mode 100644 PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_file_scanner.py create mode 100644 PROJECTS/intermediate/dlp-scanner/uv.lock diff --git a/PROJECTS/intermediate/dlp-scanner/.dlp-scanner.yml b/PROJECTS/intermediate/dlp-scanner/.dlp-scanner.yml new file mode 100644 index 00000000..422cd5dd --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/.dlp-scanner.yml @@ -0,0 +1,86 @@ +# ©AngelaMos | 2026 +# .dlp-scanner.yml + +scan: + file: + max_file_size_mb: 100 + recursive: true + exclude_patterns: + - "*.pyc" + - "__pycache__" + - ".git" + - "node_modules" + - ".venv" + include_extensions: + - ".pdf" + - ".docx" + - ".xlsx" + - ".xls" + - ".csv" + - ".json" + - ".xml" + - ".yaml" + - ".yml" + - ".txt" + - ".log" + - ".eml" + - ".msg" + - ".parquet" + - ".avro" + - ".tar.gz" + - ".tar.bz2" + - ".zip" + + database: + sample_percentage: 5 + max_rows_per_table: 10000 + timeout_seconds: 30 + exclude_tables: [] + include_tables: [] + + network: + bpf_filter: "" + entropy_threshold: 7.2 + dns_label_entropy_threshold: 4.0 + max_packets: 0 + +detection: + min_confidence: 0.20 + severity_threshold: "low" + context_window_tokens: 10 + enable_rules: + - "*" + disable_rules: [] + allowlists: + values: + - "123-45-6789" + - "000-00-0000" + - "4111111111111111" + domains: + - "example.com" + - "test.com" + file_patterns: + - "test_*" + - "*_fixture*" + - "mock_*" + +compliance: + frameworks: + - "HIPAA" + - "PCI_DSS" + - "GDPR" + - "CCPA" + - "SOX" + - "GLBA" + +output: + format: "console" + output_file: "" + redaction_style: "partial" + verbose: false + color: true + +logging: + level: "INFO" + json_output: false + log_file: "" diff --git a/PROJECTS/intermediate/dlp-scanner/.env.example b/PROJECTS/intermediate/dlp-scanner/.env.example new file mode 100644 index 00000000..01f62abb --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/.env.example @@ -0,0 +1,24 @@ +# ©AngelaMos | 2026 +# .env.example + +# PostgreSQL +PGHOST=localhost +PGPORT=5432 +PGUSER=dlp_scanner +PGPASSWORD=changeme +PGDATABASE=target_db + +# MySQL +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=dlp_scanner +MYSQL_PASSWORD=changeme +MYSQL_DATABASE=target_db + +# MongoDB +MONGO_URI=mongodb://localhost:27017 +MONGO_DATABASE=target_db + +# Logging +DLP_LOG_LEVEL=INFO +DLP_LOG_JSON=false diff --git a/PROJECTS/intermediate/dlp-scanner/.gitignore b/PROJECTS/intermediate/dlp-scanner/.gitignore new file mode 100644 index 00000000..d7e13b74 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/.gitignore @@ -0,0 +1,11 @@ +docs/ +__pycache__/ +*.pyc +.env +.venv/ +*.egg-info/ +dist/ +build/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ diff --git a/PROJECTS/intermediate/dlp-scanner/.style.yapf b/PROJECTS/intermediate/dlp-scanner/.style.yapf new file mode 100644 index 00000000..74d83416 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/.style.yapf @@ -0,0 +1,46 @@ +[style] +based_on_style = pep8 +column_limit = 75 +indent_width = 4 +continuation_indent_width = 4 +indent_closing_brackets = false +dedent_closing_brackets = true +indent_blank_lines = false +spaces_before_comment = 2 +spaces_around_power_operator = false +spaces_around_default_or_named_assign = true +space_between_ending_comma_and_closing_bracket = false +space_inside_brackets = false +spaces_around_subscript_colon = true +blank_line_before_nested_class_or_def = false +blank_line_before_class_docstring = false +blank_lines_around_top_level_definition = 2 +blank_lines_between_top_level_imports_and_variables = 2 +blank_line_before_module_docstring = false +split_before_logical_operator = true +split_before_first_argument = true +split_before_named_assigns = true +split_complex_comprehension = true +split_before_expression_after_opening_paren = false +split_before_closing_bracket = true +split_all_comma_separated_values = true +split_all_top_level_comma_separated_values = false +coalesce_brackets = false +each_dict_entry_on_separate_line = true +allow_multiline_lambdas = false +allow_multiline_dictionary_keys = false +split_penalty_import_names = 0 +join_multiple_lines = false +align_closing_bracket_with_visual_indent = true +arithmetic_precedence_indication = false +split_penalty_for_added_line_split = 275 +use_tabs = false +split_before_dot = false +split_arguments_when_comma_terminated = true +i18n_function_call = ['_', 'N_', 'gettext', 'ngettext'] +i18n_comment = ['# Translators:', '# i18n:'] +split_penalty_comprehension = 80 +split_penalty_after_opening_bracket = 280 +split_penalty_before_if_expr = 0 +split_penalty_bitwise_operator = 290 +split_penalty_logical_operator = 0 diff --git a/PROJECTS/intermediate/dlp-scanner/README.md b/PROJECTS/intermediate/dlp-scanner/README.md new file mode 100644 index 00000000..4bcedf04 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/README.md @@ -0,0 +1,112 @@ +```ruby +██████╗ ██╗ ██████╗ ███████╗ ██████╗ █████╗ ███╗ ██╗ +██╔══██╗██║ ██╔══██╗ ██╔════╝██╔════╝██╔══██╗████╗ ██║ +██║ ██║██║ ██████╔╝█████╗███████╗██║ ███████║██╔██╗ ██║ +██║ ██║██║ ██╔═══╝ ╚════╝╚════██║██║ ██╔══██║██║╚██╗██║ +██████╔╝███████╗██║ ███████║╚██████╗██║ ██║██║ ╚████║ +╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-intermediate-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/dlp-scanner) +[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) + +> Data Loss Prevention scanner for files, databases, and network traffic. + +*This is a quick overview. Security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Scans files (PDF, DOCX, XLSX, CSV, JSON, XML, YAML, Parquet, Avro, archives, emails) for PII, credentials, financial data, and PHI +- Scans databases (PostgreSQL, MySQL, MongoDB, SQLite) with schema introspection and sampling +- Scans network captures (PCAP/PCAPNG) with protocol parsing, TCP reassembly, and DNS exfiltration detection +- Confidence scoring pipeline: regex match, checksum validation (Luhn, Mod-97, Mod-11), context keyword proximity, entity co-occurrence +- Maps findings to compliance frameworks (HIPAA, PCI-DSS, GDPR, CCPA, SOX, GLBA, FERPA) +- Reports in console (Rich tables), JSON, SARIF 2.1.0, or CSV + +## Quick Start + +```bash +bash install.sh +dlp-scan file ./data +``` + +## Usage + +```bash +dlp-scan file ./data/employees/ # scan a directory +dlp-scan file ./report.pdf -f json # scan a file, JSON output +dlp-scan db postgres://user:pass@host/db # scan PostgreSQL +dlp-scan db sqlite:///path/to/local.db # scan SQLite +dlp-scan network capture.pcap # scan network traffic +dlp-scan file ./data -f sarif -o results.sarif # SARIF for CI/CD +dlp-scan report convert results.json -f csv # convert report format +dlp-scan report summary results.json # print summary stats +``` + +### Global Options + +``` +--config, -c Path to YAML config file +--verbose, -v Enable debug logging +--version Show version +``` + +### Output Formats + +| Format | Flag | Use Case | +|--------|------|----------| +| Console | `-f console` | Interactive review with Rich tables | +| JSON | `-f json` | Structured analysis and archival | +| SARIF | `-f sarif` | GitHub code scanning, CI/CD integration | +| CSV | `-f csv` | Compliance team export, spreadsheet import | + +## Stack + +**Language:** Python 3.12+ + +**CLI:** Typer 0.15+ with Rich integration + +**Detection:** Regex + checksum validators + Shannon entropy + context keyword scoring + +**File Formats:** PyMuPDF, python-docx, openpyxl, xlrd, defusedxml, lxml, pyarrow, fastavro, extract-msg + +**Databases:** asyncpg (PostgreSQL), aiomysql (MySQL), pymongo async (MongoDB), aiosqlite (SQLite) + +**Network:** dpkt (PCAP parsing), TCP reassembly, DPI protocol identification, DNS exfiltration heuristics + +**Config:** Pydantic 2.10+ models with YAML config loading (ruamel.yaml) + +**Quality:** ruff, mypy (strict), yapf, pytest + hypothesis, structlog + +## Configuration + +Copy `.dlp-scanner.yml` to your project root and customize. Key settings: + +```yaml +detection: + min_confidence: 0.20 # minimum score to report + enable_rules: ["*"] # glob patterns for rule IDs + allowlists: + values: ["123-45-6789"] # suppress known test values + +output: + format: "console" # console, json, sarif, csv + redaction_style: "partial" # partial, full, none +``` + +## Learn + +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | DLP theory and real-world breaches | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | + +## License + +[AGPLv3](https://www.gnu.org/licenses/agpl-3.0) diff --git a/PROJECTS/intermediate/dlp-scanner/install.sh b/PROJECTS/intermediate/dlp-scanner/install.sh new file mode 100755 index 00000000..19db9e7f --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/install.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh + +set -euo pipefail + +command -v uv >/dev/null 2>&1 || { + echo "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +} + +echo "Syncing dependencies..." +uv sync + +echo "Downloading spaCy model (optional, for NLP-based detection)..." +uv run python -m spacy download en_core_web_sm 2>/dev/null || true + +echo "" +echo "Setup complete. Run the scanner with:" +echo " uv run dlp-scan --help" +echo "" +echo "Quick start:" +echo " uv run dlp-scan scan file ./path/to/scan" +echo " uv run dlp-scan scan db sqlite:///path/to/db.sqlite3" +echo " uv run dlp-scan scan network ./capture.pcap" diff --git a/PROJECTS/intermediate/dlp-scanner/learn/00-OVERVIEW.md b/PROJECTS/intermediate/dlp-scanner/learn/00-OVERVIEW.md new file mode 100644 index 00000000..bd006f81 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/learn/00-OVERVIEW.md @@ -0,0 +1,76 @@ +# 00-OVERVIEW.md + +# DLP Scanner + +## What This Is + +A command-line Data Loss Prevention scanner that detects sensitive data across three surfaces: files (PDF, DOCX, XLSX, CSV, JSON, XML, YAML, Parquet, Avro, archives, emails), databases (PostgreSQL, MySQL, MongoDB, SQLite), and network captures (PCAP/PCAPNG with protocol parsing and TCP reassembly). It uses a confidence scoring pipeline combining regex matching, checksum validation (Luhn for credit cards, Mod-97 for IBANs, Mod-11 for NHS numbers), keyword proximity analysis, and Shannon entropy detection. Findings are classified by severity and mapped to compliance frameworks (HIPAA, PCI-DSS, GDPR, CCPA, SOX, GLBA, FERPA). Output supports console Rich tables, JSON, SARIF 2.1.0 for CI/CD, and CSV for compliance teams. + +## Why This Matters + +Data breaches involving PII exposure keep appearing because organizations cannot find sensitive data they do not know exists. The 2017 Equifax breach exposed 147 million SSNs from an unpatched Apache Struts application, but the underlying problem was that SSNs were stored in plaintext across multiple database tables without anyone tracking where that data lived. In 2019, Capital One lost 100 million credit applications from an S3 bucket because a misconfigured WAF allowed server-side request forgery, and nobody had scanned those files to realize unencrypted SSNs and credit card numbers sat in flat CSV exports. The Marriott breach (2018) exposed 500 million records including 5.25 million unencrypted passport numbers, partially because the Starwood reservation system merged without a data inventory that would have flagged those fields as sensitive. + +These are not failure-of-firewall problems. They are failure-of-visibility problems. DLP tools exist to answer "where is our sensitive data?" before attackers answer it for you. Commercial solutions (Symantec DLP, Microsoft Purview, Netskope) cost six figures and require enterprise deployment, but the core detection logic is straightforward: pattern matching with validation, context analysis to reduce false positives, and compliance framework mapping to prioritize remediation. + +This project builds a DLP engine from scratch, teaching you the same detection techniques that power production systems. + +**Real world scenarios where this applies:** +- Security engineers scanning file shares before a cloud migration to find PII that needs encryption +- Compliance teams auditing database tables for HIPAA-regulated PHI that should not be in plaintext +- SOC analysts inspecting PCAP captures for credentials or PII transmitted in the clear +- DevOps teams running DLP checks in CI/CD pipelines to catch secrets before they reach production +- Incident responders determining what sensitive data was accessible from a compromised network segment + +## What You'll Learn + +**Security Concepts:** +- Data classification tiers and how PII, PHI, PCI, and credential data map to regulatory requirements +- Confidence scoring: why regex alone produces false positives and how checksum validation, context keywords, and entity co-occurrence reduce them +- Compliance framework mapping: HIPAA's 18 identifiers, PCI-DSS cardholder data, GDPR personal data categories, CCPA consumer information +- Network DLP: detecting sensitive data in transit, DNS exfiltration via high-entropy subdomain labels, base64-encoded payloads in HTTP bodies +- Redaction strategies: why you never store the raw matched content in findings + +**Technical Skills:** +- Building a multi-format text extraction pipeline that handles 14+ file formats through a unified Protocol interface +- Database schema introspection across 4 database engines with statistical sampling (TABLESAMPLE BERNOULLI, $sample aggregation) +- TCP stream reassembly from raw packets using sequence-number ordering and bidirectional flow key normalization +- Confidence scoring pipeline: base scores, checksum boosts, context keyword proximity windows, entity co-occurrence +- SARIF 2.1.0 output for GitHub code scanning integration + +**Tools and Techniques:** +- Typer CLI with Annotated-style parameters and global option propagation through Click context +- Pydantic 2.x for configuration validation with YAML loading +- structlog with stdlib integration for structured JSON logging +- orjson for high-performance JSON serialization +- asyncpg, aiomysql, pymongo async, aiosqlite for async database access +- dpkt for fast PCAP parsing (100x faster than Scapy) +- pytest with hypothesis for property-based testing of detection rules + +## Prerequisites + +**Required knowledge:** +- Python fundamentals: dataclasses, type hints, list comprehensions, context managers +- Basic networking: TCP/IP, ports, packets, what PCAP files contain +- Basic SQL: SELECT, WHERE, table schemas, column types +- Security basics: what PII is, why SSNs and credit card numbers need protection, what compliance frameworks exist + +**Tools you'll need:** +- Python 3.12+ (uses modern generic syntax and `from __future__ import annotations`) +- uv package manager (install: `curl -LsSf https://astral.sh/uv/install.sh | sh`) +- A terminal with UTF-8 support (for Rich console output) + +**Helpful but not required:** +- Experience with regex and pattern matching +- Familiarity with dpkt or Scapy for packet analysis +- Knowledge of database URIs and connection strings +- Understanding of SARIF format for CI/CD security tooling + +## Quick Start + +```bash +bash install.sh +dlp-scan file ./data +dlp-scan file ./data -f json -o results.json +dlp-scan db sqlite:///path/to/database.db +dlp-scan report summary results.json +``` diff --git a/PROJECTS/intermediate/dlp-scanner/learn/01-CONCEPTS.md b/PROJECTS/intermediate/dlp-scanner/learn/01-CONCEPTS.md new file mode 100644 index 00000000..e1ec8e53 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/learn/01-CONCEPTS.md @@ -0,0 +1,133 @@ +# 01-CONCEPTS.md + +# DLP Concepts + +## What is Data Loss Prevention? + +DLP is the practice of detecting and preventing sensitive data from being stored, transmitted, or accessed in unauthorized ways. The three modes of DLP correspond to the three scan surfaces in this project: + +- **Data at rest**: files on disk, records in databases, documents in cloud storage. Our file scanner and database scanner cover this surface. +- **Data in motion**: network traffic, API calls, email transmissions. Our network scanner covers this surface. +- **Data in use**: clipboard contents, screen captures, application memory. Not covered here (requires endpoint agents). + +The fundamental question DLP answers: "Where is our sensitive data, and is it protected?" + +## Detection Techniques + +### Pattern Matching with Validation + +The simplest approach: regex patterns that match structural formats like SSNs (XXX-XX-XXXX), credit card numbers (16 digits with known prefixes), and API keys (known prefix patterns like `AKIA` for AWS). + +The problem with regex alone is false positive rates. The string `123-45-6789` matches an SSN pattern but appears in test data, serial numbers, and phone extensions. The string `4532015112830366` matches a Visa card pattern but could be a random 16-digit identifier. + +This is why production DLP systems never rely on regex alone. They add validation layers: + +**Checksum validation** eliminates structurally invalid matches. Credit card numbers use the Luhn algorithm: double every second digit from right, subtract 9 if the result exceeds 9, and verify the total is divisible by 10. A random 16-digit number has a ~10% chance of passing Luhn, which is still useful signal. IBANs use Mod-97 (ISO 7064): rearrange the country code and check digits, convert letters to numbers, and verify the result mod 97 equals 1. NHS numbers use Mod-11 with weighted digit multiplication. + +**SSN area validation** checks that the first three digits are not 000, 666, or 900-999 (never assigned by the SSA). Group and serial numbers must also be non-zero. This eliminates ranges that the Social Security Administration has never used. + +### Context Keyword Scoring + +A 9-digit number matching SSN format near the word "social security" is more likely to be an actual SSN than the same number in a column labeled "serial_number". Context scoring scans a bidirectional window around each match for relevant keywords: + +``` +For SSN patterns: "ssn", "social security", "social_security_number", "tax id" +For credit cards: "credit card", "card number", "payment", "billing" +For API keys: "api_key", "secret", "token", "authorization" +``` + +Keywords found within the window (default: 10 tokens in each direction) add a boost of +0.05 to +0.35 depending on proximity. Closer keywords contribute more confidence. + +### Shannon Entropy + +Random-looking strings often indicate secrets: API keys, encrypted values, base64-encoded credentials. Shannon entropy measures the randomness of a string: + +``` +H = -sum(p(x) * log2(p(x))) for each unique character x +``` + +English text has entropy around 3.5-4.5 bits per character. Base64-encoded data is around 5.5-6.0. Hex-encoded data is around 3.5-4.0. Truly random data approaches log2(alphabet_size). A 40-character string with entropy above 4.5 is flagged as a potential secret. + +### Confidence Scoring Pipeline + +Each detection produces a confidence score between 0.0 and 1.0: + +``` +1. Regex match -> base_score (0.10 to 0.85, configured per rule) +2. Checksum validation -> +0.30 if the checksum passes +3. Context keyword search -> +0.05 to +0.35 based on keyword proximity +4. Entity co-occurrence -> +0.10 to +0.20 if multiple PII types appear nearby +5. Final score capped at 1.0 +``` + +The score maps to severity: +- 0.85+ = critical +- 0.65+ = high +- 0.40+ = medium +- 0.20+ = low +- below 0.20 = discarded + +An SSN match (base 0.45) with valid area/group/serial and the word "ssn" nearby scores 0.45 + 0.30 (area validation acts as implicit checksum) + 0.15 (context) = 0.90, classified as critical. The same pattern without context scores 0.45, classified as medium, which is appropriate because it might be a phone number fragment. + +## Compliance Frameworks + +Regulatory frameworks define what data types require protection and what happens when they are exposed: + +**HIPAA (Health Insurance Portability and Accountability Act)**: Defines 18 types of Protected Health Information (PHI) including SSNs, medical record numbers, health plan beneficiary numbers, and biometric identifiers. A covered entity that fails to protect PHI faces fines from $100 to $50,000 per violation (up to $1.5 million per year per category). The 2015 Anthem breach exposed 78.8 million records and resulted in a $16 million settlement with HHS. + +**PCI-DSS (Payment Card Industry Data Security Standard)**: Requires protection of cardholder data: primary account numbers (PAN), cardholder names, expiration dates, and service codes. PAN must be rendered unreadable (encrypted, hashed, truncated, or tokenized). The Heartland Payment Systems breach (2008) compromised 130 million credit card numbers and cost the company $140 million in compensation. + +**GDPR (General Data Protection Regulation)**: Applies to personal data of EU residents including names, email addresses, phone numbers, IP addresses, and location data. Fines reach 4% of annual global revenue or 20 million euros, whichever is higher. Meta was fined 1.2 billion euros in 2023 for transferring EU user data to the US without adequate safeguards. + +**CCPA (California Consumer Privacy Act)**: Covers personal information of California residents. Similar categories to GDPR but with different enforcement mechanisms. Consumers can sue directly for data breaches involving unencrypted personal information ($100-$750 per consumer per incident). + +## Network DLP Concepts + +### DNS Exfiltration + +Attackers encode stolen data in DNS queries to bypass firewalls that do not inspect DNS traffic. The data is encoded in subdomain labels: + +``` +aGVsbG8gd29ybGQ.evil.com (base64 "hello world" in subdomain) +``` + +Detection signals: +- **Label entropy**: legitimate subdomains (www, mail, api) have low entropy. Base64-encoded data has entropy above 4.0 +- **QNAME length**: normal queries are under 50 characters. Exfiltration queries exceed 100+ +- **TXT query volume**: TXT records are used to receive exfiltrated data. A spike in TXT queries to a single domain is suspicious +- **Subdomain label length**: DNS labels above 50 characters are almost never legitimate + +The OilRig APT group (attributed to Iran) used DNS tunneling extensively in campaigns against Middle Eastern governments, encoding stolen documents in subdomain queries to command-and-control infrastructure. DNSCat2 and Iodine are open-source tools that implement this technique. + +### Protocol Identification + +Deep Packet Inspection (DPI) identifies application protocols from payload byte prefixes without relying on port numbers: + +- HTTP requests start with methods: `GET `, `POST `, `PUT `, `DELETE ` +- HTTP responses start with `HTTP/` +- TLS records start with `\x16\x03` (handshake + TLS version) +- SSH connections start with `SSH-` +- SMTP starts with `220 ` (server greeting) + +This matters because sensitive data in HTTP traffic (API keys in headers, SSNs in POST bodies) requires different handling than the same data in an encrypted TLS stream (where you can only flag that sensitive data was transmitted, not read the content). + +### TCP Stream Reassembly + +Application-layer data spans multiple TCP packets. Reassembly reconstructs the original byte stream: + +1. Track flows by 4-tuple: (src_ip, dst_ip, src_port, dst_port) +2. Use bidirectional flow keys so both directions of a conversation map to the same flow +3. Store segments indexed by TCP sequence number +4. Sort by sequence number and concatenate payloads, deduplicating retransmissions + +Without reassembly, a credit card number split across two packets would be missed by pattern matching on individual payloads. + +## Redaction + +DLP reports must never contain the raw sensitive data they detect. Redaction strategies: + +- **Partial**: preserve structure but mask content: `***-**-6789`, `4532****0366` +- **Full**: replace entirely: `[REDACTED]` +- **None**: no redaction (for debugging only, never in production reports) + +Partial redaction is preferred for triage because analysts can identify the data type and approximate value without exposing the full sensitive content. The last 4 digits of an SSN or credit card are commonly used as verification tokens and are considered non-sensitive by PCI-DSS. diff --git a/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md b/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..7c0725de --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md @@ -0,0 +1,539 @@ +# 02-ARCHITECTURE.md + +# System Architecture + +## High-Level Pipeline + +The scanner follows a linear pipeline: CLI parses arguments, the engine orchestrates, scanners extract and detect, and reporters format output. + +``` +┌──────────────────────────────────────────────────────────┐ +│ CLI Layer (Typer) │ +│ │ +│ dlp-scan file ./data -f json -o results.json │ +│ dlp-scan db postgres://user:pass@host/db │ +│ dlp-scan network capture.pcap │ +│ dlp-scan report summary results.json │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ ScanEngine │ +│ │ +│ Loads config ─► Builds DetectorRegistry ─► Selects │ +│ scanner type ─► Runs scan ─► Routes to reporter │ +└──────────────────────┬───────────────────────────────────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────┐ ┌──────────────┐ +│ FileScanner │ │DBScanner │ │NetworkScanner│ +│ │ │ │ │ │ +│ Walk dirs │ │ Schema │ │ PCAP parse │ +│ Extract text │ │ introspect│ │ TCP reassembly│ +│ Run detectors│ │ Sample │ │ DNS exfil │ +│ │ │ rows │ │ DPI protocol │ +│ │ │ Detect │ │ Detect │ +└──────┬───────┘ └────┬─────┘ └──────┬───────┘ + │ │ │ + └──────────────┼──────────────┘ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ DetectorRegistry │ +│ │ +│ PatternDetector ─► ContextBoost ─► CooccurrenceBoost │ +│ │ │ +│ └─► EntropyDetector (parallel) │ +│ │ +│ Rules: PII | Financial | Credentials | Health │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Reporter Layer │ +│ │ +│ ConsoleReporter ─► Rich tables with severity colors │ +│ JsonReporter ─► Structured JSON with metadata │ +│ SarifReporter ─► SARIF 2.1.0 for CI/CD pipelines │ +│ CsvReporter ─► Flat CSV for compliance teams │ +└──────────────────────────────────────────────────────────┘ +``` + +## Component Breakdown + +### CLI Layer + +**Purpose:** Parse command-line arguments, propagate global options, route to the correct scan command or report utility. + +**Files:** `cli.py`, `commands/scan.py`, `commands/report.py` + +The root Typer app in `cli.py` defines a callback that captures `--config`, `--verbose`, and `--version` into Click's context object. The scan commands (`file`, `db`, `network`) are defined in `commands/scan.py` and registered as top-level commands through a `register(app)` function that calls `app.command("file")(scan_file)` for each. This avoids nesting under a `scan` subgroup while keeping the command definitions in their own module. + +The `report` subgroup is a separate Typer instance added via `app.add_typer(report_app, name="report")`. It provides `convert` (JSON to other formats) and `summary` (print Rich table from JSON results). + +### ScanEngine + +**Purpose:** Single orchestration point that connects config to scanners to reporters. + +**File:** `engine.py` + +The engine takes a `ScanConfig` and constructs a `DetectorRegistry` by unpacking detection configuration into individual parameters: + +```python +class ScanEngine: + def __init__(self, config: ScanConfig) -> None: + self._config = config + detection = config.detection + allowlist_vals = detection.allowlists.values + self._registry = DetectorRegistry( + enable_patterns=detection.enable_rules, + disable_patterns=detection.disable_rules, + allowlist_values=( + frozenset(allowlist_vals) + if allowlist_vals else None + ), + context_window_tokens=( + detection.context_window_tokens + ), + ) +``` + +The engine exposes `scan_files`, `scan_database`, and `scan_network`, each of which constructs the appropriate scanner, runs it, and returns a `ScanResult`. Report generation uses a `REPORTER_MAP` dict that maps format strings to reporter classes. + +### DetectorRegistry + +**Purpose:** Central hub that loads detection rules, filters them by enable/disable globs, and runs the full scoring pipeline against text. + +**File:** `detectors/registry.py` + +The registry loads all rules from four rule modules (PII, Financial, Credentials, Health), filters them using `fnmatch.fnmatch` against enable/disable patterns, and wraps the survivors in a `PatternDetector`. When `detect()` is called: + +1. `PatternDetector` runs all regex patterns, validates matches with checksums (Luhn, Mod-97, Mod-11), and filters against the allowlist +2. `apply_context_boost` scans a token window around each match for relevant keywords and adjusts scores based on proximity +3. `_apply_cooccurrence_boost` adds a bonus when multiple different PII types appear within 500 characters of each other +4. `EntropyDetector` independently finds high-entropy regions using a sliding window + +``` +Text Input + │ + ▼ +┌─────────────────────────┐ +│ PatternDetector │ +│ │ +│ For each rule: │ +│ regex.finditer(text) │ +│ ─► allowlist filter │ +│ ─► validator (Luhn, │ +│ Mod-97, SSN area) │ +│ ─► base_score + boost │ +└─────────┬───────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Context Boost │ +│ │ +│ Token window ±10 tokens │ +│ Keyword proximity search │ +│ Distance-weighted boost │ +│ (0.05 to 0.35) │ +└─────────┬───────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Co-occurrence Boost │ +│ │ +│ Different rule_ids │ +│ within 500 chars ─► +0.15│ +└─────────┬───────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Entropy Detector │ +│ │ +│ Sliding 256-byte window │ +│ Shannon H >= 7.2 bits │ +│ Independent matches │ +└─────────┬───────────────┘ + │ + ▼ + DetectorMatch[] +``` + +### Scanners + +**Purpose:** Each scanner handles a different scan surface (files, databases, network) and converts raw data into text that the DetectorRegistry can process. + +**Files:** `scanners/file_scanner.py`, `scanners/db_scanner.py`, `scanners/network_scanner.py` + +All scanners follow the same `Scanner` protocol: a `scan(target: str) -> ScanResult` method. They share a common flow: iterate over targets, extract text, run detection, classify matches into findings with severity/compliance/remediation metadata, and aggregate into a `ScanResult`. + +**FileScanner** walks a directory tree, applies extension and exclusion filters, dispatches each file to the appropriate extractor based on extension, and runs the detector on each `TextChunk`. The extension-to-extractor mapping is built once by `_build_extension_map`, which iterates over all extractor instances and indexes by their `supported_extensions`. + +**DatabaseScanner** connects via URI scheme detection (postgres, mysql, mongodb, sqlite), introspects the schema to find text-type columns, samples rows using database-native sampling (TABLESAMPLE BERNOULLI for PostgreSQL, RAND() for MySQL, $sample for MongoDB), and scans column values. + +**NetworkScanner** reads PCAP files using dpkt, extracts TCP/UDP payloads, decodes them to text, and runs detection. The companion modules in `network/` provide TCP stream reassembly, DNS query parsing, protocol identification via DPI, and DNS exfiltration detection. + +### Extractors + +**Purpose:** Convert binary and structured file formats into uniform `TextChunk` objects that carry both the extracted text and a `Location` describing where it came from. + +**Files:** `extractors/plaintext.py`, `extractors/pdf.py`, `extractors/office.py`, `extractors/structured.py`, `extractors/archive.py`, `extractors/email.py` + +All extractors implement the `Extractor` protocol: `extract(path) -> list[TextChunk]` and `supported_extensions -> frozenset[str]`. + +``` +┌───────────────────────────────────────────────┐ +│ Extractor Protocol │ +│ extract(path) -> list[TextChunk] │ +│ supported_extensions -> frozenset[str] │ +└───────────────────────────────────────────────┘ + │ + ┌────┴────┬──────────┬──────────┬──────┐ + ▼ ▼ ▼ ▼ ▼ +Plaintext PDF Office Structured Archive +.txt .log .pdf .docx .csv .json .zip +.cfg .py .xlsx .xml .yaml .tar.gz +.html .md .xls .parquet .tar.bz2 +.ts .go .avro +... .tsv +``` + +The `PlaintextExtractor` chunks files into 500-line blocks to keep memory bounded. Binary format extractors (PDF via PyMuPDF, DOCX via python-docx, XLSX via openpyxl) each return one `TextChunk` per page/sheet/section. The archive extractor recurses into compressed files up to a configurable depth with zip bomb protection (compression ratio threshold check). + +### Reporters + +**Purpose:** Take a `ScanResult` and serialize it into the requested output format. + +**Files:** `reporters/console.py`, `reporters/json_report.py`, `reporters/sarif.py`, `reporters/csv_report.py` + +Each reporter has a `generate(result) -> str` method. The `ConsoleReporter` also has a `display(result)` method for Rich-formatted terminal output with severity-colored tables. + +The JSON reporter outputs a structured document with `scan_metadata`, `findings`, and `summary` sections. The SARIF reporter produces a SARIF 2.1.0 document with `tool.driver.rules`, mapping severity levels through `SARIF_SEVERITY_MAP` (critical/high to "error", medium to "warning", low to "note"). The CSV reporter flattens findings into rows. + +## Data Models + +### Core Models + +```python +@dataclass(frozen=True, slots=True) +class Location: + source_type: str + uri: str + line: int | None = None + column: int | None = None + byte_offset: int | None = None + table_name: str | None = None + column_name: str | None = None + sheet_name: str | None = None + + +@dataclass(slots=True) +class Finding: + finding_id: str + rule_id: str + rule_name: str + severity: Severity + confidence: float + location: Location + redacted_snippet: str + compliance_frameworks: list[str] + remediation: str + detected_at: datetime + + +@dataclass(slots=True) +class ScanResult: + scan_id: str + tool_version: str + scan_started_at: datetime + scan_completed_at: datetime | None + targets_scanned: int + findings: list[Finding] + errors: list[str] +``` + +`Location` is frozen because it represents a fact about where something was found. `Finding` is mutable because fields like `finding_id` and `detected_at` get defaults from factory functions. `ScanResult` aggregates findings and provides computed properties (`findings_by_severity`, `findings_by_rule`, `findings_by_framework`) that group counts for summary reporting. + +The `TextChunk` dataclass carries extracted text paired with its `Location`, forming the bridge between extractors and detectors. Every text fragment knows exactly where it came from, which lets findings carry precise location information through the pipeline. + +### Detection Models + +```python +@dataclass(frozen=True, slots=True) +class DetectionRule: + rule_id: str + rule_name: str + pattern: re.Pattern[str] + base_score: float + context_keywords: list[str] + validator: Callable[[str], bool] | None + compliance_frameworks: list[str] + + +@dataclass(frozen=True, slots=True) +class DetectorMatch: + rule_id: str + rule_name: str + start: int + end: int + matched_text: str + score: float + context_keywords: list[str] + compliance_frameworks: list[str] +``` + +`DetectionRule` is a specification: the regex pattern to match, the base confidence score, optional checksum validator, and context keywords. `DetectorMatch` is a result: what was found, where in the text, and the current score after validation. The `score` field gets modified through the boost pipeline (context, co-occurrence) before being mapped to a `Severity` level and placed into a `Finding`. + +## Configuration Architecture + +``` +┌────────────────────────────────────────────┐ +│ .dlp-scanner.yml │ +│ │ +│ scan: │ +│ file: { max_file_size_mb, recursive } │ +│ database: { sample_percentage } │ +│ network: { bpf_filter, max_packets } │ +│ detection: │ +│ min_confidence, enable_rules, │ +│ disable_rules, allowlists │ +│ compliance: { frameworks } │ +│ output: { format, redaction_style } │ +│ logging: { level, json_output } │ +└────────────────┬───────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────┐ +│ load_config(path) -> ScanConfig │ +│ │ +│ 1. Check CLI --config flag │ +│ 2. Search candidates: │ +│ .dlp-scanner.yml │ +│ .dlp-scanner.yaml │ +│ ~/.dlp-scanner.yml │ +│ 3. Parse YAML via ruamel.yaml │ +│ 4. Validate with Pydantic 2.x models │ +│ 5. Return ScanConfig with defaults │ +└────────────────────────────────────────────┘ +``` + +Every configuration value has a constant default defined in `constants.py`. The Pydantic models in `config.py` use these constants as field defaults, so a completely empty config file produces a working scanner. The config loader uses `ruamel.yaml` (not PyYAML) because it preserves comments and handles YAML 1.2. + +The YAML structure uses a `scan:` top-level key to group scanner-specific config, while `detection:`, `compliance:`, `output:`, and `logging:` sit at root level. This mirrors how users think about configuration: "how to scan" vs. "what to detect" vs. "how to report". + +## Data Flow: File Scan + +Step-by-step walkthrough of `dlp-scan file ./data -f json`: + +``` +1. Typer parses args + └─► main() callback stores config_path="" and verbose=False in ctx.obj + +2. scan_file() receives ctx, target="./data", format="json" + └─► _run_scan() validates format, loads config, sets logging to WARNING + (WARNING for machine-readable formats keeps stdout clean) + +3. ScanEngine(config) constructs DetectorRegistry + └─► Registry loads 28 rules from PII/Financial/Credential/Health modules + └─► Filters through enable_rules=["*"], disable_rules=[] + +4. engine.scan_files("./data") + └─► FileScanner.scan() creates ScanResult, walks directory + +5. For each file in ./data/**/*: + └─► Check extension against include_extensions + └─► Check path against exclude_patterns + └─► Check file size against max_file_size_mb + └─► Select extractor by extension (e.g. .csv -> CsvExtractor) + └─► extractor.extract(path) -> list[TextChunk] + +6. For each TextChunk: + └─► registry.detect(chunk.text) -> list[DetectorMatch] + ├─► PatternDetector: regex match + allowlist + validator + ├─► apply_context_boost: keyword proximity scoring + ├─► _apply_cooccurrence_boost: multi-PII bonus + └─► EntropyDetector: high-entropy region detection + +7. For each DetectorMatch above min_confidence: + └─► score_to_severity(match.score) -> Severity + └─► get_frameworks_for_rule(match.rule_id) -> compliance list + └─► get_remediation_for_rule(match.rule_id) -> guidance string + └─► redact(chunk.text, start, end, style="partial") -> snippet + └─► Append Finding to ScanResult + +8. Back in _run_scan(): + └─► engine.generate_report(result, "json") + └─► JsonReporter().generate(result) -> JSON string + └─► typer.echo(output) -> stdout +``` + +## Design Patterns + +### Protocol-Based Polymorphism + +The codebase uses Python's `typing.Protocol` instead of abstract base classes for extension points. The `Extractor`, `Scanner`, and `Detector` protocols define structural interfaces without requiring inheritance. + +```python +class Extractor(Protocol): + def extract(self, path: str) -> list[TextChunk]: ... + + @property + def supported_extensions(self) -> frozenset[str]: ... +``` + +Any class with matching method signatures satisfies the protocol. This means you can add a new extractor (say, for .pptx files) without importing the base module. The type checker verifies compliance; the runtime never checks inheritance. + +**Why not ABCs:** Abstract base classes force an import dependency and mandate `super().__init__()` chains. Protocols are lighter and match Python's duck typing philosophy. Since extractors are stateless (no shared state or lifecycle), there is nothing an ABC would provide beyond the type contract. + +### Registry Pattern + +The `DetectorRegistry` centralizes rule management: loading, filtering, and execution. Individual rule modules (pii.py, financial.py, credentials.py, health.py) each export a list of `DetectionRule` objects. The registry merges them into `ALL_RULES`, applies glob filtering, and wraps the result in a `PatternDetector`. + +This keeps rule definitions declarative. Adding a new rule is a matter of appending a `DetectionRule` to the appropriate list. The registry handles filtering and execution without rule authors needing to understand the scoring pipeline. + +### Command Registration Pattern + +CLI commands are defined in `commands/scan.py` as plain functions and registered on the root app through a `register(app)` function: + +```python +def register(app: typer.Typer) -> None: + app.command("file")(scan_file) + app.command("db")(scan_db) + app.command("network")(scan_network) +``` + +This achieves top-level commands (`dlp-scan file`, not `dlp-scan scan file`) while keeping the command logic out of `cli.py`. The `_run_scan` helper deduplicates the shared logic (config loading, format validation, output routing) across all three scan types. + +## Compliance Mapping + +The compliance module maps rule IDs to regulatory frameworks and remediation guidance using two static dictionaries: + +``` +RULE_FRAMEWORK_MAP: rule_id -> [frameworks] +RULE_REMEDIATION_MAP: rule_id -> guidance string +``` + +When a `DetectorMatch` is converted to a `Finding` inside a scanner, the scanner calls `get_frameworks_for_rule` and `get_remediation_for_rule` to decorate the finding with compliance metadata. If the detection rule itself also carries `compliance_frameworks`, both sets are merged. + +This design keeps detection rules independent of compliance logic. The PII module does not need to know that HIPAA cares about SSNs. The compliance module owns that mapping, and it can be updated independently when regulations change. + +## Redaction Pipeline + +``` +matched text + │ + ▼ + style == "none"? ─yes─► raw snippet with context + │ no + ▼ + style == "full"? ─yes─► [REDACTED] with context + │ no + ▼ + _partial_redact() + │ + ├─ 9+ digit number ─► *****6789 (mask all but last 4) + ├─ email address ─► j****@example.com + └─ generic string ─► keep last 25% + │ + ▼ + _build_snippet() + │ + └─ ±20 chars context ─► "...SSN: *****6789 for..." +``` + +Partial redaction is the default because it gives analysts enough to identify the data type and triage priority without exposing the full sensitive value. The last 4 digits of SSNs and credit cards are considered non-sensitive by PCI-DSS (you can print them on receipts), so partial redaction for those types is compliant. + +## Network Analysis Architecture + +``` +┌────────────────────────────────────────────┐ +│ PCAP File │ +│ (.pcap or .pcapng) │ +└────────────────┬───────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────┐ +│ pcap.read_pcap() │ +│ │ +│ dpkt.pcap.Reader / dpkt.pcapng.Reader │ +│ Parse Ethernet -> IP -> TCP/UDP │ +│ Yield PacketInfo(src_ip, dst_ip, │ +│ src_port, dst_port, payload, │ +│ tcp_seq, tcp_flags) │ +└────────────────┬───────────────────────────┘ + │ + ┌───────┴───────┐ + ▼ ▼ +┌─────────────┐ ┌───────────────┐ +│FlowTracker │ │DnsExfilDetector│ +│ │ │ │ +│Track by │ │Label length │ +│4-tuple key │ │check (>50) │ +│ │ │ │ +│Reassemble │ │Subdomain │ +│TCP streams │ │entropy (>4.0) │ +│by seq num │ │ │ +│ │ │QNAME length │ +│Dedup retx │ │check (>100) │ +└──────┬──────┘ │ │ + │ │TXT volume │ + ▼ │ratio check │ +┌─────────────┐ └───────┬───────┘ +│Protocol ID │ │ +│(DPI) │ ▼ +│ │ ExfilIndicator[] +│HTTP: method │ +│ prefix │ +│TLS: \x16\x03│ +│SSH: SSH- │ +│SMTP: 220 │ +└──────┬──────┘ + │ + ▼ + Reassembled text + sent to DetectorRegistry +``` + +The flow tracker creates bidirectional flow keys by sorting the forward and reverse 4-tuples, so `(A, B, 80, 12345)` and `(B, A, 12345, 80)` map to the same flow. TCP reassembly sorts segments by sequence number and deduplicates retransmissions. Without reassembly, a credit card number split across two TCP segments would be missed. + +The DNS exfiltration detector runs independently of the regex-based detectors. It analyzes DNS queries for encoding signals: base64-like entropy in subdomain labels, abnormally long labels, long QNAMEs, and suspicious TXT query volume ratios. The OilRig APT campaign used exactly these patterns to exfiltrate stolen documents through DNS tunneling to C2 infrastructure. + +## Error Handling Strategy + +Errors are collected, not thrown. Each scanner appends error messages to `ScanResult.errors` and continues scanning the remaining targets. The CLI checks `result.errors` after the scan completes and exits with code 1 if any errors occurred, but the partial results are still reported. + +This "collect and continue" approach means a single corrupt PDF in a directory of 10,000 files does not abort the scan. The Equifax breach investigation found that scanning tools that failed on individual files often left entire directories unscanned, which is why modern DLP tools treat extraction failures as warnings rather than fatal errors. + +## Performance Considerations + +**File scanning** is I/O-bound. The scanner processes files sequentially to avoid overwhelming disk I/O. Text extraction for binary formats (PDF, Office) can be CPU-intensive, but these files are typically a small fraction of the total. + +**Detection** scales linearly with text length times rule count. With 28 rules and an average text chunk of 500 lines, a single detection pass takes microseconds. The entropy detector is more expensive due to its sliding window, so it only runs when enabled and only against high-level text chunks (not individual regex matches). + +**Memory** stays bounded through chunking. The plaintext extractor reads 500 lines at a time. Archive extraction enforces depth limits and zip bomb ratio checks. + +## Key Files Reference + +- `cli.py` - Entry point, global options, Typer app +- `engine.py` - Orchestration, connects config to scanners to reporters +- `config.py` - Pydantic models, YAML loading, config search +- `constants.py` - All magic numbers, thresholds, type literals +- `models.py` - Finding, Location, ScanResult, TextChunk +- `compliance.py` - Rule-to-framework mapping, severity classification +- `redaction.py` - Partial/full/none redaction strategies +- `detectors/registry.py` - Rule loading, filtering, scoring pipeline +- `detectors/pattern.py` - Regex matching with allowlist and checksum validation +- `detectors/context.py` - Keyword proximity boost, co-occurrence boost +- `detectors/entropy.py` - Shannon entropy detection, sliding window +- `detectors/rules/` - Rule definitions (pii, financial, credentials, health) +- `extractors/` - Text extraction from 14+ file formats +- `scanners/` - File, database, network scan implementations +- `network/` - PCAP parsing, flow tracking, DPI, DNS exfiltration +- `reporters/` - Console, JSON, SARIF, CSV output +- `commands/` - CLI command implementations (scan, report) + +## Next Steps + +Now that you understand the architecture: +1. Read [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) for the code walkthrough +2. Try modifying a detection rule in `detectors/rules/pii.py` to see how the scoring pipeline responds diff --git a/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md b/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..f20db4bd --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md @@ -0,0 +1,801 @@ +# 03-IMPLEMENTATION.md + +# Implementation Guide + +This document walks through how the code works. We cover the detection engine, file extraction, network analysis, and CLI integration, with code snippets from the actual project. + +## File Structure + +``` +src/dlp_scanner/ +├── __init__.py +├── cli.py # Typer entry point +├── engine.py # Scan orchestration +├── config.py # Pydantic config models +├── constants.py # Thresholds, types, defaults +├── models.py # Finding, Location, ScanResult +├── compliance.py # Rule-to-framework mapping +├── redaction.py # Snippet masking +├── log.py # structlog configuration +├── commands/ +│ ├── scan.py # file, db, network commands +│ └── report.py # convert, summary commands +├── detectors/ +│ ├── base.py # DetectionRule, DetectorMatch +│ ├── pattern.py # Regex + checksum detection +│ ├── context.py # Keyword proximity scoring +│ ├── entropy.py # Shannon entropy detection +│ ├── registry.py # Central detector registry +│ └── rules/ +│ ├── pii.py # SSN, email, phone, passport +│ ├── financial.py # Credit cards, IBAN, NHS +│ ├── credentials.py # AWS, GitHub, JWT, Stripe +│ └── health.py # Medical records, DEA, NPI +├── extractors/ +│ ├── base.py # Extractor protocol +│ ├── plaintext.py # .txt, .log, .cfg, source code +│ ├── pdf.py # .pdf via PyMuPDF +│ ├── office.py # .docx, .xlsx, .xls +│ ├── structured.py # .csv, .json, .xml, .yaml, .parquet, .avro +│ ├── archive.py # .zip, .tar.gz, .tar.bz2 +│ └── email.py # .eml, .msg +├── network/ +│ ├── pcap.py # PCAP/PCAPNG packet reader +│ ├── flow_tracker.py # TCP flow reassembly +│ ├── protocols.py # DPI protocol identification +│ └── exfiltration.py # DNS exfil detection +├── reporters/ +│ ├── base.py # Reporter protocol +│ ├── console.py # Rich terminal output +│ ├── json_report.py # Structured JSON +│ ├── sarif.py # SARIF 2.1.0 +│ └── csv_report.py # Flat CSV +└── scanners/ + ├── base.py # Scanner protocol + ├── file_scanner.py # Directory walking + extraction + ├── db_scanner.py # DB schema introspection + └── network_scanner.py # PCAP payload scanning +``` + +## Building the Detection Engine + +### Detection Rules + +Every detection rule is a data structure, not a class hierarchy. The `DetectionRule` dataclass holds the regex pattern, base confidence score, optional validator function, context keywords, and compliance framework tags: + +```python +@dataclass(frozen=True, slots=True) +class DetectionRule: + rule_id: str + rule_name: str + pattern: re.Pattern[str] + base_score: float + context_keywords: list[str] = field(default_factory=list) + validator: Callable[[str], bool] | None = None + compliance_frameworks: list[str] = field( + default_factory=list + ) +``` + +Rule modules export plain lists of these structs. Here is the SSN rule from `detectors/rules/pii.py`: + +```python +SSN_PATTERN = re.compile( + r"\b(?!000|666|9\d{2})\d{3}" + r"[-\s]?" + r"(?!00)\d{2}" + r"[-\s]?" + r"(?!0000)\d{4}\b" +) + +PII_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id="PII_SSN", + rule_name="US Social Security Number", + pattern=SSN_PATTERN, + base_score=0.45, + context_keywords=SSN_CONTEXT, + validator=_validate_ssn, + compliance_frameworks=[ + "HIPAA", "CCPA", "GLBA", "GDPR", + ], + ), + ... +] +``` + +The regex uses negative lookaheads (`(?!000|666|9\d{2})`) to reject SSN area numbers the Social Security Administration has never assigned. This is a first-pass structural filter. The real validation happens in `_validate_ssn`, which the `PatternDetector` calls for every regex match. + +**Why base_score is 0.45, not higher:** A 9-digit number matching the SSN format appears in serial numbers, zip+4 codes, phone fragments, and test data constantly. The string `456-78-9012` matches the SSN pattern and passes area/group/serial validation, but without context it could be anything. A base of 0.45 keeps it in the "medium" severity tier until context boosts push it higher. + +### Checksum Validation + +The three checksum validators demonstrate different mathematical approaches to the same problem: distinguishing real identifiers from random digit sequences. + +**Luhn algorithm** for credit cards (in `detectors/rules/financial.py`): + +```python +def luhn_check(number: str) -> bool: + digits = [int(d) for d in number if d.isdigit()] + if len(digits) < 13: + return False + + odd_digits = digits[-1::-2] + even_digits = digits[-2::-2] + total = sum(odd_digits) + for d in even_digits: + total += sum(divmod(d * 2, 10)) + return total % 10 == 0 +``` + +The algorithm works right-to-left: take every other digit starting from the rightmost, sum them. For the remaining digits, double each, and if the result exceeds 9, subtract 9 (which is what `sum(divmod(d * 2, 10))` does). If the grand total is divisible by 10, the number is valid. A random 16-digit number has about a 10% chance of passing Luhn, so it reduces false positives by roughly 90%. + +**Mod-97** for IBANs (ISO 7064): + +```python +def iban_check(value: str) -> bool: + cleaned = value.replace(" ", "").upper() + if len(cleaned) < 15 or len(cleaned) > 34: + return False + + rearranged = cleaned[4:] + cleaned[:4] + numeric = "" + for char in rearranged: + if char.isalpha(): + numeric += str(ord(char) - ord("A") + 10) + else: + numeric += char + + return int(numeric) % 97 == 1 +``` + +Move the country code and check digits (first 4 chars) to the end, convert letters to two-digit numbers (A=10, B=11, etc.), then check that the entire number mod 97 equals 1. The false positive rate is approximately 1 in 97. + +**Mod-11** for NHS numbers: + +```python +def nhs_check(value: str) -> bool: + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 10 or not digits.isdigit(): + return False + + weights = range(10, 1, -1) + total = sum( + int(d) * w + for d, w in zip(digits[:9], weights, strict=False) + ) + remainder = 11 - (total % 11) + if remainder == 11: + remainder = 0 + if remainder == 10: + return False + return remainder == int(digits[9]) +``` + +Multiply the first 9 digits by descending weights (10, 9, 8, ..., 2), sum them, compute `11 - (sum mod 11)`, and compare to the check digit. If the result is 10, the number is invalid (NHS never issues these). If the result is 11, the check digit is 0. + +### Pattern Detection + +The `PatternDetector` in `detectors/pattern.py` iterates over all active rules, runs each regex against the input text, filters through the allowlist, and applies checksum validation: + +```python +class PatternDetector: + def detect(self, text: str) -> list[DetectorMatch]: + matches: list[DetectorMatch] = [] + + for rule in self._rules: + for m in rule.pattern.finditer(text): + matched_text = m.group() + + if self._is_allowlisted(matched_text): + continue + + score = rule.base_score + + if rule.validator is not None: + if rule.validator(matched_text): + score = min(1.0, score + CHECKSUM_BOOST) + else: + continue + + matches.append( + DetectorMatch( + rule_id=rule.rule_id, + ... + score=score, + ) + ) + + return matches +``` + +When a rule has a validator and the match fails validation, the match is discarded entirely (`continue`). A Visa pattern that matches `4532015112830366` but fails Luhn is not a credit card. When validation passes, the score gets a +0.30 boost (`CHECKSUM_BOOST`). This is aggressive because checksum-passing matches are overwhelmingly real: the Luhn+Visa prefix combination has a false positive rate under 1%. + +The allowlist uses a frozen set lookup, defaulting to `KNOWN_TEST_VALUES` (common test card numbers, example SSNs like `123-45-6789`). This prevents DLP tools from flagging their own test data, which is a common complaint in production deployments. + +### Context Keyword Scoring + +After pattern detection, `apply_context_boost` in `detectors/context.py` scans the surrounding text for keywords that indicate the matched value is actually sensitive data: + +```python +def apply_context_boost( + text: str, + matches: list[DetectorMatch], + window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS, +) -> list[DetectorMatch]: + tokens = text.lower().split() + boosted: list[DetectorMatch] = [] + + for match in matches: + if not match.context_keywords: + boosted.append(match) + continue + + char_to_token = _char_offset_to_token_index( + text, match.start + ) + window_start = max( + 0, char_to_token - window_tokens + ) + window_end = min( + len(tokens), char_to_token + window_tokens + ) + window_text = " ".join( + tokens[window_start:window_end] + ) + + boost = _compute_keyword_boost( + window_text, + match.context_keywords, + window_tokens, + ) + + new_score = min(1.0, match.score + boost) + ... +``` + +The window is bidirectional: 10 tokens in each direction from the match. The boost is distance-weighted: a keyword right next to the match contributes up to `CONTEXT_BOOST_MAX` (0.35), while one at the edge of the window contributes almost nothing. This reflects a real observation: "SSN: 456-78-9012" is almost certainly an SSN, while "SSN" appearing 50 words away from "456-78-9012" is weaker signal. + +The `_compute_keyword_boost` function finds the best keyword match in the window and computes `CONTEXT_BOOST_MAX * proximity_factor`, where proximity is `1.0 - (distance / max_distance)`. Only the highest-scoring keyword matters, not the sum of all keywords. This prevents keyword stuffing from inflating scores. + +### Co-occurrence Boost + +After context boosting, `_apply_cooccurrence_boost` checks whether multiple different PII types appear near each other: + +```python +def _apply_cooccurrence_boost( + matches: list[DetectorMatch], +) -> list[DetectorMatch]: + if len(matches) < 2: + return matches + + proximity_threshold = 500 + + for i, match in enumerate(matches): + has_neighbor = False + for j, other in enumerate(matches): + if i == j: + continue + if other.rule_id == match.rule_id: + continue + distance = abs(match.start - other.start) + if distance < proximity_threshold: + has_neighbor = True + break + + if has_neighbor: + new_score = min( + 1.0, match.score + COOCCURRENCE_BOOST + ) + ... +``` + +An SSN near a credit card number is stronger evidence than either alone. The boost is +0.15 (`COOCCURRENCE_BOOST`), and it requires different `rule_id` values (two SSNs next to each other do not trigger it). The 500-character threshold roughly corresponds to a short paragraph or a few database columns. + +This heuristic matters in practice. The Capital One breach data contained CSV exports where SSNs, credit card numbers, and addresses appeared in adjacent columns. Co-occurrence detection would have flagged these files as critical priority. + +### Shannon Entropy Detection + +The `EntropyDetector` in `detectors/entropy.py` finds high-entropy regions that may contain secrets, encrypted data, or base64-encoded credentials: + +```python +def shannon_entropy(data: bytes) -> float: + if not data: + return 0.0 + + counts = Counter(data) + total = len(data) + return -sum( + (c / total) * math.log2(c / total) + for c in counts.values() + ) +``` + +Shannon entropy measures the average information content per byte. English text sits around 3.5-4.5 bits. Base64-encoded data is 5.5-6.0. Truly random bytes approach 8.0 (log2(256)). The detector uses a sliding window of 256 bytes with a 128-byte step: + +```python +def detect_high_entropy_regions( + data: bytes, + threshold: float = DEFAULT_ENTROPY_THRESHOLD, + window_size: int = WINDOW_SIZE, + step: int = WINDOW_STEP, +) -> list[tuple[int, int, float]]: + ... + while i + window_size <= len(data): + window = data[i:i + window_size] + h = shannon_entropy(window) + + if h >= threshold: + end = i + window_size + while end + step <= len(data): + next_window = data[ + end - window_size + step:end + step + ] + next_h = shannon_entropy(next_window) + if next_h < threshold: + break + h = max(h, next_h) + end += step + + regions.append((i, end, h)) + i = end + else: + i += step +``` + +When the entropy exceeds the threshold (default 7.2), the detector extends the region forward until entropy drops below the threshold. This merges adjacent high-entropy windows into a single region rather than reporting dozens of overlapping detections. + +The default threshold of 7.2 is intentionally high. Network payloads containing binary protocol data or compressed content often hit 6.0-7.0, which would generate massive false positive volume. At 7.2, the detector primarily catches encrypted blobs, base64-encoded secrets, and random key material. + +## File Extraction Pipeline + +### The Extractor Protocol + +All extractors implement a two-method protocol: + +```python +class Extractor(Protocol): + def extract(self, path: str) -> list[TextChunk]: ... + + @property + def supported_extensions(self) -> frozenset[str]: ... +``` + +The `FileScanner` builds an extension-to-extractor map at initialization by iterating over all extractor instances and indexing by their supported extensions. When scanning a file, it looks up the extractor by the file's extension and calls `extract`. + +### Plaintext Extraction + +The `PlaintextExtractor` reads files in 500-line chunks to keep memory bounded: + +```python +class PlaintextExtractor: + def extract(self, path: str) -> list[TextChunk]: + chunks: list[TextChunk] = [] + + with open( + path, encoding="utf-8", errors="replace", + ) as f: + lines: list[str] = [] + line_number = 1 + chunk_start = 1 + + for line in f: + lines.append(line) + if len(lines) >= CHUNK_MAX_LINES: + chunks.append( + TextChunk( + text="".join(lines), + location=Location( + source_type="file", + uri=path, + line=chunk_start, + ), + ) + ) + chunk_start = line_number + 1 + lines = [] + line_number += 1 + + if lines: + chunks.append(...) + + return chunks +``` + +Each `TextChunk` carries the starting line number in its `Location`, so findings can report where in the file the match occurred. The `errors="replace"` parameter means binary-contaminated text files (common in log files with embedded binary data) will not crash the extractor. + +### Extension Map Construction + +The `_build_extension_map` function in `file_scanner.py` constructs the mapping from extensions to extractors: + +```python +def _build_extension_map() -> dict[str, Extractor]: + extractors: list[Extractor] = [ + PlaintextExtractor(), + PDFExtractor(), + DocxExtractor(), + XlsxExtractor(), + XlsExtractor(), + CsvExtractor(), + JsonExtractor(), + XmlExtractor(), + YamlExtractor(), + ParquetExtractor(), + AvroExtractor(), + ArchiveExtractor(), + EmlExtractor(), + MsgExtractor(), + ] + + ext_map: dict[str, Extractor] = {} + for extractor in extractors: + for ext in extractor.supported_extensions: + ext_map[ext] = extractor + + return ext_map +``` + +Adding a new format means creating an extractor class with `extract` and `supported_extensions`, then adding it to this list. The scanner does not need to know anything about the format. + +### File Scanner Walk Logic + +The `FileScanner._scan_directory` method applies a chain of filters before dispatching to an extractor: + +```python +def _scan_directory(self, directory, result): + iterator = ( + directory.rglob("*") + if self._file_config.recursive + else directory.glob("*") + ) + + for path in iterator: + if not path.is_file(): + continue + if self._is_excluded(path, directory): + continue + + suffix = _get_full_suffix(path) + if suffix not in self._allowed_extensions: + continue + + file_size = path.stat().st_size + if file_size > max_bytes: + continue + if file_size == 0: + continue + + self._scan_file(path, result) + result.targets_scanned += 1 +``` + +The `_get_full_suffix` function handles compound extensions like `.tar.gz` and `.tar.bz2` by checking the filename suffix before falling back to `path.suffix.lower()`. The exclusion check matches against the relative path, the filename, and individual path components, so a pattern like `__pycache__` matches regardless of depth. + +## Network Analysis + +### PCAP Parsing + +The `read_pcap` function in `network/pcap.py` reads packets using dpkt and yields `PacketInfo` structs: + +```python +def read_pcap(path, max_packets=0): + with open(path, "rb") as f: + try: + pcap = dpkt.pcap.Reader(f) + except ValueError: + f.seek(0) + pcap = dpkt.pcapng.Reader(f) + + count = 0 + for timestamp, buf in pcap: + if max_packets > 0 and count >= max_packets: + break + + packet = _parse_ethernet(timestamp, buf) + if packet is not None: + yield packet + count += 1 +``` + +The try/except fallback handles both PCAP (libpcap) and PCAPNG (Wireshark's newer format). dpkt is used instead of Scapy because it is roughly 100x faster for bulk packet parsing. Scapy constructs rich protocol objects with dissection layers; dpkt does minimal parsing and gives you raw bytes. + +### TCP Flow Reassembly + +The `FlowTracker` in `network/flow_tracker.py` groups packets into flows and reassembles TCP streams: + +```python +def make_flow_key(packet): + forward = ( + packet.src_ip, packet.dst_ip, + packet.src_port, packet.dst_port, + ) + reverse = ( + packet.dst_ip, packet.src_ip, + packet.dst_port, packet.src_port, + ) + return min(forward, reverse) +``` + +The bidirectional key is the lexicographically smaller of the forward and reverse 4-tuples. This means `(A->B)` and `(B->A)` packets land in the same flow. The `reassemble_stream` method sorts segments by TCP sequence number and deduplicates retransmissions: + +```python +def reassemble_stream(self, key): + flow = self._flows.get(key) + if flow is None: + return b"" + + sorted_segments = sorted( + flow.segments, key=lambda s: s[0] + ) + + seen_offsets: set[int] = set() + parts: list[bytes] = [] + for seq, data in sorted_segments: + if seq not in seen_offsets: + seen_offsets.add(seq) + parts.append(data) + + return b"".join(parts) +``` + +TCP retransmissions reuse the same sequence number, so deduplication by sequence number prevents duplicate data in the reassembled stream. This is a simplified reassembly that does not handle overlapping segments (where retransmissions contain different data), but it covers the common case. + +### Protocol Identification + +The `identify_protocol` function in `network/protocols.py` performs Deep Packet Inspection using byte prefix matching: + +```python +def identify_protocol(payload: bytes) -> str: + if not payload: + return "unknown" + + if _is_http_request(payload): + return "http" + if payload.startswith(HTTP_RESPONSE_PREFIX): + return "http" + if (len(payload) > 2 + and payload[:2] == TLS_RECORD_PREFIX): + return "tls" + if payload.startswith(SSH_PREFIX): + return "ssh" + if payload.startswith(SMTP_BANNER_PREFIX): + return "smtp" + + return "unknown" +``` + +HTTP requests are identified by checking if the first word before a space is a known HTTP method (`GET`, `POST`, `PUT`, etc.). TLS records start with `\x16\x03` (ContentType=Handshake + major version 3). SSH banners start with `SSH-`. SMTP server greetings start with `220`. + +This matters for DLP because the same sensitive data requires different handling depending on the transport protocol. An SSN in an HTTP body can be read and flagged with high confidence. The same SSN in a TLS-encrypted stream cannot be read, but you can flag the flow as "encrypted traffic containing unknown data" and correlate with other signals. + +### DNS Exfiltration Detection + +The `DnsExfilDetector` in `network/exfiltration.py` analyzes DNS queries for patterns that suggest data tunneling: + +```python +def _check_subdomain_entropy(self, name, src_ip, dst_ip): + parts = name.split(".") + if len(parts) < 3: + return None + + subdomain = ".".join(parts[:-2]) + if not subdomain: + return None + + entropy = shannon_entropy_str(subdomain) + if entropy > self._entropy_threshold: + return ExfilIndicator( + indicator_type="dns_high_entropy", + description=( + f"High subdomain entropy ({entropy:.2f}) " + f"suggesting DNS tunneling" + ), + confidence=min( + 0.95, + 0.50 + (entropy - 3.0) * 0.15, + ), + source_ip=src_ip, + dst_ip=dst_ip, + evidence=name, + ) +``` + +Legitimate subdomains (`www`, `mail`, `api`, `cdn`) have very low entropy. A query like `aGVsbG8gd29ybGQ.evil.com` has subdomain entropy above 4.0 because the base64-encoded data uses most of the alphanumeric character space. The detector extracts everything before the last two domain labels (the registerable domain), computes Shannon entropy, and flags queries above the threshold. + +The confidence score scales linearly from 0.50 (at entropy 3.0) to 0.95 (at entropy 6.0). This captures the observation that higher entropy means more confident detection: entropy 4.1 might be a CDN hash, but entropy 5.5 is almost certainly encoded data. + +## Compliance and Severity Classification + +### Severity Mapping + +The `score_to_severity` function in `compliance.py` maps confidence scores to severity levels using a threshold table: + +```python +SEVERITY_SCORE_THRESHOLDS = [ + (0.85, "critical"), + (0.65, "high"), + (0.40, "medium"), + (0.20, "low"), +] + +def score_to_severity(score: float) -> Severity: + for threshold, severity in SEVERITY_SCORE_THRESHOLDS: + if score >= threshold: + return severity + return "low" +``` + +The thresholds are tuned so that: +- **Critical** (0.85+): checksum-validated matches with context keywords (e.g., SSN near "social security") +- **High** (0.65+): checksum-validated matches or strong context without validation +- **Medium** (0.40+): pattern matches without strong validation or context +- **Low** (0.20+): weak matches that might be false positives + +### Framework Mapping + +The `RULE_FRAMEWORK_MAP` in `compliance.py` is a static lookup table: + +```python +RULE_FRAMEWORK_MAP = { + "PII_SSN": ["HIPAA", "CCPA", "GLBA", "GDPR"], + "FIN_CREDIT_CARD": ["PCI_DSS", "GLBA"], + "FIN_IBAN": ["GDPR", "GLBA"], + "HEALTH_MEDICAL_RECORD": ["HIPAA"], + ... +} +``` + +Each rule maps to the compliance frameworks that regulate that data type. SSNs trigger four frameworks because they are considered protected health information (HIPAA), personal information (CCPA), financial identifiers (GLBA), and personal data (GDPR). Credit card PANs only trigger PCI-DSS and GLBA because HIPAA and GDPR do not specifically regulate financial card numbers. + +The mapping is intentionally conservative. An SSN could trigger SOX if it appears in financial reporting data, but without business context the scanner cannot determine that. The listed frameworks are the ones where the mere presence of the data type creates a compliance obligation. + +## Redaction + +The `redact` function in `redaction.py` builds a snippet with masked content: + +```python +def redact(text, start, end, style="partial"): + matched = text[start:end] + + if style == "none": + return _build_snippet(text, start, end, matched) + if style == "full": + return _build_snippet( + text, start, end, REDACTED_LABEL + ) + + redacted = _partial_redact(matched) + return _build_snippet(text, start, end, redacted) +``` + +The `_partial_redact` function applies format-aware masking: + +```python +def _partial_redact(value): + stripped = value.replace("-", "").replace(" ", "") + + if len(stripped) >= 9 and stripped.isdigit(): + return MASK_CHAR * (len(value) - 4) + value[-4:] + + if "@" in value: + local, domain = value.rsplit("@", maxsplit=1) + masked_local = ( + local[0] + MASK_CHAR * (len(local) - 1) + ) + return f"{masked_local}@{domain}" + + if len(value) > 8: + visible = max(4, len(value) // 4) + return ( + MASK_CHAR * (len(value) - visible) + + value[-visible:] + ) + + return MASK_CHAR * len(value) +``` + +For digit sequences (SSNs, credit cards), it preserves the last 4 digits: `***-**-6789`. For emails, it keeps the first character and domain: `j****@example.com`. For other strings (API keys, tokens), it shows the last 25%. Short values under 8 characters are fully masked. + +The `_build_snippet` function adds 20 characters of context on each side and prepends/appends `...` when the context is truncated. This gives analysts enough surrounding text to understand what the data was near without exposing full document contents. + +## CLI Integration + +### Global Option Propagation + +The Typer callback stores global options in Click's context dict: + +```python +@app.callback() +def main(ctx: typer.Context, config: ..., verbose: ..., version: ...): + ctx.ensure_object(dict) + ctx.obj["config_path"] = config + ctx.obj["verbose"] = verbose +``` + +Subcommands retrieve these via `ctx.ensure_object(dict)`: + +```python +def _run_scan(ctx, scan_type, target, output_format, output_file): + obj: dict[str, Any] = ctx.ensure_object(dict) + config_path = obj.get("config_path", "") + verbose = obj.get("verbose", False) +``` + +This pattern lets `dlp-scan -v -c custom.yml file ./data` propagate the verbose flag and config path to the file scan command without duplicating those options on every subcommand. + +### Logging Strategy + +The logging level adapts to the output format: + +```python +if verbose: + configure_logging(level="DEBUG") +elif output_format == "console": + configure_logging(level="INFO") +else: + configure_logging(level="WARNING") +``` + +When output is machine-readable (JSON, SARIF, CSV), logging is set to WARNING so that structlog messages written to stderr do not contaminate stdout. This prevents `dlp-scan file ./data -f json | jq` from breaking because log lines mixed into the JSON output. For console output, INFO-level logging provides progress feedback. Verbose mode enables DEBUG for troubleshooting. + +### Report Conversion + +The `report convert` command reads a JSON scan result and regenerates it in another format: + +```python +@report_app.command("convert") +def convert(input_file, output_format="sarif", output_file=""): + raw = path.read_bytes() + data = orjson.loads(raw) + result = _rebuild_result(data) + + config = ScanConfig() + engine = ScanEngine(config) + + output = engine.generate_report(result, fmt) + ... +``` + +The `_rebuild_result` function deserializes the JSON structure back into `ScanResult`, `Finding`, and `Location` objects. It reads from the `scan_metadata` section for scan-level fields and iterates `findings` to reconstruct each `Finding` with its `Location`. This is necessary because `orjson.loads` produces plain dicts, but the reporters expect typed dataclass instances. + +## Testing Strategy + +### Property-Based Testing + +The project uses Hypothesis for property-based testing of detection rules. Instead of testing a few known inputs, Hypothesis generates random strings constrained by rule formats and verifies that the detection pipeline handles them correctly. + +For validators: Hypothesis generates random digit sequences and verifies that `luhn_check`, `iban_check`, and `nhs_check` only return True for inputs that satisfy the mathematical properties (divisibility by 10, mod 97 = 1, mod 11 check digit match). + +For the context boost: Hypothesis generates random text with embedded keywords at varying distances and verifies that the boost is always between 0 and `CONTEXT_BOOST_MAX`, and that closer keywords produce higher boosts. + +### Running Tests + +```bash +uv run pytest -m unit # fast unit tests +uv run pytest -m integration # tests with file I/O +uv run pytest --cov=src # coverage report +``` + +The test suite uses markers (`unit`, `integration`, `slow`) to separate fast tests from those requiring real filesystem access. The `conftest.py` provides shared fixtures for temporary directories, sample configs, and test data files. + +## Dependencies + +- **typer**: CLI framework with type-hint argument declaration. The `Annotated` style avoids decorators stacking up. +- **rich**: Terminal tables with colors. Used by `ConsoleReporter` for severity-colored output. +- **structlog**: Structured logging with stdlib integration. JSON or console rendering based on config. +- **pydantic**: Config validation. Catches invalid YAML values before the scan starts. +- **orjson**: Fast JSON serialization. 3-10x faster than stdlib json for large finding lists. +- **ruamel.yaml**: YAML parser that handles 1.2 spec and preserves comments. +- **dpkt**: PCAP parsing. ~100x faster than Scapy for bulk packet iteration. +- **pymupdf**: PDF text extraction with layout preservation. +- **python-docx/openpyxl/xlrd**: Office format extraction. +- **defusedxml/lxml**: Safe XML parsing (defusedxml blocks XXE attacks). +- **pyarrow/fastavro**: Columnar format extraction (Parquet, Avro). +- **asyncpg/aiomysql/pymongo/aiosqlite**: Async database drivers. + +## Next Steps + +You have seen how the code works. Now: +1. Try the challenges in [04-CHALLENGES.md](./04-CHALLENGES.md) for extension ideas +2. Modify a detection rule and run the tests to see how the scoring changes +3. Scan your own files with `dlp-scan file ./your-directory` and inspect the output diff --git a/PROJECTS/intermediate/dlp-scanner/learn/04-CHALLENGES.md b/PROJECTS/intermediate/dlp-scanner/learn/04-CHALLENGES.md new file mode 100644 index 00000000..7341b5b6 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/learn/04-CHALLENGES.md @@ -0,0 +1,422 @@ +# 04-CHALLENGES.md + +# Extension Challenges + +You have built a DLP scanner with file, database, and network scanning, a confidence scoring pipeline, compliance mapping, and multi-format reporting. These challenges extend it into new territory. + +Ordered by difficulty. The easy ones take an hour or two. The advanced ones are multi-day efforts that teach you skills used in production DLP systems. + +## Easy Challenges + +### Challenge 1: Add a New PII Rule (Date of Birth) + +**What to build:** A detection rule for dates of birth in common formats: `MM/DD/YYYY`, `YYYY-MM-DD`, `DD-Mon-YYYY`. + +**Why it matters:** Date of birth is classified as PHI under HIPAA's 18 identifiers and as personal data under GDPR. The 2015 Anthem breach exposed 78.8 million records including DOBs, and the combination of DOB + name + zip code is enough to uniquely identify 87% of the US population (Latanya Sweeney's research at Carnegie Mellon). + +**What you will learn:** +- Writing regex patterns that match multiple date formats +- Adding a validation function that rejects impossible dates (month 13, day 32, Feb 30) +- Tuning base_score relative to false positive rate (dates appear everywhere) + +**Hints:** +- Create the rule in a new file `detectors/rules/pii_extended.py` and add the rules list to `ALL_RULES` in `registry.py` +- Use a low base_score (0.10-0.15) because date strings are extremely common +- Context keywords like "date of birth", "dob", "birthday", "born on" should provide the majority of the signal +- The validator should parse the matched string into a real date and reject invalid ones +- Add the rule to `RULE_FRAMEWORK_MAP` in `compliance.py` with HIPAA and GDPR + +**Test it works:** Create a text file with "Patient DOB: 03/15/1987" and "Order date: 03/15/1987". The first should score higher than the second due to context keywords. + +### Challenge 2: HTML Report Output + +**What to build:** A new reporter that generates a standalone HTML file with a sortable findings table, severity color coding, and a summary chart. + +**Why it matters:** Compliance teams often need to share scan results with non-technical stakeholders who do not have command-line tools. An HTML report that opens in a browser is more accessible than JSON or CSV. + +**What you will learn:** +- Implementing the reporter pattern (match the existing protocol) +- HTML template generation in Python (string templates or Jinja2) +- Adding a new output format to the CLI without modifying existing code + +**Hints:** +- Create `reporters/html_report.py` with a `HtmlReporter` class +- Add `"html"` to `REPORTER_MAP` in `engine.py` and `VALID_FORMATS` in `commands/scan.py` +- Use inline CSS so the report is a single self-contained HTML file with no external dependencies +- Color severity levels using the same scheme as the console reporter (red for critical, yellow for medium, green for low) +- Include a summary section at the top with counts by severity and framework + +**Test it works:** Run `dlp-scan file ./data -f html -o report.html` and open the file in a browser. The table should be sortable by clicking column headers (add minimal JavaScript for this). + +### Challenge 3: Allowlist by File Path Pattern + +**What to build:** Extend the allowlist system to suppress findings from files matching glob patterns. Currently, `allowlists.file_patterns` exists in the config but is not enforced during scanning. + +**Why it matters:** Test fixtures, mock data, and seed files intentionally contain fake PII. Teams waste hours triaging findings from `tests/fixtures/sample_data.csv` that contain test credit card numbers. Path-based allowlisting eliminates this noise. + +**What you will learn:** +- Connecting config to scan-time behavior +- Glob pattern matching with `fnmatch` +- The difference between value-level and file-level suppression + +**Hints:** +- The `AllowlistConfig.file_patterns` field already exists in `config.py` +- Add the check in `FileScanner._scan_file` before running detection, or in `_scan_directory` before scanning the file +- Match against the relative path from the scan target, not the absolute path +- Patterns like `test_*`, `*_fixture*`, and `mock_*` should match filenames + +**Test it works:** Create a file `test_data.txt` with a valid SSN. Scan with `file_patterns: ["test_*"]` in config. The SSN should not appear in results. + +## Intermediate Challenges + +### Challenge 4: Incremental Scanning with Hash Cache + +**What to build:** A scan cache that stores SHA-256 hashes of scanned files and skips unchanged files on subsequent scans. + +**Why it matters:** Large codebases and file shares contain millions of files. Re-scanning unchanged files wastes time. Symantec DLP and Microsoft Purview both use content hashing to skip unchanged files, reducing scan time by 60-90% on repeated scans. + +**What you will learn:** +- Content-addressable caching strategies +- SQLite as an embedded metadata store +- Cache invalidation (the hardest problem in computer science, and one you actually have to solve) + +**Implementation approach:** + +1. **Create `cache.py`** with a `ScanCache` class backed by SQLite + - Table: `(file_path TEXT, content_hash TEXT, scan_time TEXT, finding_count INTEGER)` + - Hash computation: SHA-256 of file contents + - Lookup: if the file exists in cache with the same hash, skip scanning and load cached finding count + +2. **Integrate with `FileScanner`** + - Before extracting text, check the cache + - After scanning, store the hash and finding count + - Add `--no-cache` flag to force full rescan + +3. **Handle invalidation edge cases:** + - What if detection rules change between scans? (The same file might produce different findings with new rules) + - What if the config changes min_confidence? (Previously-suppressed findings might now be reportable) + - What if a file is deleted? (Stale cache entries should not appear in results) + +**Hints:** +- Store a hash of the active rule set and config in the cache. If either changes, invalidate the entire cache +- Use `aiosqlite` to match the async pattern of the database scanner, or use synchronous sqlite3 since file scanning is already synchronous +- The cache file should live next to the config: `.dlp-scanner-cache.db` + +**Extra credit:** Add `dlp-scan cache stats` and `dlp-scan cache clear` subcommands. + +### Challenge 5: Severity Override by Compliance Framework + +**What to build:** A config option that overrides severity based on compliance framework requirements. For example, any PCI-DSS finding should be at least "high" regardless of confidence score, because PCI-DSS does not have a concept of "low severity" unencrypted card data. + +**Why it matters:** Different compliance frameworks have different severity thresholds. GDPR treats unencrypted email addresses as medium priority for remediation, but PCI-DSS treats any unencrypted PAN as a blocking finding. Production DLP tools let compliance teams configure per-framework severity floors. + +**What you will learn:** +- Adding config-driven behavior to the scoring pipeline +- The tension between confidence-based and policy-based severity +- How production DLP tools balance detection accuracy with compliance requirements + +**Implementation approach:** + +1. **Add to config:** + ```yaml + compliance: + severity_overrides: + PCI_DSS: "high" + HIPAA: "medium" + ``` + +2. **Apply after scoring:** In the `_match_to_finding` function (or equivalent), after computing severity from confidence, check if any of the finding's compliance frameworks have a severity floor, and upgrade if necessary + +3. **Preserve original confidence:** The confidence score should not change. Only the severity classification changes. This lets analysts see that a finding scored 0.35 (normally "low") but was elevated to "high" because of PCI-DSS policy + +**Hints:** +- Add `severity_overrides: dict[str, str]` to `ComplianceConfig` in `config.py` +- Use `SEVERITY_ORDER` from `constants.py` to compare severity levels numerically +- Log when a severity is overridden so analysts understand why a low-confidence finding shows up as high severity + +### Challenge 6: Database Column Name Heuristic Scoring + +**What to build:** A pre-scan heuristic that boosts detection confidence for columns whose names suggest sensitive data (e.g., `ssn`, `credit_card_number`, `patient_dob`). + +**Why it matters:** Database schema names are strong metadata signals. A column named `ssn` in a table named `employees` is almost certainly storing Social Security Numbers, even before you look at the data. The Capital One breach investigation found that the compromised S3 bucket contained CSV exports with column headers like `SSN` and `AccountNumber`, which would have been trivially detectable with column-name analysis. + +**What you will learn:** +- Schema introspection as a detection signal +- Combining metadata and content signals +- How production DLP tools use schema analysis to prioritize scanning + +**Implementation approach:** + +1. **Create a column name classifier** with patterns mapping column names to rule IDs: + ``` + *ssn*, *social_sec* -> PII_SSN + *credit_card*, *card_num*, *pan* -> FIN_CREDIT_CARD + *email*, *e_mail* -> PII_EMAIL + *dob*, *date_of_birth*, *birthday* -> PII_DOB + ``` + +2. **Apply as a context boost** in the database scanner: when a column name matches a pattern, add a pre-boost to the base score before running the normal detection pipeline + +3. **Carry through to findings:** Add the column name match as additional evidence in the finding's metadata + +**Hints:** +- Implement this in `scanners/db_scanner.py` before the detection loop +- Use `fnmatch` for column name pattern matching (same as rule filtering) +- A modest boost (+0.15 to +0.25) is appropriate. Column names are strong signals but not definitive (a column named `ssn_backup_old` might be empty or encrypted) + +## Advanced Challenges + +### Challenge 7: Custom Rule Language + +**What to build:** A YAML-based rule definition format that lets users create detection rules without writing Python. Rules should support regex patterns, base scores, context keywords, and compliance framework tags. + +**Why it matters:** Production DLP tools (Symantec DLP, Netskope) let compliance teams define custom rules through policy editors because not every regulated data type is covered by built-in rules. European IBANs, Brazilian CPFs, Indian Aadhaar numbers, and industry-specific identifiers all need custom patterns. + +**What you will learn:** +- DSL design (keeping it simple enough to be useful, complex enough to be powerful) +- Safe regex compilation (preventing ReDoS) +- Hot reloading user-defined rules + +**Implementation approach:** + +1. **Define the rule schema:** + ```yaml + rules: + - id: CUSTOM_BR_CPF + name: "Brazilian CPF Number" + pattern: '\b\d{3}\.\d{3}\.\d{3}-\d{2}\b' + base_score: 0.40 + context_keywords: ["cpf", "cadastro"] + compliance: ["LGPD"] + validator: "mod11" + ``` + +2. **Build a rule loader** that reads YAML files from a `rules/` directory, compiles regex patterns safely (with timeout protection against catastrophic backtracking), and creates `DetectionRule` objects + +3. **Register custom rules** alongside built-in rules in the `DetectorRegistry` + +4. **Add built-in validator references** (mod11, luhn, mod97) that users can reference by name instead of writing Python + +**Gotchas:** +- Regex compilation must be safe: a user-provided pattern like `(a+)+b` causes catastrophic backtracking. Consider using the `regex` library with timeout, or validate patterns against known ReDoS patterns +- Custom rules should not be able to override or shadow built-in rules. Use ID prefixes (`CUSTOM_`) to namespace them +- Validator functions referenced by name need a registry of their own + +### Challenge 8: Real-Time File Monitoring + +**What to build:** A watch mode that monitors directories for file changes using filesystem events and scans new or modified files automatically. + +**Why it matters:** Batch scanning finds problems after the fact. Real-time monitoring catches sensitive data as soon as it hits disk. This is how endpoint DLP agents (CrowdStrike Falcon DLP, Digital Guardian) work: they hook filesystem events and scan in real time. + +**What you will learn:** +- Filesystem event monitoring with `watchdog` or `inotify` +- Event debouncing (a single file save can trigger multiple events) +- Background scanning without blocking the event loop + +**Architecture changes:** + +``` +┌──────────────────────────────┐ +│ FileSystemEventHandler │ +│ (watchdog or inotify) │ +│ │ +│ on_modified -> debounce │ +│ on_created -> scan_file │ +│ on_moved -> scan_dest │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ ScanQueue (asyncio.Queue) │ +│ │ +│ Dedup by path │ +│ Rate limit scanning │ +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ FileScanner.scan(file) │ +│ → Finding → Alert │ +└──────────────────────────────┘ +``` + +**Implementation steps:** + +1. Add `watchdog` as a dependency +2. Create `commands/watch.py` with a `dlp-scan watch ./directory` command +3. Implement a debouncer that batches filesystem events within a 500ms window +4. Use the existing `FileScanner._scan_file` for individual file scanning +5. Output findings to console in real time (stream mode, not batch) + +**Gotchas:** +- Editor save operations often create temporary files, write to them, then rename. This generates create, modify, and rename events. You need to scan the final file, not the intermediate temp files +- Large file copies trigger `on_modified` repeatedly as data is written. Debounce by waiting until the file size stabilizes +- The watch mode should respect the same exclude patterns and extension filters as batch scanning + +### Challenge 9: SIEM Integration via Syslog + +**What to build:** A reporter that sends findings to a SIEM (Splunk, Elastic, QRadar) via syslog (RFC 5424) or HTTP Event Collector (Splunk HEC). + +**Why it matters:** DLP findings are useless if they sit in a JSON file that nobody reads. Production DLP deployments send alerts to SIEMs where SOC analysts triage them alongside firewall logs, EDR alerts, and authentication events. Correlating a DLP finding with a VPN login from an unusual location turns a medium-severity alert into an incident. + +**What you will learn:** +- Syslog protocol formatting (RFC 5424 structured data) +- HTTP-based log shipping (Splunk HEC, Elastic Ingest) +- Alert fatigue management (batching, deduplication, severity filtering) + +**Implementation approach:** + +1. **Create `reporters/syslog_reporter.py`** that formats findings as RFC 5424 syslog messages: + ``` + <134>1 2026-04-08T10:30:00Z scanner dlp-scan - - + [finding@dlp rule_id="PII_SSN" severity="critical" + confidence="0.92" uri="employees.csv"] SSN detected + ``` + +2. **Add Splunk HEC support** as an alternative transport: POST JSON payloads to `https://splunk:8088/services/collector/event` with an HEC token + +3. **Add config:** + ```yaml + output: + siem: + type: "syslog" # or "splunk_hec" + host: "siem.corp.com" + port: 514 + protocol: "tcp" # or "udp" + hec_token: "" # for Splunk HEC + ``` + +4. **Implement batching:** Send findings in batches of 50 with a 5-second flush interval to avoid overwhelming the SIEM + +## Expert Challenges + +### Challenge 10: Machine Learning False Positive Reduction + +**What to build:** A feedback loop where analysts can mark findings as true positive or false positive, and a classifier learns to suppress likely false positives on future scans. + +**Why it matters:** The single biggest complaint about DLP tools is false positive volume. Symantec DLP deployments commonly see 40-60% false positive rates on initial rollout. Analysts spend hours dismissing findings that match SSN patterns but are actually serial numbers, batch IDs, or zip+4 codes. A classifier trained on analyst feedback can reduce false positives by 70-80% while maintaining detection recall. + +**What you will learn:** +- Feature engineering from detection signals (confidence, context keywords found, rule type, file type, surrounding text patterns) +- Online learning: updating a model as new feedback arrives without retraining from scratch +- The precision-recall tradeoff in security tooling (a false negative is a missed breach; a false positive is analyst fatigue) + +**Implementation phases:** + +**Phase 1: Feedback Collection** +- Add `dlp-scan feedback --true-positive` and `--false-positive` commands +- Store feedback in a SQLite database: `(finding_id, rule_id, features_json, label, timestamp)` +- Extract features: confidence, rule_id, file extension, context keywords matched, co-occurrence count, surrounding text entropy + +**Phase 2: Classifier** +- Train a logistic regression or gradient boosted tree on accumulated feedback +- Features: one-hot encode rule_id, numeric confidence, boolean context_found, file_extension category +- Use scikit-learn with ONNX export for deployment without the full sklearn dependency + +**Phase 3: Integration** +- After the detection pipeline produces matches, run the classifier as a post-filter +- Matches classified as likely false positives get demoted (severity lowered, or moved to a "suppressed" section) +- Never fully suppress a detection. Always show suppressed findings in a separate section so analysts can audit the classifier + +**Success criteria:** +- [ ] Feedback collection works and stores features +- [ ] Classifier trains on 50+ labeled examples +- [ ] False positive rate drops by at least 30% on held-out test set +- [ ] No true positives are fully suppressed (only demoted) +- [ ] Model retrains automatically when feedback count crosses thresholds (100, 500, 1000) + +## Real-World Integration Challenges + +### Integrate with GitHub Code Scanning + +**The goal:** Upload SARIF output to GitHub Code Scanning so DLP findings appear as annotations on pull requests. + +**What you will learn:** +- GitHub Code Scanning API +- SARIF upload via GitHub Actions +- CI/CD pipeline integration for security tooling + +**Steps:** + +1. Create a GitHub Actions workflow that runs `dlp-scan file . -f sarif -o results.sarif` on pull requests +2. Upload the SARIF file using the `github/codeql-action/upload-sarif` action +3. Configure `on: pull_request` to scan only changed files (use `git diff --name-only` to get the list) +4. Set severity filtering so only high/critical findings block the PR + +### Scan AWS S3 Buckets + +**The goal:** Add an S3 scanner that lists objects in a bucket, downloads them to a temp directory, and scans with the existing file scanner. + +**What you will learn:** +- boto3 integration for S3 object listing and download +- Temporary file management for large object scanning +- Credential handling (IAM roles vs. access keys) + +**Steps:** + +1. Add `dlp-scan s3 s3://bucket-name/prefix` command +2. Use boto3 to list objects, filter by extension +3. Download each object to a temp directory (use `tempfile.mkdtemp`) +4. Scan with `FileScanner` and map findings back to S3 URIs +5. Clean up temp files after scanning + +This directly addresses the Capital One breach scenario: unencrypted PII in S3 buckets that nobody knew existed. + +## Performance Challenge + +### Handle 1 Million Files + +**The goal:** Make the file scanner handle a directory with 1 million files without running out of memory or taking more than an hour. + +**Current bottleneck:** `Path.rglob("*")` generates a list of all files before scanning starts. With 1 million files, this consumes significant memory and delays the first scan result. + +**Optimization approaches:** + +**Approach 1: Streaming directory walk** +- Replace `rglob` with `os.scandir` recursive walk that yields files one at a time +- Process and discard each file before reading the next +- Memory stays constant regardless of directory size + +**Approach 2: Parallel extraction** +- Use `concurrent.futures.ThreadPoolExecutor` for I/O-bound extraction (file reads, PDF parsing) +- Use `concurrent.futures.ProcessPoolExecutor` for CPU-bound detection (regex matching on large texts) +- Tune pool sizes based on profiling + +**Approach 3: Prioritized scanning** +- Scan high-risk extensions first (`.csv`, `.xlsx`, `.sql`) before low-risk ones (`.log`, `.txt`) +- Report findings as they are discovered (streaming output) instead of waiting for the full scan to complete + +**Benchmark it:** + +```bash +time dlp-scan file /large-directory -f json -o results.json +``` + +Target: under 60 minutes for 1 million files with an average file size of 10KB. + +## Challenge Completion + +Track your progress: + +- [ ] Easy 1: Date of Birth Rule +- [ ] Easy 2: HTML Report Output +- [ ] Easy 3: Allowlist by File Path +- [ ] Intermediate 4: Incremental Scanning +- [ ] Intermediate 5: Severity Override +- [ ] Intermediate 6: Column Name Heuristic +- [ ] Advanced 7: Custom Rule Language +- [ ] Advanced 8: Real-Time Monitoring +- [ ] Advanced 9: SIEM Integration +- [ ] Expert 10: ML False Positive Reduction +- [ ] Integration: GitHub Code Scanning +- [ ] Integration: S3 Bucket Scanning +- [ ] Performance: 1 Million Files + +## Study Real Implementations + +Compare your work to production DLP tools: + +- **Nightfall AI**: Cloud-native DLP with ML-based detection. Open-sourced their detection patterns. Look at how they handle multi-format extraction +- **truffleHog**: Focuses on credential detection in git repos. Their entropy-based detection and regex patterns for API keys are similar to this project's credential rules +- **detect-secrets**: Yelp's secret scanner. Compare their plugin architecture to the detector registry pattern in this project +- **Microsoft Purview**: Enterprise DLP with 300+ built-in sensitive information types. Their documentation on exact data match (EDM) and trainable classifiers shows where the field is heading diff --git a/PROJECTS/intermediate/dlp-scanner/pyproject.toml b/PROJECTS/intermediate/dlp-scanner/pyproject.toml new file mode 100644 index 00000000..637bf74d --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/pyproject.toml @@ -0,0 +1,181 @@ +# ©AngelaMos | 2026 +# pyproject.toml + +[project] +name = "dlp-scanner" +version = "0.1.0" +description = "Data Loss Prevention scanner for files, databases, and network traffic" +requires-python = ">=3.12" +dependencies = [ + "typer>=0.15.0", + "rich>=14.0.0", + "structlog>=25.0.0", + "pydantic>=2.10.0", + "orjson>=3.10.0", + "ruamel.yaml>=0.18.0", + "pymupdf>=1.25.0", + "python-docx>=1.1.0", + "openpyxl>=3.1.0", + "xlrd>=2.0.0", + "defusedxml>=0.7.0", + "lxml>=5.0.0", + "pyarrow>=16.0.0", + "fastavro>=1.9.0", + "extract-msg>=0.50.0", + "asyncpg>=0.30.0", + "aiomysql>=0.2.0", + "pymongo>=4.10.0", + "aiosqlite>=0.21.0", + "dpkt>=1.9.0", +] + +[project.scripts] +dlp-scan = "dlp_scanner.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/dlp_scanner"] + +[dependency-groups] +dev = [ + "ruff>=0.11.0", + "mypy>=1.15.0", + "yapf>=0.43.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=6.0.0", + "hypothesis>=6.130.0", +] + +[tool.ruff] +line-length = 75 +indent-width = 4 +target-version = "py312" +src = ["src"] + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "B", + "S", + "C90", + "N", + "UP", + "SIM", + "PTH", + "PERF", + "RUF", + "PL", + "TRY", + "LOG", +] +ignore = [ + "S101", + "S112", + "TRY003", + "PLR2004", + "PLR0913", + "PLR0911", + "PLC0415", + "PTH123", + "PERF401", + "E501", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "PLR2004", "S104", "S105", "S106"] +"src/dlp_scanner/detectors/rules/**/*.py" = ["E501"] +"src/dlp_scanner/scanners/db_scanner.py" = ["S608"] +"src/dlp_scanner/network/protocols.py" = ["S110"] +"src/dlp_scanner/extractors/structured.py" = ["N817"] + +[tool.ruff.lint.mccabe] +max-complexity = 12 + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_configs = true +show_error_codes = true +show_column_numbers = true +pretty = true +mypy_path = "src" + +[[tool.mypy.overrides]] +module = [ + "dpkt.*", + "extract_msg.*", + "fastavro.*", + "xlrd.*", + "docx.*", + "openpyxl.*", + "fitz.*", + "defusedxml.*", + "lxml.*", + "aiomysql.*", + "pymongo.*", + "asyncpg.*", + "aiosqlite.*", + "pyarrow.*", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "dlp_scanner.extractors.email", + "dlp_scanner.extractors.office", + "dlp_scanner.extractors.structured", + "dlp_scanner.scanners.db_scanner", + "dlp_scanner.scanners.network_scanner", + "dlp_scanner.network.pcap", +] +disallow_any_expr = false +warn_return_any = false +disable_error_code = ["attr-defined", "unused-coroutine", "no-untyped-call", "import-untyped"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = [ + "--strict-markers", + "--tb=short", +] +markers = [ + "unit: fast unit tests with no I/O", + "integration: tests requiring real file system or DB", + "slow: long-running tests", +] + +[tool.coverage.run] +source = ["src"] +branch = true +omit = [ + "*/tests/*", + "src/dlp_scanner/extractors/pdf.py", + "src/dlp_scanner/extractors/office.py", + "src/dlp_scanner/extractors/archive.py", + "src/dlp_scanner/extractors/email.py", + "src/dlp_scanner/network/pcap.py", + "src/dlp_scanner/scanners/network_scanner.py", + "src/dlp_scanner/scanners/db_scanner.py", + "src/dlp_scanner/reporters/base.py", + "src/dlp_scanner/scanners/base.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "\\.\\.\\.", +] diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/__init__.py new file mode 100644 index 00000000..4e0a5ce6 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/__init__.py @@ -0,0 +1,6 @@ +""" +©AngelaMos | 2026 +__init__.py +""" + +__version__ = "0.1.0" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/cli.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/cli.py new file mode 100644 index 00000000..1cff3630 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/cli.py @@ -0,0 +1,73 @@ +""" +©AngelaMos | 2026 +cli.py +""" + + +from typing import Annotated + +import typer + +from dlp_scanner import __version__ +from dlp_scanner.commands.report import report_app +from dlp_scanner.commands.scan import register + + +app = typer.Typer( + name = "dlp-scan", + help = ( + "Data Loss Prevention scanner for files, " + "databases, and network traffic" + ), + no_args_is_help = True, +) + + +def _version_callback(value: bool) -> None: + """ + Print version and exit + """ + if value: + typer.echo(f"dlp-scanner {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + ctx: typer.Context, + config: Annotated[ + str, + typer.Option( + "--config", + "-c", + help = "Path to config YAML file", + ), + ] = "", + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help = "Enable verbose output", + ), + ] = False, + version: Annotated[ + bool, + typer.Option( + "--version", + callback = _version_callback, + is_eager = True, + help = "Show version and exit", + ), + ] = False, +) -> None: + """ + DLP Scanner - detect sensitive data across files, databases, and network captures + """ + ctx.ensure_object(dict) + ctx.obj["config_path"] = config + ctx.obj["verbose"] = verbose + + +register(app) +app.add_typer(report_app, name = "report") diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/report.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/report.py new file mode 100644 index 00000000..bae4109f --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/report.py @@ -0,0 +1,187 @@ +""" +©AngelaMos | 2026 +report.py +""" + + +from datetime import datetime +from pathlib import Path +from typing import Annotated, Any + +import orjson +import typer + +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) + + +report_app = typer.Typer(help = "Report conversion and summary") + +VALID_FORMATS: frozenset[str] = frozenset( + { + "console", + "json", + "sarif", + "csv", + } +) + + +@report_app.command("convert") +def convert( + input_file: Annotated[ + str, + typer.Argument(help = "JSON scan results file"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = "Target format (json, sarif, csv)", + ), + ] = "sarif", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = "Write converted report to file", + ), + ] = "", +) -> None: + """ + Convert a JSON scan result to another format + """ + from dlp_scanner.config import ScanConfig + from dlp_scanner.engine import ScanEngine + + if output_format not in VALID_FORMATS: + typer.echo( + f"Invalid format: {output_format}", + err = True, + ) + raise typer.Exit(code = 1) + + path = Path(input_file) + if not path.exists(): + typer.echo( + f"File not found: {input_file}", + err = True, + ) + raise typer.Exit(code = 1) + + raw = path.read_bytes() + data = orjson.loads(raw) + result = _rebuild_result(data) + + config = ScanConfig() + engine = ScanEngine(config) + + output = engine.generate_report(result, output_format) + + if output_file: + Path(output_file).write_text(output) + typer.echo(f"Converted report written to " + f"{output_file}") + else: + typer.echo(output) + + +@report_app.command("summary") +def summary( + input_file: Annotated[ + str, + typer.Argument(help = "JSON scan results file"), + ], +) -> None: + """ + Print summary statistics from a scan result file + """ + path = Path(input_file) + if not path.exists(): + typer.echo( + f"File not found: {input_file}", + err = True, + ) + raise typer.Exit(code = 1) + + raw = path.read_bytes() + data = orjson.loads(raw) + result = _rebuild_result(data) + + from dlp_scanner.reporters.console import ( + ConsoleReporter, + ) + + reporter = ConsoleReporter() + reporter.display(result) + + +def _rebuild_result( + data: dict[str, + Any], +) -> ScanResult: + """ + Rebuild a ScanResult from deserialized JSON report + """ + meta = data.get("scan_metadata", {}) + result = ScanResult( + targets_scanned = meta.get("targets_scanned", + 0), + ) + result.scan_id = meta.get("scan_id", result.scan_id) + + if meta.get("scan_completed_at"): + result.scan_completed_at = ( + datetime.fromisoformat(meta["scan_completed_at"]) + ) + + result.errors = meta.get("errors", []) + + for f_data in data.get("findings", []): + loc_data = f_data.get("location", {}) + location = Location( + source_type = loc_data.get("source_type", + "file"), + uri = loc_data.get("uri", + ""), + line = loc_data.get("line"), + column = loc_data.get("column"), + table_name = loc_data.get("table_name"), + column_name = loc_data.get("column_name"), + ) + + finding = Finding( + rule_id = f_data.get("rule_id", + ""), + rule_name = f_data.get("rule_name", + ""), + severity = f_data.get("severity", + "low"), + confidence = f_data.get("confidence", + 0.0), + location = location, + redacted_snippet = f_data.get("redacted_snippet", + ""), + compliance_frameworks = f_data.get( + "compliance_frameworks", + [] + ), + remediation = f_data.get("remediation", + ""), + ) + + if f_data.get("finding_id"): + finding.finding_id = f_data["finding_id"] + if f_data.get("detected_at"): + finding.detected_at = ( + datetime.fromisoformat(f_data["detected_at"]) + ) + + result.findings.append(finding) + + return result diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/scan.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/scan.py new file mode 100644 index 00000000..cd3fd366 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/commands/scan.py @@ -0,0 +1,195 @@ +""" +©AngelaMos | 2026 +scan.py +""" + + +from pathlib import Path +from typing import Annotated, Any + +import typer + + +FORMAT_HELP: str = ("Output format (console, json, sarif, csv)") +OUTPUT_HELP: str = "Write report to file" +VALID_FORMATS: frozenset[str] = frozenset( + { + "console", + "json", + "sarif", + "csv", + } +) + + +def scan_file( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help = "File or directory path"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = FORMAT_HELP, + ), + ] = "console", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = OUTPUT_HELP, + ), + ] = "", +) -> None: + """ + Scan files and directories for sensitive data + """ + _run_scan(ctx, "file", target, output_format, output_file) + + +def scan_db( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help = "Database connection URI"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = FORMAT_HELP, + ), + ] = "console", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = OUTPUT_HELP, + ), + ] = "", +) -> None: + """ + Scan database tables for sensitive data + """ + _run_scan(ctx, "db", target, output_format, output_file) + + +def scan_network( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help = "PCAP file path"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = FORMAT_HELP, + ), + ] = "console", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = OUTPUT_HELP, + ), + ] = "", +) -> None: + """ + Scan network capture files for sensitive data in transit + """ + _run_scan( + ctx, + "network", + target, + output_format, + output_file, + ) + + +def register(app: typer.Typer) -> None: + """ + Register scan commands on the root app + """ + app.command("file")(scan_file) + app.command("db")(scan_db) + app.command("network")(scan_network) + + +def _run_scan( + ctx: typer.Context, + scan_type: str, + target: str, + output_format: str, + output_file: str, +) -> None: + """ + Shared scan execution logic + """ + from dlp_scanner.config import ( + ScanConfig, + load_config, + ) + from dlp_scanner.engine import ScanEngine + from dlp_scanner.log import configure_logging + + if output_format not in VALID_FORMATS: + typer.echo( + f"Invalid format: {output_format}. " + f"Choose from: " + f"{', '.join(sorted(VALID_FORMATS))}", + err = True, + ) + raise typer.Exit(code = 1) + + obj: dict[str, Any] = ctx.ensure_object(dict) + config_path: str = obj.get("config_path", "") + verbose: bool = obj.get("verbose", False) + + if verbose: + configure_logging(level = "DEBUG") + elif output_format == "console": + configure_logging(level = "INFO") + else: + configure_logging(level = "WARNING") + + config: ScanConfig + cfg_path = Path(config_path) if config_path else None + if cfg_path and cfg_path.exists(): + config = load_config(cfg_path) + else: + config = ScanConfig() + + config.output.format = output_format + if output_file: + config.output.output_file = output_file + + engine = ScanEngine(config) + + scan_methods = { + "file": engine.scan_files, + "db": engine.scan_database, + "network": engine.scan_network, + } + + result = scan_methods[scan_type](target) + + if output_file: + engine.write_report(result, output_file) + typer.echo(f"Report written to {output_file}") + elif output_format == "console": + engine.display_console(result) + else: + output = engine.generate_report(result) + typer.echo(output) + + if result.errors: + raise typer.Exit(code = 1) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/compliance.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/compliance.py new file mode 100644 index 00000000..8761f095 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/compliance.py @@ -0,0 +1,245 @@ +""" +©AngelaMos | 2026 +compliance.py +""" + + +from dlp_scanner.constants import ( + SEVERITY_SCORE_THRESHOLDS, + Severity, +) + + +RULE_FRAMEWORK_MAP: dict[str, + list[str]] = { + "PII_SSN": ["HIPAA", + "CCPA", + "GLBA", + "GDPR"], + "PII_EMAIL": ["GDPR", + "CCPA"], + "PII_PHONE": ["GDPR", + "CCPA", + "HIPAA"], + "PII_PHONE_INTL": ["GDPR", + "CCPA"], + "PII_PASSPORT_US": ["GDPR", + "CCPA"], + "PII_PASSPORT_UK": ["GDPR"], + "PII_DRIVERS_LICENSE": ["CCPA", + "HIPAA"], + "PII_DRIVERS_LICENSE_FL": ["CCPA", + "HIPAA"], + "PII_DRIVERS_LICENSE_IL": ["CCPA", + "HIPAA"], + "PII_IPV4": ["GDPR"], + "FIN_CREDIT_CARD_VISA": ["PCI_DSS", + "GLBA"], + "FIN_CREDIT_CARD_MC": ["PCI_DSS", + "GLBA"], + "FIN_CREDIT_CARD_AMEX": ["PCI_DSS", + "GLBA"], + "FIN_CREDIT_CARD_DISC": ["PCI_DSS", + "GLBA"], + "FIN_IBAN": ["GDPR", + "GLBA"], + "FIN_NHS_NUMBER": ["GDPR"], + "CRED_AWS_ACCESS_KEY": [], + "CRED_GITHUB_TOKEN": [], + "CRED_GITHUB_FINE_GRAINED": [], + "CRED_GITHUB_OAUTH": [], + "CRED_GITHUB_APP": [], + "CRED_JWT": [], + "CRED_STRIPE_KEY": [], + "CRED_SLACK_TOKEN": [], + "CRED_GENERIC_API_KEY": [], + "CRED_PRIVATE_KEY": [], + "HEALTH_MEDICAL_RECORD": ["HIPAA"], + "HEALTH_DEA_NUMBER": ["HIPAA"], + "HEALTH_NPI": ["HIPAA"], + "NET_HIGH_ENTROPY": [], + "NET_DNS_EXFIL_LONG_LABEL": [], + "NET_DNS_EXFIL_HIGH_ENTROPY": [], + "NET_DNS_EXFIL_LONG_QNAME": [], + "NET_DNS_EXFIL_TXT_VOLUME": [], + "NET_ENCODED_BASE64": [], + "NET_ENCODED_HEX": [], + } + +RULE_REMEDIATION_MAP: dict[ + str, + str] = { + "PII_SSN": ( + "Remove or encrypt SSNs. Use tokenization " + "for storage. Never store in plaintext." + ), + "PII_EMAIL": ( + "Evaluate if email storage is necessary. " + "Hash or pseudonymize where possible." + ), + "PII_PHONE": ( + "Restrict access to phone number fields. " + "Consider masking in non-production environments." + ), + "PII_PHONE_INTL": ( + "Restrict access to phone number fields. " + "Consider masking in non-production environments." + ), + "PII_PASSPORT_US": ( + "Passport numbers must be encrypted at rest. " + "Limit access to identity verification systems." + ), + "PII_PASSPORT_UK": ( + "Passport numbers must be encrypted at rest. " + "Limit access to identity verification systems." + ), + "PII_IPV4": ( + "Evaluate whether IP address storage is necessary. " + "Anonymize or pseudonymize where possible." + ), + "PII_DRIVERS_LICENSE": ( + "Encrypt driver's license numbers at rest. " + "Restrict access per CCPA/HIPAA requirements." + ), + "PII_DRIVERS_LICENSE_FL": ( + "Encrypt driver's license numbers at rest. " + "Restrict access per CCPA/HIPAA requirements." + ), + "PII_DRIVERS_LICENSE_IL": ( + "Encrypt driver's license numbers at rest. " + "Restrict access per CCPA/HIPAA requirements." + ), + "FIN_CREDIT_CARD_VISA": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_CREDIT_CARD_MC": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_CREDIT_CARD_AMEX": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_CREDIT_CARD_DISC": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_IBAN": ( + "Encrypt IBAN numbers at rest. " + "Restrict access to financial systems." + ), + "FIN_NHS_NUMBER": ( + "NHS numbers are personal data under UK GDPR. " + "Encrypt at rest and restrict access." + ), + "CRED_AWS_ACCESS_KEY": ( + "Rotate exposed AWS credentials immediately. " + "Use IAM roles or Vault dynamic secrets." + ), + "CRED_GITHUB_TOKEN": ( + "Revoke the token at github.com/settings/tokens. " + "Use environment variables, not hardcoded values." + ), + "CRED_GITHUB_FINE_GRAINED": ( + "Revoke the token at github.com/settings/tokens. " + "Use environment variables, not hardcoded values." + ), + "CRED_GITHUB_OAUTH": ( + "Revoke the OAuth token in GitHub settings. " + "Store tokens in a secrets manager." + ), + "CRED_GITHUB_APP": ( + "Revoke the app installation token. " + "Rotate app private keys if compromised." + ), + "CRED_JWT": ( + "Rotate the signing key if the JWT secret is " + "exposed. Never hardcode tokens in source." + ), + "CRED_STRIPE_KEY": ( + "Rotate the Stripe key at dashboard.stripe.com. " + "Use restricted keys with minimal permissions." + ), + "CRED_SLACK_TOKEN": ( + "Revoke the Slack token in workspace settings. " + "Use environment variables for bot tokens." + ), + "CRED_GENERIC_API_KEY": ( + "Rotate the exposed API key immediately. " + "Store secrets in a vault, not in source code." + ), + "CRED_PRIVATE_KEY": ( + "Rotate the compromised key pair. Store private " + "keys in a secrets manager, never in source code." + ), + "HEALTH_MEDICAL_RECORD": ( + "MRNs are PHI under HIPAA. Encrypt at rest and " + "apply minimum necessary access controls." + ), + "HEALTH_DEA_NUMBER": ( + "DEA numbers identify prescribers of controlled " + "substances. Encrypt and restrict access per HIPAA." + ), + "HEALTH_NPI": ( + "NPIs are provider identifiers under HIPAA. " + "Restrict access to authorized systems only." + ), + "NET_HIGH_ENTROPY": ( + "High entropy data may indicate encrypted or " + "compressed secrets in transit. Investigate the flow." + ), + "NET_DNS_EXFIL_LONG_LABEL": ( + "Unusually long DNS labels may indicate DNS " + "tunneling. Investigate the queried domain." + ), + "NET_DNS_EXFIL_HIGH_ENTROPY": ( + "High-entropy DNS subdomains suggest data " + "exfiltration via DNS tunneling. Block the domain." + ), + "NET_DNS_EXFIL_LONG_QNAME": ( + "Excessively long DNS QNAMEs may carry encoded " + "data. Investigate and block suspicious domains." + ), + "NET_DNS_EXFIL_TXT_VOLUME": ( + "High ratio of TXT queries to a domain suggests " + "DNS-based command and control. Investigate traffic." + ), + "NET_ENCODED_BASE64": ( + "Base64-encoded payloads in network traffic may " + "carry exfiltrated data. Inspect the content." + ), + "NET_ENCODED_HEX": ( + "Hex-encoded payloads in network traffic may " + "indicate data exfiltration. Inspect the content." + ), + } + +DEFAULT_REMEDIATION: str = ( + "Review and restrict access to this data. " + "Apply encryption at rest if required by policy." +) + + +def get_frameworks_for_rule(rule_id: str) -> list[str]: + """ + Return applicable compliance frameworks for a rule + """ + return RULE_FRAMEWORK_MAP.get(rule_id, []) + + +def get_remediation_for_rule(rule_id: str) -> str: + """ + Return remediation guidance for a rule + """ + return RULE_REMEDIATION_MAP.get(rule_id, DEFAULT_REMEDIATION) + + +def score_to_severity(score: float) -> Severity: + """ + Convert a confidence score to a severity level + """ + for threshold, severity in SEVERITY_SCORE_THRESHOLDS: + if score >= threshold: + return severity + return "low" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/config.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/config.py new file mode 100644 index 00000000..73813991 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/config.py @@ -0,0 +1,173 @@ +""" +©AngelaMos | 2026 +config.py +""" + + +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field +from ruamel.yaml import YAML + +from dlp_scanner.constants import ( + DEFAULT_CONTEXT_WINDOW_TOKENS, + DEFAULT_DB_MAX_ROWS, + DEFAULT_DB_SAMPLE_PERCENTAGE, + DEFAULT_DB_TIMEOUT_SECONDS, + DEFAULT_DNS_ENTROPY_THRESHOLD, + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_EXCLUDE_PATTERNS, + DEFAULT_MAX_FILE_SIZE_MB, + DEFAULT_MIN_CONFIDENCE, + SCANNABLE_EXTENSIONS, + OutputFormat, + RedactionStyle, + Severity, +) + + +class FileScanConfig(BaseModel): + """ + Configuration for file scanning + """ + max_file_size_mb: int = DEFAULT_MAX_FILE_SIZE_MB + recursive: bool = True + exclude_patterns: list[str] = Field( + default_factory = lambda: list(DEFAULT_EXCLUDE_PATTERNS) + ) + include_extensions: list[str] = Field( + default_factory = lambda: sorted(SCANNABLE_EXTENSIONS) + ) + + +class DatabaseScanConfig(BaseModel): + """ + Configuration for database scanning + """ + sample_percentage: int = DEFAULT_DB_SAMPLE_PERCENTAGE + max_rows_per_table: int = DEFAULT_DB_MAX_ROWS + timeout_seconds: int = DEFAULT_DB_TIMEOUT_SECONDS + exclude_tables: list[str] = Field(default_factory = list) + include_tables: list[str] = Field(default_factory = list) + + +class NetworkScanConfig(BaseModel): + """ + Configuration for network traffic scanning + """ + bpf_filter: str = "" + entropy_threshold: float = DEFAULT_ENTROPY_THRESHOLD + dns_label_entropy_threshold: float = (DEFAULT_DNS_ENTROPY_THRESHOLD) + max_packets: int = 0 + + +class AllowlistConfig(BaseModel): + """ + Allowlists for suppressing known false positives + """ + values: list[str] = Field(default_factory = list) + domains: list[str] = Field(default_factory = list) + file_patterns: list[str] = Field(default_factory = list) + + +class DetectionConfig(BaseModel): + """ + Configuration for detection behavior + """ + min_confidence: float = DEFAULT_MIN_CONFIDENCE + severity_threshold: Severity = "low" + context_window_tokens: int = (DEFAULT_CONTEXT_WINDOW_TOKENS) + enable_rules: list[str] = Field(default_factory = lambda: ["*"]) + disable_rules: list[str] = Field(default_factory = list) + allowlists: AllowlistConfig = Field(default_factory = AllowlistConfig) + + +class ComplianceConfig(BaseModel): + """ + Configuration for compliance framework mapping + """ + frameworks: list[str] = Field( + default_factory = lambda: [ + "HIPAA", + "PCI_DSS", + "GDPR", + "CCPA",] + ) + + +class OutputConfig(BaseModel): + """ + Configuration for output and reporting + """ + format: OutputFormat = "console" + output_file: str = "" + redaction_style: RedactionStyle = "partial" + verbose: bool = False + color: bool = True + + +class LoggingConfig(BaseModel): + """ + Configuration for logging behavior + """ + level: str = "INFO" + json_output: bool = False + log_file: str = "" + + +class ScanConfig(BaseModel): + """ + Root configuration model for the DLP scanner + """ + file: FileScanConfig = Field(default_factory = FileScanConfig) + database: DatabaseScanConfig = Field( + default_factory = DatabaseScanConfig + ) + network: NetworkScanConfig = Field(default_factory = NetworkScanConfig) + detection: DetectionConfig = Field(default_factory = DetectionConfig) + compliance: ComplianceConfig = Field( + default_factory = ComplianceConfig + ) + output: OutputConfig = Field(default_factory = OutputConfig) + logging: LoggingConfig = Field(default_factory = LoggingConfig) + + +def load_config(path: Path | None = None) -> ScanConfig: + """ + Load configuration from a YAML file or return defaults + """ + if path is None: + candidates = [ + Path(".dlp-scanner.yml"), + Path(".dlp-scanner.yaml"), + Path.home() / ".dlp-scanner.yml", + ] + for candidate in candidates: + if candidate.exists(): + path = candidate + break + + if path is None or not path.exists(): + return ScanConfig() + + yaml = YAML(typ = "safe") + raw: dict[str, Any] = yaml.load(path) or {} + + scan_section = raw.get("scan", {}) + return ScanConfig( + file = FileScanConfig(**scan_section.get("file", + {})), + database = DatabaseScanConfig(**scan_section.get("database", + {})), + network = NetworkScanConfig(**scan_section.get("network", + {})), + detection = DetectionConfig(**raw.get("detection", + {})), + compliance = ComplianceConfig(**raw.get("compliance", + {})), + output = OutputConfig(**raw.get("output", + {})), + logging = LoggingConfig(**raw.get("logging", + {})), + ) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/constants.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/constants.py new file mode 100644 index 00000000..41bf3850 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/constants.py @@ -0,0 +1,171 @@ +""" +©AngelaMos | 2026 +constants.py +""" + + +from typing import Literal + + +Severity = Literal["critical", "high", "medium", "low"] +OutputFormat = Literal["console", "json", "sarif", "csv"] +RedactionStyle = Literal["partial", "full", "none"] +ScanTargetType = Literal["file", "database", "network"] + +SEVERITY_ORDER: dict[Severity, + int] = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + } + +SEVERITY_SCORE_THRESHOLDS: list[tuple[float, + Severity]] = [ + (0.85, + "critical"), + (0.65, + "high"), + (0.40, + "medium"), + (0.20, + "low"), + ] + +COMPLIANCE_FRAMEWORKS: list[str] = [ + "HIPAA", + "PCI_DSS", + "GDPR", + "CCPA", + "SOX", + "GLBA", + "FERPA", +] + +DEFAULT_CONTEXT_WINDOW_TOKENS: int = 10 +DEFAULT_MIN_CONFIDENCE: float = 0.20 +DEFAULT_ENTROPY_THRESHOLD: float = 7.2 +DEFAULT_DNS_ENTROPY_THRESHOLD: float = 4.0 +DEFAULT_MAX_FILE_SIZE_MB: int = 100 +DEFAULT_DB_SAMPLE_PERCENTAGE: int = 5 +DEFAULT_DB_MAX_ROWS: int = 10000 +DEFAULT_DB_TIMEOUT_SECONDS: int = 30 + +CHECKSUM_BOOST: float = 0.30 +CONTEXT_BOOST_MAX: float = 0.35 +CONTEXT_BOOST_MIN_FLOOR: float = 0.40 +COOCCURRENCE_BOOST: float = 0.15 + +KNOWN_TEST_VALUES: frozenset[str] = frozenset( + { + "123-45-6789", + "000-00-0000", + "078-05-1120", + "219-09-9999", + "4111111111111111", + "5500000000000004", + "340000000000009", + "6011000000000004", + "test@example.com", + "user@test.com", + } +) + +DEFAULT_EXCLUDE_PATTERNS: list[str] = [ + "*.pyc", + "__pycache__", + ".git", + "node_modules", + ".venv", + ".env", + "*.egg-info", +] + +SCANNABLE_EXTENSIONS: frozenset[str] = frozenset( + { + ".pdf", + ".docx", + ".xlsx", + ".xls", + ".csv", + ".json", + ".xml", + ".yaml", + ".yml", + ".txt", + ".log", + ".cfg", + ".ini", + ".toml", + ".conf", + ".eml", + ".msg", + ".parquet", + ".avro", + ".md", + ".rst", + ".html", + ".htm", + ".tsv", + ".py", + ".js", + ".ts", + ".go", + ".rb", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".rs", + ".env", + ".sh", + ".bat", + ".ps1", + ".tf", + ".hcl", + } +) + +TEXT_DB_COLUMN_TYPES_PG: frozenset[str] = frozenset( + { + "text", + "character varying", + "character", + "json", + "jsonb", + "varchar", + } +) + +TEXT_DB_COLUMN_TYPES_MYSQL: frozenset[str] = frozenset( + { + "varchar", + "text", + "mediumtext", + "longtext", + "json", + "char", + "tinytext", + } +) + +SEVERITY_COLORS: dict[Severity, + str] = { + "critical": "bold red", + "high": "red", + "medium": "yellow", + "low": "green", + } + +SARIF_SEVERITY_MAP: dict[Severity, + str] = { + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + } + +MAX_ARCHIVE_DEPTH: int = 3 +MAX_ARCHIVE_MEMBER_SIZE_MB: int = 50 +ZIP_BOMB_RATIO_THRESHOLD: int = 100 diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/base.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/base.py new file mode 100644 index 00000000..d8438432 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/base.py @@ -0,0 +1,51 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +import re +from dataclasses import dataclass, field +from typing import Protocol +from collections.abc import Callable + + +@dataclass(frozen = True, slots = True) +class DetectionRule: + """ + A single detection rule combining regex, validation, and context + """ + rule_id: str + rule_name: str + pattern: re.Pattern[str] + base_score: float + context_keywords: list[str] = field(default_factory = list) + validator: Callable[[str], bool] | None = None + compliance_frameworks: list[str] = field(default_factory = list) + severity_override: str | None = None + + +@dataclass(frozen = True, slots = True) +class DetectorMatch: + """ + A raw match from a detector before scoring + """ + rule_id: str + rule_name: str + start: int + end: int + matched_text: str + score: float + context_keywords: list[str] = field(default_factory = list) + compliance_frameworks: list[str] = field(default_factory = list) + + +class Detector(Protocol): + """ + Protocol for all detection strategies + """ + def detect(self, text: str) -> list[DetectorMatch]: + """ + Scan text and return all matches + """ + ... diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/context.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/context.py new file mode 100644 index 00000000..7330e21c --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/context.py @@ -0,0 +1,142 @@ +""" +©AngelaMos | 2026 +context.py +""" + + +from dlp_scanner.constants import ( + CONTEXT_BOOST_MAX, + CONTEXT_BOOST_MIN_FLOOR, + COOCCURRENCE_BOOST, + DEFAULT_CONTEXT_WINDOW_TOKENS, +) +from dlp_scanner.detectors.base import DetectorMatch + + +def apply_context_boost( + text: str, + matches: list[DetectorMatch], + window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS, +) -> list[DetectorMatch]: + """ + Boost match scores based on nearby context keywords + """ + if not matches: + return matches + + tokens = text.lower().split() + boosted: list[DetectorMatch] = [] + + for match in matches: + if not match.context_keywords: + boosted.append(match) + continue + + char_to_token = _char_offset_to_token_index(text, match.start) + window_start = max(0, char_to_token - window_tokens) + window_end = min(len(tokens), char_to_token + window_tokens) + window_text = " ".join(tokens[window_start : window_end]) + + boost = _compute_keyword_boost( + window_text, + match.context_keywords, + window_tokens, + ) + + new_score = min(1.0, match.score + boost) + if boost > 0 and new_score < CONTEXT_BOOST_MIN_FLOOR: + new_score = CONTEXT_BOOST_MIN_FLOOR + + boosted.append( + DetectorMatch( + rule_id = match.rule_id, + rule_name = match.rule_name, + start = match.start, + end = match.end, + matched_text = match.matched_text, + score = new_score, + context_keywords = match.context_keywords, + compliance_frameworks = match.compliance_frameworks, + ) + ) + + return _apply_cooccurrence_boost(boosted) + + +def _compute_keyword_boost( + window_text: str, + keywords: list[str], + window_tokens: int, +) -> float: + """ + Compute score boost based on keyword proximity + """ + best_boost = 0.0 + + for keyword in keywords: + kw_lower = keyword.lower() + pos = window_text.find(kw_lower) + if pos < 0: + continue + + center = len(window_text) // 2 + distance = abs(pos - center) + max_distance = window_tokens * 5 + + proximity_factor = 1.0 - min(1.0, distance / max(1, max_distance)) + boost = CONTEXT_BOOST_MAX * proximity_factor + best_boost = max(best_boost, boost) + + return best_boost + + +def _apply_cooccurrence_boost( + matches: list[DetectorMatch], +) -> list[DetectorMatch]: + """ + Boost scores when multiple PII types appear near each other + """ + if len(matches) < 2: + return matches + + proximity_threshold = 500 + boosted: list[DetectorMatch] = [] + + for i, match in enumerate(matches): + has_neighbor = False + for j, other in enumerate(matches): + if i == j: + continue + if other.rule_id == match.rule_id: + continue + distance = abs(match.start - other.start) + if distance < proximity_threshold: + has_neighbor = True + break + + if has_neighbor: + new_score = min(1.0, match.score + COOCCURRENCE_BOOST) + boosted.append( + DetectorMatch( + rule_id = match.rule_id, + rule_name = match.rule_name, + start = match.start, + end = match.end, + matched_text = match.matched_text, + score = new_score, + context_keywords = match.context_keywords, + compliance_frameworks = match.compliance_frameworks, + ) + ) + else: + boosted.append(match) + + return boosted + + +def _char_offset_to_token_index(text: str, char_offset: int) -> int: + """ + Convert a character offset to an approximate token index + """ + prefix = text[: char_offset] + return len(prefix.split()) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/entropy.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/entropy.py new file mode 100644 index 00000000..5ed03851 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/entropy.py @@ -0,0 +1,126 @@ +""" +©AngelaMos | 2026 +entropy.py +""" + + +import math +from collections import Counter + +from dlp_scanner.constants import DEFAULT_ENTROPY_THRESHOLD +from dlp_scanner.detectors.base import DetectorMatch + + +WINDOW_SIZE: int = 256 +WINDOW_STEP: int = 128 + + +def shannon_entropy(data: bytes) -> float: + """ + Calculate Shannon entropy in bits per byte + """ + if not data: + return 0.0 + + counts = Counter(data) + total = len(data) + return -sum( + (c / total) * math.log2(c / total) for c in counts.values() + ) + + +def shannon_entropy_str(text: str) -> float: + """ + Calculate Shannon entropy for a string + """ + return shannon_entropy(text.encode("utf-8")) + + +def detect_high_entropy_regions( + data: bytes, + threshold: float = DEFAULT_ENTROPY_THRESHOLD, + window_size: int = WINDOW_SIZE, + step: int = WINDOW_STEP, +) -> list[tuple[int, + int, + float]]: + """ + Find regions of high entropy using a sliding window + + Returns list of (start_offset, end_offset, entropy_value) + """ + if len(data) < window_size: + h = shannon_entropy(data) + if h >= threshold: + return [(0, len(data), h)] + return [] + + regions: list[tuple[int, int, float]] = [] + i = 0 + + while i + window_size <= len(data): + window = data[i : i + window_size] + h = shannon_entropy(window) + + if h >= threshold: + end = i + window_size + while end + step <= len(data): + next_window = data[end - window_size + step : end + step] + next_h = shannon_entropy(next_window) + if next_h < threshold: + break + h = max(h, next_h) + end += step + + regions.append((i, end, h)) + i = end + else: + i += step + + return regions + + +class EntropyDetector: + """ + Detects high-entropy data that may indicate encrypted + or compressed content + """ + def __init__( + self, + threshold: float = DEFAULT_ENTROPY_THRESHOLD, + ) -> None: + self._threshold = threshold + + def detect(self, text: str) -> list[DetectorMatch]: + """ + Scan text for high-entropy regions + """ + data = text.encode("utf-8") + regions = detect_high_entropy_regions( + data, + threshold = self._threshold, + ) + + matches: list[DetectorMatch] = [] + for start, end, entropy_val in regions: + score = min( + 1.0, + (entropy_val - self._threshold) / + (8.0 - self._threshold) * 0.5 + 0.5, + ) + + matches.append( + DetectorMatch( + rule_id = "NET_HIGH_ENTROPY", + rule_name = "High Entropy Data", + start = start, + end = end, + matched_text = + f"[{end - start} bytes, H={entropy_val:.2f}]", + score = score, + context_keywords = [], + compliance_frameworks = [], + ) + ) + + return matches diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/pattern.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/pattern.py new file mode 100644 index 00000000..060c7714 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/pattern.py @@ -0,0 +1,68 @@ +""" +©AngelaMos | 2026 +pattern.py +""" + + +from dlp_scanner.constants import CHECKSUM_BOOST, KNOWN_TEST_VALUES +from dlp_scanner.detectors.base import ( + DetectionRule, + DetectorMatch, +) + + +class PatternDetector: + """ + Detects sensitive data using regex patterns with optional + checksum validation + """ + def __init__( + self, + rules: list[DetectionRule], + allowlist_values: frozenset[str] | None = None, + ) -> None: + self._rules = rules + self._allowlist = allowlist_values or KNOWN_TEST_VALUES + + def detect(self, text: str) -> list[DetectorMatch]: + """ + Scan text against all registered patterns + """ + matches: list[DetectorMatch] = [] + + for rule in self._rules: + for m in rule.pattern.finditer(text): + matched_text = m.group() + + if self._is_allowlisted(matched_text): + continue + + score = rule.base_score + + if rule.validator is not None: + if rule.validator(matched_text): + score = min(1.0, score + CHECKSUM_BOOST) + else: + continue + + matches.append( + DetectorMatch( + rule_id = rule.rule_id, + rule_name = rule.rule_name, + start = m.start(), + end = m.end(), + matched_text = matched_text, + score = score, + context_keywords = rule.context_keywords, + compliance_frameworks = rule.compliance_frameworks, + ) + ) + + return matches + + def _is_allowlisted(self, value: str) -> bool: + """ + Check if a matched value is in the allowlist + """ + normalized = value.strip() + return normalized in self._allowlist diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/registry.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/registry.py new file mode 100644 index 00000000..55e2f46c --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/registry.py @@ -0,0 +1,112 @@ +""" +©AngelaMos | 2026 +registry.py +""" + + +import fnmatch + +from dlp_scanner.detectors.base import ( + DetectionRule, + DetectorMatch, +) +from dlp_scanner.detectors.context import ( + apply_context_boost, +) +from dlp_scanner.detectors.entropy import EntropyDetector +from dlp_scanner.detectors.pattern import PatternDetector +from dlp_scanner.detectors.rules.credentials import ( + CREDENTIAL_RULES, +) +from dlp_scanner.detectors.rules.financial import ( + FINANCIAL_RULES, +) +from dlp_scanner.detectors.rules.health import HEALTH_RULES +from dlp_scanner.detectors.rules.pii import PII_RULES + + +ALL_RULES: list[DetectionRule] = [ + *PII_RULES, + *FINANCIAL_RULES, + *CREDENTIAL_RULES, + *HEALTH_RULES, +] + + +class DetectorRegistry: + """ + Central registry that loads, filters, and runs all detectors + """ + def __init__( + self, + enable_patterns: list[str] | None = None, + disable_patterns: list[str] | None = None, + allowlist_values: frozenset[str] | None = None, + context_window_tokens: int = 10, + entropy_threshold: float = 7.2, + enable_entropy: bool = True, + ) -> None: + active_rules = _filter_rules( + ALL_RULES, + enable_patterns or ["*"], + disable_patterns or [], + ) + + self._pattern_detector = PatternDetector( + rules = active_rules, + allowlist_values = allowlist_values, + ) + self._entropy_detector = ( + EntropyDetector(threshold = entropy_threshold) + if enable_entropy else None + ) + self._context_window = context_window_tokens + + def detect(self, text: str) -> list[DetectorMatch]: + """ + Run all detectors against text and return scored matches + """ + matches = self._pattern_detector.detect(text) + matches = apply_context_boost( + text, + matches, + window_tokens = self._context_window, + ) + + if self._entropy_detector is not None: + entropy_matches = (self._entropy_detector.detect(text)) + matches.extend(entropy_matches) + + return matches + + @property + def rule_count(self) -> int: + """ + Return the number of active pattern rules + """ + return len(self._pattern_detector._rules) + + +def _filter_rules( + rules: list[DetectionRule], + enable_patterns: list[str], + disable_patterns: list[str], +) -> list[DetectionRule]: + """ + Filter rules by enable/disable glob patterns + """ + filtered: list[DetectionRule] = [] + + for rule in rules: + enabled = any( + fnmatch.fnmatch(rule.rule_id, + pat) for pat in enable_patterns + ) + disabled = any( + fnmatch.fnmatch(rule.rule_id, + pat) for pat in disable_patterns + ) + if enabled and not disabled: + filtered.append(rule) + + return filtered diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py new file mode 100644 index 00000000..afffee1b --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py @@ -0,0 +1,157 @@ +""" +©AngelaMos | 2026 +credentials.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +AWS_ACCESS_KEY_PATTERN = re.compile(r"\b((?:AKIA|ASIA)[0-9A-Z]{16})\b") + +GITHUB_CLASSIC_PAT_PATTERN = re.compile(r"\bghp_[a-zA-Z0-9]{36}\b") + +GITHUB_FINE_GRAINED_PATTERN = re.compile( + r"\bgithub_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}\b" +) + +GITHUB_OAUTH_PATTERN = re.compile(r"\bgho_[a-zA-Z0-9]{36}\b") + +GITHUB_APP_PATTERN = re.compile(r"\bghs_[a-zA-Z0-9]{36}\b") + +JWT_PATTERN = re.compile( + r"\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b" +) + +STRIPE_KEY_PATTERN = re.compile( + r"\b(?:sk|pk)_(?:test|live)_[a-zA-Z0-9]{24,}\b" +) + +SLACK_TOKEN_PATTERN = re.compile(r"\bxox[baprs]-[a-zA-Z0-9\-]{10,48}\b") + +GENERIC_API_KEY_PATTERN = re.compile( + r"(?i)(?:api[_\-]?key|apikey|api[_\-]?token|access[_\-]?key|secret[_\-]?key)" + r"\s*[:=]\s*['\"]?" + r"([a-zA-Z0-9\-_.]{20,64})" + r"['\"]?" +) + +PRIVATE_KEY_PATTERN = re.compile( + r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----" +) + +API_KEY_CONTEXT = [ + "api_key", + "apikey", + "api key", + "secret", + "token", + "authorization", + "bearer", + "credential", + "password", + "access_key", +] + +CREDENTIAL_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "CRED_AWS_ACCESS_KEY", + rule_name = "AWS Access Key ID", + pattern = AWS_ACCESS_KEY_PATTERN, + base_score = 0.85, + context_keywords = [ + "aws", + "amazon", + "access_key", + "aws_access_key_id", + ], + ), + DetectionRule( + rule_id = "CRED_GITHUB_TOKEN", + rule_name = "GitHub Personal Access Token", + pattern = GITHUB_CLASSIC_PAT_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "token", + "pat"], + ), + DetectionRule( + rule_id = "CRED_GITHUB_FINE_GRAINED", + rule_name = "GitHub Fine-Grained PAT", + pattern = GITHUB_FINE_GRAINED_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "token"], + ), + DetectionRule( + rule_id = "CRED_GITHUB_OAUTH", + rule_name = "GitHub OAuth Token", + pattern = GITHUB_OAUTH_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "oauth"], + ), + DetectionRule( + rule_id = "CRED_GITHUB_APP", + rule_name = "GitHub App Token", + pattern = GITHUB_APP_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "app"], + ), + DetectionRule( + rule_id = "CRED_JWT", + rule_name = "JSON Web Token", + pattern = JWT_PATTERN, + base_score = 0.70, + context_keywords = [ + "jwt", + "token", + "bearer", + "authorization", + ], + ), + DetectionRule( + rule_id = "CRED_STRIPE_KEY", + rule_name = "Stripe API Key", + pattern = STRIPE_KEY_PATTERN, + base_score = 0.90, + context_keywords = [ + "stripe", + "payment", + "api_key", + ], + ), + DetectionRule( + rule_id = "CRED_SLACK_TOKEN", + rule_name = "Slack Token", + pattern = SLACK_TOKEN_PATTERN, + base_score = 0.85, + context_keywords = [ + "slack", + "token", + "webhook", + ], + ), + DetectionRule( + rule_id = "CRED_GENERIC_API_KEY", + rule_name = "Generic API Key", + pattern = GENERIC_API_KEY_PATTERN, + base_score = 0.50, + context_keywords = API_KEY_CONTEXT, + ), + DetectionRule( + rule_id = "CRED_PRIVATE_KEY", + rule_name = "Private Key", + pattern = PRIVATE_KEY_PATTERN, + base_score = 0.95, + context_keywords = [ + "private key", + "rsa", + "ssh", + "certificate", + ], + ), +] diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py new file mode 100644 index 00000000..83558018 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py @@ -0,0 +1,184 @@ +""" +©AngelaMos | 2026 +financial.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +VISA_PATTERN = re.compile( + r"\b4[0-9]{3}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b" +) + +MASTERCARD_PATTERN = re.compile( + r"\b(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)" + r"[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b" +) + +AMEX_PATTERN = re.compile(r"\b3[47][0-9]{2}[-\s]?[0-9]{6}[-\s]?[0-9]{5}\b") + +DISCOVER_PATTERN = re.compile( + r"\b6(?:011|5[0-9]{2})[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b" +) + +IBAN_PATTERN = re.compile( + r"\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}[A-Z0-9]{0,16}\b" +) + +NHS_PATTERN = re.compile(r"\b\d{3}[-\s]?\d{3}[-\s]?\d{4}\b") + + +def luhn_check(number: str) -> bool: + """ + Validate a number using the Luhn algorithm + """ + digits = [int(d) for d in number if d.isdigit()] + if len(digits) < 13: + return False + + odd_digits = digits[-1 ::-2] + even_digits = digits[-2 ::-2] + total = sum(odd_digits) + for d in even_digits: + total += sum(divmod(d * 2, 10)) + return total % 10 == 0 + + +def iban_check(value: str) -> bool: + """ + Validate an IBAN using the mod-97 algorithm + """ + cleaned = value.replace(" ", "").upper() + if len(cleaned) < 15 or len(cleaned) > 34: + return False + + rearranged = cleaned[4 :] + cleaned[: 4] + numeric = "" + for char in rearranged: + if char.isalpha(): + numeric += str(ord(char) - ord("A") + 10) + else: + numeric += char + + return int(numeric) % 97 == 1 + + +def nhs_check(value: str) -> bool: + """ + Validate a UK NHS number using mod-11 + """ + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 10 or not digits.isdigit(): + return False + + weights = range(10, 1, -1) + total = sum( + int(d) * w for d, w in zip(digits[: 9], weights, strict = False) + ) + remainder = 11 - (total % 11) + if remainder == 11: + remainder = 0 + if remainder == 10: + return False + return remainder == int(digits[9]) + + +CREDIT_CARD_CONTEXT = [ + "credit card", + "card number", + "cc", + "cvv", + "cvc", + "expiry", + "expiration", + "visa", + "mastercard", + "amex", + "card no", + "payment card", + "pan", +] + +IBAN_CONTEXT = [ + "iban", + "bank account", + "account number", + "swift", + "bic", + "wire transfer", + "bank transfer", +] + +NHS_CONTEXT = [ + "nhs", + "nhs number", + "national health", + "health service", + "patient id", + "patient number", +] + +FINANCIAL_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "FIN_CREDIT_CARD_VISA", + rule_name = "Visa Credit Card Number", + pattern = VISA_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_CREDIT_CARD_MC", + rule_name = "Mastercard Credit Card Number", + pattern = MASTERCARD_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_CREDIT_CARD_AMEX", + rule_name = "American Express Card Number", + pattern = AMEX_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_CREDIT_CARD_DISC", + rule_name = "Discover Card Number", + pattern = DISCOVER_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_IBAN", + rule_name = "IBAN Number", + pattern = IBAN_PATTERN, + base_score = 0.40, + context_keywords = IBAN_CONTEXT, + validator = iban_check, + compliance_frameworks = ["GDPR", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_NHS_NUMBER", + rule_name = "UK NHS Number", + pattern = NHS_PATTERN, + base_score = 0.15, + context_keywords = NHS_CONTEXT, + validator = nhs_check, + compliance_frameworks = ["GDPR"], + ), +] diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/health.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/health.py new file mode 100644 index 00000000..ead20a9c --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/health.py @@ -0,0 +1,140 @@ +""" +©AngelaMos | 2026 +health.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +MEDICAL_RECORD_PATTERN = re.compile( + r"\b(?:MRN|MR#|MED)\s*[-:#]?\s*\d{6,10}\b", + re.IGNORECASE, +) + +DEA_NUMBER_PATTERN = re.compile(r"\b[A-Z][A-Z9]\d{7}\b") + +NPI_PATTERN = re.compile(r"\b\d{10}\b") + +PHI_CONTEXT_KEYWORDS = [ + "patient", + "diagnosis", + "treatment", + "medical", + "health", + "hospital", + "clinical", + "physician", + "prescription", + "medication", + "lab result", + "blood type", + "allergies", + "insurance", + "claim", + "icd", + "cpt", + "hcpcs", + "hipaa", + "phi", + "protected health", + "discharge", + "admission", + "prognosis", +] + +MEDICAL_RECORD_CONTEXT = [ + "medical record", + "mrn", + "patient id", + "chart number", + "record number", + "health record", + "ehr", + "emr", +] + +DEA_CONTEXT = [ + "dea", + "dea number", + "drug enforcement", + "prescriber", + "controlled substance", +] + +NPI_CONTEXT = [ + "npi", + "national provider", + "provider id", + "provider number", + "provider identifier", + "cms", +] + + +def _validate_npi(value: str) -> bool: + """ + Validate an NPI using Luhn with the 80840 prefix + """ + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 10 or not digits.isdigit(): + return False + + prefixed = "80840" + digits + total = 0 + for i, d in enumerate(reversed(prefixed)): + n = int(d) + if i % 2 == 1: + n *= 2 + if n > 9: + n -= 9 + total += n + return total % 10 == 0 + + +def _validate_dea_number(value: str) -> bool: + """ + Validate a DEA number using its check digit algorithm + """ + if len(value) != 9: + return False + digits = value[2 :] + if not digits.isdigit(): + return False + + odd_sum = (int(digits[0]) + int(digits[2]) + int(digits[4])) + even_sum = (int(digits[1]) + int(digits[3]) + int(digits[5])) + check = (odd_sum + even_sum * 2) % 10 + return check == int(digits[6]) + + +HEALTH_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "HEALTH_MEDICAL_RECORD", + rule_name = "Medical Record Number", + pattern = MEDICAL_RECORD_PATTERN, + base_score = 0.55, + context_keywords = MEDICAL_RECORD_CONTEXT, + compliance_frameworks = ["HIPAA"], + ), + DetectionRule( + rule_id = "HEALTH_DEA_NUMBER", + rule_name = "DEA Registration Number", + pattern = DEA_NUMBER_PATTERN, + base_score = 0.35, + context_keywords = DEA_CONTEXT, + validator = _validate_dea_number, + compliance_frameworks = ["HIPAA"], + ), + DetectionRule( + rule_id = "HEALTH_NPI", + rule_name = "National Provider Identifier", + pattern = NPI_PATTERN, + base_score = 0.10, + context_keywords = NPI_CONTEXT, + validator = _validate_npi, + compliance_frameworks = ["HIPAA"], + ), +] diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py new file mode 100644 index 00000000..c543c785 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py @@ -0,0 +1,209 @@ +""" +©AngelaMos | 2026 +pii.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +SSN_PATTERN = re.compile( + r"\b(?!000|666|9\d{2})\d{3}" + r"[-\s]?" + r"(?!00)\d{2}" + r"[-\s]?" + r"(?!0000)\d{4}\b" +) + +EMAIL_PATTERN = re.compile( + r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b" +) + +PHONE_US_PATTERN = re.compile( + r"\b(?:\+?1[-.\s]?)?" + r"(?:\(?[2-9]\d{2}\)?[-.\s]?)" + r"[2-9]\d{2}[-.\s]?\d{4}\b" +) + +PHONE_E164_PATTERN = re.compile(r"\+[1-9]\d{6,14}\b") + +PASSPORT_US_PATTERN = re.compile(r"\b[A-Z]{1,2}\d{6,7}\b") + +PASSPORT_UK_PATTERN = re.compile(r"\b\d{9}\b") + +IPV4_PATTERN = re.compile( + r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}" + r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b" +) + +DRIVERS_LICENSE_CA_PATTERN = re.compile(r"\b[A-Z]\d{7}\b") +DRIVERS_LICENSE_FL_PATTERN = re.compile(r"\b[A-Z]\d{12}\b") +DRIVERS_LICENSE_IL_PATTERN = re.compile(r"\b[A-Z]\d{11}\b") + + +def _validate_ssn(value: str) -> bool: + """ + Validate SSN area, group, and serial numbers + """ + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 9 or not digits.isdigit(): + return False + + area = int(digits[0 : 3]) + group = int(digits[3 : 5]) + serial = int(digits[5 : 9]) + + if area in {0, 666} or area >= 900: + return False + if group == 0: + return False + return serial != 0 + + +SSN_CONTEXT = [ + "ssn", + "social security", + "social security number", + "ss#", + "taxpayer id", + "sin", + "tax id", +] + +EMAIL_CONTEXT = [ + "email", + "e-mail", + "mail", + "contact", + "reach at", +] + +PHONE_CONTEXT = [ + "phone", + "mobile", + "cell", + "tel", + "telephone", + "fax", + "contact number", + "call", +] + +PASSPORT_CONTEXT = [ + "passport", + "pass no", + "travel document", + "passport number", + "document number", +] + +DRIVERS_LICENSE_CONTEXT = [ + "driver's license", + "drivers license", + "driver license", + "dl#", + "dl number", + "license number", + "licence number", +] + +PII_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + pattern = SSN_PATTERN, + base_score = 0.45, + context_keywords = SSN_CONTEXT, + validator = _validate_ssn, + compliance_frameworks = [ + "HIPAA", + "CCPA", + "GLBA", + "GDPR", + ], + ), + DetectionRule( + rule_id = "PII_EMAIL", + rule_name = "Email Address", + pattern = EMAIL_PATTERN, + base_score = 0.30, + context_keywords = EMAIL_CONTEXT, + compliance_frameworks = ["GDPR", + "CCPA"], + ), + DetectionRule( + rule_id = "PII_PHONE", + rule_name = "US Phone Number", + pattern = PHONE_US_PATTERN, + base_score = 0.25, + context_keywords = PHONE_CONTEXT, + compliance_frameworks = [ + "GDPR", + "CCPA", + "HIPAA", + ], + ), + DetectionRule( + rule_id = "PII_PHONE_INTL", + rule_name = "International Phone Number", + pattern = PHONE_E164_PATTERN, + base_score = 0.30, + context_keywords = PHONE_CONTEXT, + compliance_frameworks = ["GDPR", + "CCPA"], + ), + DetectionRule( + rule_id = "PII_PASSPORT_US", + rule_name = "US Passport Number", + pattern = PASSPORT_US_PATTERN, + base_score = 0.15, + context_keywords = PASSPORT_CONTEXT, + compliance_frameworks = ["GDPR", + "CCPA"], + ), + DetectionRule( + rule_id = "PII_PASSPORT_UK", + rule_name = "UK Passport Number", + pattern = PASSPORT_UK_PATTERN, + base_score = 0.10, + context_keywords = PASSPORT_CONTEXT, + compliance_frameworks = ["GDPR"], + ), + DetectionRule( + rule_id = "PII_IPV4", + rule_name = "IPv4 Address", + pattern = IPV4_PATTERN, + base_score = 0.15, + context_keywords = [], + compliance_frameworks = ["GDPR"], + ), + DetectionRule( + rule_id = "PII_DRIVERS_LICENSE", + rule_name = "US Driver's License (CA)", + pattern = DRIVERS_LICENSE_CA_PATTERN, + base_score = 0.10, + context_keywords = DRIVERS_LICENSE_CONTEXT, + compliance_frameworks = ["CCPA", + "HIPAA"], + ), + DetectionRule( + rule_id = "PII_DRIVERS_LICENSE_FL", + rule_name = "US Driver's License (FL)", + pattern = DRIVERS_LICENSE_FL_PATTERN, + base_score = 0.10, + context_keywords = DRIVERS_LICENSE_CONTEXT, + compliance_frameworks = ["CCPA", + "HIPAA"], + ), + DetectionRule( + rule_id = "PII_DRIVERS_LICENSE_IL", + rule_name = "US Driver's License (IL)", + pattern = DRIVERS_LICENSE_IL_PATTERN, + base_score = 0.10, + context_keywords = DRIVERS_LICENSE_CONTEXT, + compliance_frameworks = ["CCPA", + "HIPAA"], + ), +] diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/engine.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/engine.py new file mode 100644 index 00000000..b45f5b18 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/engine.py @@ -0,0 +1,146 @@ +""" +©AngelaMos | 2026 +engine.py +""" + + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.constants import OutputFormat +from dlp_scanner.detectors.registry import ( + DetectorRegistry, +) +from dlp_scanner.models import ScanResult +from dlp_scanner.reporters.console import ( + ConsoleReporter, +) +from dlp_scanner.reporters.csv_report import ( + CsvReporter, +) +from dlp_scanner.reporters.json_report import ( + JsonReporter, +) +from dlp_scanner.reporters.sarif import SarifReporter +from dlp_scanner.scanners.db_scanner import ( + DatabaseScanner, +) +from dlp_scanner.scanners.file_scanner import ( + FileScanner, +) +from dlp_scanner.scanners.network_scanner import ( + NetworkScanner, +) + + +log = structlog.get_logger() + +REPORTER_MAP: dict[str, + type] = { + "console": ConsoleReporter, + "json": JsonReporter, + "sarif": SarifReporter, + "csv": CsvReporter, + } + + +class ScanEngine: + """ + Orchestrates the full scan pipeline + """ + def __init__(self, config: ScanConfig) -> None: + self._config = config + detection = config.detection + allowlist_vals = detection.allowlists.values + self._registry = DetectorRegistry( + enable_patterns = detection.enable_rules, + disable_patterns = detection.disable_rules, + allowlist_values = ( + frozenset(allowlist_vals) if allowlist_vals else None + ), + context_window_tokens = (detection.context_window_tokens), + ) + + def scan_files(self, target: str) -> ScanResult: + """ + Scan filesystem target for sensitive data + """ + scanner = FileScanner(self._config, self._registry) + result = scanner.scan(target) + log.info( + "file_scan_complete", + target = target, + findings = len(result.findings), + targets = result.targets_scanned, + ) + return result + + def scan_database(self, target: str) -> ScanResult: + """ + Scan database target for sensitive data + """ + scanner = DatabaseScanner(self._config, self._registry) + result = scanner.scan(target) + log.info( + "database_scan_complete", + target = target, + findings = len(result.findings), + targets = result.targets_scanned, + ) + return result + + def scan_network(self, target: str) -> ScanResult: + """ + Scan network capture file for sensitive data + """ + scanner = NetworkScanner(self._config, self._registry) + result = scanner.scan(target) + log.info( + "network_scan_complete", + target = target, + findings = len(result.findings), + targets = result.targets_scanned, + ) + return result + + def generate_report( + self, + result: ScanResult, + output_format: OutputFormat | None = None, + ) -> str: + """ + Generate report string in the requested format + """ + fmt = output_format or self._config.output.format + reporter_cls = REPORTER_MAP[fmt] + reporter = reporter_cls() + output: str = reporter.generate(result) + return output + + def display_console( + self, + result: ScanResult, + ) -> None: + """ + Display Rich-formatted results to console + """ + reporter = ConsoleReporter() + reporter.display(result) + + def write_report( + self, + result: ScanResult, + output_path: str, + output_format: OutputFormat | None = None, + ) -> None: + """ + Generate report and write to file + """ + content = self.generate_report(result, output_format) + with open(output_path, "w") as f: + f.write(content) + log.info( + "report_written", + path = output_path, + format = output_format or self._config.output.format, + ) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/archive.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/archive.py new file mode 100644 index 00000000..18fe4a39 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/archive.py @@ -0,0 +1,191 @@ +""" +©AngelaMos | 2026 +archive.py +""" + + +import tarfile +import zipfile + +import structlog + +from dlp_scanner.constants import ( + MAX_ARCHIVE_DEPTH, + MAX_ARCHIVE_MEMBER_SIZE_MB, + ZIP_BOMB_RATIO_THRESHOLD, +) +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +ARCHIVE_EXTENSIONS: frozenset[str] = frozenset( + { + ".zip", + ".tar", + ".tar.gz", + ".tgz", + ".tar.bz2", + } +) + +MAX_MEMBER_BYTES: int = MAX_ARCHIVE_MEMBER_SIZE_MB * 1024 * 1024 + + +class ArchiveExtractor: + """ + Extracts text content from archive files with security guards + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return ARCHIVE_EXTENSIONS + + def extract( + self, + path: str, + depth: int = 0, + ) -> list[TextChunk]: + """ + Extract text from archive members + """ + if depth >= MAX_ARCHIVE_DEPTH: + log.warning( + "archive_depth_exceeded", + path = path, + depth = depth, + ) + return [] + + if path.endswith(".zip"): + return self._extract_zip(path, depth) + + if any(path.endswith(ext) + for ext in (".tar", ".tar.gz", ".tgz", ".tar.bz2")): + return self._extract_tar(path, depth) + + return [] + + def _extract_zip(self, path: str, depth: int) -> list[TextChunk]: + """ + Extract from ZIP with bomb and traversal protection + """ + chunks: list[TextChunk] = [] + + try: + with zipfile.ZipFile(path, "r") as zf: + for info in zf.infolist(): + if not self._is_safe_zip_member(info): + continue + + data = zf.read(info.filename) + if not data: + continue + + try: + text = data.decode("utf-8", errors = "replace") + except Exception: + continue + + if text.strip(): + chunks.append( + TextChunk( + text = text, + location = Location( + source_type = "archive", + uri = f"{path}!{info.filename}", + ), + ) + ) + + except Exception: + log.warning("zip_extract_failed", path = path) + + return chunks + + def _extract_tar(self, path: str, depth: int) -> list[TextChunk]: + """ + Extract from TAR with traversal protection + """ + chunks: list[TextChunk] = [] + + try: + with tarfile.open(path) as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + + if not self._is_safe_tar_member(member): + continue + + if member.size > MAX_MEMBER_BYTES: + continue + + extracted = tf.extractfile(member) + if extracted is None: + continue + + data = extracted.read() + try: + text = data.decode("utf-8", errors = "replace") + except Exception: + continue + + if text.strip(): + chunks.append( + TextChunk( + text = text, + location = Location( + source_type = "archive", + uri = f"{path}!{member.name}", + ), + ) + ) + + except Exception: + log.warning("tar_extract_failed", path = path) + + return chunks + + def _is_safe_zip_member(self, info: zipfile.ZipInfo) -> bool: + """ + Check a ZIP member for path traversal and bomb indicators + """ + if ".." in info.filename or info.filename.startswith("/"): + log.warning( + "zip_path_traversal_blocked", + filename = info.filename, + ) + return False + + if "\x00" in info.filename: + return False + + if info.file_size > MAX_MEMBER_BYTES: + return False + + if (info.compress_size > 0 and info.file_size / info.compress_size + > ZIP_BOMB_RATIO_THRESHOLD): + log.warning( + "zip_bomb_detected", + filename = info.filename, + ratio = info.file_size / info.compress_size, + ) + return False + + return True + + def _is_safe_tar_member(self, member: tarfile.TarInfo) -> bool: + """ + Check a TAR member for path traversal + """ + if ".." in member.name or member.name.startswith("/"): + log.warning( + "tar_path_traversal_blocked", + filename = member.name, + ) + return False + + return not (member.issym() or member.islnk()) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/base.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/base.py new file mode 100644 index 00000000..2e008b1e --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/base.py @@ -0,0 +1,27 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +from typing import Protocol + +from dlp_scanner.models import TextChunk + + +class Extractor(Protocol): + """ + Protocol for text extraction from different file formats + """ + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text chunks from a file at the given path + """ + ... + + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + ... diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/email.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/email.py new file mode 100644 index 00000000..7dba9cf5 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/email.py @@ -0,0 +1,123 @@ +""" +©AngelaMos | 2026 +email.py +""" + + +import email as email_lib +from email import policy + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +EMAIL_EXTENSIONS: frozenset[str] = frozenset({ + ".eml", + ".msg", +}) + + +class EmlExtractor: + """ + Extracts text from RFC 2822 EML files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".eml"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse EML and extract headers and body text + """ + chunks: list[TextChunk] = [] + + try: + with open(path, "rb") as f: + msg = email_lib.message_from_binary_file( + f, + policy = policy.default + ) + + parts: list[str] = [] + + for header in ("From", "To", "Cc", "Subject"): + value = msg.get(header) + if value: + parts.append(f"{header}: {value}") + + body = msg.get_body(preferencelist = ("plain", "html")) + if body is not None: + content = body.get_content() + if content: + parts.append(content) + + if parts: + chunks.append( + TextChunk( + text = "\n".join(parts), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("eml_extract_failed", path = path) + + return chunks + + +class MsgExtractor: + """ + Extracts text from Outlook MSG files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".msg"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse MSG and extract headers and body text + """ + import extract_msg + + chunks: list[TextChunk] = [] + + try: + with extract_msg.Message(path) as msg: + parts: list[str] = [] + + if msg.sender: + parts.append(f"From: {msg.sender}") + if msg.to: + parts.append(f"To: {msg.to}") + if msg.subject: + parts.append(f"Subject: {msg.subject}") + if msg.body: + parts.append(msg.body) + + if parts: + chunks.append( + TextChunk( + text = "\n".join(parts), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("msg_extract_failed", path = path) + + return chunks diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/office.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/office.py new file mode 100644 index 00000000..25f6497f --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/office.py @@ -0,0 +1,178 @@ +""" +©AngelaMos | 2026 +office.py +""" + + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +DOCX_EXTENSIONS: frozenset[str] = frozenset({".docx"}) +XLSX_EXTENSIONS: frozenset[str] = frozenset({".xlsx"}) +XLS_EXTENSIONS: frozenset[str] = frozenset({".xls"}) +OFFICE_EXTENSIONS: frozenset[str] = ( + DOCX_EXTENSIONS | XLSX_EXTENSIONS | XLS_EXTENSIONS +) + + +class DocxExtractor: + """ + Extracts text from DOCX files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return DOCX_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from paragraphs, tables, and metadata + """ + from docx import Document + + chunks: list[TextChunk] = [] + + try: + doc = Document(path) + paragraphs: list[str] = [] + + for para in doc.paragraphs: + if para.text.strip(): + paragraphs.append(para.text) + + for table in doc.tables: + for row in table.rows: + cells = [ + cell.text + for cell in row.cells + if cell.text.strip() + ] + if cells: + paragraphs.append(" | ".join(cells)) + + if doc.core_properties.author: + paragraphs.append(f"Author: {doc.core_properties.author}") + if doc.core_properties.title: + paragraphs.append(f"Title: {doc.core_properties.title}") + + if paragraphs: + chunks.append( + TextChunk( + text = "\n".join(paragraphs), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("docx_extract_failed", path = path) + + return chunks + + +class XlsxExtractor: + """ + Extracts text from XLSX files using openpyxl + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return XLSX_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from all sheets and cells + """ + from openpyxl import load_workbook + + chunks: list[TextChunk] = [] + + try: + wb = load_workbook( + path, + read_only = True, + data_only = True, + ) + for sheet in wb.worksheets: + rows: list[str] = [] + for row in sheet.iter_rows(values_only = True): + cell_values = [str(c) for c in row if c is not None] + if cell_values: + rows.append(" | ".join(cell_values)) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + sheet_name = sheet.title, + ), + ) + ) + wb.close() + except Exception: + log.warning("xlsx_extract_failed", path = path) + + return chunks + + +class XlsExtractor: + """ + Extracts text from legacy XLS files using xlrd + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return XLS_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from legacy Excel workbooks + """ + import xlrd + + chunks: list[TextChunk] = [] + + try: + wb = xlrd.open_workbook(path) + for sheet in wb.sheets(): + rows: list[str] = [] + for row_idx in range(sheet.nrows): + cell_values = [ + str(sheet.cell_value(row_idx, + col)) + for col in range(sheet.ncols) + if sheet.cell_value(row_idx, col) + ] + if cell_values: + rows.append(" | ".join(cell_values)) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + sheet_name = sheet.name, + ), + ) + ) + except Exception: + log.warning("xls_extract_failed", path = path) + + return chunks diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/pdf.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/pdf.py new file mode 100644 index 00000000..07d938a5 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/pdf.py @@ -0,0 +1,56 @@ +""" +©AngelaMos | 2026 +pdf.py +""" + + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +PDF_EXTENSIONS: frozenset[str] = frozenset({".pdf"}) + + +class PDFExtractor: + """ + Extracts text from PDF files using PyMuPDF + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return PDF_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from each page of a PDF + """ + import fitz + + chunks: list[TextChunk] = [] + + try: + doc = fitz.open(path) + for page_num in range(len(doc)): + page = doc[page_num] + text = page.get_text("text") + if text.strip(): + chunks.append( + TextChunk( + text = text, + location = Location( + source_type = "file", + uri = path, + line = page_num + 1, + ), + ) + ) + doc.close() + except Exception: + log.warning("pdf_extract_failed", path = path) + + return chunks diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/plaintext.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/plaintext.py new file mode 100644 index 00000000..5771447f --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/plaintext.py @@ -0,0 +1,112 @@ +""" +©AngelaMos | 2026 +plaintext.py +""" + + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +PLAINTEXT_EXTENSIONS: frozenset[str] = frozenset( + { + ".txt", + ".log", + ".cfg", + ".ini", + ".conf", + ".toml", + ".md", + ".rst", + ".html", + ".htm", + ".tsv", + ".env", + ".sh", + ".bat", + ".ps1", + ".py", + ".js", + ".ts", + ".go", + ".rb", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".rs", + ".tf", + ".hcl", + } +) + +CHUNK_MAX_LINES: int = 500 + + +class PlaintextExtractor: + """ + Extracts text from plaintext and source code files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return PLAINTEXT_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Read a text file and return chunks + """ + chunks: list[TextChunk] = [] + + try: + with open( + path, + encoding = "utf-8", + errors = "replace", + ) as f: + lines: list[str] = [] + line_number = 1 + chunk_start = 1 + + for line in f: + lines.append(line) + if len(lines) >= CHUNK_MAX_LINES: + chunks.append( + TextChunk( + text = "".join(lines), + location = Location( + source_type = "file", + uri = path, + line = chunk_start, + ), + ) + ) + chunk_start = line_number + 1 + lines = [] + line_number += 1 + + if lines: + chunks.append( + TextChunk( + text = "".join(lines), + location = Location( + source_type = "file", + uri = path, + line = chunk_start, + ), + ) + ) + + except OSError: + log.warning( + "file_read_failed", + path = path, + ) + + return chunks diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/structured.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/structured.py new file mode 100644 index 00000000..52eec32d --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/extractors/structured.py @@ -0,0 +1,327 @@ +""" +©AngelaMos | 2026 +structured.py +""" + + +import csv +import json +from typing import Any + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + + +class CsvExtractor: + """ + Extracts text from CSV and TSV files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".csv", ".tsv"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Read CSV row by row and concatenate cell values + """ + chunks: list[TextChunk] = [] + + try: + with open( + path, + newline = "", + encoding = "utf-8-sig", + ) as f: + dialect = csv.Sniffer().sniff(f.read(4096)) + f.seek(0) + reader = csv.reader(f, dialect) + rows: list[str] = [] + + for _row_num, row in enumerate(reader, 1): + cells = [c for c in row if c.strip()] + if cells: + rows.append(" | ".join(cells)) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + line = 1, + ), + ) + ) + + except Exception: + log.warning("csv_extract_failed", path = path) + + return chunks + + +class JsonExtractor: + """ + Extracts text values from JSON files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".json"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse JSON and extract all string values recursively + """ + chunks: list[TextChunk] = [] + + try: + with open(path, encoding = "utf-8") as f: + data = json.load(f) + + strings = _extract_json_strings(data) + if strings: + chunks.append( + TextChunk( + text = "\n".join(strings), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("json_extract_failed", path = path) + + return chunks + + +class XmlExtractor: + """ + Extracts text from XML files using defusedxml + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".xml"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse XML safely and extract all text content + """ + import defusedxml.ElementTree as ET + + chunks: list[TextChunk] = [] + + try: + tree = ET.parse(path) + root = tree.getroot() + texts: list[str] = [] + + for elem in root.iter(): + if elem.text and elem.text.strip(): + texts.append(elem.text.strip()) + if elem.tail and elem.tail.strip(): + texts.append(elem.tail.strip()) + for attr_val in elem.attrib.values(): + if attr_val.strip(): + texts.append(attr_val.strip()) + + if texts: + chunks.append( + TextChunk( + text = "\n".join(texts), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("xml_extract_failed", path = path) + + return chunks + + +class YamlExtractor: + """ + Extracts text from YAML files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".yaml", ".yml"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse YAML safely and extract string values + """ + from ruamel.yaml import YAML + + chunks: list[TextChunk] = [] + + try: + yaml = YAML(typ = "safe") + with open(path) as f: + data = yaml.load(f) + + if data: + strings = _extract_json_strings(data) + if strings: + chunks.append( + TextChunk( + text = "\n".join(strings), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("yaml_extract_failed", path = path) + + return chunks + + +class ParquetExtractor: + """ + Extracts text from Parquet files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".parquet"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Read Parquet file and extract string columns + """ + import pyarrow.parquet as pq + + chunks: list[TextChunk] = [] + + try: + pf = pq.ParquetFile(path) + schema = pf.schema_arrow + + string_cols = [ + field.name for field in schema if str(field.type) in ( + "string", + "large_string", + "utf8", + "large_utf8",) + ] + + if not string_cols: + return chunks + + for batch in pf.iter_batches( + batch_size = 5000, + columns = string_cols, + ): + rows: list[str] = [] + table_dict = batch.to_pydict() + for col_name, values in table_dict.items(): + for val in values: + if val is not None and str(val).strip(): + rows.append(f"{col_name}: {val}") + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("parquet_extract_failed", path = path) + + return chunks + + +class AvroExtractor: + """ + Extracts text from Avro files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".avro"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Read Avro file and extract string fields + """ + from fastavro import reader + + chunks: list[TextChunk] = [] + + try: + with open(path, "rb") as f: + rows: list[str] = [] + for record in reader(f): + strings = _extract_json_strings(record) + rows.extend(strings) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("avro_extract_failed", path = path) + + return chunks + + +def _extract_json_strings( + data: Any, + prefix: str = "", +) -> list[str]: + """ + Recursively extract all string values from a JSON-like structure + """ + strings: list[str] = [] + + if isinstance(data, str): + if data.strip(): + label = f"{prefix}: {data}" if prefix else data + strings.append(label) + elif isinstance(data, dict): + for key, val in data.items(): + key_path = (f"{prefix}.{key}" if prefix else str(key)) + strings.extend(_extract_json_strings(val, key_path)) + elif isinstance(data, list): + for item in data: + strings.extend(_extract_json_strings(item, prefix)) + + return strings diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/log.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/log.py new file mode 100644 index 00000000..e9ee5546 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/log.py @@ -0,0 +1,80 @@ +""" +©AngelaMos | 2026 +log.py +""" + + +import logging +import sys +from typing import Any + +import orjson +import structlog + + +def _orjson_serializer( + data: Any, + **_kwargs: Any, +) -> str: + """ + Serialize log data using orjson for performance + """ + return orjson.dumps(data).decode("utf-8") + + +def configure_logging( + level: str = "INFO", + json_output: bool = False, + log_file: str = "", +) -> None: + """ + Set up structlog with stdlib integration + """ + shared_processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt = "iso"), + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.StackInfoRenderer(), + ] + + if json_output: + renderer: structlog.types.Processor = ( + structlog.processors.JSONRenderer( + serializer = _orjson_serializer + ) + ) + else: + renderer = structlog.dev.ConsoleRenderer(colors = True) + + structlog.configure( + processors = [ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory = structlog.stdlib.LoggerFactory(), + wrapper_class = structlog.stdlib.BoundLogger, + cache_logger_on_first_use = True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain = shared_processors, + processors = [ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + handler: logging.Handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(getattr(logging, level.upper())) + + if log_file: + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/models.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/models.py new file mode 100644 index 00000000..2c5edab3 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/models.py @@ -0,0 +1,112 @@ +""" +©AngelaMos | 2026 +models.py +""" + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, UTC + +from dlp_scanner.constants import Severity + + +@dataclass(frozen = True, slots = True) +class Location: + """ + Where a finding was detected + """ + source_type: str + uri: str + line: int | None = None + column: int | None = None + byte_offset: int | None = None + table_name: str | None = None + column_name: str | None = None + sheet_name: str | None = None + + +@dataclass(slots = True) +class Finding: + """ + A fully scored and classified detection result + """ + finding_id: str = field( + default_factory = lambda: uuid.uuid4().hex[: 12] + ) + rule_id: str = "" + rule_name: str = "" + severity: Severity = "low" + confidence: float = 0.0 + location: Location = field( + default_factory = lambda: Location( + source_type = "unknown", + uri = "",) + ) + redacted_snippet: str = "" + compliance_frameworks: list[str] = field(default_factory = list) + remediation: str = "" + detected_at: datetime = field( + default_factory = lambda: datetime.now(UTC) + ) + + +@dataclass(slots = True) +class ScanResult: + """ + Aggregated results from a complete scan run + """ + scan_id: str = field(default_factory = lambda: uuid.uuid4().hex[: 16]) + tool_version: str = "0.1.0" + scan_started_at: datetime = field( + default_factory = lambda: datetime.now(UTC) + ) + scan_completed_at: datetime | None = None + targets_scanned: int = 0 + findings: list[Finding] = field(default_factory = list) + errors: list[str] = field(default_factory = list) + + @property + def findings_by_severity(self) -> dict[str, int]: + """ + Count findings grouped by severity level + """ + counts: dict[str, + int] = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + } + for f in self.findings: + counts[f.severity] = counts.get(f.severity, 0) + 1 + return counts + + @property + def findings_by_rule(self) -> dict[str, int]: + """ + Count findings grouped by rule ID + """ + counts: dict[str, int] = {} + for f in self.findings: + counts[f.rule_id] = counts.get(f.rule_id, 0) + 1 + return counts + + @property + def findings_by_framework(self) -> dict[str, int]: + """ + Count findings grouped by compliance framework + """ + counts: dict[str, int] = {} + for f in self.findings: + for fw in f.compliance_frameworks: + counts[fw] = counts.get(fw, 0) + 1 + return counts + + +@dataclass(frozen = True, slots = True) +class TextChunk: + """ + A piece of extracted text with its source location + """ + text: str + location: Location diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/exfiltration.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/exfiltration.py new file mode 100644 index 00000000..b10f4845 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/exfiltration.py @@ -0,0 +1,279 @@ +""" +©AngelaMos | 2026 +exfiltration.py +""" + + +import re +from collections import defaultdict +from dataclasses import dataclass + +import structlog + +from dlp_scanner.constants import ( + DEFAULT_DNS_ENTROPY_THRESHOLD, +) +from dlp_scanner.detectors.entropy import ( + shannon_entropy_str, +) +from dlp_scanner.network.protocols import DnsQuery + + +log = structlog.get_logger() + +DNS_LABEL_MAX_NORMAL: int = 50 +DNS_QNAME_MAX_NORMAL: int = 100 +TXT_VOLUME_THRESHOLD: float = 0.05 + +BASE64_PATTERN = re.compile(rb"[A-Za-z0-9+/]{40,}={0,2}") +HEX_PATTERN = re.compile(rb"[0-9A-Fa-f]{64,}") + + +@dataclass(frozen = True, slots = True) +class ExfilIndicator: + """ + An indicator of potential data exfiltration + """ + + indicator_type: str + description: str + confidence: float + source_ip: str + dest_ip: str + evidence: str + + +class DnsExfilDetector: + """ + Detects DNS-based data exfiltration patterns + """ + def __init__( + self, + entropy_threshold: float = (DEFAULT_DNS_ENTROPY_THRESHOLD), + ) -> None: + self._entropy_threshold = entropy_threshold + self._indicators: list[ExfilIndicator] = [] + self._domain_txt_counts: dict[str, int] = defaultdict(int) + self._domain_total_counts: dict[str, int] = defaultdict(int) + + def analyze_query( + self, + query: DnsQuery, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Analyze a single DNS query for exfiltration + """ + name = query.name + domain = _extract_base_domain(name) + + self._domain_total_counts[domain] += 1 + if query.query_type == "TXT": + self._domain_txt_counts[domain] += 1 + + indicator = self._check_label_length(name, src_ip, dst_ip) + if indicator is not None: + self._indicators.append(indicator) + return indicator + + indicator = self._check_subdomain_entropy(name, src_ip, dst_ip) + if indicator is not None: + self._indicators.append(indicator) + return indicator + + indicator = self._check_qname_length(name, src_ip, dst_ip) + if indicator is not None: + self._indicators.append(indicator) + return indicator + + return None + + def check_txt_volume( + self, + ) -> list[ExfilIndicator]: + """ + Check for suspicious TXT query volume ratios + """ + indicators: list[ExfilIndicator] = [] + + for domain, txt_count in (self._domain_txt_counts.items()): + total = self._domain_total_counts.get(domain, 0) + if total == 0: + continue + + ratio = txt_count / total + if ratio > TXT_VOLUME_THRESHOLD: + indicator = ExfilIndicator( + indicator_type = "dns_txt_volume", + description = ( + f"High TXT query ratio " + f"({ratio:.1%}) for " + f"{domain}" + ), + confidence = min(0.90, + 0.50 + ratio), + source_ip = "", + dest_ip = "", + evidence = (f"{txt_count} TXT / " + f"{total} total"), + ) + indicators.append(indicator) + + self._indicators.extend(indicators) + return indicators + + def get_indicators( + self, + ) -> list[ExfilIndicator]: + """ + Return all collected exfiltration indicators + """ + return list(self._indicators) + + def _check_label_length( + self, + name: str, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Flag suspiciously long DNS labels + """ + for label in name.split("."): + if len(label) > DNS_LABEL_MAX_NORMAL: + return ExfilIndicator( + indicator_type = ("dns_long_label"), + description = ( + f"DNS label length " + f"{len(label)} exceeds " + f"normal threshold" + ), + confidence = 0.75, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = name, + ) + return None + + def _check_subdomain_entropy( + self, + name: str, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Flag high-entropy subdomains suggesting tunneling + """ + parts = name.split(".") + if len(parts) < 3: + return None + + subdomain = ".".join(parts[:-2]) + if not subdomain: + return None + + entropy = shannon_entropy_str(subdomain) + if entropy > self._entropy_threshold: + return ExfilIndicator( + indicator_type = ("dns_high_entropy"), + description = ( + f"High subdomain entropy " + f"({entropy:.2f}) suggesting " + f"DNS tunneling" + ), + confidence = min( + 0.95, + 0.50 + (entropy - 3.0) * 0.15, + ), + source_ip = src_ip, + dest_ip = dst_ip, + evidence = name, + ) + return None + + def _check_qname_length( + self, + name: str, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Flag excessively long QNAMEs + """ + if len(name) > DNS_QNAME_MAX_NORMAL: + return ExfilIndicator( + indicator_type = "dns_long_qname", + description = ( + f"QNAME length {len(name)} " + f"exceeds normal threshold" + ), + confidence = 0.65, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = name, + ) + return None + + +def detect_base64_payload( + data: bytes, + src_ip: str = "", + dst_ip: str = "", +) -> list[ExfilIndicator]: + """ + Detect base64 or hex-encoded data in payloads + """ + indicators: list[ExfilIndicator] = [] + + for m in BASE64_PATTERN.finditer(data): + matched = m.group() + indicators.append( + ExfilIndicator( + indicator_type = "base64_payload", + description = ( + f"Base64-encoded data " + f"({len(matched)} bytes) " + f"in network payload" + ), + confidence = 0.55, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = matched[: 80].decode( + "ascii", + errors = "replace" + ), + ) + ) + + for m in HEX_PATTERN.finditer(data): + matched = m.group() + indicators.append( + ExfilIndicator( + indicator_type = "hex_payload", + description = ( + f"Hex-encoded data " + f"({len(matched)} bytes) " + f"in network payload" + ), + confidence = 0.45, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = matched[: 80].decode( + "ascii", + errors = "replace" + ), + ) + ) + + return indicators + + +def _extract_base_domain(name: str) -> str: + """ + Extract the registerable domain from a QNAME + """ + parts = name.rstrip(".").split(".") + if len(parts) >= 2: + return ".".join(parts[-2 :]) + return name diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/flow_tracker.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/flow_tracker.py new file mode 100644 index 00000000..b5fb6519 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/flow_tracker.py @@ -0,0 +1,126 @@ +""" +©AngelaMos | 2026 +flow_tracker.py +""" + + +from dataclasses import dataclass, field + +import structlog + +from dlp_scanner.network.pcap import PacketInfo + + +log = structlog.get_logger() + +FlowKey = tuple[str, str, int, int] + + +@dataclass(slots = True) +class FlowStats: + """ + Aggregated statistics for a network flow + """ + + src_ip: str = "" + dst_ip: str = "" + src_port: int = 0 + dst_port: int = 0 + protocol: str = "" + packet_count: int = 0 + total_bytes: int = 0 + start_time: float = 0.0 + end_time: float = 0.0 + segments: list[tuple[int, bytes]] = field(default_factory = list) + + +class FlowTracker: + """ + Tracks and reassembles network flows from packets + """ + def __init__(self) -> None: + self._flows: dict[FlowKey, FlowStats] = {} + + def add_packet(self, packet: PacketInfo) -> None: + """ + Add a packet to its corresponding flow + """ + key = make_flow_key(packet) + flow = self._flows.get(key) + + if flow is None: + flow = FlowStats( + src_ip = packet.src_ip, + dst_ip = packet.dst_ip, + src_port = packet.src_port, + dst_port = packet.dst_port, + protocol = packet.protocol, + start_time = packet.timestamp, + ) + self._flows[key] = flow + + flow.packet_count += 1 + flow.total_bytes += len(packet.payload) + flow.end_time = packet.timestamp + + if packet.payload: + flow.segments.append((packet.tcp_seq, packet.payload)) + + def get_flows(self) -> list[FlowStats]: + """ + Return all tracked flows + """ + return list(self._flows.values()) + + def get_flow(self, key: FlowKey) -> FlowStats | None: + """ + Get a specific flow by key + """ + return self._flows.get(key) + + def reassemble_stream(self, key: FlowKey) -> bytes: + """ + Reassemble TCP payload ordered by sequence number + """ + flow = self._flows.get(key) + if flow is None: + return b"" + + sorted_segments = sorted(flow.segments, key = lambda s: s[0]) + + seen_offsets: set[int] = set() + parts: list[bytes] = [] + for seq, data in sorted_segments: + if seq not in seen_offsets: + seen_offsets.add(seq) + parts.append(data) + + return b"".join(parts) + + @property + def flow_count(self) -> int: + """ + Return the number of tracked flows + """ + return len(self._flows) + + +def make_flow_key( + packet: PacketInfo, +) -> FlowKey: + """ + Create a bidirectional flow key from a packet + """ + forward = ( + packet.src_ip, + packet.dst_ip, + packet.src_port, + packet.dst_port, + ) + reverse = ( + packet.dst_ip, + packet.src_ip, + packet.dst_port, + packet.src_port, + ) + return min(forward, reverse) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/pcap.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/pcap.py new file mode 100644 index 00000000..6bd1f4b2 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/pcap.py @@ -0,0 +1,115 @@ +""" +©AngelaMos | 2026 +pcap.py +""" + + +import socket +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import structlog + + +log = structlog.get_logger() + +TCP_PROTO: int = 6 +UDP_PROTO: int = 17 + + +@dataclass(frozen = True, slots = True) +class PacketInfo: + """ + Parsed network packet with extracted metadata + """ + + timestamp: float + src_ip: str + dst_ip: str + src_port: int + dst_port: int + protocol: str + payload: bytes + raw_length: int + tcp_flags: int = 0 + tcp_seq: int = 0 + + +def read_pcap( + path: Path, + max_packets: int = 0, +) -> Iterator[PacketInfo]: + """ + Read packets from a PCAP or PCAPNG file + """ + import dpkt + + with open(path, "rb") as f: + try: + pcap = dpkt.pcap.Reader(f) + except ValueError: + f.seek(0) + pcap = dpkt.pcapng.Reader(f) + + count = 0 + for timestamp, buf in pcap: + if max_packets > 0 and count >= max_packets: + break + + packet = _parse_ethernet(timestamp, buf) + if packet is not None: + yield packet + count += 1 + + +def _parse_ethernet( + timestamp: float, + buf: bytes, +) -> PacketInfo | None: + """ + Parse an Ethernet frame into a PacketInfo + """ + import dpkt + + try: + eth = dpkt.ethernet.Ethernet(buf) + except (dpkt.NeedData, dpkt.UnpackError): + return None + + if not isinstance(eth.data, dpkt.ip.IP): + return None + + ip_pkt = eth.data + src_ip = socket.inet_ntoa(ip_pkt.src) + dst_ip = socket.inet_ntoa(ip_pkt.dst) + + if isinstance(ip_pkt.data, dpkt.tcp.TCP): + tcp = ip_pkt.data + return PacketInfo( + timestamp = timestamp, + src_ip = src_ip, + dst_ip = dst_ip, + src_port = tcp.sport, + dst_port = tcp.dport, + protocol = "tcp", + payload = bytes(tcp.data), + raw_length = len(buf), + tcp_flags = tcp.flags, + tcp_seq = tcp.seq, + ) + + if isinstance(ip_pkt.data, dpkt.udp.UDP): + udp = ip_pkt.data + return PacketInfo( + timestamp = timestamp, + src_ip = src_ip, + dst_ip = dst_ip, + src_port = udp.sport, + dst_port = udp.dport, + protocol = "udp", + payload = bytes(udp.data), + raw_length = len(buf), + ) + + return None diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/protocols.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/protocols.py new file mode 100644 index 00000000..edf2abda --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/network/protocols.py @@ -0,0 +1,250 @@ +""" +©AngelaMos | 2026 +protocols.py +""" + + +import socket +from dataclasses import dataclass, field + +import structlog + + +log = structlog.get_logger() + +HTTP_METHODS: frozenset[bytes] = frozenset( + { + b"GET", + b"POST", + b"PUT", + b"DELETE", + b"HEAD", + b"OPTIONS", + b"PATCH", + } +) + +HTTP_RESPONSE_PREFIX: bytes = b"HTTP/" +TLS_RECORD_PREFIX: bytes = b"\x16\x03" +SSH_PREFIX: bytes = b"SSH-" +SMTP_BANNER_PREFIX: bytes = b"220 " +DNS_PORT: int = 53 + +DNS_QTYPES: dict[int, + str] = { + 1: "A", + 2: "NS", + 5: "CNAME", + 6: "SOA", + 12: "PTR", + 15: "MX", + 16: "TXT", + 28: "AAAA", + 33: "SRV", + 255: "ANY", + } + + +@dataclass(frozen = True, slots = True) +class HttpMessage: + """ + Parsed HTTP request or response + """ + + method: str + uri: str + version: str + headers: dict[str, str] + body: str + is_request: bool + + +@dataclass(frozen = True, slots = True) +class DnsQuery: + """ + A single DNS query entry + """ + + name: str + query_type: str + query_class: str + + +@dataclass(frozen = True, slots = True) +class DnsRecord: + """ + Parsed DNS message with queries and answers + """ + + queries: list[DnsQuery] = field(default_factory = list) + answers: list[str] = field(default_factory = list) + is_response: bool = False + transaction_id: int = 0 + + +def parse_http( + payload: bytes, +) -> HttpMessage | None: + """ + Parse HTTP request or response from raw payload + """ + import dpkt + + try: + if _is_http_request(payload): + req = dpkt.http.Request(payload) + headers = dict(req.headers) + body = _decode_body(req.body) + return HttpMessage( + method = req.method, + uri = req.uri, + version = req.version, + headers = headers, + body = body, + is_request = True, + ) + + if payload.startswith(HTTP_RESPONSE_PREFIX): + resp = dpkt.http.Response(payload) + headers = dict(resp.headers) + body = _decode_body(resp.body) + return HttpMessage( + method = "", + uri = "", + version = resp.version, + headers = headers, + body = body, + is_request = False, + ) + except (dpkt.NeedData, dpkt.UnpackError): + return None + + return None + + +def parse_dns( + payload: bytes, +) -> DnsRecord | None: + """ + Parse DNS message from raw UDP payload + """ + import dpkt + + try: + dns = dpkt.dns.DNS(payload) + except (dpkt.NeedData, dpkt.UnpackError): + return None + + queries: list[DnsQuery] = [] + for qd in dns.qd: + qtype = DNS_QTYPES.get(qd.type, str(qd.type)) + queries.append( + DnsQuery( + name = qd.name, + query_type = qtype, + query_class = str(qd.cls), + ) + ) + + answers: list[str] = [] + for an in dns.an: + _parse_answer(an, answers) + + return DnsRecord( + queries = queries, + answers = answers, + is_response = bool(dns.qr), + transaction_id = dns.id, + ) + + +def identify_protocol( + payload: bytes, +) -> str: + """ + Identify application-layer protocol via DPI + """ + if not payload: + return "unknown" + + if _is_http_request(payload): + return "http" + + if payload.startswith(HTTP_RESPONSE_PREFIX): + return "http" + + if (len(payload) > 2 and payload[: 2] == TLS_RECORD_PREFIX): + return "tls" + + if payload.startswith(SSH_PREFIX): + return "ssh" + + if payload.startswith(SMTP_BANNER_PREFIX): + return "smtp" + + return "unknown" + + +def _is_http_request(payload: bytes) -> bool: + """ + Check if payload starts with an HTTP method + """ + first_space = payload.find(b" ") + if first_space < 3 or first_space > 7: + return False + return payload[: first_space] in HTTP_METHODS + + +def _decode_body(body: bytes | str) -> str: + """ + Decode HTTP body bytes to string + """ + if isinstance(body, str): + return body + if not body: + return "" + try: + return body.decode("utf-8", errors = "replace") + except Exception: + return "" + + +def _parse_answer( + an: object, + answers: list[str], +) -> None: + """ + Parse a single DNS answer record + """ + try: + an_type = getattr(an, "type", 0) + rdata = getattr(an, "rdata", b"") + + if an_type == 1 and len(rdata) == 4: + answers.append(socket.inet_ntoa(rdata)) + elif an_type == 16 and rdata: + answers.append(_parse_txt_rdata(rdata)) + elif hasattr(an, "cname") and an.cname: + answers.append(an.cname) + elif hasattr(an, "name") and an.name: + answers.append(an.name) + except Exception: + pass + + +def _parse_txt_rdata(rdata: bytes) -> str: + """ + Parse TXT record rdata (length-prefixed strings) + """ + parts: list[str] = [] + i = 0 + while i < len(rdata): + length = rdata[i] + i += 1 + if i + length <= len(rdata): + chunk = rdata[i : i + length] + parts.append(chunk.decode("utf-8", errors = "replace")) + i += length + else: + break + return " ".join(parts) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/redaction.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/redaction.py new file mode 100644 index 00000000..2982866b --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/redaction.py @@ -0,0 +1,84 @@ +""" +©AngelaMos | 2026 +redaction.py +""" + + +from dlp_scanner.constants import RedactionStyle + + +REDACTED_LABEL: str = "[REDACTED]" +MASK_CHAR: str = "*" +SNIPPET_CONTEXT_CHARS: int = 20 + + +def redact( + text: str, + start: int, + end: int, + style: RedactionStyle = "partial", +) -> str: + """ + Redact matched text according to the chosen strategy + """ + matched = text[start : end] + + if style == "none": + return _build_snippet(text, start, end, matched) + + if style == "full": + return _build_snippet(text, start, end, REDACTED_LABEL) + + redacted = _partial_redact(matched) + return _build_snippet(text, start, end, redacted) + + +def _partial_redact(value: str) -> str: + """ + Partially mask a value, keeping the last few chars visible + """ + stripped = value.replace("-", "").replace(" ", "") + + if len(stripped) >= 9 and stripped.isdigit(): + return MASK_CHAR * (len(value) - 4) + value[-4 :] + + if "@" in value: + local, domain = value.rsplit("@", maxsplit = 1) + masked_local = local[0] + MASK_CHAR * (len(local) - 1) + return f"{masked_local}@{domain}" + + if len(value) > 8: + visible = max(4, len(value) // 4) + return (MASK_CHAR * (len(value) - visible) + value[-visible :]) + + return MASK_CHAR * len(value) + + +def _build_snippet( + text: str, + start: int, + end: int, + replacement: str, +) -> str: + """ + Build a snippet with context around the redacted match + """ + context_start = max(0, start - SNIPPET_CONTEXT_CHARS) + context_end = min(len(text), end + SNIPPET_CONTEXT_CHARS) + + prefix = text[context_start : start] + suffix = text[end : context_end] + + prefix = prefix.replace("\n", " ").strip() + suffix = suffix.replace("\n", " ").strip() + + parts: list[str] = [] + if context_start > 0: + parts.append("...") + parts.append(prefix) + parts.append(replacement) + parts.append(suffix) + if context_end < len(text): + parts.append("...") + + return "".join(parts) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/base.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/base.py new file mode 100644 index 00000000..05940a20 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/base.py @@ -0,0 +1,20 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +from typing import Protocol + +from dlp_scanner.models import ScanResult + + +class Reporter(Protocol): + """ + Protocol for all report output formats + """ + def generate(self, result: ScanResult) -> str: + """ + Generate report content as a string + """ + ... diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/console.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/console.py new file mode 100644 index 00000000..7c3f6315 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/console.py @@ -0,0 +1,162 @@ +""" +©AngelaMos | 2026 +console.py +""" + + +from rich.console import Console +from rich.table import Table + +from dlp_scanner.constants import SEVERITY_COLORS +from dlp_scanner.models import ScanResult + + +TRUNCATE_SNIPPET: int = 60 + + +class ConsoleReporter: + """ + Rich console output with severity-colored tables + """ + def __init__( + self, + console: Console | None = None, + ) -> None: + self._console = console or Console() + + def generate(self, result: ScanResult) -> str: + """ + Generate plain-text table for piping + """ + lines: list[str] = [] + lines.append( + f"Scan {result.scan_id} | " + f"{len(result.findings)} findings | " + f"{result.targets_scanned} targets" + ) + lines.append("") + + for finding in result.findings: + loc = finding.location.uri + if finding.location.line is not None: + loc += f":{finding.location.line}" + if finding.location.table_name: + loc += (f" [{finding.location.table_name}]") + + snippet = finding.redacted_snippet + if len(snippet) > TRUNCATE_SNIPPET: + snippet = (snippet[: TRUNCATE_SNIPPET] + "...") + + frameworks = ", ".join(finding.compliance_frameworks) + + lines.append( + f"[{finding.severity.upper()}] " + f"{finding.rule_name} | " + f"{loc} | " + f"{finding.confidence:.0%} | " + f"{snippet} | " + f"{frameworks}" + ) + + lines.append("") + lines.append(_format_summary(result)) + return "\n".join(lines) + + def display(self, result: ScanResult) -> None: + """ + Print Rich-formatted table to console + """ + self._console.print() + + if not result.findings: + self._console.print("[green]No findings detected.[/green]") + _print_summary(self._console, result) + return + + table = Table( + title = ( + f"DLP Scan Results " + f"({len(result.findings)} findings)" + ), + show_lines = True, + ) + + table.add_column("Severity", width = 10, justify = "center") + table.add_column("Rule", width = 25) + table.add_column("Location", width = 30) + table.add_column("Confidence", width = 10) + table.add_column("Snippet", width = 40) + table.add_column("Compliance", width = 20) + + for finding in result.findings: + color = SEVERITY_COLORS.get(finding.severity, "white") + + loc = finding.location.uri + if finding.location.line is not None: + loc += f":{finding.location.line}" + if finding.location.table_name: + loc += (f"\n[{finding.location.table_name}]") + + snippet = finding.redacted_snippet + if len(snippet) > TRUNCATE_SNIPPET: + snippet = (snippet[: TRUNCATE_SNIPPET] + "...") + + frameworks = "\n".join(finding.compliance_frameworks) + + table.add_row( + f"[{color}]{finding.severity.upper()}" + f"[/{color}]", + finding.rule_name, + loc, + f"{finding.confidence:.0%}", + snippet, + frameworks, + ) + + self._console.print(table) + _print_summary(self._console, result) + + if result.errors: + self._console.print() + self._console.print( + f"[yellow]{len(result.errors)} " + f"error(s) during scan[/yellow]" + ) + + +def _format_summary(result: ScanResult) -> str: + """ + Format summary statistics as plain text + """ + by_sev = result.findings_by_severity + parts: list[str] = [] + for sev in ("critical", "high", "medium", "low"): + count = by_sev.get(sev, 0) + if count > 0: + parts.append(f"{sev}: {count}") + + summary = " | ".join(parts) if parts else "clean" + return ( + f"Summary: {summary} " + f"({result.targets_scanned} targets scanned)" + ) + + +def _print_summary(console: Console, result: ScanResult) -> None: + """ + Print formatted summary using Rich + """ + console.print() + by_sev = result.findings_by_severity + parts: list[str] = [] + for sev in ("critical", "high", "medium", "low"): + count = by_sev.get(sev, 0) + if count > 0: + color = SEVERITY_COLORS.get(sev, "white") + parts.append(f"[{color}]{sev}: {count}[/{color}]") + + summary = (" | ".join(parts) if parts else "[green]clean") + console.print( + f"Summary: {summary} " + f"({result.targets_scanned} targets)" + ) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/csv_report.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/csv_report.py new file mode 100644 index 00000000..6675a9ab --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/csv_report.py @@ -0,0 +1,64 @@ +""" +©AngelaMos | 2026 +csv_report.py +""" + + +import csv +import io + +from dlp_scanner.models import ScanResult + + +CSV_COLUMNS: list[str] = [ + "finding_id", + "scan_date", + "severity", + "confidence", + "rule_id", + "rule_name", + "source_type", + "uri", + "line", + "column", + "table_name", + "redacted_snippet", + "compliance_frameworks", + "remediation", +] + + +class CsvReporter: + """ + CSV export for compliance team consumption + """ + def generate(self, result: ScanResult) -> str: + """ + Generate CSV report as a string + """ + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(CSV_COLUMNS) + + for finding in result.findings: + frameworks = ";".join(finding.compliance_frameworks) + writer.writerow( + [ + finding.finding_id, + finding.detected_at.isoformat(), + finding.severity, + f"{finding.confidence:.4f}", + finding.rule_id, + finding.rule_name, + finding.location.source_type, + finding.location.uri, + finding.location.line or "", + finding.location.column or "", + finding.location.table_name or "", + finding.redacted_snippet, + frameworks, + finding.remediation, + ] + ) + + return output.getvalue() diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/json_report.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/json_report.py new file mode 100644 index 00000000..bb91c529 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/json_report.py @@ -0,0 +1,110 @@ +""" +©AngelaMos | 2026 +json_report.py +""" + + +from typing import Any + +import orjson + +from dlp_scanner.models import ScanResult + + +class JsonReporter: + """ + Structured JSON report with metadata and summary + """ + def generate(self, result: ScanResult) -> str: + """ + Generate JSON report as a formatted string + """ + report = _build_report(result) + return orjson.dumps( + report, + option = (orjson.OPT_INDENT_2 + | orjson.OPT_NON_STR_KEYS), + ).decode("utf-8") + + +def _build_report( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build the complete report structure + """ + return { + "scan_metadata": _build_metadata(result), + "findings": [_serialize_finding(f) for f in result.findings], + "summary": _build_summary(result), + } + + +def _build_metadata( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build scan metadata section + """ + return { + "scan_id": + result.scan_id, + "tool_version": + result.tool_version, + "scan_started_at": (result.scan_started_at.isoformat()), + "scan_completed_at": ( + result.scan_completed_at.isoformat() + if result.scan_completed_at else None + ), + "targets_scanned": + result.targets_scanned, + "total_findings": + len(result.findings), + "errors": + result.errors, + } + + +def _serialize_finding( + finding: Any, +) -> dict[str, + Any]: + """ + Serialize a single finding to dict + """ + return { + "finding_id": finding.finding_id, + "rule_id": finding.rule_id, + "rule_name": finding.rule_name, + "severity": finding.severity, + "confidence": round(finding.confidence, + 4), + "location": { + "source_type": (finding.location.source_type), + "uri": finding.location.uri, + "line": finding.location.line, + "column": finding.location.column, + "table_name": (finding.location.table_name), + "column_name": (finding.location.column_name), + }, + "redacted_snippet": (finding.redacted_snippet), + "compliance_frameworks": (finding.compliance_frameworks), + "remediation": finding.remediation, + "detected_at": (finding.detected_at.isoformat()), + } + + +def _build_summary( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build summary statistics section + """ + return { + "by_severity": result.findings_by_severity, + "by_rule": result.findings_by_rule, + "by_framework": result.findings_by_framework, + } diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/sarif.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/sarif.py new file mode 100644 index 00000000..3d309ebd --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/reporters/sarif.py @@ -0,0 +1,171 @@ +""" +©AngelaMos | 2026 +sarif.py +""" + + +from typing import Any + +import orjson + +from dlp_scanner.constants import SARIF_SEVERITY_MAP +from dlp_scanner.models import Finding, ScanResult + + +SARIF_SCHEMA: str = ( + "https://raw.githubusercontent.com/" + "oasis-tcs/sarif-spec/main/sarif-2.1/" + "schema/sarif-schema-2.1.0.json" +) +SARIF_VERSION: str = "2.1.0" +TOOL_NAME: str = "dlp-scanner" + + +class SarifReporter: + """ + SARIF 2.1.0 output for CI/CD integration + """ + def generate(self, result: ScanResult) -> str: + """ + Generate SARIF 2.1.0 report as JSON string + """ + sarif = _build_sarif(result) + return orjson.dumps( + sarif, + option = (orjson.OPT_INDENT_2 + | orjson.OPT_NON_STR_KEYS), + ).decode("utf-8") + + +def _build_sarif( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build complete SARIF document + """ + rules = _collect_rules(result.findings) + results = [_build_result(f, rules) for f in result.findings] + + return { + "$schema": + SARIF_SCHEMA, + "version": + SARIF_VERSION, + "runs": [ + { + "tool": { + "driver": { + "name": TOOL_NAME, + "version": (result.tool_version), + "rules": list(rules.values()), + } + }, + "results": results, + } + ], + } + + +def _collect_rules( + findings: list[Finding], +) -> dict[str, + dict[str, + Any]]: + """ + Collect unique rules from findings + """ + rules: dict[str, dict[str, Any]] = {} + + for finding in findings: + if finding.rule_id in rules: + continue + + rules[finding.rule_id] = { + "id": finding.rule_id, + "name": finding.rule_name, + "shortDescription": { + "text": finding.rule_name, + }, + "properties": { + "compliance_frameworks": (finding.compliance_frameworks), + }, + } + + return rules + + +def _build_result( + finding: Finding, + rules: dict[str, + dict[str, + Any]], +) -> dict[str, + Any]: + """ + Build a single SARIF result entry + """ + level = SARIF_SEVERITY_MAP.get(finding.severity, "note") + + location = _build_location(finding) + + return { + "ruleId": finding.rule_id, + "ruleIndex": list(rules.keys()).index(finding.rule_id), + "level": level, + "message": { + "text": ( + f"{finding.rule_name} detected " + f"with {finding.confidence:.0%} " + f"confidence" + ), + }, + "locations": [location], + "properties": { + "confidence": round(finding.confidence, + 4), + "redactedSnippet": (finding.redacted_snippet), + "complianceFrameworks": (finding.compliance_frameworks), + "remediation": finding.remediation, + }, + } + + +def _build_location( + finding: Finding, +) -> dict[str, + Any]: + """ + Build SARIF location from finding + """ + loc = finding.location + + physical: dict[str, + Any] = { + "artifactLocation": { + "uri": loc.uri + }, + } + + region: dict[str, Any] = {} + if loc.line is not None: + region["startLine"] = loc.line + if loc.column is not None: + region["startColumn"] = loc.column + if region: + physical["region"] = region + + result: dict[str, + Any] = { + "physicalLocation": physical, + } + + if loc.table_name: + result["logicalLocations"] = [ + { + "name": loc.table_name, + "kind": "table", + } + ] + + return result diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/__init__.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/base.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/base.py new file mode 100644 index 00000000..74f56f8d --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/base.py @@ -0,0 +1,20 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +from typing import Protocol + +from dlp_scanner.models import ScanResult + + +class Scanner(Protocol): + """ + Protocol for all scan strategies + """ + def scan(self, target: str) -> ScanResult: + """ + Scan the target and return aggregated results + """ + ... diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py new file mode 100644 index 00000000..125a632d --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py @@ -0,0 +1,530 @@ +""" +©AngelaMos | 2026 +db_scanner.py +""" + + +import asyncio +from datetime import datetime, UTC +from typing import Any +from urllib.parse import urlparse + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.constants import ( + TEXT_DB_COLUMN_TYPES_MYSQL, + TEXT_DB_COLUMN_TYPES_PG, +) +from dlp_scanner.detectors.base import DetectorMatch +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.models import ( + Location, + ScanResult, +) +from dlp_scanner.scoring import match_to_finding + + +log = structlog.get_logger() + +POSTGRES_SCHEMES: frozenset[str] = frozenset({ + "postgresql", + "postgres", +}) +MYSQL_SCHEMES: frozenset[str] = frozenset({ + "mysql", + "mysql+aiomysql", +}) +MONGODB_SCHEMES: frozenset[str] = frozenset({ + "mongodb", + "mongodb+srv", +}) +SQLITE_SCHEMES: frozenset[str] = frozenset({ + "sqlite", +}) + + +class DatabaseScanner: + """ + Scans database tables for sensitive data in text columns + """ + def __init__( + self, + config: ScanConfig, + registry: DetectorRegistry, + ) -> None: + self._db_config = config.database + self._detection_config = config.detection + self._redaction_style = config.output.redaction_style + self._registry = registry + + def scan(self, target: str) -> ScanResult: + """ + Scan a database identified by connection URI + """ + return asyncio.run(self._scan_async(target)) + + async def _scan_async( + self, + connection_uri: str, + ) -> ScanResult: + """ + Dispatch to the appropriate database scanner + """ + result = ScanResult() + parsed = urlparse(connection_uri) + scheme = parsed.scheme.lower() + + try: + if scheme in POSTGRES_SCHEMES: + await self._scan_postgres(connection_uri, result) + elif scheme in MYSQL_SCHEMES: + await self._scan_mysql(connection_uri, result) + elif scheme in MONGODB_SCHEMES: + await self._scan_mongodb(connection_uri, result) + elif scheme in SQLITE_SCHEMES: + await self._scan_sqlite(connection_uri, result) + else: + result.errors.append( + f"Unsupported database scheme: " + f"{scheme}" + ) + except Exception as exc: + log.warning( + "database_scan_failed", + scheme = scheme, + error = str(exc), + ) + result.errors.append(f"Database scan failed: {exc}") + + result.scan_completed_at = datetime.now(UTC) + return result + + async def _scan_postgres( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan PostgreSQL using asyncpg with TABLESAMPLE + """ + import asyncpg + + conn = await asyncpg.connect( + uri, + timeout = self._db_config.timeout_seconds, + ) + + try: + tables = await self._get_pg_tables(conn) + tables = self._filter_tables(tables) + + for table_name in tables: + text_cols = ( + await self._get_pg_text_columns(conn, + table_name) + ) + if not text_cols: + continue + + col_list = ", ".join(f'"{c}"' for c in text_cols) + query = ( + f"SELECT {col_list} " + f'FROM "{table_name}" ' + f"TABLESAMPLE BERNOULLI(" + f"{self._db_config.sample_percentage}" + f") LIMIT " + f"{self._db_config.max_rows_per_table}" + ) + + rows = await conn.fetch(query) + self._process_record_rows( + rows, + text_cols, + table_name, + uri, + result, + ) + result.targets_scanned += 1 + finally: + await conn.close() + + async def _get_pg_tables( + self, + conn: Any, + ) -> list[str]: + """ + List user tables in PostgreSQL + """ + rows = await conn.fetch( + "SELECT table_name " + "FROM information_schema.tables " + "WHERE table_schema = 'public' " + "AND table_type = 'BASE TABLE'" + ) + return [r["table_name"] for r in rows] + + async def _get_pg_text_columns( + self, + conn: Any, + table_name: str, + ) -> list[str]: + """ + Find text-type columns in a PostgreSQL table + """ + rows = await conn.fetch( + "SELECT column_name " + "FROM information_schema.columns " + "WHERE table_name = $1 " + "AND data_type = ANY($2::text[])", + table_name, + list(TEXT_DB_COLUMN_TYPES_PG), + ) + return [r["column_name"] for r in rows] + + async def _scan_mysql( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan MySQL using aiomysql with random sampling + """ + import aiomysql + + parsed = urlparse(uri) + conn = await aiomysql.connect( + host = parsed.hostname or "localhost", + port = parsed.port or 3306, + user = parsed.username or "root", + password = parsed.password or "", + db = parsed.path.lstrip("/"), + connect_timeout = (self._db_config.timeout_seconds), + ) + + try: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT table_name " + "FROM information_schema.tables " + "WHERE table_schema = DATABASE() " + "AND table_type = 'BASE TABLE'" + ) + raw_tables = await cur.fetchall() + tables = [r["TABLE_NAME"] for r in raw_tables] + tables = self._filter_tables(tables) + + for table_name in tables: + text_cols = ( + await self._get_mysql_text_cols(cur, + table_name) + ) + if not text_cols: + continue + + col_list = ", ".join(f"`{c}`" for c in text_cols) + limit = (self._db_config.max_rows_per_table) + await cur.execute( + f"SELECT {col_list} " + f"FROM `{table_name}` " + f"ORDER BY RAND() " + f"LIMIT {limit}" + ) + rows = await cur.fetchall() + self._process_dict_rows( + rows, + text_cols, + table_name, + uri, + result, + ) + result.targets_scanned += 1 + finally: + conn.close() + + async def _get_mysql_text_cols( + self, + cursor: Any, + table_name: str, + ) -> list[str]: + """ + Find text-type columns in a MySQL table + """ + placeholders = ",".join(["%s"] * len(TEXT_DB_COLUMN_TYPES_MYSQL)) + await cursor.execute( + "SELECT column_name " + "FROM information_schema.columns " + "WHERE table_name = %s " + "AND table_schema = DATABASE() " + f"AND data_type IN ({placeholders})", + (table_name, + *TEXT_DB_COLUMN_TYPES_MYSQL), + ) + rows = await cursor.fetchall() + return [r["COLUMN_NAME"] for r in rows] + + async def _scan_mongodb( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan MongoDB collections using pymongo async + """ + from pymongo import AsyncMongoClient + + parsed = urlparse(uri) + db_name = parsed.path.lstrip("/").split("?")[0] + + if not db_name: + result.errors.append("MongoDB URI must include database name") + return + + client: AsyncMongoClient[dict[str, Any]] = (AsyncMongoClient(uri)) + + try: + db = client[db_name] + collections = (await db.list_collection_names()) + collections = self._filter_tables(collections) + + for coll_name in collections: + coll = db[coll_name] + sample_size = (self._db_config.max_rows_per_table) + cursor = coll.aggregate( + [{ + "$sample": { + "size": sample_size + } + }] + ) + + async for doc in cursor: + text_parts: list[str] = [] + _extract_mongo_strings(doc, text_parts) + if not text_parts: + continue + + combined = "\n".join(text_parts) + matches = self._registry.detect(combined) + self._append_findings( + matches, + combined, + table_name = coll_name, + uri = uri, + result = result, + ) + + result.targets_scanned += 1 + finally: + client.close() + + async def _scan_sqlite( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan SQLite database using aiosqlite + """ + import aiosqlite + + parsed = urlparse(uri) + db_path = parsed.path + while db_path.startswith("//"): + db_path = db_path[1 :] + + async with aiosqlite.connect(db_path) as db: + cursor = await db.execute( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%'" + ) + rows = await cursor.fetchall() + tables = [r[0] for r in rows] + tables = self._filter_tables(tables) + + for table_name in tables: + text_cols = ( + await self._get_sqlite_text_cols(db, + table_name) + ) + if not text_cols: + continue + + col_list = ", ".join(f'"{c}"' for c in text_cols) + limit = (self._db_config.max_rows_per_table) + cursor = await db.execute( + f"SELECT {col_list} " + f'FROM "{table_name}" ' + f"ORDER BY RANDOM() " + f"LIMIT {limit}" + ) + fetched = await cursor.fetchall() + for row in fetched: + for idx, col_name in enumerate(text_cols): + val = row[idx] + if val is None: + continue + text = str(val) + if not text.strip(): + continue + matches = self._registry.detect(text) + self._append_findings( + matches, + text, + table_name = table_name, + column_name = col_name, + uri = uri, + result = result, + ) + result.targets_scanned += 1 + + async def _get_sqlite_text_cols( + self, + db: Any, + table_name: str, + ) -> list[str]: + """ + Find text-type columns in a SQLite table + """ + cursor = await db.execute(f'PRAGMA table_info("{table_name}")') + rows = await cursor.fetchall() + text_types = frozenset({"text", "varchar", "char", "clob"}) + return [ + r[1] + for r in rows + if r[2].lower() in text_types or "text" in r[2].lower() + ] + + def _filter_tables( + self, + tables: list[str], + ) -> list[str]: + """ + Apply include/exclude table filters + """ + include = self._db_config.include_tables + exclude = frozenset(self._db_config.exclude_tables) + + if include: + include_set = frozenset(include) + tables = [t for t in tables if t in include_set] + + return [t for t in tables if t not in exclude] + + def _process_record_rows( + self, + rows: list[Any], + columns: list[str], + table_name: str, + uri: str, + result: ScanResult, + ) -> None: + """ + Process asyncpg Record rows through detection + """ + for row in rows: + for col_name in columns: + val = row[col_name] + if val is None: + continue + text = str(val) + if not text.strip(): + continue + matches = self._registry.detect(text) + self._append_findings( + matches, + text, + table_name = table_name, + column_name = col_name, + uri = uri, + result = result, + ) + + def _process_dict_rows( + self, + rows: list[dict[str, + Any]], + columns: list[str], + table_name: str, + uri: str, + result: ScanResult, + ) -> None: + """ + Process dictionary rows through detection + """ + for row in rows: + for col_name in columns: + val = row.get(col_name) + if val is None: + continue + text = str(val) + if not text.strip(): + continue + matches = self._registry.detect(text) + self._append_findings( + matches, + text, + table_name = table_name, + column_name = col_name, + uri = uri, + result = result, + ) + + def _append_findings( + self, + matches: list[DetectorMatch], + text: str, + table_name: str, + uri: str, + result: ScanResult, + column_name: str = "", + ) -> None: + """ + Convert detector matches to findings and append + """ + min_confidence = (self._detection_config.min_confidence) + + location = Location( + source_type = "database", + uri = uri, + table_name = table_name, + column_name = column_name or None, + ) + + for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, + text, + location, + self._redaction_style, + ) + result.findings.append(finding) + + +def _extract_mongo_strings( + doc: dict[str, + Any], + parts: list[str], + prefix: str = "", +) -> None: + """ + Recursively extract string values from a MongoDB document + """ + for key, val in doc.items(): + if key == "_id": + continue + key_path = (f"{prefix}.{key}" if prefix else key) + if isinstance(val, str) and val.strip(): + parts.append(f"{key_path}: {val}") + elif isinstance(val, dict): + _extract_mongo_strings(val, parts, key_path) + elif isinstance(val, list): + for item in val: + if (isinstance(item, str) and item.strip()): + parts.append(f"{key_path}: {item}") + elif isinstance(item, dict): + _extract_mongo_strings(item, parts, key_path) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py new file mode 100644 index 00000000..b83ac5a1 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py @@ -0,0 +1,224 @@ +""" +©AngelaMos | 2026 +file_scanner.py +""" + + +import fnmatch +from datetime import datetime, UTC +from pathlib import Path + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.extractors.archive import ArchiveExtractor +from dlp_scanner.extractors.base import Extractor +from dlp_scanner.extractors.email import ( + EmlExtractor, + MsgExtractor, +) +from dlp_scanner.extractors.office import ( + DocxExtractor, + XlsExtractor, + XlsxExtractor, +) +from dlp_scanner.extractors.pdf import PDFExtractor +from dlp_scanner.extractors.plaintext import ( + PlaintextExtractor, +) +from dlp_scanner.extractors.structured import ( + AvroExtractor, + CsvExtractor, + JsonExtractor, + ParquetExtractor, + XmlExtractor, + YamlExtractor, +) +from dlp_scanner.models import ( + ScanResult, + TextChunk, +) +from dlp_scanner.scoring import match_to_finding + + +log = structlog.get_logger() + +MB_BYTES: int = 1024 * 1024 + + +class FileScanner: + """ + Scans files in a directory tree for sensitive data + """ + def __init__( + self, + config: ScanConfig, + registry: DetectorRegistry, + ) -> None: + self._file_config = config.file + self._detection_config = config.detection + self._redaction_style = config.output.redaction_style + self._registry = registry + self._extension_map = _build_extension_map() + self._allowed_extensions = frozenset( + self._file_config.include_extensions + ) + + def scan(self, target: str) -> ScanResult: + """ + Walk a directory and scan all matching files + """ + result = ScanResult() + target_path = Path(target) + + if target_path.is_file(): + self._scan_file(target_path, result) + result.targets_scanned = 1 + elif target_path.is_dir(): + self._scan_directory(target_path, result) + else: + result.errors.append(f"Target not found: {target}") + + result.scan_completed_at = datetime.now(UTC) + return result + + def _scan_directory( + self, + directory: Path, + result: ScanResult, + ) -> None: + """ + Recursively walk a directory and scan matching files + """ + max_bytes = (self._file_config.max_file_size_mb * MB_BYTES) + iterator = ( + directory.rglob("*") + if self._file_config.recursive else directory.glob("*") + ) + + for path in iterator: + if not path.is_file(): + continue + + if self._is_excluded(path, directory): + continue + + suffix = _get_full_suffix(path) + if suffix not in self._allowed_extensions: + continue + + try: + file_size = path.stat().st_size + except OSError: + continue + + if file_size > max_bytes: + log.debug( + "file_skipped_too_large", + path = str(path), + size = file_size, + ) + continue + + if file_size == 0: + continue + + self._scan_file(path, result) + result.targets_scanned += 1 + + def _scan_file( + self, + path: Path, + result: ScanResult, + ) -> None: + """ + Extract text from a single file and run detection + """ + suffix = _get_full_suffix(path) + extractor = self._extension_map.get(suffix) + + if extractor is None: + return + + try: + chunks = extractor.extract(str(path)) + except Exception: + log.warning("extraction_failed", path = str(path)) + result.errors.append(f"Extraction failed: {path}") + return + + min_confidence = (self._detection_config.min_confidence) + + for chunk in chunks: + matches = self._registry.detect(chunk.text) + for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, + chunk.text, + chunk.location, + self._redaction_style, + ) + result.findings.append(finding) + + def _is_excluded( + self, + path: Path, + base: Path, + ) -> bool: + """ + Check if a path matches any exclude pattern + """ + relative = str(path.relative_to(base)) + for pattern in self._file_config.exclude_patterns: + if fnmatch.fnmatch(relative, pattern): + return True + if fnmatch.fnmatch(path.name, pattern): + return True + if any(fnmatch.fnmatch(part, pattern) for part in path.parts): + return True + return False + + +def _build_extension_map() -> dict[str, Extractor]: + """ + Build a mapping from file extension to extractor instance + """ + extractors: list[Extractor] = [ + PlaintextExtractor(), + PDFExtractor(), + DocxExtractor(), + XlsxExtractor(), + XlsExtractor(), + CsvExtractor(), + JsonExtractor(), + XmlExtractor(), + YamlExtractor(), + ParquetExtractor(), + AvroExtractor(), + ArchiveExtractor(), + EmlExtractor(), + MsgExtractor(), + ] + + ext_map: dict[str, Extractor] = {} + for extractor in extractors: + for ext in extractor.supported_extensions: + ext_map[ext] = extractor + + return ext_map + + +def _get_full_suffix(path: Path) -> str: + """ + Get full suffix including compound extensions + """ + name = path.name + if name.endswith(".tar.gz"): + return ".tar.gz" + if name.endswith(".tar.bz2"): + return ".tar.bz2" + return path.suffix.lower() diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py new file mode 100644 index 00000000..2ebc54a6 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py @@ -0,0 +1,338 @@ +""" +©AngelaMos | 2026 +network_scanner.py +""" + + +from datetime import datetime, UTC +from pathlib import Path + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.detectors.base import DetectorMatch +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) +from dlp_scanner.network.exfiltration import ( + DnsExfilDetector, + ExfilIndicator, + detect_base64_payload, +) +from dlp_scanner.network.flow_tracker import ( + FlowTracker, + make_flow_key, +) +from dlp_scanner.network.pcap import read_pcap +from dlp_scanner.network.protocols import ( + DNS_PORT, + identify_protocol, + parse_dns, + parse_http, +) +from dlp_scanner.scoring import match_to_finding + + +log = structlog.get_logger() + +EXFIL_RULE_MAP: dict[str, tuple[str, str]] = { + "dns_long_label": ( + "NET_DNS_EXFIL_LONG_LABEL", + "DNS Exfiltration: Long Label", + ), + "dns_high_entropy": ( + "NET_DNS_EXFIL_HIGH_ENTROPY", + "DNS Exfiltration: High Entropy Subdomain", + ), + "dns_long_qname": ( + "NET_DNS_EXFIL_LONG_QNAME", + "DNS Exfiltration: Long QNAME", + ), + "dns_txt_volume": ( + "NET_DNS_EXFIL_TXT_VOLUME", + "DNS Exfiltration: High TXT Volume", + ), + "base64_payload": ( + "NET_ENCODED_BASE64", + "Encoded Payload: Base64", + ), + "hex_payload": ( + "NET_ENCODED_HEX", + "Encoded Payload: Hex", + ), +} + + +class NetworkScanner: + """ + Scans network capture files for sensitive data in transit + """ + def __init__( + self, + config: ScanConfig, + registry: DetectorRegistry, + ) -> None: + self._net_config = config.network + self._detection_config = config.detection + self._redaction_style = config.output.redaction_style + self._registry = registry + + def scan(self, target: str) -> ScanResult: + """ + Scan a PCAP file for sensitive data in payloads + """ + result = ScanResult() + target_path = Path(target) + + if not target_path.exists(): + result.errors.append(f"PCAP file not found: {target}") + result.scan_completed_at = datetime.now(UTC) + return result + + try: + self._scan_pcap(target_path, result) + except Exception as exc: + log.warning( + "pcap_scan_failed", + path = str(target_path), + error = str(exc), + ) + result.errors.append(f"PCAP scan failed: {exc}") + + result.scan_completed_at = datetime.now(UTC) + return result + + def _scan_pcap( + self, + path: Path, + result: ScanResult, + ) -> None: + """ + Read packets, reassemble flows, and run detection + """ + tracker = FlowTracker() + dns_detector = DnsExfilDetector( + entropy_threshold = ( + self._net_config.dns_label_entropy_threshold + ), + ) + packet_count = 0 + + for packet in read_pcap( + path, + max_packets = self._net_config.max_packets, + ): + packet_count += 1 + tracker.add_packet(packet) + + if ( + packet.protocol == "udp" + and ( + packet.src_port == DNS_PORT + or packet.dst_port == DNS_PORT + ) + ): + self._process_dns_packet( + packet.payload, + packet.src_ip, + packet.dst_ip, + path, + packet_count, + dns_detector, + result, + ) + + if packet.payload: + exfil_indicators = detect_base64_payload( + packet.payload, + src_ip = packet.src_ip, + dst_ip = packet.dst_ip, + ) + for indicator in exfil_indicators: + finding = _indicator_to_finding( + indicator, + str(path), + packet_count, + ) + result.findings.append(finding) + + txt_indicators = dns_detector.check_txt_volume() + for indicator in txt_indicators: + finding = _indicator_to_finding( + indicator, + str(path), + packet_count, + ) + result.findings.append(finding) + + self._scan_reassembled_flows(tracker, path, result) + + result.targets_scanned = packet_count + + def _process_dns_packet( + self, + payload: bytes, + src_ip: str, + dst_ip: str, + path: Path, + packet_num: int, + dns_detector: DnsExfilDetector, + result: ScanResult, + ) -> None: + """ + Parse DNS and check for exfiltration patterns + """ + dns_record = parse_dns(payload) + if dns_record is None: + return + + for query in dns_record.queries: + indicator = dns_detector.analyze_query( + query, + src_ip, + dst_ip, + ) + if indicator is not None: + finding = _indicator_to_finding( + indicator, + str(path), + packet_num, + ) + result.findings.append(finding) + + def _scan_reassembled_flows( + self, + tracker: FlowTracker, + path: Path, + result: ScanResult, + ) -> None: + """ + Reassemble TCP streams and scan for sensitive data + """ + min_confidence = self._detection_config.min_confidence + + for flow in tracker.get_flows(): + key = ( + flow.src_ip, + flow.dst_ip, + flow.src_port, + flow.dst_port, + ) + stream = tracker.reassemble_stream(key) + if not stream: + continue + + protocol = identify_protocol(stream) + text = self._extract_scannable_text( + stream, + protocol, + ) + + if not text or not text.strip(): + continue + + matches = self._registry.detect(text) + + location = Location( + source_type = "network", + uri = str(path), + ) + + for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, + text, + location, + self._redaction_style, + ) + result.findings.append(finding) + + def _extract_scannable_text( + self, + stream: bytes, + protocol: str, + ) -> str: + """ + Extract text content from a reassembled stream + """ + if protocol == "http": + return self._extract_http_text(stream) + + if protocol in ("tls", "ssh"): + return "" + + try: + return stream.decode("utf-8", errors = "replace") + except Exception: + return "" + + def _extract_http_text( + self, + stream: bytes, + ) -> str: + """ + Extract scannable text from HTTP messages + """ + http_msg = parse_http(stream) + if http_msg is None: + try: + return stream.decode( + "utf-8", + errors = "replace", + ) + except Exception: + return "" + + parts: list[str] = [] + + if http_msg.is_request and http_msg.uri: + parts.append(http_msg.uri) + + for header_name in ("cookie", "authorization", "set-cookie"): + val = http_msg.headers.get(header_name, "") + if val: + parts.append(val) + + if http_msg.body: + parts.append(http_msg.body) + + return "\n".join(parts) + + +def _indicator_to_finding( + indicator: ExfilIndicator, + uri: str, + packet_num: int, +) -> Finding: + """ + Convert an exfiltration indicator to a Finding + """ + rule_id, rule_name = EXFIL_RULE_MAP.get( + indicator.indicator_type, + ("NET_EXFIL_UNKNOWN", "Network Exfiltration Indicator"), + ) + + severity = "high" if indicator.confidence >= 0.70 else "medium" + + location = Location( + source_type = "network", + uri = uri, + byte_offset = packet_num, + ) + + return Finding( + rule_id = rule_id, + rule_name = rule_name, + severity = severity, + confidence = indicator.confidence, + location = location, + redacted_snippet = indicator.evidence[:120], + compliance_frameworks = [], + remediation = indicator.description, + ) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scoring.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scoring.py new file mode 100644 index 00000000..48b56827 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scoring.py @@ -0,0 +1,52 @@ +""" +©AngelaMos | 2026 +scoring.py +""" + + +from dlp_scanner.compliance import ( + get_frameworks_for_rule, + get_remediation_for_rule, + score_to_severity, +) +from dlp_scanner.constants import RedactionStyle +from dlp_scanner.detectors.base import DetectorMatch +from dlp_scanner.models import Finding, Location +from dlp_scanner.redaction import redact + + +def match_to_finding( + match: DetectorMatch, + text: str, + location: Location, + redaction_style: RedactionStyle, +) -> Finding: + """ + Convert a detector match into a fully classified finding + """ + severity = score_to_severity(match.score) + frameworks = get_frameworks_for_rule(match.rule_id) + if match.compliance_frameworks: + combined = ( + set(frameworks) | set(match.compliance_frameworks) + ) + frameworks = sorted(combined) + remediation = get_remediation_for_rule(match.rule_id) + + snippet = redact( + text, + match.start, + match.end, + style = redaction_style, + ) + + return Finding( + rule_id = match.rule_id, + rule_name = match.rule_name, + severity = severity, + confidence = match.score, + location = location, + redacted_snippet = snippet, + compliance_frameworks = frameworks, + remediation = remediation, + ) diff --git a/PROJECTS/intermediate/dlp-scanner/tests/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/conftest.py b/PROJECTS/intermediate/dlp-scanner/tests/conftest.py new file mode 100644 index 00000000..910839d9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/conftest.py @@ -0,0 +1,87 @@ +""" +©AngelaMos | 2026 +conftest.py +""" + + +import tempfile +from pathlib import Path +from collections.abc import Generator + +import pytest + +from dlp_scanner.config import ScanConfig +from dlp_scanner.models import Finding, Location + + +@pytest.fixture +def default_config() -> ScanConfig: + """ + Provide a default ScanConfig instance + """ + return ScanConfig() + + +@pytest.fixture +def sample_location() -> Location: + """ + Provide a sample file location + """ + return Location( + source_type = "file", + uri = "test/employees.csv", + line = 42, + column = 15, + ) + + +@pytest.fixture +def sample_finding(sample_location: Location) -> Finding: + """ + Provide a sample finding + """ + return Finding( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + severity = "critical", + confidence = 0.95, + location = sample_location, + redacted_snippet = "...SSN: ***-**-6789...", + compliance_frameworks = ["HIPAA", + "CCPA"], + remediation = "Encrypt or remove SSN data", + ) + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """ + Provide a temporary directory for test files + """ + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def temp_dir_with_pii( + temp_dir: Path, +) -> Path: + """ + Provide a temp directory containing files with known PII + """ + csv_path = temp_dir / "employees.csv" + csv_path.write_text( + "name,ssn,email\n" + "John Doe,123-45-6789,john@example.com\n" + "Jane Smith,987-65-4321,jane@example.com\n" + ) + + txt_path = temp_dir / "clean.txt" + txt_path.write_text("No sensitive data here at all.") + + json_path = temp_dir / "config.json" + json_path.write_text( + '{"api_key": "sk_live_abc123def456ghi789jkl012mno345"}\n' + ) + + return temp_dir diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_cli.py b/PROJECTS/intermediate/dlp-scanner/tests/test_cli.py new file mode 100644 index 00000000..6d28a671 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_cli.py @@ -0,0 +1,312 @@ +""" +©AngelaMos | 2026 +test_cli.py +""" + + +import json +import tempfile +from pathlib import Path +from collections.abc import Generator + +import pytest +from typer.testing import CliRunner + +from dlp_scanner.cli import app + + +runner = CliRunner() + + +@pytest.fixture +def pii_dir() -> Generator[Path, None, None]: + """ + Provide a temp directory with valid detectable SSNs + """ + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + csv_path = root / "employees.csv" + csv_path.write_text( + "name,ssn\n" + "Alice,456-78-9012\n" + "Bob,234-56-7890\n" + ) + yield root + + +@pytest.fixture +def json_result_file() -> Generator[Path, None, None]: + """ + Provide a JSON scan results file for report tests + """ + data = { + "scan_metadata": { + "scan_id": "test-123", + "tool_version": "0.1.0", + "scan_started_at": ("2026-01-01T00:00:00+00:00"), + "scan_completed_at": ("2026-01-01T00:01:00+00:00"), + "targets_scanned": 1, + "total_findings": 1, + "errors": [], + }, + "findings": [ + { + "finding_id": "f-001", + "rule_id": "PII_SSN", + "rule_name": ("US Social Security Number"), + "severity": "critical", + "confidence": 0.95, + "location": { + "source_type": "file", + "uri": "data.csv", + "line": 5, + "column": None, + "table_name": None, + "column_name": None, + }, + "redacted_snippet": "***-**-6789", + "compliance_frameworks": [ + "HIPAA", + "CCPA", + ], + "remediation": "Encrypt data", + "detected_at": ("2026-01-01T00:00:30+00:00"), + } + ], + "summary": { + "by_severity": { + "critical": 1 + }, + "by_rule": { + "PII_SSN": 1 + }, + "by_framework": { + "HIPAA": 1, + "CCPA": 1, + }, + }, + } + + with tempfile.NamedTemporaryFile( + suffix = ".json", + delete = False, + mode = "w", + ) as f: + json.dump(data, f) + path = Path(f.name) + + yield path + path.unlink(missing_ok = True) + + +class TestCliHelp: + def test_help_shows_commands(self) -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "file" in result.output + assert "db" in result.output + assert "network" in result.output + assert "report" in result.output + + def test_version(self) -> None: + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output + + def test_file_help(self) -> None: + result = runner.invoke(app, ["file", "--help"]) + assert result.exit_code == 0 + assert "TARGET" in result.output + + def test_db_help(self) -> None: + result = runner.invoke(app, ["db", "--help"]) + assert result.exit_code == 0 + assert "TARGET" in result.output + + def test_network_help(self) -> None: + result = runner.invoke(app, ["network", "--help"]) + assert result.exit_code == 0 + assert "TARGET" in result.output + + def test_report_help(self) -> None: + result = runner.invoke(app, ["report", "--help"]) + assert result.exit_code == 0 + assert "convert" in result.output + assert "summary" in result.output + + +class TestFileScan: + def test_scan_console_output(self, pii_dir: Path) -> None: + result = runner.invoke(app, ["file", str(pii_dir)]) + assert result.exit_code == 0 + + def test_scan_json_output(self, pii_dir: Path) -> None: + result = runner.invoke( + app, + ["file", + str(pii_dir), + "-f", + "json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "findings" in data + + def test_scan_to_file(self, pii_dir: Path) -> None: + with tempfile.NamedTemporaryFile(suffix = ".json", + delete = False) as f: + out_path = f.name + + result = runner.invoke( + app, + [ + "file", + str(pii_dir), + "-f", + "json", + "-o", + out_path, + ], + ) + assert "Report written" in result.output + content = Path(out_path).read_text() + assert "findings" in content + Path(out_path).unlink(missing_ok = True) + + def test_scan_nonexistent_target(self) -> None: + result = runner.invoke(app, ["file", "/no/such/path"]) + assert result.exit_code == 1 + + def test_invalid_format(self, pii_dir: Path) -> None: + result = runner.invoke( + app, + [ + "file", + str(pii_dir), + "-f", + "invalid", + ], + ) + assert result.exit_code == 1 + + def test_with_config_flag(self, pii_dir: Path) -> None: + result = runner.invoke( + app, + [ + "--config", + "nonexistent.yml", + "file", + str(pii_dir), + ], + ) + assert result.exit_code == 0 + + def test_with_verbose_flag(self, pii_dir: Path) -> None: + result = runner.invoke( + app, + ["--verbose", + "file", + str(pii_dir)], + ) + assert result.exit_code == 0 + + +class TestReportCommands: + def test_convert_to_sarif(self, json_result_file: Path) -> None: + result = runner.invoke( + app, + [ + "report", + "convert", + str(json_result_file), + "-f", + "sarif", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["version"] == "2.1.0" + + def test_convert_to_csv(self, json_result_file: Path) -> None: + result = runner.invoke( + app, + [ + "report", + "convert", + str(json_result_file), + "-f", + "csv", + ], + ) + assert result.exit_code == 0 + assert "PII_SSN" in result.output + + def test_convert_to_file(self, json_result_file: Path) -> None: + with tempfile.NamedTemporaryFile(suffix = ".sarif", + delete = False) as f: + out_path = f.name + + result = runner.invoke( + app, + [ + "report", + "convert", + str(json_result_file), + "-f", + "sarif", + "-o", + out_path, + ], + ) + assert result.exit_code == 0 + assert "Converted" in result.output + content = Path(out_path).read_text() + data = json.loads(content) + assert data["version"] == "2.1.0" + Path(out_path).unlink(missing_ok = True) + + def test_convert_missing_file(self) -> None: + result = runner.invoke( + app, + [ + "report", + "convert", + "/no/such/file.json", + ], + ) + assert result.exit_code == 1 + + def test_convert_invalid_format(self, json_result_file: Path) -> None: + result = runner.invoke( + app, + [ + "report", + "convert", + str(json_result_file), + "-f", + "invalid", + ], + ) + assert result.exit_code == 1 + + def test_summary(self, json_result_file: Path) -> None: + result = runner.invoke( + app, + [ + "report", + "summary", + str(json_result_file), + ], + ) + assert result.exit_code == 0 + + def test_summary_missing_file(self) -> None: + result = runner.invoke( + app, + [ + "report", + "summary", + "/no/such/file.json", + ], + ) + assert result.exit_code == 1 diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_compliance.py b/PROJECTS/intermediate/dlp-scanner/tests/test_compliance.py new file mode 100644 index 00000000..220bd671 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_compliance.py @@ -0,0 +1,71 @@ +""" +©AngelaMos | 2026 +test_compliance.py +""" + + +from dlp_scanner.compliance import ( + get_frameworks_for_rule, + get_remediation_for_rule, + score_to_severity, + DEFAULT_REMEDIATION, +) + + +class TestScoreToSeverity: + def test_critical_threshold(self) -> None: + assert score_to_severity(0.85) == "critical" + assert score_to_severity(0.99) == "critical" + assert score_to_severity(1.0) == "critical" + + def test_high_threshold(self) -> None: + assert score_to_severity(0.65) == "high" + assert score_to_severity(0.84) == "high" + + def test_medium_threshold(self) -> None: + assert score_to_severity(0.40) == "medium" + assert score_to_severity(0.64) == "medium" + + def test_low_threshold(self) -> None: + assert score_to_severity(0.20) == "low" + assert score_to_severity(0.39) == "low" + + def test_below_minimum(self) -> None: + assert score_to_severity(0.19) == "low" + assert score_to_severity(0.0) == "low" + + +class TestFrameworkMapping: + def test_ssn_maps_to_hipaa_and_ccpa(self) -> None: + frameworks = get_frameworks_for_rule("PII_SSN") + assert "HIPAA" in frameworks + assert "CCPA" in frameworks + assert "GLBA" in frameworks + + def test_credit_card_maps_to_pci(self) -> None: + for rule_id in ( + "FIN_CREDIT_CARD_VISA", + "FIN_CREDIT_CARD_MC", + "FIN_CREDIT_CARD_AMEX", + "FIN_CREDIT_CARD_DISC", + ): + frameworks = get_frameworks_for_rule(rule_id) + assert "PCI_DSS" in frameworks + + def test_unknown_rule_returns_empty(self) -> None: + assert get_frameworks_for_rule("UNKNOWN") == [] + + def test_credential_rules_have_no_frameworks( + self, + ) -> None: + frameworks = get_frameworks_for_rule("CRED_AWS_ACCESS_KEY") + assert frameworks == [] + + +class TestRemediation: + def test_known_rule_has_remediation(self) -> None: + text = get_remediation_for_rule("PII_SSN") + assert "encrypt" in text.lower() or "tokeniz" in text.lower() + + def test_unknown_rule_returns_default(self) -> None: + assert get_remediation_for_rule("UNKNOWN") == DEFAULT_REMEDIATION diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_config.py b/PROJECTS/intermediate/dlp-scanner/tests/test_config.py new file mode 100644 index 00000000..7b2110bc --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_config.py @@ -0,0 +1,81 @@ +""" +©AngelaMos | 2026 +test_config.py +""" + + +from pathlib import Path + +from dlp_scanner.config import ScanConfig, load_config +from dlp_scanner.constants import ( + DEFAULT_DB_SAMPLE_PERCENTAGE, + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MAX_FILE_SIZE_MB, + DEFAULT_MIN_CONFIDENCE, +) + + +class TestScanConfig: + def test_defaults(self) -> None: + config = ScanConfig() + assert config.file.max_file_size_mb == DEFAULT_MAX_FILE_SIZE_MB + assert config.file.recursive is True + assert config.database.sample_percentage == DEFAULT_DB_SAMPLE_PERCENTAGE + assert config.network.entropy_threshold == DEFAULT_ENTROPY_THRESHOLD + assert config.detection.min_confidence == DEFAULT_MIN_CONFIDENCE + assert config.output.format == "console" + assert config.output.redaction_style == "partial" + + def test_exclude_patterns_populated(self) -> None: + config = ScanConfig() + assert "*.pyc" in config.file.exclude_patterns + assert ".git" in config.file.exclude_patterns + + def test_include_extensions_populated(self) -> None: + config = ScanConfig() + assert ".pdf" in config.file.include_extensions + assert ".csv" in config.file.include_extensions + + def test_default_frameworks(self) -> None: + config = ScanConfig() + assert "HIPAA" in config.compliance.frameworks + assert "PCI_DSS" in config.compliance.frameworks + + +class TestLoadConfig: + def test_load_missing_file_returns_defaults(self) -> None: + config = load_config(Path("/nonexistent/config.yml")) + assert config == ScanConfig() + + def test_load_none_returns_defaults(self) -> None: + config = load_config(None) + assert isinstance(config, ScanConfig) + + def test_load_yaml_config(self, tmp_path: Path) -> None: + config_path = tmp_path / ".dlp-scanner.yml" + config_path.write_text( + "scan:\n" + " file:\n" + " max_file_size_mb: 50\n" + " recursive: false\n" + "detection:\n" + " min_confidence: 0.5\n" + "output:\n" + " format: json\n" + ) + config = load_config(config_path) + assert config.file.max_file_size_mb == 50 + assert config.file.recursive is False + assert config.detection.min_confidence == 0.5 + assert config.output.format == "json" + + def test_load_partial_config_fills_defaults( + self, + tmp_path: Path + ) -> None: + config_path = tmp_path / ".dlp-scanner.yml" + config_path.write_text("output:\n format: sarif\n") + config = load_config(config_path) + assert config.output.format == "sarif" + assert config.file.max_file_size_mb == DEFAULT_MAX_FILE_SIZE_MB + assert config.detection.min_confidence == DEFAULT_MIN_CONFIDENCE diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_context.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_context.py new file mode 100644 index 00000000..2c7361b1 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_context.py @@ -0,0 +1,120 @@ +""" +©AngelaMos | 2026 +test_context.py +""" + + +from dlp_scanner.detectors.base import DetectorMatch +from dlp_scanner.detectors.context import ( + apply_context_boost, + _apply_cooccurrence_boost, +) + + +def _make_match( + rule_id: str = "PII_SSN", + start: int = 20, + end: int = 31, + score: float = 0.45, + keywords: list[str] | None = None, +) -> DetectorMatch: + resolved_keywords = ( + keywords if keywords is not None else ["ssn", + "social security"] + ) + return DetectorMatch( + rule_id = rule_id, + rule_name = "Test Rule", + start = start, + end = end, + matched_text = "234-56-7890", + score = score, + context_keywords = resolved_keywords, + compliance_frameworks = [], + ) + + +class TestContextBoost: + def test_boost_with_keyword_present(self) -> None: + text = "Employee SSN: 234-56-7890 on file" + match = _make_match(start = 14, end = 25) + boosted = apply_context_boost(text, [match]) + assert boosted[0].score > match.score + + def test_no_boost_without_keyword(self) -> None: + text = "Some random number 234-56-7890 here" + match = _make_match( + start = 19, + end = 30, + keywords = ["nonexistent_keyword"], + ) + boosted = apply_context_boost(text, [match]) + assert boosted[0].score == match.score + + def test_no_boost_with_empty_keywords(self) -> None: + text = "SSN: 234-56-7890" + match = _make_match(start = 5, end = 16, keywords = []) + boosted = apply_context_boost(text, [match]) + assert boosted[0].score == match.score + + def test_empty_matches_returns_empty(self) -> None: + result = apply_context_boost("any text", []) + assert result == [] + + +class TestCooccurrenceBoost: + def test_nearby_different_rules_boosted(self) -> None: + matches = [ + _make_match(rule_id = "PII_SSN", + start = 10, + end = 21), + _make_match( + rule_id = "PII_EMAIL", + start = 30, + end = 50, + keywords = ["email"], + ), + ] + boosted = _apply_cooccurrence_boost(matches) + assert all( + b.score > m.score + for b, m in zip(boosted, matches, strict = False) + ) + + def test_same_rule_not_boosted(self) -> None: + matches = [ + _make_match(rule_id = "PII_SSN", + start = 10, + end = 21), + _make_match(rule_id = "PII_SSN", + start = 50, + end = 61), + ] + boosted = _apply_cooccurrence_boost(matches) + assert all( + b.score == m.score + for b, m in zip(boosted, matches, strict = False) + ) + + def test_distant_matches_not_boosted(self) -> None: + matches = [ + _make_match(rule_id = "PII_SSN", + start = 10, + end = 21), + _make_match( + rule_id = "PII_EMAIL", + start = 1000, + end = 1020, + keywords = ["email"], + ), + ] + boosted = _apply_cooccurrence_boost(matches) + assert all( + b.score == m.score + for b, m in zip(boosted, matches, strict = False) + ) + + def test_single_match_not_boosted(self) -> None: + matches = [_make_match()] + boosted = _apply_cooccurrence_boost(matches) + assert boosted[0].score == matches[0].score diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_entropy.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_entropy.py new file mode 100644 index 00000000..095a39f0 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_entropy.py @@ -0,0 +1,83 @@ +""" +©AngelaMos | 2026 +test_entropy.py +""" + + +import os + +from dlp_scanner.detectors.entropy import ( + shannon_entropy, + shannon_entropy_str, + detect_high_entropy_regions, + EntropyDetector, +) + + +class TestShannonEntropy: + def test_all_same_bytes_is_zero(self) -> None: + data = b"\x00" * 100 + assert shannon_entropy(data) == 0.0 + + def test_empty_data_is_zero(self) -> None: + assert shannon_entropy(b"") == 0.0 + + def test_english_text_in_expected_range(self) -> None: + text = b"the quick brown fox jumps over the lazy dog" + h = shannon_entropy(text) + assert 3.5 <= h <= 5.0 + + def test_random_bytes_near_maximum(self) -> None: + data = os.urandom(10000) + h = shannon_entropy(data) + assert h > 7.5 + + def test_two_byte_values_is_one_bit(self) -> None: + data = b"\x00\x01" * 50 + h = shannon_entropy(data) + assert abs(h - 1.0) < 0.01 + + def test_string_entropy_matches_bytes(self) -> None: + text = "hello world" + h_str = shannon_entropy_str(text) + h_bytes = shannon_entropy(text.encode("utf-8")) + assert abs(h_str - h_bytes) < 0.001 + + +class TestHighEntropyRegions: + def test_random_data_detected(self) -> None: + data = os.urandom(1024) + regions = detect_high_entropy_regions(data, threshold = 7.0) + assert len(regions) > 0 + + def test_plaintext_not_detected(self) -> None: + data = b"the quick brown fox " * 100 + regions = detect_high_entropy_regions(data, threshold = 7.0) + assert len(regions) == 0 + + def test_short_data_below_window(self) -> None: + data = os.urandom(100) + regions = detect_high_entropy_regions( + data, + threshold = 7.0, + window_size = 256 + ) + assert len(regions) <= 1 + + +class TestEntropyDetector: + def test_detect_high_entropy_text(self) -> None: + import base64 + + detector = EntropyDetector(threshold = 5.5) + raw = os.urandom(2048) + high_entropy_text = base64.b85encode(raw).decode("ascii") + matches = detector.detect(high_entropy_text) + assert len(matches) > 0 + assert all(m.rule_id == "NET_HIGH_ENTROPY" for m in matches) + + def test_no_detection_in_normal_text(self) -> None: + detector = EntropyDetector(threshold = 7.0) + text = "This is a normal text document with nothing suspicious." + matches = detector.detect(text) + assert len(matches) == 0 diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_pattern.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_pattern.py new file mode 100644 index 00000000..f1e4afab --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_pattern.py @@ -0,0 +1,58 @@ +""" +©AngelaMos | 2026 +test_pattern.py +""" + + +from dlp_scanner.detectors.pattern import PatternDetector +from dlp_scanner.detectors.rules.pii import PII_RULES + + +class TestPatternDetector: + def test_detects_ssn_in_text(self) -> None: + detector = PatternDetector( + rules = PII_RULES, + allowlist_values = frozenset() + ) + text = "Employee SSN is 234-56-7890 on file." + matches = detector.detect(text) + ssn_matches = [m for m in matches if m.rule_id == "PII_SSN"] + assert len(ssn_matches) == 1 + assert ssn_matches[0].matched_text == "234-56-7890" + + def test_skips_allowlisted_values(self) -> None: + detector = PatternDetector(rules = PII_RULES) + text = "Test SSN: 123-45-6789" + matches = detector.detect(text) + ssn_matches = [m for m in matches if m.rule_id == "PII_SSN"] + assert len(ssn_matches) == 0 + + def test_detects_email(self) -> None: + detector = PatternDetector( + rules = PII_RULES, + allowlist_values = frozenset() + ) + text = "Contact: alice@company.com for details." + matches = detector.detect(text) + email_matches = [m for m in matches if m.rule_id == "PII_EMAIL"] + assert len(email_matches) == 1 + + def test_no_matches_in_clean_text(self) -> None: + detector = PatternDetector(rules = PII_RULES) + text = "This is a perfectly clean document." + matches = detector.detect(text) + assert len(matches) == 0 + + def test_multiple_matches_in_one_text(self) -> None: + detector = PatternDetector( + rules = PII_RULES, + allowlist_values = frozenset() + ) + text = ( + "Name: John, SSN: 234-56-7890, " + "Email: john@test.org, Phone: (555) 234-5678" + ) + matches = detector.detect(text) + rule_ids = {m.rule_id for m in matches} + assert "PII_SSN" in rule_ids + assert "PII_EMAIL" in rule_ids diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_credentials.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_credentials.py new file mode 100644 index 00000000..e0289f4a --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_credentials.py @@ -0,0 +1,123 @@ +""" +©AngelaMos | 2026 +test_credentials.py +""" + + +import pytest + +from dlp_scanner.detectors.rules.credentials import ( + AWS_ACCESS_KEY_PATTERN, + GITHUB_CLASSIC_PAT_PATTERN, + GITHUB_FINE_GRAINED_PATTERN, + JWT_PATTERN, + STRIPE_KEY_PATTERN, + SLACK_TOKEN_PATTERN, + PRIVATE_KEY_PATTERN, + GENERIC_API_KEY_PATTERN, +) + + +class TestAWSAccessKey: + def test_long_term_key_matches(self) -> None: + assert ( + AWS_ACCESS_KEY_PATTERN.search("AKIAIOSFODNN7EXAMPLE") + is not None + ) + + def test_session_key_matches(self) -> None: + assert ( + AWS_ACCESS_KEY_PATTERN.search("ASIAQWERTYUIOP123456") + is not None + ) + + def test_invalid_prefix_rejected(self) -> None: + assert ( + AWS_ACCESS_KEY_PATTERN.search("ABCDIOSFODNN7EXAMPLE") is None + ) + + +class TestGitHubTokens: + def test_classic_pat_matches(self) -> None: + token = "ghp_" + "a" * 36 + assert (GITHUB_CLASSIC_PAT_PATTERN.search(token) is not None) + + def test_fine_grained_pat_matches(self) -> None: + token = "github_pat_" + "a" * 22 + "_" + "b" * 59 + assert (GITHUB_FINE_GRAINED_PATTERN.search(token) is not None) + + def test_invalid_prefix_rejected(self) -> None: + assert ( + GITHUB_CLASSIC_PAT_PATTERN.search("xyz_" + "a" * 36) is None + ) + + +class TestJWT: + def test_jwt_matches(self) -> None: + token = ( + "eyJhbGciOiJIUzI1NiJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIn0" + ".abc123def456" + ) + assert JWT_PATTERN.search(token) is not None + + def test_non_jwt_rejected(self) -> None: + assert (JWT_PATTERN.search("not.a.jwt.token") is None) + + +class TestStripeKey: + @pytest.mark.parametrize( + "key", + [ + "sk_test_" + "a" * 24, + "sk_live_" + "b" * 24, + "pk_test_" + "c" * 24, + "pk_live_" + "d" * 30, + ], + ) + def test_stripe_keys_match(self, key: str) -> None: + assert STRIPE_KEY_PATTERN.search(key) is not None + + def test_invalid_stripe_key(self) -> None: + assert (STRIPE_KEY_PATTERN.search("sk_invalid_abc") is None) + + +class TestSlackToken: + @pytest.mark.parametrize( + "token", + [ + "xoxb-" + "a" * 20, + "xoxp-" + "b" * 30, + "xoxa-" + "c" * 15, + ], + ) + def test_slack_tokens_match(self, token: str) -> None: + assert (SLACK_TOKEN_PATTERN.search(token) is not None) + + +class TestPrivateKey: + @pytest.mark.parametrize( + "header", + [ + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN OPENSSH PRIVATE KEY-----", + ], + ) + def test_private_key_headers_match(self, header: str) -> None: + assert (PRIVATE_KEY_PATTERN.search(header) is not None) + + +class TestGenericAPIKey: + @pytest.mark.parametrize( + "text", + [ + 'api_key = "abcdef1234567890abcdef"', + "API_KEY: abcdef1234567890abcdef", + "secret_key='very_secret_key_value_12345'", + 'access_key = "abc123def456ghi789jkl012"', + ], + ) + def test_generic_api_keys_match(self, text: str) -> None: + assert (GENERIC_API_KEY_PATTERN.search(text) is not None) diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_financial.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_financial.py new file mode 100644 index 00000000..76ee6a9b --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_financial.py @@ -0,0 +1,125 @@ +""" +©AngelaMos | 2026 +test_financial.py +""" + + +import pytest + +from dlp_scanner.detectors.rules.financial import ( + VISA_PATTERN, + MASTERCARD_PATTERN, + AMEX_PATTERN, + IBAN_PATTERN, + luhn_check, + iban_check, + nhs_check, +) + + +class TestLuhnAlgorithm: + @pytest.mark.parametrize( + "number", + [ + "4532015112830366", + "4916338506082832", + "5425233430109903", + "2223000048410010", + "374245455400126", + "6011000990139424", + ], + ) + def test_valid_cards_pass_luhn(self, number: str) -> None: + assert luhn_check(number) is True + + @pytest.mark.parametrize( + "number", + [ + "4532015112830367", + "1234567890123456", + "1111111111111112", + "9999999999999991", + ], + ) + def test_invalid_cards_fail_luhn(self, number: str) -> None: + assert luhn_check(number) is False + + def test_too_short_fails(self) -> None: + assert luhn_check("123456") is False + + def test_with_spaces(self) -> None: + assert luhn_check("4532 0151 1283 0366") is True + + def test_with_dashes(self) -> None: + assert luhn_check("4532-0151-1283-0366") is True + + +class TestIBANCheck: + @pytest.mark.parametrize( + "iban", + [ + "GB29NWBK60161331926819", + "DE89370400440532013000", + "FR7630006000011234567890189", + "NL91ABNA0417164300", + ], + ) + def test_valid_ibans(self, iban: str) -> None: + assert iban_check(iban) is True + + @pytest.mark.parametrize( + "iban", + [ + "GB29NWBK60161331926818", + "XX00INVALID", + "DE00000000000000000000", + "SHORT", + ], + ) + def test_invalid_ibans(self, iban: str) -> None: + assert iban_check(iban) is False + + def test_iban_with_spaces(self) -> None: + assert iban_check("GB29 NWBK 6016 1331 9268 19") is True + + +class TestNHSCheck: + def test_valid_nhs_number(self) -> None: + assert nhs_check("9434765919") is True + + def test_invalid_nhs_number(self) -> None: + assert nhs_check("1234567890") is False + + def test_nhs_too_short(self) -> None: + assert nhs_check("12345") is False + + def test_nhs_non_numeric(self) -> None: + assert nhs_check("abcdefghij") is False + + +class TestCreditCardPatterns: + def test_visa_pattern_matches(self) -> None: + assert VISA_PATTERN.search("4532015112830366") is not None + + def test_mastercard_classic_matches(self) -> None: + assert MASTERCARD_PATTERN.search("5425233430109903") is not None + + def test_mastercard_2series_matches(self) -> None: + assert MASTERCARD_PATTERN.search("2223000048410010") is not None + + def test_amex_matches(self) -> None: + assert AMEX_PATTERN.search("374245455400126") is not None + + def test_visa_with_spaces(self) -> None: + assert VISA_PATTERN.search("4532 0151 1283 0366") is not None + + def test_visa_with_dashes(self) -> None: + assert VISA_PATTERN.search("4532-0151-1283-0366") is not None + + +class TestIBANPattern: + def test_iban_pattern_matches_gb(self) -> None: + assert IBAN_PATTERN.search("GB29NWBK60161331926819") is not None + + def test_iban_pattern_matches_de(self) -> None: + assert IBAN_PATTERN.search("DE89370400440532013000") is not None diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_health.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_health.py new file mode 100644 index 00000000..0f0b7d2d --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_health.py @@ -0,0 +1,112 @@ +""" +©AngelaMos | 2026 +test_health.py +""" + + +import pytest + +from dlp_scanner.detectors.rules.health import ( + MEDICAL_RECORD_PATTERN, + DEA_NUMBER_PATTERN, + NPI_PATTERN, + _validate_dea_number, + _validate_npi, +) + + +class TestMedicalRecordPattern: + @pytest.mark.parametrize( + "text", + [ + "MRN: 123456", + "MRN:12345678", + "MR# 9876543210", + "MED-1234567890", + "mrn 00123456", + ], + ) + def test_valid_mrns_match(self, text: str) -> None: + assert ( + MEDICAL_RECORD_PATTERN.search(text) is not None + ) + + @pytest.mark.parametrize( + "text", + [ + "MRN: 12345", + "MORNING coffee", + "random text without MRN", + ], + ) + def test_invalid_mrns_rejected(self, text: str) -> None: + assert ( + MEDICAL_RECORD_PATTERN.search(text) is None + ) + + +class TestDEANumberPattern: + def test_valid_dea_format_matches(self) -> None: + assert ( + DEA_NUMBER_PATTERN.search("AB1234563") is not None + ) + + def test_lowercase_rejected(self) -> None: + assert ( + DEA_NUMBER_PATTERN.search("ab1234563") is None + ) + + def test_too_short_rejected(self) -> None: + assert ( + DEA_NUMBER_PATTERN.search("AB12345") is None + ) + + +class TestDEAValidation: + def test_valid_dea_number(self) -> None: + assert _validate_dea_number("AB1234563") is True + + def test_invalid_check_digit(self) -> None: + assert _validate_dea_number("AB1234560") is False + + def test_too_short(self) -> None: + assert _validate_dea_number("AB12345") is False + + def test_non_numeric_digits(self) -> None: + assert _validate_dea_number("ABabcdefg") is False + + def test_valid_with_9_prefix(self) -> None: + assert _validate_dea_number("A91234563") is True + + +class TestNPIPattern: + def test_ten_digit_matches(self) -> None: + assert NPI_PATTERN.search("1234567890") is not None + + def test_nine_digit_rejected(self) -> None: + assert NPI_PATTERN.search("123456789") is None + + def test_eleven_digit_no_exact_match(self) -> None: + match = NPI_PATTERN.search("12345678901") + if match is not None: + assert len(match.group()) == 10 + + +class TestNPIValidation: + def test_valid_npi(self) -> None: + assert _validate_npi("1234567893") is True + + def test_invalid_check_digit(self) -> None: + assert _validate_npi("1234567890") is False + + def test_non_numeric(self) -> None: + assert _validate_npi("abcdefghij") is False + + def test_too_short(self) -> None: + assert _validate_npi("12345") is False + + def test_valid_npi_second(self) -> None: + assert _validate_npi("1679576722") is True + + def test_all_zeros_invalid(self) -> None: + assert _validate_npi("0000000000") is False diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_pii.py b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_pii.py new file mode 100644 index 00000000..61ced3b7 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_detectors/test_rules/test_pii.py @@ -0,0 +1,138 @@ +""" +©AngelaMos | 2026 +test_pii.py +""" + + +import pytest + +from dlp_scanner.detectors.rules.pii import ( + SSN_PATTERN, + EMAIL_PATTERN, + PHONE_US_PATTERN, + IPV4_PATTERN, + _validate_ssn, +) + + +class TestSSNPattern: + @pytest.mark.parametrize( + "text", + [ + "234-56-7890", + "567-89-0123", + "001-01-0001", + "899-99-9999", + ], + ) + def test_valid_ssns_match(self, text: str) -> None: + assert SSN_PATTERN.search(text) is not None + + @pytest.mark.parametrize( + "text", + [ + "000-45-6789", + "666-45-6789", + "900-45-6789", + "999-45-6789", + "123-00-6789", + "123-45-0000", + ], + ) + def test_invalid_ssns_rejected(self, text: str) -> None: + match = SSN_PATTERN.search(text) + if match is not None: + assert not _validate_ssn(match.group()) + + +class TestSSNValidation: + def test_valid_ssn(self) -> None: + assert _validate_ssn("234-56-7890") is True + + def test_invalid_area_000(self) -> None: + assert _validate_ssn("000-45-6789") is False + + def test_invalid_area_666(self) -> None: + assert _validate_ssn("666-45-6789") is False + + def test_invalid_area_900_plus(self) -> None: + assert _validate_ssn("950-45-6789") is False + + def test_invalid_group_00(self) -> None: + assert _validate_ssn("123-00-6789") is False + + def test_invalid_serial_0000(self) -> None: + assert _validate_ssn("123-45-0000") is False + + def test_bare_format(self) -> None: + assert _validate_ssn("234567890") is True + + def test_non_numeric(self) -> None: + assert _validate_ssn("abc-de-fghi") is False + + +class TestEmailPattern: + @pytest.mark.parametrize( + "text", + [ + "user@example.com", + "first.last@company.org", + "user+tag@domain.co.uk", + "test_email@test.museum", + ], + ) + def test_valid_emails_match(self, text: str) -> None: + assert EMAIL_PATTERN.search(text) is not None + + @pytest.mark.parametrize( + "text", + [ + "not-an-email", + "@nodomain", + "user@", + "user@.com", + ], + ) + def test_invalid_emails_rejected(self, text: str) -> None: + assert EMAIL_PATTERN.search(text) is None + + +class TestPhoneUSPattern: + @pytest.mark.parametrize( + "text", + [ + "(555) 234-5678", + "555-234-5678", + "555.234.5678", + "+1 555-234-5678", + "1-555-234-5678", + ], + ) + def test_valid_phones_match(self, text: str) -> None: + assert PHONE_US_PATTERN.search(text) is not None + + +class TestIPv4Pattern: + @pytest.mark.parametrize( + "text", + [ + "192.168.1.1", + "10.0.0.1", + "255.255.255.255", + "0.0.0.0", + "172.16.0.1", + ], + ) + def test_valid_ips_match(self, text: str) -> None: + assert IPV4_PATTERN.search(text) is not None + + @pytest.mark.parametrize( + "text", + [ + "256.1.1.1", + "1.1.1.256", + "999.999.999.999", + ], + ) + def test_invalid_ips_rejected(self, text: str) -> None: + assert IPV4_PATTERN.search(text) is None diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_engine.py b/PROJECTS/intermediate/dlp-scanner/tests/test_engine.py new file mode 100644 index 00000000..ecb853d3 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_engine.py @@ -0,0 +1,220 @@ +""" +©AngelaMos | 2026 +test_engine.py +""" + + +import json +import tempfile +from pathlib import Path +from collections.abc import Generator + +import pytest + +from dlp_scanner.config import ScanConfig +from dlp_scanner.engine import ScanEngine +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) + + +@pytest.fixture +def engine() -> ScanEngine: + """ + Provide a ScanEngine with default config + """ + return ScanEngine(ScanConfig()) + + +@pytest.fixture +def pii_dir() -> Generator[Path, None, None]: + """ + Provide a temp directory with valid detectable SSNs + """ + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + csv_path = root / "employees.csv" + csv_path.write_text( + "name,ssn\n" + "Alice,456-78-9012\n" + "Bob,234-56-7890\n" + ) + yield root + + +@pytest.fixture +def clean_dir() -> Generator[Path, None, None]: + """ + Provide a temp directory with no sensitive data + """ + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + txt_path = root / "readme.txt" + txt_path.write_text("This file contains no sensitive data.") + yield root + + +@pytest.fixture +def result_with_findings() -> ScanResult: + """ + Provide a ScanResult with test findings + """ + result = ScanResult(targets_scanned = 1) + result.findings = [ + Finding( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + severity = "critical", + confidence = 0.95, + location = Location( + source_type = "file", + uri = "data.csv", + line = 5, + ), + redacted_snippet = "***-**-6789", + compliance_frameworks = ["HIPAA", + "CCPA"], + remediation = "Encrypt data", + ), + ] + return result + + +class TestScanEngine: + def test_scan_files_finds_pii( + self, + engine: ScanEngine, + pii_dir: Path + ) -> None: + result = engine.scan_files(str(pii_dir)) + assert len(result.findings) > 0 + + def test_scan_files_clean_dir( + self, + engine: ScanEngine, + clean_dir: Path + ) -> None: + result = engine.scan_files(str(clean_dir)) + assert len(result.findings) == 0 + + def test_scan_files_nonexistent(self, engine: ScanEngine) -> None: + result = engine.scan_files("/no/such/path") + assert len(result.errors) > 0 + + def test_scan_files_sets_completed_at( + self, + engine: ScanEngine, + pii_dir: Path + ) -> None: + result = engine.scan_files(str(pii_dir)) + assert result.scan_completed_at is not None + + def test_scan_database_sqlite(self, engine: ScanEngine) -> None: + with tempfile.NamedTemporaryFile(suffix = ".db", + delete = False) as f: + db_path = f.name + + import sqlite3 + + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users " + "(name TEXT, ssn TEXT)") + conn.execute( + "INSERT INTO users VALUES " + "('Alice', '456-78-9012')" + ) + conn.commit() + conn.close() + + uri = f"sqlite:///{db_path}" + result = engine.scan_database(uri) + assert len(result.findings) > 0 + + Path(db_path).unlink(missing_ok = True) + + def test_generate_report_json( + self, + engine: ScanEngine, + result_with_findings: ScanResult, + ) -> None: + output = engine.generate_report(result_with_findings, "json") + data = json.loads(output) + assert "findings" in data + assert len(data["findings"]) == 1 + + def test_generate_report_sarif( + self, + engine: ScanEngine, + result_with_findings: ScanResult, + ) -> None: + output = engine.generate_report(result_with_findings, "sarif") + data = json.loads(output) + assert data["version"] == "2.1.0" + + def test_generate_report_csv( + self, + engine: ScanEngine, + result_with_findings: ScanResult, + ) -> None: + output = engine.generate_report(result_with_findings, "csv") + lines = output.strip().split("\n") + assert len(lines) == 2 + + def test_generate_report_console( + self, + engine: ScanEngine, + result_with_findings: ScanResult, + ) -> None: + output = engine.generate_report(result_with_findings, "console") + assert "PII_SSN" in output or "Social" in output + + def test_generate_report_uses_config_default( + self, + result_with_findings: ScanResult, + ) -> None: + config = ScanConfig() + config.output.format = "json" + engine = ScanEngine(config) + output = engine.generate_report(result_with_findings) + data = json.loads(output) + assert "findings" in data + + def test_display_console( + self, + engine: ScanEngine, + result_with_findings: ScanResult, + ) -> None: + engine.display_console(result_with_findings) + + def test_write_report( + self, + engine: ScanEngine, + result_with_findings: ScanResult, + ) -> None: + with tempfile.NamedTemporaryFile( + suffix = ".json", + delete = False, + mode = "w", + ) as f: + output_path = f.name + + engine.write_report( + result_with_findings, + output_path, + "json", + ) + content = Path(output_path).read_text() + data = json.loads(content) + assert len(data["findings"]) == 1 + + Path(output_path).unlink(missing_ok = True) + + +class TestReporterMap: + def test_all_formats_have_reporters(self) -> None: + from dlp_scanner.engine import REPORTER_MAP + + expected = {"console", "json", "sarif", "csv"} + assert set(REPORTER_MAP.keys()) == expected diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_extractors/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/test_extractors/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_extractors/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_network/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/test_network/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_network/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_exfiltration.py b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_exfiltration.py new file mode 100644 index 00000000..e956c217 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_exfiltration.py @@ -0,0 +1,160 @@ +""" +©AngelaMos | 2026 +test_exfiltration.py +""" + + +from dlp_scanner.network.exfiltration import ( + DnsExfilDetector, + _extract_base_domain, + detect_base64_payload, +) +from dlp_scanner.network.protocols import DnsQuery + + +class TestExtractBaseDomain: + def test_simple_domain(self) -> None: + assert (_extract_base_domain("www.example.com") == "example.com") + + def test_deep_subdomain(self) -> None: + result = _extract_base_domain("a.b.c.example.com") + assert result == "example.com" + + def test_trailing_dot(self) -> None: + result = _extract_base_domain("www.example.com.") + assert result == "example.com" + + def test_single_label(self) -> None: + assert _extract_base_domain("localhost") == ("localhost") + + def test_two_labels(self) -> None: + assert (_extract_base_domain("example.com") == "example.com") + + +class TestDnsExfilDetector: + def test_normal_query_no_indicator(self) -> None: + detector = DnsExfilDetector() + query = DnsQuery( + name = "www.google.com", + query_type = "A", + query_class = "1", + ) + result = detector.analyze_query(query, "10.0.0.1", "8.8.8.8") + assert result is None + + def test_long_label_detected(self) -> None: + detector = DnsExfilDetector() + long_label = "a" * 55 + query = DnsQuery( + name = f"{long_label}.evil.com", + query_type = "A", + query_class = "1", + ) + result = detector.analyze_query(query, "10.0.0.1", "1.2.3.4") + assert result is not None + assert (result.indicator_type == "dns_long_label") + + def test_high_entropy_subdomain(self) -> None: + detector = DnsExfilDetector(entropy_threshold = 3.5) + encoded = "aGVsbG8gd29ybGQgdGhpcw" + query = DnsQuery( + name = f"{encoded}.evil.com", + query_type = "A", + query_class = "1", + ) + result = detector.analyze_query(query, "10.0.0.1", "1.2.3.4") + assert result is not None + assert (result.indicator_type == "dns_high_entropy") + + def test_long_qname_detected(self) -> None: + detector = DnsExfilDetector() + parts = ["abc"] * 40 + name = ".".join(parts) + ".evil.com" + query = DnsQuery( + name = name, + query_type = "A", + query_class = "1", + ) + result = detector.analyze_query(query, "10.0.0.1", "1.2.3.4") + assert result is not None + + def test_txt_volume_detection(self) -> None: + detector = DnsExfilDetector() + for _ in range(10): + detector.analyze_query( + DnsQuery( + name = "data.evil.com", + query_type = "TXT", + query_class = "1", + ), + "10.0.0.1", + "1.2.3.4", + ) + + indicators = detector.check_txt_volume() + assert len(indicators) > 0 + assert (indicators[0].indicator_type == "dns_txt_volume") + + def test_get_indicators_accumulates( + self, + ) -> None: + detector = DnsExfilDetector() + long_label = "x" * 55 + query = DnsQuery( + name = f"{long_label}.evil.com", + query_type = "A", + query_class = "1", + ) + detector.analyze_query(query, "10.0.0.1", "1.2.3.4") + detector.analyze_query(query, "10.0.0.1", "1.2.3.4") + + indicators = detector.get_indicators() + assert len(indicators) == 2 + + def test_short_subdomain_no_entropy_check( + self, + ) -> None: + detector = DnsExfilDetector(entropy_threshold = 3.0) + query = DnsQuery( + name = "example.com", + query_type = "A", + query_class = "1", + ) + result = detector.analyze_query(query, "10.0.0.1", "8.8.8.8") + assert result is None + + +class TestDetectBase64Payload: + def test_base64_detected(self) -> None: + payload = (b"data=" + b"A" * 50 + b"== end") + indicators = detect_base64_payload(payload) + assert len(indicators) > 0 + assert (indicators[0].indicator_type == "base64_payload") + + def test_hex_detected(self) -> None: + payload = b"0x" + b"aabbccdd" * 10 + indicators = detect_base64_payload(payload) + assert len(indicators) > 0 + types = {i.indicator_type for i in indicators} + assert "hex_payload" in types + + def test_normal_text_no_detection(self) -> None: + payload = b"Hello, this is normal text." + indicators = detect_base64_payload(payload) + assert len(indicators) == 0 + + def test_short_base64_not_detected(self) -> None: + payload = b"dGVzdA==" + indicators = detect_base64_payload(payload) + assert len(indicators) == 0 + + def test_source_ip_preserved(self) -> None: + payload = b"A" * 50 + indicators = detect_base64_payload( + payload, + src_ip = "10.0.0.1", + dst_ip = "1.2.3.4", + ) + assert len(indicators) > 0 + assert indicators[0].source_ip == "10.0.0.1" + assert indicators[0].dest_ip == "1.2.3.4" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_flow_tracker.py b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_flow_tracker.py new file mode 100644 index 00000000..642e56bf --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_flow_tracker.py @@ -0,0 +1,195 @@ +""" +©AngelaMos | 2026 +test_flow_tracker.py +""" + + +from dlp_scanner.network.flow_tracker import ( + FlowTracker, + make_flow_key, +) +from dlp_scanner.network.pcap import PacketInfo + + +def _make_packet( + src_ip: str = "192.168.1.1", + dst_ip: str = "10.0.0.1", + src_port: int = 12345, + dst_port: int = 80, + protocol: str = "tcp", + payload: bytes = b"data", + timestamp: float = 1.0, + tcp_seq: int = 0, +) -> PacketInfo: + """ + Helper to create a PacketInfo for testing + """ + return PacketInfo( + timestamp = timestamp, + src_ip = src_ip, + dst_ip = dst_ip, + src_port = src_port, + dst_port = dst_port, + protocol = protocol, + payload = payload, + raw_length = len(payload) + 54, + tcp_seq = tcp_seq, + ) + + +class TestMakeFlowKey: + def test_bidirectional_key(self) -> None: + pkt_fwd = _make_packet( + src_ip = "192.168.1.1", + dst_ip = "10.0.0.1", + src_port = 12345, + dst_port = 80, + ) + pkt_rev = _make_packet( + src_ip = "10.0.0.1", + dst_ip = "192.168.1.1", + src_port = 80, + dst_port = 12345, + ) + assert make_flow_key(pkt_fwd) == make_flow_key(pkt_rev) + + def test_different_ports_different_key( + self, + ) -> None: + pkt1 = _make_packet(src_port = 1000) + pkt2 = _make_packet(src_port = 2000) + assert make_flow_key(pkt1) != make_flow_key(pkt2) + + +class TestFlowTracker: + def test_add_single_packet(self) -> None: + tracker = FlowTracker() + pkt = _make_packet() + tracker.add_packet(pkt) + + assert tracker.flow_count == 1 + flows = tracker.get_flows() + assert flows[0].packet_count == 1 + assert flows[0].total_bytes == 4 + + def test_add_multiple_packets_same_flow( + self, + ) -> None: + tracker = FlowTracker() + pkt1 = _make_packet(timestamp = 1.0) + pkt2 = _make_packet(timestamp = 2.0) + tracker.add_packet(pkt1) + tracker.add_packet(pkt2) + + assert tracker.flow_count == 1 + flow = tracker.get_flows()[0] + assert flow.packet_count == 2 + assert flow.total_bytes == 8 + assert flow.start_time == 1.0 + assert flow.end_time == 2.0 + + def test_different_flows_tracked(self) -> None: + tracker = FlowTracker() + pkt1 = _make_packet(dst_port = 80) + pkt2 = _make_packet(dst_port = 443) + tracker.add_packet(pkt1) + tracker.add_packet(pkt2) + + assert tracker.flow_count == 2 + + def test_bidirectional_packets_same_flow( + self, + ) -> None: + tracker = FlowTracker() + pkt_out = _make_packet( + src_ip = "192.168.1.1", + dst_ip = "10.0.0.1", + ) + pkt_in = _make_packet( + src_ip = "10.0.0.1", + dst_ip = "192.168.1.1", + src_port = 80, + dst_port = 12345, + ) + tracker.add_packet(pkt_out) + tracker.add_packet(pkt_in) + + assert tracker.flow_count == 1 + flow = tracker.get_flows()[0] + assert flow.packet_count == 2 + + def test_reassemble_stream_ordered( + self, + ) -> None: + tracker = FlowTracker() + pkt1 = _make_packet( + payload = b"first", + tcp_seq = 100, + timestamp = 1.0, + ) + pkt2 = _make_packet( + payload = b"second", + tcp_seq = 200, + timestamp = 2.0, + ) + pkt3 = _make_packet( + payload = b"third", + tcp_seq = 150, + timestamp = 1.5, + ) + tracker.add_packet(pkt1) + tracker.add_packet(pkt2) + tracker.add_packet(pkt3) + + key = make_flow_key(pkt1) + stream = tracker.reassemble_stream(key) + assert stream == b"firstthirdsecond" + + def test_reassemble_deduplicates_retransmits( + self, + ) -> None: + tracker = FlowTracker() + pkt1 = _make_packet( + payload = b"data", + tcp_seq = 100, + ) + pkt2 = _make_packet( + payload = b"data", + tcp_seq = 100, + ) + tracker.add_packet(pkt1) + tracker.add_packet(pkt2) + + key = make_flow_key(pkt1) + stream = tracker.reassemble_stream(key) + assert stream == b"data" + + def test_reassemble_unknown_key(self) -> None: + tracker = FlowTracker() + result = tracker.reassemble_stream(("1.1.1.1", "2.2.2.2", 1, 2)) + assert result == b"" + + def test_get_flow_by_key(self) -> None: + tracker = FlowTracker() + pkt = _make_packet() + tracker.add_packet(pkt) + + key = make_flow_key(pkt) + flow = tracker.get_flow(key) + assert flow is not None + assert flow.packet_count == 1 + + def test_get_flow_missing_key(self) -> None: + tracker = FlowTracker() + flow = tracker.get_flow(("1.1.1.1", "2.2.2.2", 0, 0)) + assert flow is None + + def test_empty_payload_not_stored(self) -> None: + tracker = FlowTracker() + pkt = _make_packet(payload = b"") + tracker.add_packet(pkt) + + key = make_flow_key(pkt) + flow = tracker.get_flow(key) + assert flow is not None + assert len(flow.segments) == 0 diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_pcap.py b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_pcap.py new file mode 100644 index 00000000..263bd647 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_pcap.py @@ -0,0 +1,60 @@ +""" +©AngelaMos | 2026 +test_pcap.py +""" + + +from dlp_scanner.network.pcap import PacketInfo + + +class TestPacketInfo: + def test_tcp_packet_construction(self) -> None: + pkt = PacketInfo( + timestamp = 1000.0, + src_ip = "192.168.1.1", + dst_ip = "10.0.0.1", + src_port = 12345, + dst_port = 80, + protocol = "tcp", + payload = b"hello", + raw_length = 100, + tcp_flags = 0x02, + tcp_seq = 1000, + ) + assert pkt.src_ip == "192.168.1.1" + assert pkt.dst_ip == "10.0.0.1" + assert pkt.protocol == "tcp" + assert pkt.payload == b"hello" + assert pkt.tcp_seq == 1000 + + def test_udp_packet_defaults(self) -> None: + pkt = PacketInfo( + timestamp = 1000.0, + src_ip = "10.0.0.1", + dst_ip = "8.8.8.8", + src_port = 54321, + dst_port = 53, + protocol = "udp", + payload = b"\x00", + raw_length = 50, + ) + assert pkt.tcp_flags == 0 + assert pkt.tcp_seq == 0 + assert pkt.protocol == "udp" + + def test_packet_is_frozen(self) -> None: + pkt = PacketInfo( + timestamp = 1.0, + src_ip = "1.1.1.1", + dst_ip = "2.2.2.2", + src_port = 1, + dst_port = 2, + protocol = "tcp", + payload = b"", + raw_length = 0, + ) + try: + pkt.src_ip = "changed" + raise AssertionError() + except AttributeError: + pass diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_protocols.py b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_protocols.py new file mode 100644 index 00000000..d59dd607 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_network/test_protocols.py @@ -0,0 +1,158 @@ +""" +©AngelaMos | 2026 +test_protocols.py +""" + + +from dlp_scanner.network.protocols import ( + _is_http_request, + _parse_txt_rdata, + identify_protocol, + parse_dns, + parse_http, +) + + +class TestIdentifyProtocol: + def test_http_get_request(self) -> None: + payload = b"GET / HTTP/1.1\r\nHost: x\r\n\r\n" + assert identify_protocol(payload) == "http" + + def test_http_post_request(self) -> None: + payload = b"POST /api HTTP/1.1\r\n\r\n" + assert identify_protocol(payload) == "http" + + def test_http_response(self) -> None: + payload = b"HTTP/1.1 200 OK\r\n\r\n" + assert identify_protocol(payload) == "http" + + def test_tls_handshake(self) -> None: + payload = b"\x16\x03\x01\x00\x05hello" + assert identify_protocol(payload) == "tls" + + def test_ssh_banner(self) -> None: + payload = b"SSH-2.0-OpenSSH_8.9\r\n" + assert identify_protocol(payload) == "ssh" + + def test_smtp_banner(self) -> None: + payload = b"220 mail.example.com ESMTP" + assert identify_protocol(payload) == "smtp" + + def test_unknown_protocol(self) -> None: + payload = b"\x00\x01\x02\x03" + assert identify_protocol(payload) == "unknown" + + def test_empty_payload(self) -> None: + assert identify_protocol(b"") == "unknown" + + +class TestIsHttpRequest: + def test_get_is_http(self) -> None: + assert _is_http_request(b"GET /path HTTP/1.1") + + def test_delete_is_http(self) -> None: + assert _is_http_request(b"DELETE /resource HTTP/1.1") + + def test_random_bytes_not_http(self) -> None: + assert not _is_http_request(b"\x00\x01\x02") + + def test_short_payload_not_http(self) -> None: + assert not _is_http_request(b"HI") + + +class TestParseHttp: + def test_parse_get_request(self) -> None: + raw = ( + b"GET /index.html HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"\r\n" + ) + result = parse_http(raw) + assert result is not None + assert result.method == "GET" + assert result.uri == "/index.html" + assert result.is_request is True + assert "host" in result.headers + + def test_parse_post_with_body(self) -> None: + body = b"key=value" + raw = ( + b"POST /api HTTP/1.1\r\n" + b"Content-Length: 9\r\n" + b"\r\n" + body + ) + result = parse_http(raw) + assert result is not None + assert result.method == "POST" + assert result.body == "key=value" + + def test_parse_response(self) -> None: + raw = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/html\r\n" + b"Content-Length: 5\r\n" + b"\r\nhello" + ) + result = parse_http(raw) + assert result is not None + assert result.is_request is False + assert result.body == "hello" + + def test_invalid_data_returns_none(self) -> None: + assert parse_http(b"\x00\x01") is None + + +class TestParseDns: + def test_parse_dns_query(self) -> None: + import dpkt + + dns = dpkt.dns.DNS() + dns.id = 1234 + dns.qr = dpkt.dns.DNS_Q + dns.opcode = dpkt.dns.DNS_QUERY + q = dpkt.dns.DNS.Q() + q.name = "example.com" + q.type = dpkt.dns.DNS_A + q.cls = dpkt.dns.DNS_IN + dns.qd = [q] + + result = parse_dns(bytes(dns)) + assert result is not None + assert len(result.queries) == 1 + assert result.queries[0].name == "example.com" + assert result.queries[0].query_type == "A" + assert result.is_response is False + assert result.transaction_id == 1234 + + def test_parse_txt_query(self) -> None: + import dpkt + + dns = dpkt.dns.DNS() + dns.id = 5678 + dns.qr = dpkt.dns.DNS_Q + q = dpkt.dns.DNS.Q() + q.name = "data.evil.com" + q.type = dpkt.dns.DNS_TXT + q.cls = dpkt.dns.DNS_IN + dns.qd = [q] + + result = parse_dns(bytes(dns)) + assert result is not None + assert result.queries[0].query_type == "TXT" + assert (result.queries[0].name == "data.evil.com") + + def test_invalid_data_returns_none(self) -> None: + assert parse_dns(b"\x00\x01") is None + + +class TestParseTxtRdata: + def test_single_string(self) -> None: + rdata = b"\x05hello" + assert _parse_txt_rdata(rdata) == "hello" + + def test_multiple_strings(self) -> None: + rdata = b"\x02hi\x05world" + assert _parse_txt_rdata(rdata) == "hi world" + + def test_empty_rdata(self) -> None: + assert _parse_txt_rdata(b"") == "" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_csv_report.py b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_csv_report.py new file mode 100644 index 00000000..b6bef166 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_csv_report.py @@ -0,0 +1,104 @@ +""" +©AngelaMos | 2026 +test_csv_report.py +""" + + +import csv +import io + +import pytest + +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) +from dlp_scanner.reporters.csv_report import ( + CSV_COLUMNS, + CsvReporter, +) + + +@pytest.fixture +def result_with_findings() -> ScanResult: + """ + Provide a ScanResult with test findings + """ + result = ScanResult(targets_scanned = 1) + result.findings = [ + Finding( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + severity = "critical", + confidence = 0.95, + location = Location( + source_type = "file", + uri = "data.csv", + line = 5, + ), + redacted_snippet = "***-**-6789", + compliance_frameworks = [ + "HIPAA", + "CCPA", + ], + remediation = "Encrypt data", + ), + ] + return result + + +class TestCsvReporter: + def test_generates_valid_csv( + self, + result_with_findings: ScanResult + ) -> None: + reporter = CsvReporter() + output = reporter.generate(result_with_findings) + reader = csv.reader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 2 + + def test_header_matches_columns( + self, + result_with_findings: ScanResult + ) -> None: + reporter = CsvReporter() + output = reporter.generate(result_with_findings) + reader = csv.reader(io.StringIO(output)) + header = next(reader) + assert header == CSV_COLUMNS + + def test_finding_row_data( + self, + result_with_findings: ScanResult + ) -> None: + reporter = CsvReporter() + output = reporter.generate(result_with_findings) + reader = csv.reader(io.StringIO(output)) + next(reader) + row = next(reader) + assert row[2] == "critical" + assert row[4] == "PII_SSN" + assert row[7] == "data.csv" + assert "HIPAA" in row[12] + assert "CCPA" in row[12] + + def test_empty_result(self) -> None: + reporter = CsvReporter() + result = ScanResult() + output = reporter.generate(result) + reader = csv.reader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 1 + + def test_frameworks_semicolon_separated( + self, + result_with_findings: ScanResult + ) -> None: + reporter = CsvReporter() + output = reporter.generate(result_with_findings) + reader = csv.reader(io.StringIO(output)) + next(reader) + row = next(reader) + assert row[12] == "HIPAA;CCPA" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_json_report.py b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_json_report.py new file mode 100644 index 00000000..8d677eab --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_json_report.py @@ -0,0 +1,159 @@ +""" +©AngelaMos | 2026 +test_json_report.py +""" + + +import json + +import pytest + +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) +from dlp_scanner.reporters.json_report import ( + JsonReporter, +) + + +@pytest.fixture +def result_with_findings() -> ScanResult: + """ + Provide a ScanResult with test findings + """ + result = ScanResult(targets_scanned = 3) + result.findings = [ + Finding( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + severity = "critical", + confidence = 0.95, + location = Location( + source_type = "file", + uri = "employees.csv", + line = 2, + ), + redacted_snippet = "SSN: ***-**-6789", + compliance_frameworks = [ + "HIPAA", + "CCPA", + ], + remediation = "Encrypt SSN data", + ), + Finding( + rule_id = "PII_EMAIL", + rule_name = "Email Address", + severity = "medium", + confidence = 0.65, + location = Location( + source_type = "file", + uri = "contacts.json", + ), + redacted_snippet = "j***@example.com", + compliance_frameworks = ["GDPR"], + remediation = "Hash emails", + ), + ] + return result + + +@pytest.fixture +def empty_result() -> ScanResult: + """ + Provide a ScanResult with no findings + """ + return ScanResult(targets_scanned = 5) + + +class TestJsonReporter: + def test_generates_valid_json( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + assert isinstance(data, dict) + + def test_has_metadata_section( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + meta = data["scan_metadata"] + assert meta["scan_id"] + assert meta["tool_version"] == "0.1.0" + assert meta["targets_scanned"] == 3 + assert meta["total_findings"] == 2 + + def test_has_findings_section( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + findings = data["findings"] + assert len(findings) == 2 + assert findings[0]["rule_id"] == "PII_SSN" + assert findings[0]["severity"] == "critical" + assert findings[0]["confidence"] == 0.95 + + def test_finding_has_location( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + loc = data["findings"][0]["location"] + assert loc["source_type"] == "file" + assert loc["uri"] == "employees.csv" + assert loc["line"] == 2 + + def test_has_summary_section( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + summary = data["summary"] + assert summary["by_severity"]["critical"] == 1 + assert summary["by_severity"]["medium"] == 1 + assert summary["by_rule"]["PII_SSN"] == 1 + assert summary["by_framework"]["HIPAA"] == 1 + + def test_empty_result_has_zero_findings( + self, + empty_result: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(empty_result) + data = json.loads(output) + assert len(data["findings"]) == 0 + assert (data["scan_metadata"]["total_findings"] == 0) + + def test_finding_has_remediation( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + assert (data["findings"][0]["remediation"] == "Encrypt SSN data") + + def test_finding_has_compliance( + self, + result_with_findings: ScanResult + ) -> None: + reporter = JsonReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + frameworks = data["findings"][0]["compliance_frameworks"] + assert "HIPAA" in frameworks + assert "CCPA" in frameworks diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_sarif.py b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_sarif.py new file mode 100644 index 00000000..65f9cd22 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_reporters/test_sarif.py @@ -0,0 +1,174 @@ +""" +©AngelaMos | 2026 +test_sarif.py +""" + + +import json + +import pytest + +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) +from dlp_scanner.reporters.sarif import ( + SarifReporter, +) + + +@pytest.fixture +def result_with_findings() -> ScanResult: + """ + Provide a ScanResult with test findings + """ + result = ScanResult(targets_scanned = 2) + result.findings = [ + Finding( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + severity = "critical", + confidence = 0.95, + location = Location( + source_type = "file", + uri = "data/employees.csv", + line = 10, + column = 5, + ), + redacted_snippet = "***-**-6789", + compliance_frameworks = [ + "HIPAA", + "CCPA", + ], + remediation = "Encrypt SSN data", + ), + Finding( + rule_id = "CRED_AWS_ACCESS_KEY", + rule_name = "AWS Access Key", + severity = "high", + confidence = 0.85, + location = Location( + source_type = "database", + uri = "postgresql://host/db", + table_name = "config", + ), + redacted_snippet = "AKIA****", + compliance_frameworks = [], + remediation = "Rotate credentials", + ), + ] + return result + + +class TestSarifReporter: + def test_generates_valid_json( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + assert isinstance(data, dict) + + def test_has_sarif_version( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + assert data["version"] == "2.1.0" + assert "$schema" in data + + def test_has_tool_driver( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + driver = data["runs"][0]["tool"]["driver"] + assert driver["name"] == "dlp-scanner" + assert driver["version"] == "0.1.0" + + def test_rules_collected( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + rules = (data["runs"][0]["tool"]["driver"]["rules"]) + assert len(rules) == 2 + rule_ids = {r["id"] for r in rules} + assert "PII_SSN" in rule_ids + assert "CRED_AWS_ACCESS_KEY" in rule_ids + + def test_results_match_findings( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + results = data["runs"][0]["results"] + assert len(results) == 2 + + def test_severity_mapped_to_level( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + results = data["runs"][0]["results"] + assert results[0]["level"] == "error" + assert results[1]["level"] == "error" + + def test_location_has_artifact( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + loc = data["runs"][0]["results"][0]["locations"][0] + physical = loc["physicalLocation"] + assert ( + physical["artifactLocation"]["uri"] == "data/employees.csv" + ) + assert physical["region"]["startLine"] == 10 + assert (physical["region"]["startColumn"] == 5) + + def test_database_finding_has_logical_location( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + loc = data["runs"][0]["results"][1]["locations"][0] + logical = loc["logicalLocations"] + assert logical[0]["name"] == "config" + assert logical[0]["kind"] == "table" + + def test_properties_has_confidence( + self, + result_with_findings: ScanResult + ) -> None: + reporter = SarifReporter() + output = reporter.generate(result_with_findings) + data = json.loads(output) + props = data["runs"][0]["results"][0]["properties"] + assert props["confidence"] == 0.95 + assert props["redactedSnippet"] + assert "HIPAA" in (props["complianceFrameworks"]) + + def test_empty_result(self) -> None: + reporter = SarifReporter() + result = ScanResult() + output = reporter.generate(result) + data = json.loads(output) + assert len(data["runs"][0]["results"]) == 0 + assert (len(data["runs"][0]["tool"]["driver"]["rules"]) == 0) diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/__init__.py b/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_db_scanner.py b/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_db_scanner.py new file mode 100644 index 00000000..b3f157e9 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_db_scanner.py @@ -0,0 +1,278 @@ +""" +©AngelaMos | 2026 +test_db_scanner.py +""" + + +import sqlite3 +from pathlib import Path +from typing import Any + +import pytest + +from dlp_scanner.config import ScanConfig +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.scanners.db_scanner import ( + DatabaseScanner, + _extract_mongo_strings, +) + + +@pytest.fixture +def sqlite_db_with_pii(temp_dir: Path) -> str: + """ + Provide a SQLite database containing PII test data + """ + db_path = temp_dir / "test.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE employees (" + "id INTEGER PRIMARY KEY, " + "name TEXT, " + "ssn TEXT, " + "email TEXT, " + "salary REAL)" + ) + conn.execute( + "INSERT INTO employees " + "(name, ssn, email, salary) " + "VALUES (?, ?, ?, ?)", + ( + "John Doe", + "456-78-9012", + "john@example.com", + 75000.0, + ), + ) + conn.execute( + "INSERT INTO employees " + "(name, ssn, email, salary) " + "VALUES (?, ?, ?, ?)", + ( + "Jane Smith", + "234-56-7890", + "jane@example.com", + 85000.0, + ), + ) + conn.commit() + conn.close() + return f"sqlite:///{db_path}" + + +@pytest.fixture +def sqlite_db_empty(temp_dir: Path) -> str: + """ + Provide a SQLite database with an empty table + """ + db_path = temp_dir / "empty.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE logs (" + "id INTEGER PRIMARY KEY, " + "message TEXT)" + ) + conn.commit() + conn.close() + return f"sqlite:///{db_path}" + + +@pytest.fixture +def db_scanner() -> DatabaseScanner: + """ + Provide a default DatabaseScanner instance + """ + config = ScanConfig() + registry = DetectorRegistry() + return DatabaseScanner(config = config, registry = registry) + + +class TestDatabaseScanner: + def test_sqlite_scan_finds_pii( + self, + db_scanner: DatabaseScanner, + sqlite_db_with_pii: str, + ) -> None: + result = db_scanner.scan(sqlite_db_with_pii) + assert result.targets_scanned > 0 + assert len(result.findings) > 0 + + def test_sqlite_scan_finds_ssn( + self, + db_scanner: DatabaseScanner, + sqlite_db_with_pii: str, + ) -> None: + result = db_scanner.scan(sqlite_db_with_pii) + ssn_findings = [ + f for f in result.findings if f.rule_id == "PII_SSN" + ] + assert len(ssn_findings) > 0 + + def test_sqlite_scan_empty_table( + self, + db_scanner: DatabaseScanner, + sqlite_db_empty: str, + ) -> None: + result = db_scanner.scan(sqlite_db_empty) + assert result.targets_scanned > 0 + assert len(result.findings) == 0 + + def test_findings_have_database_source( + self, + db_scanner: DatabaseScanner, + sqlite_db_with_pii: str, + ) -> None: + result = db_scanner.scan(sqlite_db_with_pii) + for finding in result.findings: + assert (finding.location.source_type == "database") + + def test_findings_have_table_name( + self, + db_scanner: DatabaseScanner, + sqlite_db_with_pii: str, + ) -> None: + result = db_scanner.scan(sqlite_db_with_pii) + for finding in result.findings: + assert (finding.location.table_name == "employees") + + def test_unsupported_scheme_errors( + self, + db_scanner: DatabaseScanner, + ) -> None: + result = db_scanner.scan("ftp://localhost/db") + assert len(result.errors) > 0 + + def test_completed_at_is_set( + self, + db_scanner: DatabaseScanner, + sqlite_db_with_pii: str, + ) -> None: + result = db_scanner.scan(sqlite_db_with_pii) + assert result.scan_completed_at is not None + + def test_findings_have_remediation( + self, + db_scanner: DatabaseScanner, + sqlite_db_with_pii: str, + ) -> None: + result = db_scanner.scan(sqlite_db_with_pii) + for finding in result.findings: + assert finding.remediation + + def test_table_exclude_filter( + self, + temp_dir: Path, + ) -> None: + db_path = temp_dir / "filter.db" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE users " + "(id INTEGER, ssn TEXT)") + conn.execute("INSERT INTO users " + "VALUES (1, '123-45-6789')") + conn.execute("CREATE TABLE audit_log " + "(id INTEGER, note TEXT)") + conn.execute("INSERT INTO audit_log " + "VALUES (1, '987-65-4321')") + conn.commit() + conn.close() + + config = ScanConfig() + config.database.exclude_tables = ["audit_log"] + registry = DetectorRegistry() + scanner = DatabaseScanner(config = config, registry = registry) + + result = scanner.scan(f"sqlite:///{db_path}") + assert result.targets_scanned == 1 + + def test_table_include_filter( + self, + temp_dir: Path, + ) -> None: + db_path = temp_dir / "include.db" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE users " + "(id INTEGER, ssn TEXT)") + conn.execute("INSERT INTO users " + "VALUES (1, '123-45-6789')") + conn.execute("CREATE TABLE logs " + "(id INTEGER, msg TEXT)") + conn.execute("INSERT INTO logs " + "VALUES (1, '987-65-4321')") + conn.commit() + conn.close() + + config = ScanConfig() + config.database.include_tables = ["users"] + registry = DetectorRegistry() + scanner = DatabaseScanner(config = config, registry = registry) + + result = scanner.scan(f"sqlite:///{db_path}") + assert result.targets_scanned == 1 + + +class TestExtractMongoStrings: + def test_simple_strings(self) -> None: + doc: dict[str, + Any] = { + "name": "John", + "email": "john@test.com", + } + parts: list[str] = [] + _extract_mongo_strings(doc, parts) + assert len(parts) == 2 + + def test_nested_doc(self) -> None: + doc: dict[str, + Any] = { + "user": { + "name": "Jane", + "ssn": "123-45-6789", + } + } + parts: list[str] = [] + _extract_mongo_strings(doc, parts) + assert any("user.name" in p for p in parts) + assert any("user.ssn" in p for p in parts) + + def test_skips_id_field(self) -> None: + doc: dict[str, + Any] = { + "_id": "abc123", + "name": "Test", + } + parts: list[str] = [] + _extract_mongo_strings(doc, parts) + assert len(parts) == 1 + assert "name" in parts[0] + + def test_list_values(self) -> None: + doc: dict[str, Any] = {"emails": ["a@b.com", "c@d.com"]} + parts: list[str] = [] + _extract_mongo_strings(doc, parts) + assert len(parts) == 2 + + def test_empty_strings_skipped(self) -> None: + doc: dict[str, + Any] = { + "name": "", + "bio": " ", + } + parts: list[str] = [] + _extract_mongo_strings(doc, parts) + assert len(parts) == 0 + + def test_nested_list_of_dicts(self) -> None: + doc: dict[str, + Any] = { + "records": [ + { + "value": "secret" + }, + { + "value": "data" + }, + ] + } + parts: list[str] = [] + _extract_mongo_strings(doc, parts) + assert len(parts) == 2 diff --git a/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_file_scanner.py b/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_file_scanner.py new file mode 100644 index 00000000..f854322b --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/tests/test_scanners/test_file_scanner.py @@ -0,0 +1,210 @@ +""" +©AngelaMos | 2026 +test_file_scanner.py +""" + + +from pathlib import Path + +import pytest + +from dlp_scanner.config import ScanConfig +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.scanners.file_scanner import ( + FileScanner, + _build_extension_map, + _get_full_suffix, +) + + +@pytest.fixture +def file_scanner() -> FileScanner: + """ + Provide a default FileScanner instance + """ + config = ScanConfig() + registry = DetectorRegistry() + return FileScanner(config = config, registry = registry) + + +class TestFileScanner: + def test_scan_directory_finds_pii( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir_with_pii)) + assert result.targets_scanned > 0 + assert len(result.findings) > 0 + + def test_scan_single_file( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + csv_path = temp_dir_with_pii / "employees.csv" + result = file_scanner.scan(str(csv_path)) + assert result.targets_scanned == 1 + assert len(result.findings) > 0 + + def test_scan_clean_file_no_findings( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + txt_path = temp_dir_with_pii / "clean.txt" + result = file_scanner.scan(str(txt_path)) + assert result.targets_scanned == 1 + assert len(result.findings) == 0 + + def test_scan_nonexistent_target( + self, + file_scanner: FileScanner, + ) -> None: + result = file_scanner.scan("/nonexistent/path") + assert len(result.errors) > 0 + + def test_scan_empty_directory( + self, + file_scanner: FileScanner, + temp_dir: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir)) + assert result.targets_scanned == 0 + assert len(result.findings) == 0 + + def test_scan_respects_exclude_patterns( + self, + temp_dir: Path, + ) -> None: + secret = temp_dir / "secret.log" + secret.write_text("SSN: 123-45-6789") + + config = ScanConfig() + config.file.exclude_patterns = ["*.log"] + registry = DetectorRegistry() + scanner = FileScanner(config = config, registry = registry) + + result = scanner.scan(str(temp_dir)) + assert result.targets_scanned == 0 + + def test_scan_respects_max_file_size( + self, + temp_dir: Path, + ) -> None: + large = temp_dir / "large.txt" + large.write_text("SSN: 123-45-6789\n" * 100) + + config = ScanConfig() + config.file.max_file_size_mb = 0 + registry = DetectorRegistry() + scanner = FileScanner(config = config, registry = registry) + + result = scanner.scan(str(temp_dir)) + assert result.targets_scanned == 0 + + def test_scan_completed_at_is_set( + self, + file_scanner: FileScanner, + temp_dir: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir)) + assert result.scan_completed_at is not None + + def test_findings_have_compliance_frameworks( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir_with_pii)) + ssn_findings = [ + f for f in result.findings if f.rule_id == "PII_SSN" + ] + for finding in ssn_findings: + assert len(finding.compliance_frameworks) > 0 + + def test_findings_have_redacted_snippets( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir_with_pii)) + for finding in result.findings: + assert finding.redacted_snippet + + def test_findings_have_severity( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir_with_pii)) + valid_severities = { + "critical", + "high", + "medium", + "low", + } + for finding in result.findings: + assert finding.severity in valid_severities + + def test_scan_json_finds_api_key( + self, + file_scanner: FileScanner, + temp_dir_with_pii: Path, + ) -> None: + result = file_scanner.scan(str(temp_dir_with_pii)) + cred_findings = [ + f for f in result.findings if f.rule_id.startswith("CRED_") + ] + assert len(cred_findings) > 0 + + +class TestExtensionMap: + def test_has_common_text_types(self) -> None: + ext_map = _build_extension_map() + assert ".txt" in ext_map + assert ".csv" in ext_map + assert ".json" in ext_map + assert ".xml" in ext_map + assert ".yaml" in ext_map + + def test_has_office_types(self) -> None: + ext_map = _build_extension_map() + assert ".pdf" in ext_map + assert ".docx" in ext_map + assert ".xlsx" in ext_map + assert ".xls" in ext_map + + def test_has_archive_types(self) -> None: + ext_map = _build_extension_map() + assert ".zip" in ext_map + assert ".tar" in ext_map + assert ".tar.gz" in ext_map + + def test_has_email_types(self) -> None: + ext_map = _build_extension_map() + assert ".eml" in ext_map + assert ".msg" in ext_map + + +class TestGetFullSuffix: + def test_simple_extension(self) -> None: + assert _get_full_suffix(Path("f.txt")) == ".txt" + + def test_tar_gz(self) -> None: + path = Path("archive.tar.gz") + assert _get_full_suffix(path) == ".tar.gz" + + def test_tar_bz2(self) -> None: + path = Path("archive.tar.bz2") + assert _get_full_suffix(path) == ".tar.bz2" + + def test_uppercase_normalized(self) -> None: + assert _get_full_suffix(Path("F.TXT")) == ".txt" + + def test_no_extension(self) -> None: + assert _get_full_suffix(Path("Makefile")) == "" + + def test_dotfile(self) -> None: + result = _get_full_suffix(Path(".gitignore")) + assert result == "" diff --git a/PROJECTS/intermediate/dlp-scanner/uv.lock b/PROJECTS/intermediate/dlp-scanner/uv.lock new file mode 100644 index 00000000..207abb90 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/uv.lock @@ -0,0 +1,1377 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiomysql" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymysql" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e0/302aeffe8d90853556f47f3106b89c16cc2ec2a4d269bdfd82e3f4ae12cc/aiomysql-0.3.2.tar.gz", hash = "sha256:72d15ef5cfc34c03468eb41e1b90adb9fd9347b0b589114bd23ead569a02ac1a", size = 108311, upload-time = "2025-10-22T00:15:21.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/af/aae0153c3e28712adaf462328f6c7a3c196a1c1c27b491de4377dd3e6b52/aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2", size = 71834, upload-time = "2025-10-22T00:15:15.905Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colorclass" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/1a/31ff00a33569a3b59d65bbdc445c73e12f92ad28195b7ace299f68b9af70/colorclass-2.2.2.tar.gz", hash = "sha256:6d4fe287766166a98ca7bc6f6312daf04a0481b1eda43e7173484051c0ab4366", size = 16709, upload-time = "2021-12-09T00:41:35.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/b6/daf3e2976932da4ed3579cff7a30a53d22ea9323ee4f0d8e43be60454897/colorclass-2.2.2-py2.py3-none-any.whl", hash = "sha256:6f10c273a0ef7a1150b1120b6095cbdd68e5cf36dfd5d0fc957a2500bbf99a55", size = 18995, upload-time = "2021-12-09T00:41:34.653Z" }, +] + +[[package]] +name = "compressed-rtf" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/0c/929a4e8ef9d7143f54d77dadb5f370cc7b98534b1bd6e1124d0abe8efb24/compressed_rtf-1.0.7.tar.gz", hash = "sha256:7c30859334839f3cdc7d10796af5b434bb326b9df7cb5a65e95a8eacb2951b0e", size = 8152, upload-time = "2025-03-24T22:39:32.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/1d/62f5bf92e12335eb63517f42671ed78512d48bbc69e02a942dd7b90f03f0/compressed_rtf-1.0.7-py3-none-any.whl", hash = "sha256:b7904921d78c67a0a4b7fff9fb361a00ae2b447b6edca010ce321cd98fa0fcc0", size = 7968, upload-time = "2025-03-24T23:03:57.433Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dlp-scanner" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiomysql" }, + { name = "aiosqlite" }, + { name = "asyncpg" }, + { name = "defusedxml" }, + { name = "dpkt" }, + { name = "extract-msg" }, + { name = "fastavro" }, + { name = "lxml" }, + { name = "openpyxl" }, + { name = "orjson" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "pymongo" }, + { name = "pymupdf" }, + { name = "python-docx" }, + { name = "rich" }, + { name = "ruamel-yaml" }, + { name = "structlog" }, + { name = "typer" }, + { name = "xlrd" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "yapf" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiomysql", specifier = ">=0.2.0" }, + { name = "aiosqlite", specifier = ">=0.21.0" }, + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "defusedxml", specifier = ">=0.7.0" }, + { name = "dpkt", specifier = ">=1.9.0" }, + { name = "extract-msg", specifier = ">=0.50.0" }, + { name = "fastavro", specifier = ">=1.9.0" }, + { name = "lxml", specifier = ">=5.0.0" }, + { name = "openpyxl", specifier = ">=3.1.0" }, + { name = "orjson", specifier = ">=3.10.0" }, + { name = "pyarrow", specifier = ">=16.0.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pymongo", specifier = ">=4.10.0" }, + { name = "pymupdf", specifier = ">=1.25.0" }, + { name = "python-docx", specifier = ">=1.1.0" }, + { name = "rich", specifier = ">=14.0.0" }, + { name = "ruamel-yaml", specifier = ">=0.18.0" }, + { name = "structlog", specifier = ">=25.0.0" }, + { name = "typer", specifier = ">=0.15.0" }, + { name = "xlrd", specifier = ">=2.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.130.0" }, + { name = "mypy", specifier = ">=1.15.0" }, + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "ruff", specifier = ">=0.11.0" }, + { name = "yapf", specifier = ">=0.43.0" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "dpkt" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/7d/52f17a794db52a66e46ebb0c7549bf2f035ed61d5a920ba4aaa127dd038e/dpkt-1.9.8.tar.gz", hash = "sha256:43f8686e455da5052835fd1eda2689d51de3670aac9799b1b00cfd203927ee45", size = 180073, upload-time = "2022-08-18T05:54:13.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/79/479e2194c9096b92aecdf33634ae948d2be306c6011673e98ee1917f32c2/dpkt-1.9.8-py3-none-any.whl", hash = "sha256:4da4d111d7bf67575b571f5c678c71bddd2d8a01a3d57d489faf0a92c748fbfd", size = 194973, upload-time = "2022-08-18T05:54:10.793Z" }, +] + +[[package]] +name = "easygui" +version = "0.98.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/ad/e35f7a30272d322be09dc98592d2f55d27cc933a7fde8baccbbeb2bd9409/easygui-0.98.3.tar.gz", hash = "sha256:d653ff79ee1f42f63b5a090f2f98ce02335d86ad8963b3ce2661805cafe99a04", size = 85583, upload-time = "2022-04-01T13:15:50.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/a7/b276ff776533b423710a285c8168b52551cb2ab0855443131fdc7fd8c16f/easygui-0.98.3-py2.py3-none-any.whl", hash = "sha256:33498710c68b5376b459cd3fc48d1d1f33822139eb3ed01defbc0528326da3ba", size = 92655, upload-time = "2022-04-01T13:15:49.568Z" }, +] + +[[package]] +name = "ebcdic" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/2f/633031205333bee5f9f93761af8268746aa75f38754823aabb8570eb245b/ebcdic-1.1.1-py2.py3-none-any.whl", hash = "sha256:33b4cb729bc2d0bf46cc1847b0e5946897cb8d3f53520c5b9aa5fa98d7e735f1", size = 128537, upload-time = "2019-08-09T00:54:35.544Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "extract-msg" +version = "0.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "compressed-rtf" }, + { name = "ebcdic" }, + { name = "olefile" }, + { name = "red-black-tree-mod" }, + { name = "rtfde" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/65/c70afb3b119a44b3ee36b029485dc15326cf3a7c50da19a1ecbbf949c5d1/extract_msg-0.55.0.tar.gz", hash = "sha256:cf08283498c3dfcc7f894dad1579f52e3ced9fb76b865c2355cbe757af8a54e1", size = 331170, upload-time = "2025-08-12T16:07:56.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/81/87d5241036046ea17c5c8db228f4c9e04e07e53b627015d4496a99449aaf/extract_msg-0.55.0-py3-none-any.whl", hash = "sha256:baf0cdee9a8d267b70c366bc57ceb03dbfa1e7ab2dca6824169a7fe623f0917c", size = 336033, upload-time = "2025-08-12T16:07:54.886Z" }, +] + +[[package]] +name = "fastavro" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/8b/fa2d3287fd2267be6261d0177c6809a7fa12c5600ddb33490c8dc29e77b2/fastavro-1.12.1.tar.gz", hash = "sha256:2f285be49e45bc047ab2f6bed040bb349da85db3f3c87880e4b92595ea093b2b", size = 1025661, upload-time = "2025-10-10T15:40:55.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f0/10bd1a3d08667fa0739e2b451fe90e06df575ec8b8ba5d3135c70555c9bd/fastavro-1.12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:509818cb24b98a804fc80be9c5fed90f660310ae3d59382fc811bfa187122167", size = 1009057, upload-time = "2025-10-10T15:41:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/0d985bc99e1fa9e74c636658000ba38a5cd7f5ab2708e9c62eaf736ecf1a/fastavro-1.12.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089e155c0c76e0d418d7e79144ce000524dd345eab3bc1e9c5ae69d500f71b14", size = 3391866, upload-time = "2025-10-10T15:41:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/b4951dc84ebc34aac69afcbfbb22ea4a91080422ec2bfd2c06076ff1d419/fastavro-1.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44cbff7518901c91a82aab476fcab13d102e4999499df219d481b9e15f61af34", size = 3458005, upload-time = "2025-10-10T15:41:29.017Z" }, + { url = "https://files.pythonhosted.org/packages/af/f8/5a8df450a9f55ca8441f22ea0351d8c77809fc121498b6970daaaf667a21/fastavro-1.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a275e48df0b1701bb764b18a8a21900b24cf882263cb03d35ecdba636bbc830b", size = 3295258, upload-time = "2025-10-10T15:41:31.564Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/40f25299111d737e58b85696e91138a66c25b7334f5357e7ac2b0e8966f8/fastavro-1.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2de72d786eb38be6b16d556b27232b1bf1b2797ea09599507938cdb7a9fe3e7c", size = 3430328, upload-time = "2025-10-10T15:41:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/e0/07/85157a7c57c5f8b95507d7829b5946561e5ee656ff80e9dd9a757f53ddaf/fastavro-1.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:9090f0dee63fe022ee9cc5147483366cc4171c821644c22da020d6b48f576b4f", size = 444140, upload-time = "2025-10-10T15:41:34.902Z" }, + { url = "https://files.pythonhosted.org/packages/bb/57/26d5efef9182392d5ac9f253953c856ccb66e4c549fd3176a1e94efb05c9/fastavro-1.12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:78df838351e4dff9edd10a1c41d1324131ffecbadefb9c297d612ef5363c049a", size = 1000599, upload-time = "2025-10-10T15:41:36.554Z" }, + { url = "https://files.pythonhosted.org/packages/33/cb/8ab55b21d018178eb126007a56bde14fd01c0afc11d20b5f2624fe01e698/fastavro-1.12.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780476c23175d2ae457c52f45b9ffa9d504593499a36cd3c1929662bf5b7b14b", size = 3335933, upload-time = "2025-10-10T15:41:39.07Z" }, + { url = "https://files.pythonhosted.org/packages/fe/03/9c94ec9bf873eb1ffb0aa694f4e71940154e6e9728ddfdc46046d7e8ced4/fastavro-1.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0714b285160fcd515eb0455540f40dd6dac93bdeacdb03f24e8eac3d8aa51f8d", size = 3402066, upload-time = "2025-10-10T15:41:41.608Z" }, + { url = "https://files.pythonhosted.org/packages/75/c8/cb472347c5a584ccb8777a649ebb28278fccea39d005fc7df19996f41df8/fastavro-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8bc2dcec5843d499f2489bfe0747999108f78c5b29295d877379f1972a3d41a", size = 3240038, upload-time = "2025-10-10T15:41:43.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/77/569ce9474c40304b3a09e109494e020462b83e405545b78069ddba5f614e/fastavro-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b1921ac35f3d89090a5816b626cf46e67dbecf3f054131f84d56b4e70496f45", size = 3369398, upload-time = "2025-10-10T15:41:45.719Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1f/9589e35e9ea68035385db7bdbf500d36b8891db474063fb1ccc8215ee37c/fastavro-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:5aa777b8ee595b50aa084104cd70670bf25a7bbb9fd8bb5d07524b0785ee1699", size = 444220, upload-time = "2025-10-10T15:41:47.39Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d2/78435fe737df94bd8db2234b2100f5453737cffd29adee2504a2b013de84/fastavro-1.12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c3d67c47f177e486640404a56f2f50b165fe892cc343ac3a34673b80cc7f1dd6", size = 1086611, upload-time = "2025-10-10T15:41:48.818Z" }, + { url = "https://files.pythonhosted.org/packages/b6/be/428f99b10157230ddac77ec8cc167005b29e2bd5cbe228345192bb645f30/fastavro-1.12.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5217f773492bac43dae15ff2931432bce2d7a80be7039685a78d3fab7df910bd", size = 3541001, upload-time = "2025-10-10T15:41:50.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/08/a2eea4f20b85897740efe44887e1ac08f30dfa4bfc3de8962bdcbb21a5a1/fastavro-1.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:469fecb25cba07f2e1bfa4c8d008477cd6b5b34a59d48715e1b1a73f6160097d", size = 3432217, upload-time = "2025-10-10T15:41:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/87/bb/b4c620b9eb6e9838c7f7e4b7be0762834443adf9daeb252a214e9ad3178c/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d71c8aa841ef65cfab709a22bb887955f42934bced3ddb571e98fdbdade4c609", size = 3366742, upload-time = "2025-10-10T15:41:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d1/e69534ccdd5368350646fea7d93be39e5f77c614cca825c990bd9ca58f67/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b81fc04e85dfccf7c028e0580c606e33aa8472370b767ef058aae2c674a90746", size = 3383743, upload-time = "2025-10-10T15:41:57.68Z" }, + { url = "https://files.pythonhosted.org/packages/58/54/b7b4a0c3fb5fcba38128542da1b26c4e6d69933c923f493548bdfd63ab6a/fastavro-1.12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9445da127751ba65975d8e4bdabf36bfcfdad70fc35b2d988e3950cce0ec0e7c", size = 1001377, upload-time = "2025-10-10T15:41:59.241Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4f/0e589089c7df0d8f57d7e5293fdc34efec9a3b758a0d4d0c99a7937e2492/fastavro-1.12.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed924233272719b5d5a6a0b4d80ef3345fc7e84fc7a382b6232192a9112d38a6", size = 3320401, upload-time = "2025-10-10T15:42:01.682Z" }, + { url = "https://files.pythonhosted.org/packages/f9/19/260110d56194ae29d7e423a336fccea8bcd103196d00f0b364b732bdb84e/fastavro-1.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3616e2f0e1c9265e92954fa099db79c6e7817356d3ff34f4bcc92699ae99697c", size = 3350894, upload-time = "2025-10-10T15:42:04.073Z" }, + { url = "https://files.pythonhosted.org/packages/d0/96/58b0411e8be9694d5972bee3167d6c1fd1fdfdf7ce253c1a19a327208f4f/fastavro-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb0337b42fd3c047fcf0e9b7597bd6ad25868de719f29da81eabb6343f08d399", size = 3229644, upload-time = "2025-10-10T15:42:06.221Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/38660660eac82c30471d9101f45b3acfdcbadfe42d8f7cdb129459a45050/fastavro-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64961ab15b74b7c168717bbece5660e0f3d457837c3cc9d9145181d011199fa7", size = 3329704, upload-time = "2025-10-10T15:42:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a9/1672910f458ecb30b596c9e59e41b7c00309b602a0494341451e92e62747/fastavro-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:792356d320f6e757e89f7ac9c22f481e546c886454a6709247f43c0dd7058004", size = 452911, upload-time = "2025-10-10T15:42:09.795Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/2e15d0938ded1891b33eff252e8500605508b799c2e57188a933f0bd744c/fastavro-1.12.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120aaf82ac19d60a1016afe410935fe94728752d9c2d684e267e5b7f0e70f6d9", size = 3541999, upload-time = "2025-10-10T15:42:11.794Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1c/6dfd082a205be4510543221b734b1191299e6a1810c452b6bc76dfa6968e/fastavro-1.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6a3462934b20a74f9ece1daa49c2e4e749bd9a35fa2657b53bf62898fba80f5", size = 3433972, upload-time = "2025-10-10T15:42:14.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/9de694625a1a4b727b1ad0958d220cab25a9b6cf7f16a5c7faa9ea7b2261/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f81011d54dd47b12437b51dd93a70a9aa17b61307abf26542fc3c13efbc6c51", size = 3368752, upload-time = "2025-10-10T15:42:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/fa/93/b44f67589e4d439913dab6720f7e3507b0fa8b8e56d06f6fc875ced26afb/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43ded16b3f4a9f1a42f5970c2aa618acb23ea59c4fcaa06680bdf470b255e5a8", size = 3386636, upload-time = "2025-10-10T15:42:18.974Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/58/41af0d539b3c95644d1e4e353cbd6ac9473e892ea21802546a8886b79078/hypothesis-6.151.11.tar.gz", hash = "sha256:f33dcb68b62c7b07c9ac49664989be898fa8ce57583f0dc080259a197c6c7ff1", size = 463779, upload-time = "2026-04-05T17:35:55.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/06/f49393eca84b87b17a67aaebf9f6251190ba1e9fe9f2236504049fc43fee/hypothesis-6.151.11-py3-none-any.whl", hash = "sha256:7ac05173206746cec8312f95164a30a4eb4916815413a278922e63ff1e404648", size = 529572, upload-time = "2026-04-05T17:35:53.438Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msoffcrypto-tool" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "olefile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/34/6250bdddaeaae24098e45449ea362fb3555a65fba30cad0ad5630ea48d1a/msoffcrypto_tool-6.0.0.tar.gz", hash = "sha256:9a5ebc4c0096b42e5d7ebc2350afdc92dc511061e935ca188468094fdd032bbe", size = 40593, upload-time = "2026-01-12T08:59:56.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/85/9e359fa9279e1d6861faaf9b6f037a3226374deb20a054c3937be6992013/msoffcrypto_tool-6.0.0-py3-none-any.whl", hash = "sha256:46c394ed5d9641e802fc79bf3fb0666a53748b23fa8c4aa634ae9d30d46fe397", size = 48791, upload-time = "2026-01-12T08:59:55.394Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "olefile" +version = "0.47" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, +] + +[[package]] +name = "oletools" +version = "0.60.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorclass" }, + { name = "easygui" }, + { name = "msoffcrypto-tool", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, + { name = "olefile" }, + { name = "pcodedmp" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2f/037f40e44706d542b94a2312ccc33ee2701ebfc9a83b46b55263d49ce55a/oletools-0.60.2.zip", hash = "sha256:ad452099f4695ffd8855113f453348200d195ee9fa341a09e197d66ee7e0b2c3", size = 3433750, upload-time = "2024-07-02T14:50:38.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/ff/05257b7183279b80ecec6333744de23f48f0faeeba46c93e6d13ce835515/oletools-0.60.2-py2.py3-none-any.whl", hash = "sha256:72ad8bd748fd0c4e7b5b4733af770d11543ebb2bf2697455f99f975fcd50cc96", size = 989449, upload-time = "2024-07-02T14:50:29.122Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pcodedmp" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oletools" }, + { name = "win-unicode-console", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/20/6d461e29135f474408d0d7f95b2456a9ba245560768ee51b788af10f7429/pcodedmp-1.2.6.tar.gz", hash = "sha256:025f8c809a126f45a082ffa820893e6a8d990d9d7ddb68694b5a9f0a6dbcd955", size = 35549, upload-time = "2019-07-30T18:05:42.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/72/b380fb5c89d89c3afafac8cf02a71a45f4f4a4f35531ca949a34683962d1/pcodedmp-1.2.6-py2.py3-none-any.whl", hash = "sha256:4441f7c0ab4cbda27bd4668db3b14f36261d86e5059ce06c0828602cbe1c4278", size = 30939, upload-time = "2019-07-30T18:05:40.483Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyarrow" +version = "23.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymongo" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/9c/a4895c4b785fc9865a84a56e14b5bd21ca75aadc3dab79c14187cdca189b/pymongo-4.16.0.tar.gz", hash = "sha256:8ba8405065f6e258a6f872fe62d797a28f383a12178c7153c01ed04e845c600c", size = 2495323, upload-time = "2026-01-07T18:05:48.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/03/6dd7c53cbde98de469a3e6fb893af896dca644c476beb0f0c6342bcc368b/pymongo-4.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd4911c40a43a821dfd93038ac824b756b6e703e26e951718522d29f6eb166a8", size = 917619, upload-time = "2026-01-07T18:04:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/e1/328915f2734ea1f355dc9b0e98505ff670f5fab8be5e951d6ed70971c6aa/pymongo-4.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25a6b03a68f9907ea6ec8bc7cf4c58a1b51a18e23394f962a6402f8e46d41211", size = 917364, upload-time = "2026-01-07T18:04:20.861Z" }, + { url = "https://files.pythonhosted.org/packages/41/fe/4769874dd9812a1bc2880a9785e61eba5340da966af888dd430392790ae0/pymongo-4.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91ac0cb0fe2bf17616c2039dac88d7c9a5088f5cb5829b27c9d250e053664d31", size = 1686901, upload-time = "2026-01-07T18:04:22.219Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8d/15707b9669fdc517bbc552ac60da7124dafe7ac1552819b51e97ed4038b4/pymongo-4.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf0ec79e8ca7077f455d14d915d629385153b6a11abc0b93283ed73a8013e376", size = 1723034, upload-time = "2026-01-07T18:04:24.055Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/3d5d16ff11d447d40c1472da1b366a31c7380d7ea2922a449c7f7f495567/pymongo-4.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d0082631a7510318befc2b4fdab140481eb4b9dd62d9245e042157085da2a70", size = 1797161, upload-time = "2026-01-07T18:04:25.964Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/725ab8664eeec73ec125b5a873448d80f5d8cf2750aaaf804cbc538a50a5/pymongo-4.16.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85dc2f3444c346ea019a371e321ac868a4fab513b7a55fe368f0cc78de8177cc", size = 1780938, upload-time = "2026-01-07T18:04:28.745Z" }, + { url = "https://files.pythonhosted.org/packages/22/50/dd7e9095e1ca35f93c3c844c92eb6eb0bc491caeb2c9bff3b32fe3c9b18f/pymongo-4.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbf3c14de75a20cc3c30bf0c6527157224a93dfb605838eabb1a2ee3be008d", size = 1714342, upload-time = "2026-01-07T18:04:30.331Z" }, + { url = "https://files.pythonhosted.org/packages/03/c9/542776987d5c31ae8e93e92680ea2b6e5a2295f398b25756234cabf38a39/pymongo-4.16.0-cp312-cp312-win32.whl", hash = "sha256:60307bb91e0ab44e560fe3a211087748b2b5f3e31f403baf41f5b7b0a70bd104", size = 887868, upload-time = "2026-01-07T18:04:32.124Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/b4045a7ccc5680fb496d01edf749c7a9367cc8762fbdf7516cf807ef679b/pymongo-4.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f513b2c6c0d5c491f478422f6b5b5c27ac1af06a54c93ef8631806f7231bd92e", size = 907554, upload-time = "2026-01-07T18:04:33.685Z" }, + { url = "https://files.pythonhosted.org/packages/60/4c/33f75713d50d5247f2258405142c0318ff32c6f8976171c4fcae87a9dbdf/pymongo-4.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:dfc320f08ea9a7ec5b2403dc4e8150636f0d6150f4b9792faaae539c88e7db3b", size = 892971, upload-time = "2026-01-07T18:04:35.594Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/148d8b5da8260f4679d6665196ae04ab14ffdf06f5fe670b0ab11942951f/pymongo-4.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d15f060bc6d0964a8bb70aba8f0cb6d11ae99715438f640cff11bbcf172eb0e8", size = 972009, upload-time = "2026-01-07T18:04:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/9f3a8daf583d0adaaa033a3e3e58194d2282737dc164014ff33c7a081103/pymongo-4.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a19ea46a0fe71248965305a020bc076a163311aefbaa1d83e47d06fa30ac747", size = 971784, upload-time = "2026-01-07T18:04:39.669Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f2/b6c24361fcde24946198573c0176406bfd5f7b8538335f3d939487055322/pymongo-4.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:311d4549d6bf1f8c61d025965aebb5ba29d1481dc6471693ab91610aaffbc0eb", size = 1947174, upload-time = "2026-01-07T18:04:41.368Z" }, + { url = "https://files.pythonhosted.org/packages/47/1a/8634192f98cf740b3d174e1018dd0350018607d5bd8ac35a666dc49c732b/pymongo-4.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ffb728d92dd5b09fc034ed91acf5595657c7ca17d4cf3751322cd554153c17", size = 1991727, upload-time = "2026-01-07T18:04:42.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/0c47ac84572b28e23028a23a3798a1f725e1c23b0cf1c1424678d16aff42/pymongo-4.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:acda193f440dd88c2023cb00aa8bd7b93a9df59978306d14d87a8b12fe426b05", size = 2082497, upload-time = "2026-01-07T18:04:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/ba/57/9f46ef9c862b2f0cf5ce798f3541c201c574128d31ded407ba4b3918d7b6/pymongo-4.16.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d9fdb386cf958e6ef6ff537d6149be7edb76c3268cd6833e6c36aa447e4443f", size = 2064947, upload-time = "2026-01-07T18:04:46.228Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/5421c0998f38e32288100a07f6cb2f5f9f352522157c901910cb2927e211/pymongo-4.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91899dd7fb9a8c50f09c3c1cf0cb73bfbe2737f511f641f19b9650deb61c00ca", size = 1980478, upload-time = "2026-01-07T18:04:48.017Z" }, + { url = "https://files.pythonhosted.org/packages/92/93/bfc448d025e12313a937d6e1e0101b50cc9751636b4b170e600fe3203063/pymongo-4.16.0-cp313-cp313-win32.whl", hash = "sha256:2cd60cd1e05de7f01927f8e25ca26b3ea2c09de8723241e5d3bcfdc70eaff76b", size = 934672, upload-time = "2026-01-07T18:04:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/12710a5e01218d50c3dd165fd72c5ed2699285f77348a3b1a119a191d826/pymongo-4.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ead8a0050c53eaa55935895d6919d393d0328ec24b2b9115bdbe881aa222673", size = 959237, upload-time = "2026-01-07T18:04:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/0c/56/d288bcd1d05bc17ec69df1d0b1d67bc710c7c5dbef86033a5a4d2e2b08e6/pymongo-4.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:dbbc5b254c36c37d10abb50e899bc3939bbb7ab1e7c659614409af99bd3e7675", size = 940909, upload-time = "2026-01-07T18:04:52.904Z" }, + { url = "https://files.pythonhosted.org/packages/30/9e/4d343f8d0512002fce17915a89477b9f916bda1205729e042d8f23acf194/pymongo-4.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a254d49a9ffe9d7f888e3c677eed3729b14ce85abb08cd74732cead6ccc3c66", size = 1026634, upload-time = "2026-01-07T18:04:54.359Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e3/341f88c5535df40c0450fda915f582757bb7d988cdfc92990a5e27c4c324/pymongo-4.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1bf44e13cf2d44d2ea2e928a8140d5d667304abe1a61c4d55b4906f389fbe64", size = 1026252, upload-time = "2026-01-07T18:04:56.642Z" }, + { url = "https://files.pythonhosted.org/packages/af/64/9471b22eb98f0a2ca0b8e09393de048502111b2b5b14ab1bd9e39708aab5/pymongo-4.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f1c5f1f818b669875d191323a48912d3fcd2e4906410e8297bb09ac50c4d5ccc", size = 2207399, upload-time = "2026-01-07T18:04:58.255Z" }, + { url = "https://files.pythonhosted.org/packages/87/ac/47c4d50b25a02f21764f140295a2efaa583ee7f17992a5e5fa542b3a690f/pymongo-4.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77cfd37a43a53b02b7bd930457c7994c924ad8bbe8dff91817904bcbf291b371", size = 2260595, upload-time = "2026-01-07T18:04:59.788Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1b/0ce1ce9dd036417646b2fe6f63b58127acff3cf96eeb630c34ec9cd675ff/pymongo-4.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36ef2fee50eee669587d742fb456e349634b4fcf8926208766078b089054b24b", size = 2366958, upload-time = "2026-01-07T18:05:01.942Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/a5a17c0d413aa9d6c17bc35c2b472e9e79cda8068ba8e93433b5f43028e9/pymongo-4.16.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55f8d5a6fe2fa0b823674db2293f92d74cd5f970bc0360f409a1fc21003862d3", size = 2346081, upload-time = "2026-01-07T18:05:03.576Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/f815533d1a88fb8a3b6c6e895bb085ffdae68ccb1e6ed7102202a307f8e2/pymongo-4.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9caacac0dd105e2555521002e2d17afc08665187017b466b5753e84c016628e6", size = 2246053, upload-time = "2026-01-07T18:05:05.459Z" }, + { url = "https://files.pythonhosted.org/packages/c6/88/4be3ec78828dc64b212c123114bd6ae8db5b7676085a7b43cc75d0131bd2/pymongo-4.16.0-cp314-cp314-win32.whl", hash = "sha256:c789236366525c3ee3cd6e4e450a9ff629a7d1f4d88b8e18a0aea0615fd7ecf8", size = 989461, upload-time = "2026-01-07T18:05:07.018Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/ab8d5af76421b34db483c9c8ebc3a2199fb80ae63dc7e18f4cf1df46306a/pymongo-4.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b0714d7764efb29bf9d3c51c964aed7c4c7237b341f9346f15ceaf8321fdb35", size = 1017803, upload-time = "2026-01-07T18:05:08.499Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/98d68020728ac6423cf02d17cfd8226bf6cce5690b163d30d3f705e8297e/pymongo-4.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:12762e7cc0f8374a8cae3b9f9ed8dabb5d438c7b33329232dd9b7de783454033", size = 997184, upload-time = "2026-01-07T18:05:09.944Z" }, + { url = "https://files.pythonhosted.org/packages/50/00/dc3a271daf06401825b9c1f4f76f018182c7738281ea54b9762aea0560c1/pymongo-4.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1c01e8a7cd0ea66baf64a118005535ab5bf9f9eb63a1b50ac3935dccf9a54abe", size = 1083303, upload-time = "2026-01-07T18:05:11.702Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4b/b5375ee21d12eababe46215011ebc63801c0d2c5ffdf203849d0d79f9852/pymongo-4.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c4872299ebe315a79f7f922051061634a64fda95b6b17677ba57ef00b2ba2a4", size = 1083233, upload-time = "2026-01-07T18:05:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e3/52efa3ca900622c7dcb56c5e70f15c906816d98905c22d2ee1f84d9a7b60/pymongo-4.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78037d02389745e247fe5ab0bcad5d1ab30726eaac3ad79219c7d6bbb07eec53", size = 2527438, upload-time = "2026-01-07T18:05:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/cb/96/43b1be151c734e7766c725444bcbfa1de6b60cc66bfb406203746839dd25/pymongo-4.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c126fb72be2518395cc0465d4bae03125119136462e1945aea19840e45d89cfc", size = 2600399, upload-time = "2026-01-07T18:05:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/e7/62/fa64a5045dfe3a1cd9217232c848256e7bc0136cffb7da4735c5e0d30e40/pymongo-4.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3867dc225d9423c245a51eaac2cfcd53dde8e0a8d8090bb6aed6e31bd6c2d4f", size = 2720960, upload-time = "2026-01-07T18:05:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/54/7b/01577eb97e605502821273a5bc16ce0fb0be5c978fe03acdbff471471202/pymongo-4.16.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f25001a955073b80510c0c3db0e043dbbc36904fd69e511c74e3d8640b8a5111", size = 2699344, upload-time = "2026-01-07T18:05:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/68/6ef6372d516f703479c3b6cbbc45a5afd307173b1cbaccd724e23919bb1a/pymongo-4.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d9885aad05f82fd7ea0c9ca505d60939746b39263fa273d0125170da8f59098", size = 2577133, upload-time = "2026-01-07T18:05:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/b5337093bb01da852f945802328665f85f8109dbe91d81ea2afe5ff059b9/pymongo-4.16.0-cp314-cp314t-win32.whl", hash = "sha256:948152b30eddeae8355495f9943a3bf66b708295c0b9b6f467de1c620f215487", size = 1040560, upload-time = "2026-01-07T18:05:23.888Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/5b448cd1b103f3889d5713dda37304c81020ff88e38a826e8a75ddff4610/pymongo-4.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f6e42c1bc985d9beee884780ae6048790eb4cd565c46251932906bdb1630034a", size = 1075081, upload-time = "2026-01-07T18:05:26.874Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" }, +] + +[[package]] +name = "pymupdf" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, + { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c6/e3e11c42f09b9c34ec332c0f37b817671b59ef4001895b854f0494092105/pymupdf-1.27.2.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6cebfbbdfd219ebdebf4d8e3914624b2e3d3a844c43f4f76935822dd9b13cc12", size = 24985299, upload-time = "2026-03-20T09:45:53.26Z" }, +] + +[[package]] +name = "pymysql" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "red-black-tree-mod" +version = "1.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/75/bfa342a2ebfc9623b701f1c6995b9906fd6dd2cedf6bce777d09e23303ac/red-black-tree-mod-1.22.tar.gz", hash = "sha256:38e3652903a2bf96379c27c2082ca0b7b905158662dd7ef0c97f4fd93a9aa908", size = 34173, upload-time = "2023-12-26T14:00:22.056Z" } + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rtfde" +version = "0.1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, + { name = "oletools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/5c/116a016b38af589e8141160bc9b034b73dde2e50c22a921751f4d982a7ca/rtfde-0.1.2.2.tar.gz", hash = "sha256:2f0cd6ecd644071e39452e6fc4f4a1435453af0ec7c90ea86fb4fc96010c7f1b", size = 33408, upload-time = "2025-12-09T17:10:31.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/24/5a653278259be44c1845ddd56dd30cfa7265281ba149b9342b79f9d4f788/rtfde-0.1.2.2-py3-none-any.whl", hash = "sha256:d43868c74f21ae9ea5acbfd4176d5de1f2cfae0ff7f267698471c606287c04ec", size = 36713, upload-time = "2025-12-09T17:10:30.893Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "win-unicode-console" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/8d/7aad74930380c8972ab282304a2ff45f3d4927108bb6693cabcc9fc6a099/win_unicode_console-0.5.zip", hash = "sha256:d4142d4d56d46f449d6f00536a73625a871cba040f0bc1a2e305a04578f07d1e", size = 31420, upload-time = "2016-06-25T19:48:54.05Z" } + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "yapf" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, +] From 2ba99859366f35a6723e1829f1c3ddebc393fb1a Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 10 Apr 2026 17:29:15 -0400 Subject: [PATCH 10/30] docs: update learn/ for dlp-scanner audit changes Reflect network_scanner.py rewrite, new scoring.py module, per-brand credit card rule IDs, NPI validator, FL/IL driver's license rules, Literal config types, and network exfil compliance mappings. --- .../dlp-scanner/learn/02-ARCHITECTURE.md | 24 +-- .../dlp-scanner/learn/03-IMPLEMENTATION.md | 162 +++++++++++++++++- 2 files changed, 173 insertions(+), 13 deletions(-) diff --git a/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md b/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md index 7c0725de..d8ef752d 100644 --- a/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md +++ b/PROJECTS/intermediate/dlp-scanner/learn/02-ARCHITECTURE.md @@ -165,13 +165,13 @@ Text Input **Files:** `scanners/file_scanner.py`, `scanners/db_scanner.py`, `scanners/network_scanner.py` -All scanners follow the same `Scanner` protocol: a `scan(target: str) -> ScanResult` method. They share a common flow: iterate over targets, extract text, run detection, classify matches into findings with severity/compliance/remediation metadata, and aggregate into a `ScanResult`. +All scanners follow the same `Scanner` protocol: a `scan(target: str) -> ScanResult` method. They share a common flow: iterate over targets, extract text, run detection, convert matches to findings via `match_to_finding` in `scoring.py` (which handles severity classification, compliance lookup, remediation, and redaction in one call), and aggregate into a `ScanResult`. **FileScanner** walks a directory tree, applies extension and exclusion filters, dispatches each file to the appropriate extractor based on extension, and runs the detector on each `TextChunk`. The extension-to-extractor mapping is built once by `_build_extension_map`, which iterates over all extractor instances and indexes by their `supported_extensions`. **DatabaseScanner** connects via URI scheme detection (postgres, mysql, mongodb, sqlite), introspects the schema to find text-type columns, samples rows using database-native sampling (TABLESAMPLE BERNOULLI for PostgreSQL, RAND() for MySQL, $sample for MongoDB), and scans column values. -**NetworkScanner** reads PCAP files using dpkt, extracts TCP/UDP payloads, decodes them to text, and runs detection. The companion modules in `network/` provide TCP stream reassembly, DNS query parsing, protocol identification via DPI, and DNS exfiltration detection. +**NetworkScanner** reads PCAP files via `read_pcap`, feeds packets into a `FlowTracker` for TCP reassembly, and processes DNS traffic inline through `parse_dns` and `DnsExfilDetector`. Each packet payload is also checked by `detect_base64_payload` for encoded data. After packet iteration, the scanner reassembles TCP flows, identifies the application protocol via `identify_protocol`, extracts text with protocol awareness (`parse_http` for HTTP bodies and sensitive headers, skip encrypted TLS/SSH, UTF-8 decode for everything else), and runs detection on the extracted text. ### Extractors @@ -317,7 +317,7 @@ class DetectorMatch: └────────────────────────────────────────────┘ ``` -Every configuration value has a constant default defined in `constants.py`. The Pydantic models in `config.py` use these constants as field defaults, so a completely empty config file produces a working scanner. The config loader uses `ruamel.yaml` (not PyYAML) because it preserves comments and handles YAML 1.2. +Every configuration value has a constant default defined in `constants.py`. The Pydantic models in `config.py` use these constants as field defaults, so a completely empty config file produces a working scanner. Constrained-choice fields (`severity_threshold`, `format`, `redaction_style`) use `Literal` types defined in `constants.py` (e.g., `Literal["critical", "high", "medium", "low"]`), so Pydantic rejects invalid values at parse time rather than silently accepting a typo. The config loader uses `ruamel.yaml` (not PyYAML) because it preserves comments and handles YAML 1.2. The YAML structure uses a `scan:` top-level key to group scanner-specific config, while `detection:`, `compliance:`, `output:`, and `logging:` sit at root level. This mirrors how users think about configuration: "how to scan" vs. "what to detect" vs. "how to report". @@ -334,7 +334,7 @@ Step-by-step walkthrough of `dlp-scan file ./data -f json`: (WARNING for machine-readable formats keeps stdout clean) 3. ScanEngine(config) constructs DetectorRegistry - └─► Registry loads 28 rules from PII/Financial/Credential/Health modules + └─► Registry loads 29 rules from PII/Financial/Credential/Health modules └─► Filters through enable_rules=["*"], disable_rules=[] 4. engine.scan_files("./data") @@ -355,10 +355,11 @@ Step-by-step walkthrough of `dlp-scan file ./data -f json`: └─► EntropyDetector: high-entropy region detection 7. For each DetectorMatch above min_confidence: - └─► score_to_severity(match.score) -> Severity - └─► get_frameworks_for_rule(match.rule_id) -> compliance list - └─► get_remediation_for_rule(match.rule_id) -> guidance string - └─► redact(chunk.text, start, end, style="partial") -> snippet + └─► match_to_finding(match, text, location, redaction_style) + ├─► score_to_severity(match.score) -> Severity + ├─► get_frameworks_for_rule(match.rule_id) -> compliance list + ├─► get_remediation_for_rule(match.rule_id) -> guidance string + └─► redact(chunk.text, start, end, style) -> snippet └─► Append Finding to ScanResult 8. Back in _run_scan(): @@ -413,7 +414,9 @@ RULE_FRAMEWORK_MAP: rule_id -> [frameworks] RULE_REMEDIATION_MAP: rule_id -> guidance string ``` -When a `DetectorMatch` is converted to a `Finding` inside a scanner, the scanner calls `get_frameworks_for_rule` and `get_remediation_for_rule` to decorate the finding with compliance metadata. If the detection rule itself also carries `compliance_frameworks`, both sets are merged. +Rule IDs match actual detection rules (e.g., `FIN_CREDIT_CARD_VISA`, `FIN_CREDIT_CARD_MC`, not a generic `FIN_CREDIT_CARD`). Network exfiltration indicators (`NET_DNS_EXFIL_*`, `NET_ENCODED_*`) are also mapped. Every rule has a remediation entry with specific guidance text; unknown rules fall back to a generic default. + +When a `DetectorMatch` is converted to a `Finding` via `match_to_finding` in `scoring.py`, the function calls `get_frameworks_for_rule` and `get_remediation_for_rule` to decorate the finding with compliance metadata. If the detection rule itself also carries `compliance_frameworks`, both sets are merged. This design keeps detection rules independent of compliance logic. The PII module does not need to know that HIPAA cares about SSNs. The compliance module owns that mapping, and it can be updated independently when regulations change. @@ -508,7 +511,7 @@ This "collect and continue" approach means a single corrupt PDF in a directory o **File scanning** is I/O-bound. The scanner processes files sequentially to avoid overwhelming disk I/O. Text extraction for binary formats (PDF, Office) can be CPU-intensive, but these files are typically a small fraction of the total. -**Detection** scales linearly with text length times rule count. With 28 rules and an average text chunk of 500 lines, a single detection pass takes microseconds. The entropy detector is more expensive due to its sliding window, so it only runs when enabled and only against high-level text chunks (not individual regex matches). +**Detection** scales linearly with text length times rule count. With 29 rules and an average text chunk of 500 lines, a single detection pass takes microseconds. The entropy detector is more expensive due to its sliding window, so it only runs when enabled and only against high-level text chunks (not individual regex matches). **Memory** stays bounded through chunking. The plaintext extractor reads 500 lines at a time. Archive extraction enforces depth limits and zip bomb ratio checks. @@ -520,6 +523,7 @@ This "collect and continue" approach means a single corrupt PDF in a directory o - `constants.py` - All magic numbers, thresholds, type literals - `models.py` - Finding, Location, ScanResult, TextChunk - `compliance.py` - Rule-to-framework mapping, severity classification +- `scoring.py` - Shared match-to-finding conversion for all scanners - `redaction.py` - Partial/full/none redaction strategies - `detectors/registry.py` - Rule loading, filtering, scoring pipeline - `detectors/pattern.py` - Regex matching with allowlist and checksum validation diff --git a/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md b/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md index f20db4bd..a760e4d0 100644 --- a/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md +++ b/PROJECTS/intermediate/dlp-scanner/learn/03-IMPLEMENTATION.md @@ -17,6 +17,7 @@ src/dlp_scanner/ ├── compliance.py # Rule-to-framework mapping ├── redaction.py # Snippet masking ├── log.py # structlog configuration +├── scoring.py # Shared match-to-finding conversion ├── commands/ │ ├── scan.py # file, db, network commands │ └── report.py # convert, summary commands @@ -174,6 +175,28 @@ def nhs_check(value: str) -> bool: Multiply the first 9 digits by descending weights (10, 9, 8, ..., 2), sum them, compute `11 - (sum mod 11)`, and compare to the check digit. If the result is 10, the number is invalid (NHS never issues these). If the result is 11, the check digit is 0. +**Luhn-80840** for NPIs (in `detectors/rules/health.py`): + +```python +def _validate_npi(value: str) -> bool: + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 10 or not digits.isdigit(): + return False + + prefixed = "80840" + digits + total = 0 + for i, d in enumerate(reversed(prefixed)): + n = int(d) + if i % 2 == 1: + n *= 2 + if n > 9: + n -= 9 + total += n + return total % 10 == 0 +``` + +NPI (National Provider Identifier) validation is a Luhn variant. The trick is prepending `80840` (the healthcare industry prefix assigned by ANSI) before running the standard Luhn algorithm. This prefix is not part of the NPI itself, but the ISO standard requires it for check digit computation. A random 10-digit number has about a 10% chance of passing, making this check useful but not definitive. The base score of 0.10 reflects that NPI patterns match many unrelated 10-digit numbers, and context keywords like "provider" or "npi" are needed to push the score into actionable territory. + ### Pattern Detection The `PatternDetector` in `detectors/pattern.py` iterates over all active rules, runs each regex against the input text, filters through the allowlist, and applies checksum validation: @@ -473,6 +496,79 @@ The `_get_full_suffix` function handles compound extensions like `.tar.gz` and ` ## Network Analysis +### Scanner Orchestration + +The `NetworkScanner` ties together the network modules into a multi-pass pipeline. The old implementation decoded raw packets as UTF-8 and ran detection directly. The rewrite is protocol-aware: + +```python +def _scan_pcap(self, path, result): + tracker = FlowTracker() + dns_detector = DnsExfilDetector( + entropy_threshold=( + self._net_config.dns_label_entropy_threshold + ), + ) + packet_count = 0 + + for packet in read_pcap( + path, + max_packets=self._net_config.max_packets, + ): + packet_count += 1 + tracker.add_packet(packet) + + if ( + packet.protocol == "udp" + and ( + packet.src_port == DNS_PORT + or packet.dst_port == DNS_PORT + ) + ): + self._process_dns_packet( + packet.payload, packet.src_ip, + packet.dst_ip, path, packet_count, + dns_detector, result, + ) + + if packet.payload: + exfil_indicators = detect_base64_payload( + packet.payload, + src_ip=packet.src_ip, + dst_ip=packet.dst_ip, + ) + for indicator in exfil_indicators: + finding = _indicator_to_finding( + indicator, str(path), packet_count, + ) + result.findings.append(finding) + + txt_indicators = dns_detector.check_txt_volume() + for indicator in txt_indicators: + ... + + self._scan_reassembled_flows(tracker, path, result) +``` + +Three things happen during the packet loop: every packet goes into the `FlowTracker` for later TCP reassembly, UDP packets on port 53 are parsed as DNS and fed to the `DnsExfilDetector`, and every payload is checked for base64/hex-encoded data by `detect_base64_payload`. After the loop, TXT query volume ratios are checked and TCP flows are reassembled for content scanning. + +The reassembled flow scanning uses protocol-aware text extraction: + +```python +def _extract_scannable_text(self, stream, protocol): + if protocol == "http": + return self._extract_http_text(stream) + if protocol in ("tls", "ssh"): + return "" + try: + return stream.decode("utf-8", errors="replace") + except Exception: + return "" +``` + +HTTP flows get parsed by `parse_http`, which extracts URIs, sensitive headers (`cookie`, `authorization`, `set-cookie`), and bodies. TLS and SSH flows are skipped entirely since the content is encrypted and cannot be scanned. Everything else falls through to a UTF-8 decode attempt. + +DNS exfiltration indicators and encoded payload detections are converted to `Finding` objects through `_indicator_to_finding`, which maps indicator types to rule IDs via the `EXFIL_RULE_MAP` lookup table. Regex-based detections from reassembled flows go through `match_to_finding` like the other scanners. + ### PCAP Parsing The `read_pcap` function in `network/pcap.py` reads packets using dpkt and yields `PacketInfo` structs: @@ -638,17 +734,77 @@ The `RULE_FRAMEWORK_MAP` in `compliance.py` is a static lookup table: ```python RULE_FRAMEWORK_MAP = { "PII_SSN": ["HIPAA", "CCPA", "GLBA", "GDPR"], - "FIN_CREDIT_CARD": ["PCI_DSS", "GLBA"], + "PII_DRIVERS_LICENSE_FL": ["CCPA", "HIPAA"], + "FIN_CREDIT_CARD_VISA": ["PCI_DSS", "GLBA"], + "FIN_CREDIT_CARD_MC": ["PCI_DSS", "GLBA"], "FIN_IBAN": ["GDPR", "GLBA"], - "HEALTH_MEDICAL_RECORD": ["HIPAA"], + "HEALTH_NPI": ["HIPAA"], + "NET_DNS_EXFIL_HIGH_ENTROPY": [], ... } ``` -Each rule maps to the compliance frameworks that regulate that data type. SSNs trigger four frameworks because they are considered protected health information (HIPAA), personal information (CCPA), financial identifiers (GLBA), and personal data (GDPR). Credit card PANs only trigger PCI-DSS and GLBA because HIPAA and GDPR do not specifically regulate financial card numbers. +Rule IDs match actual detection rules rather than using generic categories. Credit card rules are split by brand (`FIN_CREDIT_CARD_VISA`, `FIN_CREDIT_CARD_MC`, `FIN_CREDIT_CARD_AMEX`, `FIN_CREDIT_CARD_DISC`), each triggering PCI-DSS and GLBA. State-specific driver's license rules (`PII_DRIVERS_LICENSE_FL`, `PII_DRIVERS_LICENSE_IL`) map to CCPA and HIPAA alongside the generic CA pattern. Network exfiltration indicators (`NET_DNS_EXFIL_*`, `NET_ENCODED_*`) carry empty framework lists since DNS tunneling is a detection concern, not a regulatory data type. + +SSNs trigger four frameworks because they are considered protected health information (HIPAA), personal information (CCPA), financial identifiers (GLBA), and personal data (GDPR). Every rule also has a corresponding entry in `RULE_REMEDIATION_MAP` with specific guidance text. Unknown rules fall back to a generic default. The mapping is intentionally conservative. An SSN could trigger SOX if it appears in financial reporting data, but without business context the scanner cannot determine that. The listed frameworks are the ones where the mere presence of the data type creates a compliance obligation. +## Shared Scoring Module + +The `match_to_finding` function in `scoring.py` centralizes the conversion from `DetectorMatch` to `Finding`. All three scanners import from this single location instead of duplicating the severity/compliance/redaction logic: + +```python +def match_to_finding( + match: DetectorMatch, + text: str, + location: Location, + redaction_style: RedactionStyle, +) -> Finding: + severity = score_to_severity(match.score) + frameworks = get_frameworks_for_rule(match.rule_id) + if match.compliance_frameworks: + combined = ( + set(frameworks) | set(match.compliance_frameworks) + ) + frameworks = sorted(combined) + remediation = get_remediation_for_rule(match.rule_id) + + snippet = redact( + text, match.start, match.end, + style=redaction_style, + ) + + return Finding( + rule_id=match.rule_id, + rule_name=match.rule_name, + severity=severity, + confidence=match.score, + location=location, + redacted_snippet=snippet, + compliance_frameworks=frameworks, + remediation=remediation, + ) +``` + +The function chains severity classification, compliance framework lookup, remediation guidance, and redaction in one call. The framework merging logic handles the case where a detection rule carries its own `compliance_frameworks` list: those are merged with the frameworks from the compliance module, deduplicated, and sorted for deterministic output. + +Each scanner calls this in its match loop: + +```python +for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, chunk.text, chunk.location, + self._redaction_style, + ) + result.findings.append(finding) +``` + +Adding a new compliance framework or changing severity thresholds affects all three scanners uniformly without touching scanner code. + ## Redaction The `redact` function in `redaction.py` builds a snippet with masked content: From 10be86e4968b4cf935374f73c1cf4a785f68c839 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:36:02 +0000 Subject: [PATCH 11/30] chore(deps): bump axios Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../siem-dashboard/frontend/package.json | 2 +- .../siem-dashboard/frontend/pnpm-lock.yaml | 38 ++++++------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/package.json b/PROJECTS/intermediate/siem-dashboard/frontend/package.json index f5b38da0..7c98f997 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/package.json +++ b/PROJECTS/intermediate/siem-dashboard/frontend/package.json @@ -21,7 +21,7 @@ "@visx/responsive": "^3.12.0", "@visx/shape": "^3.12.0", "@visx/xychart": "^3.12.0", - "axios": "^1.13.5", + "axios": "^1.15.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/pnpm-lock.yaml b/PROJECTS/intermediate/siem-dashboard/frontend/pnpm-lock.yaml index 3d03735c..38a86c7a 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/pnpm-lock.yaml +++ b/PROJECTS/intermediate/siem-dashboard/frontend/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^3.12.0 version: 3.12.0(@react-spring/web@9.7.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) axios: - specifier: ^1.13.5 - version: 1.13.5 + specifier: ^1.15.0 + version: 1.15.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -207,21 +207,18 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.2': resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.2': resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.3.14': resolution: {integrity: sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==} @@ -234,7 +231,6 @@ packages: engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.2': resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} @@ -371,42 +367,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -492,28 +482,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} @@ -759,8 +745,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} balanced-match@0.4.2: resolution: {integrity: sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==} @@ -1215,28 +1201,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1382,8 +1364,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} qified@0.6.0: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} @@ -1472,6 +1455,7 @@ packages: rolldown-vite@7.2.5: resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} engines: {node: ^20.19.0 || >=22.12.0} + deprecated: Use 7.3.1 for migration purposes. For the most recent updates, migrate to Vite 8 once you're ready. hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -2430,11 +2414,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.5: + axios@1.15.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -2946,7 +2930,7 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} qified@0.6.0: dependencies: From d6db8a6ea3bbc3e2e8e108f36f542a4405c064e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:36:18 +0000 Subject: [PATCH 12/30] chore(deps): bump pypdf in /PROJECTS/beginner/metadata-scrubber-tool Bumps [pypdf](https://github.com/py-pdf/pypdf) from 6.9.2 to 6.10.0. - [Release notes](https://github.com/py-pdf/pypdf/releases) - [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md) - [Commits](https://github.com/py-pdf/pypdf/compare/6.9.2...6.10.0) --- updated-dependencies: - dependency-name: pypdf dependency-version: 6.10.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/beginner/metadata-scrubber-tool/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PROJECTS/beginner/metadata-scrubber-tool/uv.lock b/PROJECTS/beginner/metadata-scrubber-tool/uv.lock index 3131c6fe..85747fe8 100644 --- a/PROJECTS/beginner/metadata-scrubber-tool/uv.lock +++ b/PROJECTS/beginner/metadata-scrubber-tool/uv.lock @@ -642,14 +642,14 @@ wheels = [ [[package]] name = "pypdf" -version = "6.9.2" +version = "6.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, + { url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" }, ] [[package]] From 4920dadf35f7bee067a62e8e3cad3fc67cb040c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:50:33 +0000 Subject: [PATCH 13/30] chore(deps): bump axios Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../frontend/package.json | 2 +- .../frontend/pnpm-lock.yaml | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/package.json b/PROJECTS/intermediate/binary-analysis-tool/frontend/package.json index d34cc0f1..04b281fb 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/package.json +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/package.json @@ -17,7 +17,7 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@tanstack/react-query": "^5.90.12", - "axios": "^1.13.5", + "axios": "^1.15.0", "react": "^19.2.1", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", diff --git a/PROJECTS/intermediate/binary-analysis-tool/frontend/pnpm-lock.yaml b/PROJECTS/intermediate/binary-analysis-tool/frontend/pnpm-lock.yaml index 95688d2d..672e68e1 100644 --- a/PROJECTS/intermediate/binary-analysis-tool/frontend/pnpm-lock.yaml +++ b/PROJECTS/intermediate/binary-analysis-tool/frontend/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^5.90.12 version: 5.90.12(react@19.2.1) axios: - specifier: ^1.13.5 - version: 1.13.5 + specifier: ^1.15.0 + version: 1.15.0 react: specifier: ^19.2.1 version: 19.2.1 @@ -573,8 +573,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} babel-runtime@5.8.38: resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} @@ -1114,8 +1114,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} qified@0.5.3: resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} @@ -1876,11 +1877,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.5: + axios@1.15.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -2321,7 +2322,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} qified@0.5.3: dependencies: From 042de8543d51cd64e65407db962218939ba9917d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:06:20 +0000 Subject: [PATCH 14/30] chore(deps): bump axios Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../bug-bounty-platform/frontend/package.json | 2 +- .../frontend/pnpm-lock.yaml | 38 ++++++------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/PROJECTS/advanced/bug-bounty-platform/frontend/package.json b/PROJECTS/advanced/bug-bounty-platform/frontend/package.json index 662e476f..e9572698 100644 --- a/PROJECTS/advanced/bug-bounty-platform/frontend/package.json +++ b/PROJECTS/advanced/bug-bounty-platform/frontend/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.12", - "axios": "^1.13.0", + "axios": "^1.15.0", "react": "^19.2.1", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", diff --git a/PROJECTS/advanced/bug-bounty-platform/frontend/pnpm-lock.yaml b/PROJECTS/advanced/bug-bounty-platform/frontend/pnpm-lock.yaml index b93bf847..b4b7d170 100644 --- a/PROJECTS/advanced/bug-bounty-platform/frontend/pnpm-lock.yaml +++ b/PROJECTS/advanced/bug-bounty-platform/frontend/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^5.90.12 version: 5.90.16(react@19.2.3) axios: - specifier: ^1.13.0 - version: 1.13.2 + specifier: ^1.15.0 + version: 1.15.0 react: specifier: ^19.2.1 version: 19.2.3 @@ -192,28 +192,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.2': resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.2': resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.2': resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.2': resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} @@ -348,42 +344,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -442,28 +432,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} @@ -574,8 +560,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} babel-runtime@5.8.38: resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} @@ -968,28 +954,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -1119,8 +1101,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} qified@0.5.3: resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} @@ -1199,6 +1182,7 @@ packages: rolldown-vite@7.2.5: resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} engines: {node: ^20.19.0 || >=22.12.0} + deprecated: Use 7.3.1 for migration purposes. For the most recent updates, migrate to Vite 8 once you're ready. hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -1873,11 +1857,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.2: + axios@1.15.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -2318,7 +2302,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} qified@0.5.3: dependencies: From d292f1e4b7d7ac457acf4d0c14db85f550ccb586 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 11 Apr 2026 04:54:57 -0400 Subject: [PATCH 15/30] feat: linux ebpf security tracer project eBPF-based security monitoring tool with process, file, network, privilege escalation, and system call tracing via BCC. Includes threat detection engine, Rich TUI renderer, and learn docs. --- .../linux-ebpf-security-tracer/.gitignore | 13 + .../linux-ebpf-security-tracer/.style.yapf | 3 + .../linux-ebpf-security-tracer/README.md | 185 ++++++ .../linux-ebpf-security-tracer/install.sh | 135 ++++ .../linux-ebpf-security-tracer/justfile | 27 + .../learn/00-OVERVIEW.md | 127 ++++ .../learn/01-CONCEPTS.md | 230 +++++++ .../learn/02-ARCHITECTURE.md | 297 +++++++++ .../learn/03-IMPLEMENTATION.md | 487 +++++++++++++++ .../learn/04-CHALLENGES.md | 248 ++++++++ .../linux-ebpf-security-tracer/pyproject.toml | 65 ++ .../src/__init__.py | 4 + .../linux-ebpf-security-tracer/src/config.py | 242 +++++++ .../src/detector.py | 243 +++++++ .../src/ebpf/__init__.py | 4 + .../src/ebpf/file_tracer.c | 97 +++ .../src/ebpf/network_tracer.c | 121 ++++ .../src/ebpf/privilege_tracer.c | 80 +++ .../src/ebpf/process_tracer.c | 80 +++ .../src/ebpf/system_tracer.c | 94 +++ .../linux-ebpf-security-tracer/src/loader.py | 129 ++++ .../linux-ebpf-security-tracer/src/main.py | 160 +++++ .../src/processor.py | 232 +++++++ .../src/renderer.py | 264 ++++++++ .../testdata/sample_events.json | 92 +++ .../tests/__init__.py | 4 + .../tests/conftest.py | 61 ++ .../tests/test_detector.py | 474 ++++++++++++++ .../tests/test_processor.py | 163 +++++ .../tests/test_renderer.py | 140 +++++ .../linux-ebpf-security-tracer/uv.lock | 459 ++++++++++++++ ...4-01-credential-enumeration-audit-fixes.md | 591 ------------------ ...026-04-01-credential-enumeration-design.md | 257 -------- 33 files changed, 4960 insertions(+), 848 deletions(-) create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/README.md create mode 100755 PROJECTS/beginner/linux-ebpf-security-tracer/install.sh create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/justfile create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock delete mode 100644 docs/plans/2026-04-01-credential-enumeration-audit-fixes.md delete mode 100644 docs/superpowers/specs/2026-04-01-credential-enumeration-design.md diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore b/PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore new file mode 100644 index 00000000..7fc68866 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore @@ -0,0 +1,13 @@ +docs/ +__pycache__/ +*.pyc +.env +.venv/ +*.o +*.so +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +dist/ +build/ +*.egg-info/ diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf b/PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf new file mode 100644 index 00000000..5f3a946c --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf @@ -0,0 +1,3 @@ +[style] +based_on_style = pep8 +column_limit = 75 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/README.md b/PROJECTS/beginner/linux-ebpf-security-tracer/README.md new file mode 100644 index 00000000..c562ea44 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/README.md @@ -0,0 +1,185 @@ +# Linux eBPF Security Tracer + +Real-time syscall tracing tool using eBPF for security observability. Monitors process execution, file access, network connections, privilege changes, and system operations to detect suspicious behavior patterns. + +## Features + +- Real-time syscall monitoring via eBPF tracepoints +- 10 built-in detection rules mapped to MITRE ATT&CK techniques +- Correlated event analysis (reverse shell detection, privilege escalation) +- Multiple output formats: live color-coded stream, JSON, table summary +- Configurable severity filtering (LOW, MEDIUM, HIGH, CRITICAL) +- Process, file, network, privilege, and system event categories +- Event enrichment from /proc filesystem +- Clean signal handling and eBPF program cleanup + +## Prerequisites + +- Linux kernel 5.8+ (ring buffer support) +- Root privileges (required for eBPF) +- Python 3.10+ +- BCC (BPF Compiler Collection) with Python bindings + +## Quick Start + +```bash +# Install system dependencies and Python packages +./install.sh + +# Start tracing all syscalls +sudo uv run ebpf-tracer + +# JSON output, only MEDIUM+ severity +sudo uv run ebpf-tracer -f json -s MEDIUM + +# Only network events +sudo uv run ebpf-tracer -t network + +# Only show detection alerts +sudo uv run ebpf-tracer --detections + +# Filter by process name +sudo uv run ebpf-tracer -c nginx + +# Write events to file while streaming +sudo uv run ebpf-tracer -o events.jsonl +``` + +## Usage + +``` +ebpf-tracer [OPTIONS] + +Options: + -f, --format Output format: json, table, live [default: live] + -s, --severity Minimum severity: LOW, MEDIUM, [default: LOW] + HIGH, CRITICAL + -p, --pid Filter by specific PID + -c, --comm Filter by process name + -t, --type Event category: process, file, [default: all] + network, privilege, system, all + --no-enrich Disable /proc enrichment + -o, --output Also write events to file + --detections Show only detection alerts + --version Show version + --help Show help +``` + +## Detection Rules + +| ID | Name | Severity | MITRE ATT&CK | Trigger | +|----|------|----------|--------------|---------| +| D001 | Privilege Escalation | CRITICAL | T1548 | setuid(0) by non-root | +| D002 | Sensitive File Read | MEDIUM | T1003.008 | /etc/shadow access by non-root | +| D003 | SSH Key Access | MEDIUM | T1552.004 | SSH key file access | +| D004 | Process Injection | MEDIUM | T1055.008 | ptrace ATTACH/SEIZE | +| D005 | Kernel Module Load | HIGH | T1547.006 | init_module syscall | +| D006 | Reverse Shell | CRITICAL | T1059.004 | connect + shell execve sequence | +| D007 | Persistence via Cron | MEDIUM | T1053.003 | Write to cron directories | +| D008 | Persistence via Systemd | MEDIUM | T1543.002 | Write to systemd unit dirs | +| D009 | Log Tampering | MEDIUM | T1070.002 | Log file deletion/truncation | +| D010 | Suspicious Mount | HIGH | T1611 | mount syscall | + +## Architecture + +``` +User Space +┌─────────┐ ┌──────────────┐ ┌─────────────────┐ +│ CLI │──▶│ Event Engine │──▶│ Output Renderer │ +│ (Typer) │ │ (Processor + │ │ (JSON / Table / │ +│ │ │ Detector) │ │ Live Stream) │ +└─────────┘ └──────┬───────┘ └─────────────────┘ + │ + ┌──────┴───────┐ + │ BPF Loader │ + │ (BCC/Python)│ + └──────┬───────┘ +─────────────────────┼────────────────────────────── +Kernel Space │ + ┌──────┴───────┐ + │ Ring Buffer │ + └──────┬───────┘ + ┌───────────────┼───────────────────┐ + │ eBPF C Tracepoint Programs │ + │ ┌─────────┐┌────────┐┌─────────┐ │ + │ │ Process ││ File ││ Network │ │ + │ └─────────┘└────────┘└─────────┘ │ + │ ┌──────────┐┌────────┐ │ + │ │Privilege ││ System │ │ + │ └──────────┘└────────┘ │ + └───────────────────────────────────┘ +``` + +## Monitored Syscalls + +| Category | Syscalls | Purpose | +|----------|----------|---------| +| Process | execve, clone | New process creation | +| File | openat, unlinkat, renameat2 | File access and manipulation | +| Network | connect, accept4, bind, listen | Network activity | +| Privilege | setuid, setgid | Privilege changes | +| System | ptrace, mount, init_module | System-level operations | + +## Project Structure + +``` +src/ +├── main.py # CLI entrypoint (Typer) +├── config.py # Constants, event types, detection rules +├── loader.py # BCC program loader and ring buffer setup +├── processor.py # Event parsing, enrichment, filtering +├── detector.py # Detection engine with stateless and stateful rules +├── renderer.py # Output formatters (JSON, live, table) +└── ebpf/ + ├── process_tracer.c # execve, clone tracepoints + ├── file_tracer.c # openat, unlinkat, renameat2 tracepoints + ├── network_tracer.c # connect, accept4, bind, listen tracepoints + ├── privilege_tracer.c # setuid, setgid tracepoints + └── system_tracer.c # ptrace, mount, init_module tracepoints +``` + +## Example Output + +### Live Mode (default) + +``` +[14:30:01] LOW execve pid=1234 comm=bash /usr/bin/curl +[14:30:01] CRITICAL connect pid=1234 comm=nc 10.0.0.1:4444 [Reverse Shell] +[14:30:02] MEDIUM openat pid=5678 comm=python3 /etc/shadow [Sensitive File Read] +[14:30:03] HIGH init_module pid=9012 comm=insmod [Kernel Module Load] +``` + +### JSON Mode + +```json +{"timestamp":"2026-04-08T14:30:01+00:00","event_type":"connect","pid":1234,"comm":"nc","severity":"CRITICAL","detection":"Reverse Shell","mitre_id":"T1059.004","dest_ip":"10.0.0.1","dest_port":4444} +``` + +## Development + +```bash +# Install dev dependencies +uv sync + +# Run unit tests +just test + +# Lint +just lint + +# Format +just format +``` + +## How It Works + +1. **eBPF C programs** attach to kernel tracepoints for specific syscalls +2. When a traced syscall fires, the eBPF program captures event data (PID, UID, filename, etc.) and pushes it to a shared ring buffer +3. **Python (BCC)** polls the ring buffer and deserializes events via ctypes +4. The **processor** enriches events with data from /proc (parent process, username) +5. The **detection engine** evaluates each event against stateless rules (single-event patterns) and stateful rules (correlated event sequences) +6. The **renderer** outputs events in the selected format with severity-based color coding + +## License + +MIT diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/install.sh b/PROJECTS/beginner/linux-ebpf-security-tracer/install.sh new file mode 100755 index 00000000..e9005d7c --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/install.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[+]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +fail() { echo -e "${RED}[-]${NC} $1"; exit 1; } + +check_root() { + if [[ $EUID -ne 0 ]]; then + warn "Some steps require root. You may be prompted for sudo." + fi +} + +check_kernel() { + local version + version=$(uname -r | cut -d. -f1-2) + local major minor + major=$(echo "$version" | cut -d. -f1) + minor=$(echo "$version" | cut -d. -f2) + + if [[ $major -lt 5 ]] || { [[ $major -eq 5 ]] && [[ $minor -lt 8 ]]; }; then + fail "Kernel $version detected. Requires Linux 5.8+ for ring buffer support." + fi + info "Kernel version $(uname -r) meets requirements (5.8+)" +} + +detect_distro() { + if [[ -f /etc/os-release ]]; then + . /etc/os-release + echo "$ID" + else + echo "unknown" + fi +} + +install_system_deps() { + local distro + distro=$(detect_distro) + + case "$distro" in + ubuntu|debian|pop|linuxmint|kali) + info "Detected Debian-based system ($distro)" + sudo apt-get update -qq + sudo apt-get install -y -qq \ + bpfcc-tools \ + python3-bpfcc \ + libbpfcc-dev \ + linux-headers-"$(uname -r)" \ + 2>/dev/null || true + ;; + fedora) + info "Detected Fedora" + sudo dnf install -y \ + bcc-tools \ + python3-bcc \ + bcc-devel \ + kernel-headers \ + kernel-devel \ + 2>/dev/null || true + ;; + rhel|centos|rocky|alma) + info "Detected RHEL-based system ($distro)" + sudo yum install -y \ + bcc-tools \ + python3-bcc \ + bcc-devel \ + kernel-headers \ + kernel-devel \ + 2>/dev/null || true + ;; + arch|manjaro|endeavouros) + info "Detected Arch-based system ($distro)" + sudo pacman -Sy --noconfirm \ + bcc \ + bcc-tools \ + python-bcc \ + linux-headers \ + 2>/dev/null || true + ;; + *) + warn "Unknown distro: $distro" + warn "Install manually: bcc-tools, python3-bcc, linux-headers" + ;; + esac +} + +install_python_deps() { + if ! command -v uv &>/dev/null; then + info "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + fi + + info "Installing Python dependencies with uv..." + uv sync +} + +verify_install() { + info "Verifying installation..." + + if python3 -c "import bcc" 2>/dev/null; then + info "BCC Python bindings: OK" + else + warn "BCC Python bindings not found in system Python" + warn "Make sure python3-bpfcc (Debian) or python3-bcc (Fedora/Arch) is installed" + fi + + if [[ -d /sys/kernel/debug/tracing ]]; then + info "Tracing filesystem: OK" + else + warn "Tracing filesystem not mounted. Try: sudo mount -t debugfs debugfs /sys/kernel/debug" + fi +} + +main() { + info "eBPF Security Tracer - Installation" + echo "" + check_root + check_kernel + install_system_deps + install_python_deps + verify_install + echo "" + info "Installation complete. Run with: sudo uv run ebpf-tracer" +} + +main "$@" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/justfile b/PROJECTS/beginner/linux-ebpf-security-tracer/justfile new file mode 100644 index 00000000..51b150f6 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/justfile @@ -0,0 +1,27 @@ +# ©AngelaMos | 2026 +# justfile + +default: + @just --list + +lint: + uv run ruff check . + uv run mypy src/ + +format: + uv run yapf -r -i src/ tests/ + +check-format: + uv run yapf -r -d src/ tests/ + +test: + uv run pytest tests/ -m "not integration" + +test-all: + sudo uv run pytest tests/ + +run *ARGS: + sudo uv run ebpf-tracer {{ARGS}} + +install: + ./install.sh diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md new file mode 100644 index 00000000..b07a50c9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md @@ -0,0 +1,127 @@ +# eBPF Security Tracer - Overview + +## What This Is + +A real-time Linux syscall tracer built on eBPF that monitors process execution, file access, network connections, privilege changes, and system operations. It evaluates events against detection rules mapped to MITRE ATT&CK techniques and outputs color-coded alerts. + +## Why This Matters + +Traditional security monitoring relies on log aggregation after the fact. By the time you check syslog, an attacker may have already wiped it. eBPF lets you observe syscalls as they happen, at the kernel level, with near-zero overhead. This is how modern security tools like Falco, Tetragon, and Tracee work under the hood. + +### Real World Scenarios + +1. **Incident Response**: During a live breach, you need to see what processes are running, what files they're touching, and where they're connecting. This tool provides that visibility in real time without deploying a full SIEM stack. + +2. **Server Hardening Validation**: After locking down a production server, run the tracer to verify that only expected processes access sensitive files like `/etc/shadow` or SSH keys. Any unexpected access triggers an alert. + +3. **Container Security**: In Kubernetes environments, containers should never load kernel modules or mount host filesystems. eBPF-based tracing catches these escape attempts at the syscall level before they succeed. + +## What You'll Learn + +### Security Concepts +- Syscall-level observability and why it matters for defense +- MITRE ATT&CK technique identification from raw syscall data +- Detection engineering: turning syscall patterns into security rules +- Behavioral analysis vs signature-based detection + +### Technical Skills +- Writing eBPF C programs that attach to kernel tracepoints +- Using BCC (BPF Compiler Collection) Python bindings +- Ring buffer architecture for kernel-to-userspace communication +- Event correlation with sliding window algorithms +- Structured security event output (JSON, severity classification) + +### Tools +- BCC framework and eBPF compilation pipeline +- Python CLI tooling with Typer and Rich +- ruff, mypy, yapf for code quality +- uv for Python package management + +## Prerequisites + +### Required Knowledge +- Basic Linux administration (processes, files, permissions, networking) +- Python fundamentals (functions, classes, data structures) +- Some familiarity with C syntax (the eBPF programs are small but you need to read them) +- Understanding of what system calls are (even if you've never traced them) + +### Required Tools +- Linux with kernel 5.8+ (check with `uname -r`) +- Root access (eBPF requires CAP_SYS_ADMIN) +- Python 3.10+ +- uv package manager +- BCC tools (installed via `install.sh`) + +### Nice to Have +- Familiarity with strace or ltrace +- Basic networking concepts (TCP/IP, sockets) +- Experience with security monitoring or SIEM tools + +## Quick Start + +```bash +git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git +cd Cybersecurity-Projects/PROJECTS/beginner/linux-ebpf-security-tracer + +# Install everything +./install.sh + +# Start tracing +sudo uv run ebpf-tracer + +# In another terminal, trigger a detection: +cat /etc/shadow # triggers "Sensitive File Read" +``` + +Expected output: + +``` +eBPF Security Tracer v1.0.0 +Format: live | Min severity: LOW | Type: all +Press Ctrl+C to stop + +[14:30:01] LOW execve pid=1234 comm=bash /usr/bin/cat +[14:30:01] MEDIUM openat pid=1234 comm=cat /etc/shadow [Sensitive File Read] +``` + +## Project Structure + +``` +src/ +├── main.py # CLI entrypoint +├── config.py # All constants and detection rule metadata +├── loader.py # BCC loader, ring buffer setup, signal handling +├── processor.py # Raw event parsing, enrichment, filtering +├── detector.py # Detection engine (stateless + stateful rules) +├── renderer.py # Output formatters (JSON, live, table) +└── ebpf/ # eBPF C programs compiled by BCC at runtime + ├── process_tracer.c + ├── file_tracer.c + ├── network_tracer.c + ├── privilege_tracer.c + └── system_tracer.c +``` + +## Next Steps + +- [01-CONCEPTS.md](01-CONCEPTS.md) - eBPF fundamentals, syscall tracing, security observability +- [02-ARCHITECTURE.md](02-ARCHITECTURE.md) - System design, ring buffers, detection pipeline +- [03-IMPLEMENTATION.md](03-IMPLEMENTATION.md) - Code walkthrough of each module +- [04-CHALLENGES.md](04-CHALLENGES.md) - Extension ideas and challenges + +## Common Issues + +**"Error: eBPF tracing requires root privileges"** +Run with sudo: `sudo uv run ebpf-tracer`. eBPF programs need CAP_SYS_ADMIN to load. + +**"Kernel X.Y detected. Requires 5.8+"** +Your kernel is too old for ring buffer support. Upgrade your kernel or use a VM/container with a newer kernel. + +**"BCC Python bindings not found"** +Install the system package: `sudo apt install python3-bpfcc` (Debian/Ubuntu) or `sudo dnf install python3-bcc` (Fedora). BCC is not pip-installable, it must come from your distro's package manager. + +## Related Projects + +- [Simple Port Scanner](../../simple-port-scanner/) - Network reconnaissance basics +- [Simple Vulnerability Scanner](../../simple-vulnerability-scanner/) - Vulnerability identification +- [Linux CIS Hardening Auditor](../../linux-cis-hardening-auditor/) - System hardening diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md new file mode 100644 index 00000000..57e46d6d --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md @@ -0,0 +1,230 @@ +# Concepts - eBPF, Syscalls, and Security Observability + +## eBPF: Programmable Kernel Observability + +### What It Is + +eBPF (extended Berkeley Packet Filter) is a technology that lets you run small programs inside the Linux kernel without writing a kernel module or modifying the kernel source. Think of it as a safe, sandboxed scripting language for the kernel. + +When you load an eBPF program, the kernel's verifier checks it for safety (no infinite loops, no out-of-bounds memory access, no crashing the kernel), then JIT-compiles it to native machine code. This means eBPF programs run at near-native speed with strong safety guarantees. + +### Why It Matters for Security + +Before eBPF, you had two options for kernel-level visibility: + +1. **Kernel modules** - Full access, but a bug crashes the system. Loading untrusted code into the kernel is inherently risky. +2. **System call tracing (strace/ptrace)** - Safe but slow. ptrace-based tracing introduces 10-100x overhead on traced processes. + +eBPF gives you kernel-level visibility with user-space safety. The performance overhead is typically under 1%, and the verifier guarantees your program can't crash the kernel. + +This is why every major cloud security tool released since 2020 (Falco, Tetragon, Tracee, Datadog's runtime security) uses eBPF as its foundation. + +### How It Works + +``` +Your Python Script + │ + ▼ + BCC Compiler + (Clang/LLVM) + │ + ▼ + eBPF Bytecode + │ + ▼ + Kernel Verifier ──▶ Rejects unsafe programs + │ + ▼ + JIT Compiler + │ + ▼ + Native Machine Code + (attached to tracepoint) + │ + ▼ + Fires on every matching syscall + │ + ▼ + Ring Buffer ──▶ Your Python callback +``` + +### BCC vs libbpf + +There are two main frameworks for writing eBPF programs: + +**BCC (BPF Compiler Collection)** compiles your eBPF C code at runtime using Clang/LLVM. The advantage is rapid development: you write C code as a Python string, load it, and go. The disadvantage is that every host needs LLVM and kernel headers installed, and each program uses ~80MB of memory. + +**libbpf with CO-RE** (Compile Once, Run Everywhere) compiles your eBPF program once at build time. The binary works across kernel versions thanks to BTF (BPF Type Format) metadata. Production tools like Tetragon use this approach because it's lighter (~9MB) and doesn't need compiler toolchains on production hosts. + +This project uses BCC because we're building a learning tool, not a production agent. The Python API makes the code readable, and runtime compilation lets you experiment without a build step. + +## System Calls: The Kernel's Front Door + +### What System Calls Are + +Every interaction between a user-space program and the kernel goes through system calls. When `cat` reads a file, it calls `openat()` to get a file descriptor, `read()` to get the contents, and `write()` to print to stdout. When `curl` connects to a server, it calls `socket()`, `connect()`, and `read()`. + +There's no way around this. Even if malware is fully in-memory, even if it's written in assembly, it still needs to make syscalls to do anything useful. This makes syscall tracing a powerful detection mechanism that's very hard to evade. + +### Security-Relevant Syscalls + +Not all ~300+ Linux syscalls matter for security. Here are the ones this tool traces and why: + +**Process execution** - `execve` fires every time a new program runs. This is the most important syscall for security monitoring. Almost every attack involves executing something, whether it's a shell, a payload, or a legitimate tool being abused. + +**File access** - `openat` shows which files processes are reading or writing. An attacker reading `/etc/shadow` or writing to `/etc/cron.d/` tells a clear story. + +**Network activity** - `connect` reveals outbound connections. A web server suddenly connecting to an IP in Eastern Europe on port 4444 is a red flag. `bind` and `listen` show processes opening ports for inbound connections (bind shells). + +**Privilege changes** - `setuid` and `setgid` show privilege transitions. A process calling `setuid(0)` to become root is exactly what privilege escalation looks like. + +**System operations** - `ptrace` is used for debugging but also for process injection (MITRE ATT&CK T1055.008). `mount` can indicate container escape attempts. `init_module` loads kernel modules, which is how rootkits install themselves. + +### The Syscall Tracing Surface + +``` +User Space Process + │ + │ execve("/bin/bash", ...) + │ openat("/etc/shadow", O_RDONLY) + │ connect(sockfd, {ip=10.0.0.1, port=4444}) + │ setuid(0) + │ + ▼ + ┌─────────────────────────┐ + │ Syscall Entry Point │◀── eBPF tracepoint here + │ (kernel boundary) │ + └─────────────────────────┘ + │ + ▼ + Kernel implementation +``` + +## Detection Engineering + +### From Syscalls to Security Alerts + +A single syscall in isolation is rarely suspicious. `openat` fires thousands of times per second on a busy system. The art of detection engineering is identifying which patterns, either single events with unusual parameters or sequences of events, indicate malicious activity. + +### Stateless Detection + +Some events are suspicious on their own: + +- `setuid(0)` called by a process running as UID 1000 is almost always an escalation attempt +- `openat("/etc/shadow")` by a Python script is worth investigating +- `init_module()` loading a kernel module is always notable +- `ptrace(PTRACE_ATTACH, target_pid)` is a code injection primitive + +These are "stateless" detections because each event is evaluated independently. + +### Stateful Detection (Event Correlation) + +Other threats only become visible when you correlate multiple events: + +**Reverse shell pattern**: An attacker on a compromised server needs to get an interactive shell back to their machine. The classic approach: + +``` +1. socket(AF_INET, SOCK_STREAM) # create TCP socket +2. connect(sockfd, attacker_ip) # connect to attacker +3. dup2(sockfd, 0) # redirect stdin to socket +4. dup2(sockfd, 1) # redirect stdout to socket +5. dup2(sockfd, 2) # redirect stderr to socket +6. execve("/bin/bash") # spawn shell +``` + +No single syscall here is suspicious. Programs create sockets and connect to servers all the time. Shells are spawned constantly. But a `connect` followed by a shell `execve` from the same PID within seconds is a strong reverse shell indicator. + +This tool implements this as a stateful rule: it maintains a sliding window of recent events per PID. When a shell execve arrives, it checks if there was a recent `connect` from the same PID or its parent. + +### Real World Examples + +**2021 Log4Shell (CVE-2021-44228)**: The initial exploit triggered a JNDI lookup that downloaded and executed a payload. From a syscall perspective: the Java process (unexpected) called `connect()` to an external LDAP server, downloaded a class file, and then `execve()` spawned a shell. eBPF-based tools detected this in real time while WAFs were still being updated with signatures. + +**2020 SolarWinds Supply Chain Attack**: The compromised Orion software made unusual outbound connections to `avsvmcloud.com`. Syscall tracing would have shown the Orion process calling `connect()` to DNS/HTTP endpoints that weren't in its normal communication pattern. + +**Kubernetes Container Escapes**: CVE-2022-0185 exploited a heap overflow in the kernel's filesystem context handling. The exploit sequence involved `mount()` syscalls with crafted parameters from within a container, something that eBPF-based tools like Tetragon are specifically designed to catch. + +## MITRE ATT&CK Mapping + +The MITRE ATT&CK framework provides a common language for categorizing adversary behavior. This tool maps each detection rule to specific ATT&CK techniques: + +| Detection | Technique | Tactic | +|-----------|-----------|--------| +| Privilege Escalation | T1548 - Abuse Elevation Control | Privilege Escalation | +| Sensitive File Read | T1003.008 - /etc/passwd and /etc/shadow | Credential Access | +| SSH Key Access | T1552.004 - Private Keys | Credential Access | +| Process Injection | T1055.008 - Ptrace System Calls | Defense Evasion | +| Kernel Module Load | T1547.006 - Kernel Modules | Persistence | +| Reverse Shell | T1059.004 - Unix Shell | Execution | +| Persistence via Cron | T1053.003 - Cron | Persistence | +| Log Tampering | T1070.002 - Clear Linux Logs | Defense Evasion | + +## Common Pitfalls + +### Pitfall: Assuming Syscall Names Are Stable + +System call naming varies between architectures and kernel versions. On x86_64, `open()` was replaced by `openat()` as the primary file-opening syscall. Always use the tracepoint interface (`syscalls:sys_enter_openat`) rather than kprobes on raw syscall functions, because tracepoints are stable ABI. + +### Pitfall: Ignoring Event Volume + +On a busy server, `execve` and `openat` fire hundreds of times per second. A detection engine that does expensive processing per event will fall behind. This tool uses a ring buffer (not perf buffer) and keeps detection logic simple for this reason. + +### Pitfall: Over-Alerting + +If every `openat` of `/etc/passwd` triggers an alert, operators will disable the tool within a day. Good detection engineering means understanding what's normal. Root reading `/etc/shadow` is expected (PAM does this for every login). A Python script reading it is unusual. Context matters. + +## How Concepts Connect + +``` +eBPF Programs ──────────────────┐ +(C code in kernel) │ + │ │ + │ capture syscall args │ compile + load + │ │ + ▼ │ +Ring Buffer ◀───────────────────┘ + │ via BCC Python + │ events flow to + │ user space + ▼ +Detection Engine + │ + │ evaluate against rules + │ correlate sequences + │ + ▼ +MITRE ATT&CK Mapping + │ + │ classify severity + │ + ▼ +Alert Output +``` + +## Industry Standards + +- **MITRE ATT&CK for Linux** - Framework for categorizing adversary behavior on Linux systems +- **NIST SP 800-137** - Information Security Continuous Monitoring, which eBPF-based tools directly support +- **CIS Controls v8, Control 8** - Audit Log Management. eBPF tracing provides the raw audit data + +## Testing Your Understanding + +1. Why can't malware avoid syscall-based detection by using direct kernel memory access from user space? + +2. You see this sequence from PID 4521: `socket(AF_INET, SOCK_STREAM)`, then `connect(10.0.0.5:443)`, then `execve("/usr/bin/curl")`. Is this a reverse shell? Why or why not? + +3. A detection rule triggers on every `openat("/etc/passwd")`. On a server with 100 users logging in per hour, how many false positives per hour would you expect? How would you reduce them? + +4. What's the difference between attaching an eBPF program to a kprobe vs a tracepoint? Which is more reliable for production use? + +## Further Reading + +### Essential +- [ebpf.io](https://ebpf.io) - Official eBPF documentation and learning resources +- [BCC Reference Guide](https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md) - API reference for all BCC features +- [MITRE ATT&CK for Linux](https://attack.mitre.org/matrices/enterprise/linux/) - Full technique matrix + +### Deep Dive +- [Learning eBPF by Liz Rice](https://www.oreilly.com/library/view/learning-ebpf/9781098135119/) - Comprehensive book on eBPF programming +- [BPF Performance Tools by Brendan Gregg](https://www.brendangregg.com/bpf-performance-tools-book.html) - Reference for eBPF-based system analysis +- [Falco Rules Repository](https://github.com/falcosecurity/rules) - See how a production tool defines detection rules diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..b6c29442 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md @@ -0,0 +1,297 @@ +# Architecture - System Design and Technical Decisions + +## High Level Architecture + +``` +┌────────────────────────────────────────────────────────┐ +│ User Space │ +│ │ +│ ┌─────────┐ │ +│ │ main.py │ CLI entrypoint │ +│ │ (Typer) │ parses args, wires components │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ ┌─────────────┐ ┌────────────────┐ │ +│ │loader.py │──▶│processor.py │──▶│ renderer.py │ │ +│ │ │ │ │ │ │ │ +│ │ Compiles │ │ Parses raw │ │ JSON / Live / │ │ +│ │ & loads │ │ events, │ │ Table output │ │ +│ │ eBPF C │ │ enriches │ │ │ │ +│ │ programs │ │ from /proc │ └────────────────┘ │ +│ │ │ │ │ │ +│ │ Sets up │ │ Filters by │ │ +│ │ ring buf │ │ severity, │ │ +│ │ callback │ │ PID, comm │ ┌────────────────┐ │ +│ └──────────┘ │ │──▶│ detector.py │ │ +│ └─────────────┘ │ │ │ +│ │ Stateless │ │ +│ │ rules + │ │ +│ │ stateful │ │ +│ │ correlation │ │ +│ └────────────────┘ │ +├────────────────────────────────────────────────────────┤ +│ Kernel Space │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Ring Buffer (shared) │ │ +│ │ BPF_RINGBUF_OUTPUT, 256KB │ │ +│ └──────────┬──────────┬──────────┬─────────────┘ │ +│ │ │ │ │ +│ ┌──────────┴───┐ ┌────┴────┐ ┌──┴──────────┐ │ +│ │process_tracer│ │file_ │ │network_ │ │ +│ │ .c │ │tracer.c │ │tracer.c │ │ +│ │ │ │ │ │ │ │ +│ │ sys_enter_ │ │sys_enter│ │sys_enter_ │ │ +│ │ execve │ │_openat │ │connect │ │ +│ │ sys_enter_ │ │sys_enter│ │sys_enter_ │ │ +│ │ clone │ │_unlinkat│ │accept4 │ │ +│ │ │ │sys_enter│ │sys_enter_ │ │ +│ │ │ │_rename │ │bind/listen │ │ +│ └─────────────┘ └─────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │privilege_ │ │system_ │ │ +│ │tracer.c │ │tracer.c │ │ +│ │ │ │ │ │ +│ │sys_enter_ │ │sys_enter_ │ │ +│ │setuid │ │ptrace │ │ +│ │sys_enter_ │ │sys_enter_ │ │ +│ │setgid │ │mount │ │ +│ │ │ │sys_enter_ │ │ +│ │ │ │init_module │ │ +│ └─────────────┘ └─────────────┘ │ +└────────────────────────────────────────────────────────┘ +``` + +## Component Breakdown + +### main.py - CLI and Orchestration +Parses command line arguments via Typer and wires together the loader, processor, detector, and renderer. Handles signal-based shutdown. This is the thinnest layer: it contains no business logic, just plumbing. + +### loader.py - eBPF Program Lifecycle +Reads `.c` files from the `ebpf/` directory, compiles them via BCC, attaches them to kernel tracepoints, and sets up ring buffer polling. Also handles cleanup: detaching eBPF programs and freeing BPF objects when the tool stops. + +### processor.py - Event Parsing and Enrichment +Defines `RawEvent` (a ctypes Structure mirroring the C struct) and `TracerEvent` (a Python dataclass with enriched fields). Converts raw bytes from the ring buffer into structured Python objects. Enriches events with data from `/proc` (parent process name, username resolution). Implements filtering logic. + +### detector.py - Detection Engine +Contains all security detection logic. Stateless rules evaluate individual events (e.g., "is this a setuid(0) by non-root?"). Stateful rules correlate events across time using a per-PID sliding window (e.g., "was there a connect before this shell execve?"). Returns Detection objects that get stamped onto events. + +### renderer.py - Output Formatting +Three output modes. `LiveRenderer` uses Rich for color-coded streaming. `JsonRenderer` writes one JSON object per line to stdout. `TableRenderer` buffers events and periodically renders Rich tables. `FileRenderer` writes JSON to a file alongside any other output mode. + +### config.py - Constants and Rule Metadata +All magic numbers, file paths, detection rule definitions, severity levels, and event type mappings live here. Nothing is hardcoded elsewhere. Changing a detection rule's severity or adding a new sensitive file path only requires editing this file. + +### ebpf/*.c - Kernel-Space Programs +Five C files, one per syscall category. Each defines a `TRACEPOINT_PROBE` that fires on the corresponding `syscalls:sys_enter_*` event. Programs capture event data into a shared struct and push it to the ring buffer. The C code is intentionally minimal, all detection logic stays in Python. + +## Data Flow + +### Step by Step: From Syscall to Alert + +``` +1. Process calls execve("/bin/bash") + │ +2. Kernel hits tracepoint syscalls:sys_enter_execve + │ +3. eBPF program (process_tracer.c) fires: + - Reserves space in ring buffer + - Fills struct: pid, ppid, uid, comm, filename, timestamp + - Submits to ring buffer + │ +4. Python callback (on_event in main.py) fires: + - parse_raw_event() casts raw bytes to RawEvent ctypes struct + - Converts to TracerEvent dataclass + - Decodes comm/filename from null-terminated bytes + - Converts kernel timestamp to wall clock datetime + - Resolves UID to username via pwd module + │ +5. enrich_event() adds parent process name from /proc + │ +6. detector.evaluate() checks: + - Stateless: Is the event itself suspicious? No. + - Stateful: Is this a shell? Yes (bash). Was there a + recent connect from this PID? Check history deque. + If yes -> Detection("Reverse Shell", CRITICAL) + │ +7. should_include() applies user's filters: + - Severity >= minimum? PID matches? Comm matches? + │ +8. renderer.render() outputs: + [14:30:01] CRITICAL execve pid=1234 comm=bash + /bin/bash [Reverse Shell] +``` + +## Design Patterns + +### Pattern: Kernel Simplicity, Userspace Complexity + +The eBPF C programs do the bare minimum: read syscall arguments, fill a struct, push to ring buffer. All the interesting work (detection, correlation, enrichment, formatting) happens in Python. + +Why? eBPF programs run inside the kernel with strict constraints: +- 512-byte stack limit +- No dynamic memory allocation +- No string manipulation beyond `bpf_probe_read_*` +- The verifier rejects anything complex + +Moving logic to userspace also means you can change detection rules without recompiling eBPF programs, and you can unit test detection logic without root privileges. + +### Pattern: Single Event Struct + +All five eBPF programs use the same `struct event` layout, even though not every field is relevant to every event type. A process event doesn't need `addr_v4` and a network event doesn't need `filename`, but they share the same struct. + +This seems wasteful (the struct is ~300 bytes with mostly-zero fields for most events), but it has major advantages: +- One `RawEvent` ctypes definition in Python, not five +- One ring buffer callback, not five +- Simpler code, fewer bugs + +The alternative (per-type structs with discriminated unions) would save memory but add complexity that isn't justified at this scale. + +### Pattern: Deque-Based Correlation + +The detection engine maintains a `collections.deque` per PID with a max length. Events older than the correlation window are pruned on each evaluation. This gives O(1) append and O(n) scanning where n is small (max 64 events per PID, 10-second window). + +For a tool tracing a typical server, this means ~1000 deques in memory (one per active PID), each holding a few events. Total memory for correlation: a few megabytes at most. + +### Trade-offs + +**Ring buffer vs perf buffer**: Ring buffer (used here) requires kernel 5.8+ but provides event ordering guarantees and lower overhead via the reserve/submit zero-copy API. Perf buffer works on older kernels (4.4+) but has per-CPU allocation waste and no ordering guarantee. + +**BCC vs libbpf**: BCC requires LLVM on the host and uses ~80MB per tool. libbpf with CO-RE produces ~9MB standalone binaries. For a learning tool, BCC's Python API and iterative development experience win. For production, you'd switch to libbpf. + +**Tracepoints vs kprobes**: Tracepoints are stable ABI, they won't break between kernel versions. Kprobes hook arbitrary kernel functions and can break when internal APIs change. This tool uses tracepoints exclusively. + +## Data Models + +### RawEvent (C struct / ctypes) + +| Field | Type | Bytes | Purpose | +|-------|------|-------|---------| +| timestamp_ns | u64 | 8 | Kernel monotonic clock | +| pid | u32 | 4 | Process ID | +| ppid | u32 | 4 | Parent process ID | +| uid | u32 | 4 | User ID | +| gid | u32 | 4 | Group ID | +| event_type | u32 | 4 | Enum: EXECVE=1...INIT_MODULE=14 | +| ret_val | u32 | 4 | Return value or flags | +| comm | char[16] | 16 | Process name (TASK_COMM_LEN) | +| filename | char[256] | 256 | File path or device name | +| addr_v4 | u32 | 4 | IPv4 address (network order) | +| port | u16 | 2 | Port number (host order) | +| protocol | u16 | 2 | Address family (AF_INET=2) | +| target_uid | u32 | 4 | Target UID for setuid | +| target_gid | u32 | 4 | Target GID for setgid | +| ptrace_request | u32 | 4 | ptrace operation type | +| target_pid | u32 | 4 | Target PID for ptrace | +| **Total** | | **324** | | + +### TracerEvent (Python dataclass) + +Extends RawEvent with: +- `timestamp` as `datetime` (converted from kernel nanoseconds) +- `username` resolved from UID +- `severity`, `detection`, `detection_id`, `mitre_id` from detection engine +- `extra` dict for enrichment data (parent_comm, etc.) + +## Security Architecture + +### Privilege Model +The tool requires root (CAP_SYS_ADMIN) to load eBPF programs. It checks at startup with `os.geteuid()` and exits with a clear message if not root. + +### eBPF Safety +The kernel verifier ensures eBPF programs cannot: +- Access memory outside their stack or BPF maps +- Execute unbounded loops +- Call arbitrary kernel functions +- Crash the kernel + +### Cleanup +Signal handlers (SIGINT, SIGTERM) trigger clean shutdown. The `TracerLoader.cleanup()` method calls `bpf.cleanup()` on each BPF object, which detaches tracepoints and frees kernel resources. A `try/finally` block in `main.py` ensures cleanup runs even on exceptions. + +### Input Validation +The tool reads from kernel ring buffers (trusted) and /proc (trusted). There's no user input beyond CLI arguments, which Typer validates via type annotations. + +## Configuration + +All configuration lives in `config.py` as module-level constants: + +| Setting | Value | Purpose | +|---------|-------|---------| +| RING_BUFFER_BYTES | 256KB | Size of shared ring buffer | +| CORRELATION_WINDOW_SEC | 10 | Sliding window for stateful detection | +| MAX_EVENTS_PER_PID | 64 | Max events in correlation deque | +| MIN_KERNEL_MAJOR/MINOR | 5.8 | Minimum kernel version | +| SENSITIVE_READ_PATHS | /etc/shadow, etc. | Files that trigger D002 | +| SHELL_BINARIES | sh, bash, etc. | Binaries that count as "shells" | + +## Performance Considerations + +**Ring buffer sizing**: 256KB is enough for typical workloads. Under extreme syscall rates (>100K/sec), events may be dropped when `ringbuf_reserve` returns NULL. Increase `RING_BUFFER_BYTES` for high-throughput environments. + +**Event enrichment**: Reading `/proc//comm` for every event adds latency. The `--no-enrich` flag disables this for high-volume scenarios. + +**Username caching**: UID-to-username resolution uses a dict cache to avoid repeated `pwd.getpwuid()` calls. + +**Detection engine**: Stateless rules are O(1) per event. Stateful rules scan the deque, which is bounded at 64 entries, so worst case is O(64) comparisons. + +## Design Decisions + +### Why Python, not Go or Rust? + +BCC has mature, well-documented Python bindings. Go bindings exist (via cilium/ebpf) but use libbpf, not BCC. Rust has libbpf-rs. For a beginner project focused on teaching eBPF concepts, Python lets readers focus on the eBPF and security concepts rather than language complexity. + +### Why one ring buffer, not per-tracer? + +Each BPF program gets its own `BPF_RINGBUF_OUTPUT`, but they all use the same struct layout and the same Python callback. This keeps the callback logic simple. The alternative (per-tracer callbacks with per-tracer structs) would require five separate parsing paths. + +### Why tracepoints, not raw_tracepoints? + +Raw tracepoints provide a `bpf_raw_tp_args` struct with fewer abstractions. They're slightly faster but harder to work with, you need to manually cast arguments. Standard tracepoints provide `args->` access with named fields, which is much more readable for a learning project. + +### Why Typer for CLI? + +Consistency with other projects in the repository. Typer provides automatic help generation, type validation, and shell completion with minimal code. + +## Extensibility + +### Adding a New Syscall + +1. Add the event type to `EventType` enum in `config.py` +2. Add it to `EVENT_TYPE_CATEGORIES` +3. Write a `TRACEPOINT_PROBE` in the appropriate `.c` file (or create a new one) +4. If it needs a new detection rule, add to `DETECTION_RULES` and implement in `detector.py` + +### Adding a New Detection Rule + +1. Add a `DetectionRule` entry to `DETECTION_RULES` in `config.py` +2. Implement the check in `_check_stateless()` or `_check_stateful()` in `detector.py` +3. Add a test in `test_detector.py` + +### Adding a New Output Format + +1. Create a new renderer class in `renderer.py` with a `render(event)` method +2. Add the format name to the `OutputFormat` literal type in `config.py` +3. Handle it in `create_renderer()` + +## Limitations + +- **IPv6**: Network tracer only parses IPv4 (`sockaddr_in`). IPv6 support would require handling `sockaddr_in6` and a 128-bit address field. +- **Container awareness**: No container ID or namespace detection. Adding this would require reading `/proc//cgroup` or using BPF helpers for namespace IDs. +- **Argument capture**: Only the first argument (filename) is captured for execve. Full argv capture requires reading the pointer array, which is complex in eBPF due to verifier constraints. +- **File descriptor tracking**: The tool doesn't track fd-to-file mappings, so it can't correlate a `connect()` fd with a subsequent `dup2()` call. +- **No persistence**: Events are not stored. For historical analysis, pipe JSON output to a file or a log aggregation system. + +## Comparison with Production Tools + +| Feature | This Tool | Falco | Tetragon | Tracee | +|---------|-----------|-------|----------|--------| +| eBPF backend | BCC (Python) | libs (C) | libbpf (Go) | libbpf (Go) | +| Syscall coverage | 14 | 50+ | 30+ | 40+ | +| Detection rules | 10 | 100+ | Policy-based | 70+ | +| Enforcement | Detect only | Detect only | Detect + block | Detect only | +| Container awareness | No | Yes | Yes | Yes | +| Memory usage | ~80MB | ~50MB | ~30MB | ~60MB | +| Production ready | No (learning) | Yes | Yes | Yes | + +This tool is a learning resource. It teaches the same fundamentals that power Falco and Tetragon, but at a scale where every line of code is readable and understandable. diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..13e854f6 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md @@ -0,0 +1,487 @@ +# Implementation - Code Walkthrough + +## File Structure + +``` +src/ +├── __init__.py +├── main.py # 140 lines - CLI orchestration +├── config.py # 180 lines - Constants and rule definitions +├── loader.py # 130 lines - BCC loading and ring buffer setup +├── processor.py # 190 lines - Event parsing and enrichment +├── detector.py # 280 lines - Detection engine +├── renderer.py # 250 lines - Output formatting +└── ebpf/ + ├── __init__.py + ├── process_tracer.c # 70 lines - execve, clone + ├── file_tracer.c # 80 lines - openat, unlinkat, renameat2 + ├── network_tracer.c # 100 lines - connect, accept4, bind, listen + ├── privilege_tracer.c # 80 lines - setuid, setgid + └── system_tracer.c # 80 lines - ptrace, mount, init_module +``` + +## Building the eBPF Programs + +### The Event Struct + +Every eBPF program shares the same struct layout. Here's the definition from `process_tracer.c`: + +```c +struct event { + u64 timestamp_ns; + u32 pid; + u32 ppid; + u32 uid; + u32 gid; + u32 event_type; + u32 ret_val; + char comm[TASK_COMM_LEN]; + char filename[FILENAME_LEN]; + u32 addr_v4; + u16 port; + u16 protocol; + u32 target_uid; + u32 target_gid; + u32 ptrace_request; + u32 target_pid; +}; +``` + +This struct is duplicated in each `.c` file because BCC compiles each file independently, there's no shared header mechanism in BCC's compilation model. The Python side mirrors this with a `ctypes.Structure` in `processor.py`. + +Key sizing decisions: +- `comm` is `TASK_COMM_LEN` (16 bytes), the kernel's maximum process name length +- `filename` is 256 bytes, enough for most paths without hitting the 512-byte stack limit +- Network fields use `u32` for IPv4 and `u16` for port, matching `sockaddr_in` layout + +### Tracepoint Attachment + +The `TRACEPOINT_PROBE` macro is BCC syntactic sugar. When you write: + +```c +TRACEPOINT_PROBE(syscalls, sys_enter_execve) { + // args->filename gives you the first argument +} +``` + +BCC generates the attachment code. The `args` struct is auto-generated from the tracepoint format file at `/sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format`. You can inspect it: + +```bash +cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format +``` + +### Ring Buffer Usage + +The reserve/submit pattern avoids unnecessary memory copies: + +```c +BPF_RINGBUF_OUTPUT(events, 1 << 18); + +TRACEPOINT_PROBE(syscalls, sys_enter_execve) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + // Fill the struct directly in ring buffer memory + e->timestamp_ns = bpf_ktime_get_ns(); + e->pid = bpf_get_current_pid_tgid() >> 32; + // ... + + events.ringbuf_submit(e, 0); + return 0; +} +``` + +If `ringbuf_reserve` returns NULL, the buffer is full. The program returns 0 (required by the verifier) and the event is silently dropped. This is a deliberate design choice: dropping events is better than blocking the syscall or crashing. + +### Reading Process Context + +Getting the current process's parent PID requires reading from `task_struct`: + +```c +struct task_struct *task = + (struct task_struct *)bpf_get_current_task(); +bpf_probe_read_kernel( + &e->ppid, sizeof(e->ppid), + &task->real_parent->tgid +); +``` + +`bpf_get_current_task()` returns the current `task_struct` pointer. We can't dereference it directly (verifier would reject it), so we use `bpf_probe_read_kernel()` to safely copy the parent's tgid. + +### Network Address Parsing + +The network tracer needs to extract IP and port from `sockaddr_in`: + +```c +static __always_inline int parse_sockaddr( + struct event *e, const void *uaddr +) { + struct sockaddr_in sa = {}; + bpf_probe_read_user(&sa, sizeof(sa), uaddr); + + if (sa.sin_family == AF_INET) { + e->addr_v4 = sa.sin_addr.s_addr; + e->port = __builtin_bswap16(sa.sin_port); + e->protocol = AF_INET; + } + return 0; +} +``` + +The address is in network byte order (big-endian), so we use `__builtin_bswap16` to convert the port to host byte order. The IPv4 address stays in network order and gets converted to dotted notation in Python. + +## Building the Python Loader + +### BCC Compilation + +`loader.py` reads each `.c` file and passes it to BCC: + +```python +from bcc import BPF + +c_text = src_path.read_text() +bpf = BPF(text=c_text) +bpf["events"].open_ring_buffer(self._callback) +``` + +BCC compiles the C code using Clang/LLVM at runtime. If there's a syntax error in the C code, it fails here with a compilation error. The compiled eBPF bytecode is automatically loaded into the kernel and attached to the tracepoints declared via `TRACEPOINT_PROBE`. + +### Signal Handling + +Clean shutdown is critical. eBPF programs stay attached to the kernel until explicitly detached. If the Python process dies without cleanup, the programs keep running (wasting kernel resources) until the BPF objects are garbage collected. + +```python +def _handle_stop(signum, frame): + self._running = False + +signal.signal(signal.SIGINT, _handle_stop) +signal.signal(signal.SIGTERM, _handle_stop) + +try: + while self._running: + for bpf in self._bpf_objects: + bpf.ring_buffer_poll(timeout=100) +finally: + self.cleanup() +``` + +The 100ms poll timeout means the tool checks for shutdown every 100ms. This is a good balance between responsiveness (Ctrl+C works quickly) and CPU usage (not spinning in a tight loop). + +## Building the Event Processor + +### ctypes Struct Mapping + +The `RawEvent` struct mirrors the C layout exactly: + +```python +class RawEvent(ctypes.Structure): + _fields_ = [ + ("timestamp_ns", ctypes.c_uint64), + ("pid", ctypes.c_uint32), + ("ppid", ctypes.c_uint32), + ("uid", ctypes.c_uint32), + ("gid", ctypes.c_uint32), + ("event_type", ctypes.c_uint32), + ("ret_val", ctypes.c_uint32), + ("comm", ctypes.c_char * TASK_COMM_LEN), + ("filename", ctypes.c_char * MAX_FILENAME_LEN), + ("addr_v4", ctypes.c_uint32), + ("port", ctypes.c_uint16), + ("protocol", ctypes.c_uint16), + ("target_uid", ctypes.c_uint32), + ("target_gid", ctypes.c_uint32), + ("ptrace_request", ctypes.c_uint32), + ("target_pid", ctypes.c_uint32), + ] +``` + +Field order and types must match exactly. A mismatch means the Python side reads garbage. The ring buffer callback casts the raw pointer: + +```python +raw = ctypes.cast( + data, ctypes.POINTER(RawEvent) +).contents +``` + +### Timestamp Conversion + +Kernel timestamps from `bpf_ktime_get_ns()` are monotonic nanoseconds since boot, not wall clock time. To convert: + +```python +def _boot_time_ns(): + for line in Path("/proc/stat").read_text().splitlines(): + if line.startswith("btime"): + return int(line.split()[1]) * 1_000_000_000 + return 0 + +_BOOT_NS = _boot_time_ns() + +def _ktime_to_datetime(ktime_ns): + epoch_ns = _BOOT_NS + ktime_ns + return datetime.fromtimestamp( + epoch_ns / 1_000_000_000, tz=timezone.utc + ) +``` + +`btime` in `/proc/stat` gives the boot time in epoch seconds. Add the kernel nanoseconds to get the wall clock time. + +### IPv4 Conversion + +The kernel stores IPv4 addresses in network byte order (big endian). Converting to dotted notation: + +```python +def _ipv4_to_str(addr): + if addr == 0: + return "" + return ".".join( + str((addr >> (i * 8)) & 0xFF) + for i in range(4) + ) +``` + +For example, `0x0100007F` becomes `127.0.0.1` (byte 0 = 127, byte 1 = 0, byte 2 = 0, byte 3 = 1). + +## Building the Detection Engine + +### Stateless Rules + +Each stateless rule checks a single event against a pattern. The implementation is a series of conditional checks in `_check_stateless()`: + +```python +if event.event_type == "setuid": + if event.target_uid == 0 and event.uid != 0: + rule = DETECTION_RULES["D001"] + return Detection( + rule_id=rule.rule_id, + name=rule.name, + severity=rule.severity, + mitre_id=rule.mitre_id, + description=rule.description, + ) +``` + +The rules are data-driven. `DETECTION_RULES` in `config.py` holds the metadata (ID, name, severity, MITRE mapping). The detection engine only contains the matching logic. + +### File Path Matching + +File-based detections use prefix matching against curated path lists: + +```python +SENSITIVE_READ_PATHS = ( + "/etc/shadow", + "/etc/gshadow", + "/etc/sudoers", + "/etc/master.passwd", +) + +def _path_matches(filepath, patterns): + for pattern in patterns: + if filepath.startswith(pattern): + return True + return False +``` + +Using `startswith` rather than exact match catches paths like `/etc/shadow-` (backup) and `/etc/sudoers.d/custom`. + +### Write Detection via Flags + +The `openat` syscall's `flags` argument tells us if the file is opened for reading or writing. The eBPF program stores flags in `ret_val`: + +```python +O_WRONLY = 1 +O_RDWR = 2 +O_TRUNC = 512 + +def _is_write_flags(flags): + return bool(flags & (O_WRONLY | O_RDWR)) +``` + +This distinguishes reading a cron file (normal) from writing to one (persistence attempt). + +### Stateful Correlation + +The reverse shell detection maintains a deque per PID: + +```python +def _check_stateful(self, event): + if event.event_type != "execve": + return None + if event.comm not in SHELL_BINARIES: + return None + + hist = self._get_history(event.pid) + has_connect = any( + e.event_type == "connect" for e in hist + ) + + if not has_connect: + ppid_hist = self._history.get(event.ppid) + if ppid_hist: + has_connect = any( + e.event_type == "connect" + for e in ppid_hist + ) + + if has_connect: + return Detection(...) +``` + +The parent PID check handles the case where a process does `connect()` then `fork()` + `execve()`. The shell runs as a child process, so the connect event is in the parent's history. + +## Building the Output Renderer + +### Live Mode with Rich + +```python +class LiveRenderer: + def render(self, event): + ts = event.timestamp.strftime("%H:%M:%S") + color = SEVERITY_COLORS.get( + event.severity, "white" + ) + sev = Text(f"{event.severity:8s}", style=color) + # ... build line with Rich Text objects + self._console.print(line) +``` + +Rich's `Text` class supports per-segment styling. CRITICAL events render in bold red, MEDIUM in yellow, LOW in cyan. Detection names appear in bold magenta. + +### JSON Mode + +```python +class JsonRenderer: + def render(self, event): + d = _event_to_dict(event) + self._stream.write(json.dumps(d) + "\n") + self._stream.flush() +``` + +One JSON object per line (JSONL format). `flush()` after each event ensures real-time output when piping to other tools. + +## Testing Strategy + +### Unit Tests (no root required) + +Tests use a `make_event` fixture that creates `TracerEvent` instances without eBPF: + +```python +@pytest.fixture() +def make_event(): + def _make(event_type="execve", pid=1000, ...): + return TracerEvent( + timestamp=datetime.now(tz=timezone.utc), + event_type=event_type, + pid=pid, + ... + ) + return _make +``` + +This lets us test detection rules, filtering, and rendering purely in Python: + +```python +def test_setuid_zero_by_nonroot(self, make_event): + engine = DetectionEngine() + event = make_event( + event_type="setuid", uid=1000, target_uid=0, + ) + result = engine.evaluate(event) + assert result.detection == "Privilege Escalation" + assert result.severity == "CRITICAL" +``` + +### What's Not Tested + +Integration tests (loading eBPF programs, tracing real syscalls) require root and a compatible kernel. These can't run in CI. The `@pytest.mark.integration` marker separates them, and `just test` excludes them by default. + +## Common Pitfalls + +### Pitfall: ctypes Field Order + +If the `_fields_` order in `RawEvent` doesn't match the C struct, every field after the mismatch reads wrong data. The symptom is garbage values for seemingly random fields. Always verify field order matches exactly. + +### Pitfall: String Decoding + +Kernel strings are null-terminated byte arrays. If you forget to split on `\x00`, you'll get trailing garbage bytes in Python: + +```python +# Wrong: raw.comm.decode() might include garbage +# Right: split on null first +raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") +``` + +### Pitfall: Network Byte Order + +IPv4 addresses and ports come from the kernel in network byte order (big-endian). Ports need `__builtin_bswap16` in C or manual byte swapping in Python. IPv4 addresses can be decomposed byte-by-byte. + +### Pitfall: BPF Stack Overflow + +The eBPF stack is 512 bytes. The `struct event` alone is ~324 bytes. If you add local variables, you can exceed the limit. The reserve/submit pattern avoids this by writing directly to ring buffer memory instead of using stack-allocated structs. + +## Code Organization + +### Why No Shared C Header? + +BCC compiles each `.c` file independently. There's no `#include "common.h"` mechanism. Each file defines its own copy of `struct event`. This is redundant but matches how BCC works in practice. + +### Why config.py Instead of YAML/JSON? + +Python constants are type-checked by mypy. They're importable. They don't need a parser. For a tool this size, a config file format adds complexity without benefit. + +### Why Dataclass Instead of Pydantic? + +`TracerEvent` is a simple data container created thousands of times per second. Pydantic's validation overhead isn't justified when the data source is a trusted kernel ring buffer. Standard `dataclass` is lighter and faster. + +## Extending the Code + +### Adding a New Syscall Tracer + +To trace `mprotect` (memory protection changes, useful for detecting JIT spray attacks): + +1. Add `MPROTECT = 15` to `EventType` in `config.py` +2. Add the category mapping: `EventType.MPROTECT: "system"` +3. Add a `TRACEPOINT_PROBE` to `system_tracer.c`: + +```c +TRACEPOINT_PROBE(syscalls, sys_enter_mprotect) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) return 0; + fill_base(e, 15); + e->ret_val = args->prot; + events.ringbuf_submit(e, 0); + return 0; +} +``` + +4. Add a detection rule if `prot` includes `PROT_EXEC` on a previously non-executable region. + +## Dependencies + +| Package | Purpose | Why This One | +|---------|---------|-------------| +| typer | CLI framework | Consistent with repo, auto-help generation | +| rich | Terminal formatting | Color output, tables, text styling | +| bcc | eBPF compilation and loading | Only mature Python eBPF framework | +| pytest | Testing | Standard Python testing framework | +| ruff | Linting | Fast, comprehensive, replaces flake8 | +| mypy | Type checking | Static analysis for Python | +| yapf | Formatting | Repo standard | + +BCC is a system package, not pip-installable. Install via `apt install python3-bpfcc` (Debian) or `dnf install python3-bcc` (Fedora). + +## Build and Deploy + +```bash +# Full setup +./install.sh + +# Development +uv sync # install Python deps +just lint # ruff + mypy +just format # yapf formatting +just test # unit tests (no root) +sudo uv run ebpf-tracer # run the tool +``` + +The tool runs in-place, there's no compilation step for the Python code. The eBPF C programs are compiled by BCC at runtime when the tool starts. diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md new file mode 100644 index 00000000..ba3597e1 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md @@ -0,0 +1,248 @@ +# Challenges - Extend the eBPF Security Tracer + +## Easy Challenges + +### 1. Add IPv6 Support + +**What to build**: Extend the network tracer to parse `sockaddr_in6` and display IPv6 addresses. + +**Why it's useful**: Many modern services and cloud environments use IPv6. Attackers can use IPv6 to bypass IPv4-only monitoring. + +**What you'll learn**: IPv6 address structure, handling multiple address families in eBPF, expanding the event struct. + +**Hints**: +- Add a `u8 addr_v6[16]` field to the event struct +- Check `sa.sin_family == AF_INET6` in `parse_sockaddr` +- Use Python's `ipaddress.IPv6Address(bytes(addr_v6))` for conversion +- Don't forget to update the ctypes `RawEvent` definition + +**Test it works**: Run `curl -6 http://ipv6.google.com` while tracing and verify the IPv6 address appears in output. + +### 2. Add Process Ancestry Chain + +**What to build**: For each event, walk up the process tree (via /proc//status) to show the full ancestry: `bash -> python -> curl`. + +**Why it's useful**: Knowing that a suspicious `connect` came from `systemd -> sshd -> bash -> python -> nc` tells a much richer story than just "nc connected somewhere." + +**What you'll learn**: /proc filesystem, process tree walking, performance tradeoffs of enrichment. + +**Hints**: +- Read `/proc//status` and parse the `PPid:` line +- Walk up until PID 1 or a read error +- Cache results aggressively, process trees don't change often +- Consider a max depth (8-10) to avoid pathological cases + +**Test it works**: Run `bash -c "python3 -c 'import os; os.system(\"ls\")'` and verify the ancestry chain appears. + +### 3. Add an Event Counter Summary + +**What to build**: When the tool exits (Ctrl+C), print a summary table showing total events by type, total detections by severity, and top 10 processes by event count. + +**Why it's useful**: After a tracing session, you want a quick overview of what happened without scrolling through thousands of events. + +**What you'll learn**: Data aggregation, Rich table formatting, clean shutdown patterns. + +**Hints**: +- Add counters to the event processing pipeline (use `collections.Counter`) +- Register a cleanup function that prints the summary +- The `TableRenderer` already shows how to use Rich tables + +**Test it works**: Run the tracer for 30 seconds on a busy system, then Ctrl+C and verify the summary appears. + +## Intermediate Challenges + +### 4. Add Container-Aware Detection + +**What to build**: Detect whether events originate from inside a container and include the container ID in the output. Flag kernel module loads and mount operations from containers as CRITICAL. + +**Why it's useful**: Container escapes are a major attack vector in Kubernetes. Detecting `mount` or `init_module` from inside a container namespace is a strong indicator of an escape attempt. + +**What you'll learn**: Linux namespaces, cgroups, container runtime detection, how containers are just processes with extra isolation. + +**Hints**: +- Read `/proc//cgroup` to detect container membership +- Docker containers have cgroup paths like `/docker/` +- Kubernetes pods have paths like `/kubepods/pod/` +- PID 1 inside a container maps to a regular PID on the host +- Add a `container_id` field to `TracerEvent` + +**Test it works**: Run `docker run --rm alpine sh -c "ls /etc"` while tracing and verify the container ID appears. Test that `mount` from a container triggers a CRITICAL detection. + +### 5. Add Rate-Based Anomaly Detection + +**What to build**: Detect abnormal syscall rates. If a process makes more than N `openat` calls in T seconds (e.g., 100 opens in 5 seconds), flag it as "Rapid File Scanning." + +**Why it's useful**: Automated tools scanning for credentials, sensitive files, or exploitable configurations generate distinctive patterns of rapid file access that normal usage doesn't produce. + +**What you'll learn**: Sliding window rate calculation, threshold-based anomaly detection, tuning false positive rates. + +**Hints**: +- Use the existing per-PID deque in the detection engine +- Count events of each type in the window +- Start with high thresholds to avoid noise, then tune down +- Consider different thresholds per event type (openat is naturally high-volume, ptrace is not) +- Add a `D011` rule to `DETECTION_RULES` + +**Test it works**: Write a script that opens 200 files in a loop and verify the detection triggers. Verify that normal `ls` of a large directory does not trigger it. + +### 6. Add Syslog/JSON-over-UDP Output + +**What to build**: Add an output mode that sends events to a remote syslog server or as JSON over UDP. + +**Why it's useful**: In production, you'd feed eBPF events into a SIEM (Splunk, Elastic, Wazuh). UDP/syslog is the simplest integration point. + +**What you'll learn**: Network programming in Python, syslog protocol (RFC 5424), structured logging for SIEM integration. + +**Hints**: +- `socket.socket(socket.AF_INET, socket.SOCK_DGRAM)` for UDP +- Syslog format: `VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID MSG` +- Map severity levels to syslog priorities (CRITICAL -> LOG_CRIT, etc.) +- Add `--syslog host:port` CLI option + +**Test it works**: Run `nc -ul 1514` in one terminal, start the tracer with `--syslog localhost:1514`, and verify events arrive. + +## Advanced Challenges + +### 7. Add eBPF-Level Filtering + +**What to build**: Move PID and comm filtering into the eBPF programs so filtered events never reach userspace. Currently, all events flow through the ring buffer and get filtered in Python. + +**Why it's useful**: On a busy server generating 50K+ events per second, userspace filtering wastes ring buffer bandwidth. eBPF-level filtering reduces overhead by 10-100x for filtered workloads. + +**What you'll learn**: BPF hash maps for configuration, passing filter state from Python to eBPF, verifier-safe conditional logic. + +**Hints**: +- Use `BPF_HASH(pid_filter, u32, u32)` as a set of PIDs to include +- Populate the map from Python: `b["pid_filter"][ctypes.c_uint32(pid)] = ctypes.c_uint32(1)` +- In the eBPF program, check: `if (pid_filter.lookup(&pid) == NULL) return 0;` +- For comm filtering, use `BPF_HASH(comm_filter, char[16], u32)` +- An empty filter map means "trace all" + +**Test it works**: Benchmark event rate with and without eBPF-level filtering on a process spawning 1000 child processes per second. Measure CPU usage difference. + +### 8. Build a Real-Time Dashboard + +**What to build**: A terminal-based dashboard using Rich's Live display that shows: event rate graph, active detections, top processes, and a scrolling event log, all updating in real time. + +**Why it's useful**: Operational security monitoring needs at-a-glance visibility. A dashboard lets you watch system behavior during incident response without reading individual log lines. + +**What you'll learn**: Rich Live and Layout for TUI design, concurrent data aggregation, refresh rate management. + +**Hints**: +- Use `rich.live.Live` with `rich.layout.Layout` for multi-panel display +- Update every 500ms (2 FPS is enough for human readability) +- Track event rate with a 1-second rolling window +- Use `rich.panel.Panel` for each section +- Consider `rich.progress.SparklineColumn` for rate visualization + +**Test it works**: Run the dashboard on a system under load (e.g., `stress-ng --cpu 4 --io 4`) and verify all panels update correctly. + +## Expert Challenges + +### 9. Build a Detection Rule DSL + +**What to build**: Replace the hardcoded detection logic in `detector.py` with a rule engine that loads detection rules from YAML files, similar to Falco's rule format: + +```yaml +- rule: Reverse Shell Detected + condition: + sequence: + - event_type: connect + within: 10s + - event_type: execve + comm_in: [sh, bash, dash, zsh] + group_by: pid + severity: CRITICAL + mitre: T1059.004 + description: Shell execution following outbound connection +``` + +**Why it's useful**: Hardcoded detection rules require code changes and redeployment. A DSL lets security teams write and modify rules without touching Python code, which is how Falco, Sigma, and YARA work. + +**What you'll learn**: Rule engine design, YAML schema validation, temporal pattern matching, DSL design principles. + +**Hints**: +- Start with stateless rules (single event matching) before tackling sequences +- Use Pydantic for rule schema validation +- Support operators: `eq`, `in`, `startswith`, `regex`, `gt`, `lt` +- For sequence rules, reuse the existing deque-based correlation but make it configurable +- Add `--rules-dir` CLI option to load rules from a directory +- Consider rule priorities (first match vs best match) + +**Test it works**: Port all 10 existing detection rules to YAML. Verify all existing tests still pass against the YAML-loaded rules. Add a custom rule and verify it detects correctly. + +## Mix and Match + +- **Container + Rate Anomaly**: Detect rapid file scanning inside containers (strong indicator of container reconnaissance before an escape attempt) +- **IPv6 + Syslog**: Full-stack monitoring with IPv6 support piped to a SIEM +- **Dashboard + eBPF Filtering**: High-performance dashboard that only shows filtered events + +## Real World Integration + +- **Wazuh**: Pipe JSON output to Wazuh's `ossec.log` or use the API for real-time event ingestion +- **Elastic SIEM**: Send JSONL output to Filebeat, which ships it to Elasticsearch +- **Grafana/Loki**: Use promtail to ship events, build dashboards for event rates and detection counts +- **Slack/PagerDuty**: Add a webhook renderer that sends CRITICAL detections to Slack or triggers PagerDuty incidents + +## Performance Challenges + +### Benchmark and Optimize + +1. Generate load with `stress-ng --syscall 0 --timeout 60s` +2. Measure events/second throughput +3. Profile with `py-spy` to find Python bottlenecks +4. Target: handle 50K+ events/second without dropping events + +### Ring Buffer Tuning + +1. Start with 256KB ring buffer +2. Under load, check drop rate (add a lost event callback) +3. Experiment with 512KB, 1MB, 4MB buffers +4. Find the minimum buffer size that achieves zero drops for your workload + +## Security Challenges + +### Add File Integrity Monitoring + +Monitor writes to critical system files (`/etc/passwd`, `/etc/sudoers`, `/etc/ssh/sshd_config`) and alert on any modification. This is what tools like AIDE and Tripwire do, but in real time. + +### Add Network Allowlist/Denylist + +Maintain a list of expected outbound connections per process. Alert when a process connects to an IP or port not in its allowlist. Start with a learning mode that auto-generates the allowlist. + +### Add Anti-Evasion Detection + +Detect processes that try to evade tracing: renaming themselves to look like system processes, forking rapidly to confuse PID-based tracking, or using `prctl(PR_SET_NAME)` to change their comm. + +## Contribution Ideas + +- Port the eBPF programs from BCC to libbpf for production readiness +- Add eBPF LSM hooks for enforcement (block, not just detect) +- Build a web UI with WebSocket-based real-time event streaming +- Add Sigma rule format support (industry standard detection rules) +- Create systemd unit file for running as a daemon + +## Challenge Completion + +- [ ] Easy 1: IPv6 Support +- [ ] Easy 2: Process Ancestry Chain +- [ ] Easy 3: Event Counter Summary +- [ ] Intermediate 4: Container-Aware Detection +- [ ] Intermediate 5: Rate-Based Anomaly Detection +- [ ] Intermediate 6: Syslog/UDP Output +- [ ] Advanced 7: eBPF-Level Filtering +- [ ] Advanced 8: Real-Time Dashboard +- [ ] Expert 9: Detection Rule DSL + +## Getting Help + +**Debugging eBPF programs**: Add `bpf_trace_printk("debug: %d\n", value)` to your C code and read output with `sudo cat /sys/kernel/debug/tracing/trace_pipe`. This is the printf-debugging equivalent for eBPF. + +**Verifier errors**: The eBPF verifier prints cryptic messages. Common causes: unbounded loop, memory access without bounds check, stack overflow (>512 bytes). Reduce struct sizes or use BPF maps for large data. + +**BCC issues**: If BCC fails to compile, check that kernel headers match your running kernel: `uname -r` should match a directory in `/lib/modules/`. + +**Community resources**: +- [iovisor/bcc GitHub Issues](https://github.com/iovisor/bcc/issues) - BCC-specific questions +- [eBPF Slack](https://ebpf.io/slack) - Community chat +- [Brendan Gregg's Blog](https://www.brendangregg.com/blog/) - eBPF performance analysis diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml b/PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml new file mode 100644 index 00000000..0e15bf2d --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml @@ -0,0 +1,65 @@ +# ©AngelaMos | 2026 +# pyproject.toml + +[project] +name = "ebpf-security-tracer" +version = "1.0.0" +description = "Real-time syscall tracing tool using eBPF for security observability" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "typer>=0.21.1", + "rich>=14.0.0", +] + +[project.scripts] +ebpf-tracer = "src.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.ruff] +line-length = 75 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "C4"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +markers = [ + "integration: requires root and eBPF kernel support", +] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_ignores = false +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "bcc.*" +ignore_missing_imports = true +ignore_errors = true + +[dependency-groups] +dev = [ + "mypy>=1.19.0", + "pytest>=9.0.0", + "ruff>=0.14.0", + "yapf>=0.43.0", +] + +[tool.yapfignore] +ignore_patterns = [ + ".venv/", + "venv/", +] diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py new file mode 100644 index 00000000..c083bad9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py @@ -0,0 +1,242 @@ +""" +©AngelaMos | 2026 +config.py +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from pathlib import Path +from typing import Literal + +PACKAGE_DIR = Path(__file__).parent +EBPF_DIR = PACKAGE_DIR / "ebpf" + +TASK_COMM_LEN = 16 +MAX_FILENAME_LEN = 256 +RING_BUFFER_BYTES = 256 * 1024 +CORRELATION_WINDOW_SEC = 10 +MAX_EVENTS_PER_PID = 64 +SWEEP_INTERVAL = 1000 +MIN_KERNEL_MAJOR = 5 +MIN_KERNEL_MINOR = 8 + +Severity = Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"] +OutputFormat = Literal["json", "table", "live"] +TracerType = Literal["process", "file", "network", "privilege", "system", + "all"] + +SEVERITY_ORDER: dict[str, int] = { + "LOW": 0, + "MEDIUM": 1, + "HIGH": 2, + "CRITICAL": 3, +} + +SEVERITY_COLORS: dict[str, str] = { + "LOW": "cyan", + "MEDIUM": "yellow", + "HIGH": "red", + "CRITICAL": "bold red", +} + + +class EventType(IntEnum): + """ + Numeric identifiers for traced syscall events + """ + EXECVE = 1 + CLONE = 2 + OPENAT = 3 + UNLINKAT = 4 + RENAMEAT2 = 5 + CONNECT = 6 + ACCEPT4 = 7 + BIND = 8 + LISTEN = 9 + SETUID = 10 + SETGID = 11 + PTRACE = 12 + MOUNT = 13 + INIT_MODULE = 14 + + +EVENT_TYPE_NAMES: dict[int, str] = { + e.value: e.name.lower() + for e in EventType +} + +EVENT_TYPE_CATEGORIES: dict[int, str] = { + EventType.EXECVE: "process", + EventType.CLONE: "process", + EventType.OPENAT: "file", + EventType.UNLINKAT: "file", + EventType.RENAMEAT2: "file", + EventType.CONNECT: "network", + EventType.ACCEPT4: "network", + EventType.BIND: "network", + EventType.LISTEN: "network", + EventType.SETUID: "privilege", + EventType.SETGID: "privilege", + EventType.PTRACE: "system", + EventType.MOUNT: "system", + EventType.INIT_MODULE: "system", +} + +SENSITIVE_READ_PATHS: tuple[str, ...] = ( + "/etc/shadow", + "/etc/gshadow", + "/etc/sudoers", + "/etc/master.passwd", +) + +CREDENTIAL_PATHS: tuple[str, ...] = ( + "/.ssh/id_rsa", + "/.ssh/id_ed25519", + "/.ssh/id_ecdsa", + "/.ssh/id_dsa", + "/.ssh/authorized_keys", + "/.aws/credentials", + "/.gnupg/", +) + +CREDENTIAL_ACCESS_ALLOWLIST: tuple[str, ...] = ( + "sshd", + "ssh", + "ssh-agent", + "ssh-add", + "gpg-agent", + "gpg", + "gpg2", + "gpgsm", +) + +PERSISTENCE_CRON_PATHS: tuple[str, ...] = ( + "/etc/cron", + "/var/spool/cron", + "/etc/crontab", +) + +PERSISTENCE_SYSTEMD_PATHS: tuple[str, ...] = ( + "/etc/systemd/system/", + "/lib/systemd/system/", + "/usr/lib/systemd/system/", +) + +LOG_PATHS: tuple[str, ...] = ( + "/var/log/", + "/var/log/syslog", + "/var/log/auth.log", + "/var/log/kern.log", +) + +SHELL_BINARIES: tuple[str, ...] = ( + "sh", + "bash", + "dash", + "zsh", + "csh", + "tcsh", + "fish", + "ksh", +) + +PTRACE_ATTACH = 16 +PTRACE_SEIZE = 16902 +PTRACE_SETREGS = 13 + + +@dataclass(frozen=True) +class DetectionRule: + """ + Metadata for a single detection rule + """ + rule_id: str + name: str + severity: Severity + mitre_id: str + description: str + + +DETECTION_RULES: dict[str, DetectionRule] = { + "D001": + DetectionRule( + rule_id="D001", + name="Privilege Escalation", + severity="CRITICAL", + mitre_id="T1548", + description="setuid(0) called by non-root process", + ), + "D002": + DetectionRule( + rule_id="D002", + name="Sensitive File Read", + severity="MEDIUM", + mitre_id="T1003.008", + description=("Non-standard process reading credential files"), + ), + "D003": + DetectionRule( + rule_id="D003", + name="SSH Key Access", + severity="MEDIUM", + mitre_id="T1552.004", + description="Process accessing SSH key material", + ), + "D004": + DetectionRule( + rule_id="D004", + name="Process Injection", + severity="MEDIUM", + mitre_id="T1055.008", + description="ptrace attach to another process", + ), + "D005": + DetectionRule( + rule_id="D005", + name="Kernel Module Load", + severity="HIGH", + mitre_id="T1547.006", + description="Kernel module loaded via init_module", + ), + "D006": + DetectionRule( + rule_id="D006", + name="Reverse Shell", + severity="CRITICAL", + mitre_id="T1059.004", + description=("Shell execution following network connection"), + ), + "D007": + DetectionRule( + rule_id="D007", + name="Persistence via Cron", + severity="MEDIUM", + mitre_id="T1053.003", + description="Write operation to cron directories", + ), + "D008": + DetectionRule( + rule_id="D008", + name="Persistence via Systemd", + severity="MEDIUM", + mitre_id="T1543.002", + description=("Write operation to systemd unit directories"), + ), + "D009": + DetectionRule( + rule_id="D009", + name="Log Tampering", + severity="MEDIUM", + mitre_id="T1070.002", + description="Deletion or truncation of log files", + ), + "D010": + DetectionRule( + rule_id="D010", + name="Suspicious Mount", + severity="HIGH", + mitre_id="T1611", + description=("Filesystem mount operation detected"), + ), +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py new file mode 100644 index 00000000..d6d72688 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py @@ -0,0 +1,243 @@ +""" +©AngelaMos | 2026 +detector.py +""" +from __future__ import annotations + +import time +from collections import deque + +from .config import ( + CORRELATION_WINDOW_SEC, + CREDENTIAL_ACCESS_ALLOWLIST, + CREDENTIAL_PATHS, + DETECTION_RULES, + LOG_PATHS, + MAX_EVENTS_PER_PID, + PERSISTENCE_CRON_PATHS, + PERSISTENCE_SYSTEMD_PATHS, + PTRACE_ATTACH, + PTRACE_SEIZE, + PTRACE_SETREGS, + SENSITIVE_READ_PATHS, + SHELL_BINARIES, + SWEEP_INTERVAL, + DetectionRule, +) +from .processor import TracerEvent + + +def _apply_detection( + event: TracerEvent, + rule: DetectionRule, +) -> TracerEvent: + """ + Stamp a detection onto an event + """ + event.severity = rule.severity + event.detection = rule.name + event.detection_id = rule.rule_id + event.mitre_id = rule.mitre_id + return event + + +def _path_matches( + filepath: str, + patterns: tuple[str, ...], +) -> bool: + """ + Check if a filepath starts with any pattern + """ + for pattern in patterns: + if filepath.startswith(pattern): + return True + return False + + +def _path_contains( + filepath: str, + patterns: tuple[str, ...], +) -> bool: + """ + Check if a filepath contains any pattern + """ + for pattern in patterns: + if pattern in filepath: + return True + return False + + +O_WRONLY = 1 +O_RDWR = 2 +O_TRUNC = 512 +O_CREAT = 64 + + +def _is_write_flags(flags: int) -> bool: + """ + Determine if openat flags indicate a write operation + """ + return bool(flags & (O_WRONLY | O_RDWR)) + + +class DetectionEngine: + """ + Evaluates events against detection rules + """ + + def __init__(self) -> None: + """ + Initialize event history for correlation + """ + self._history: dict[int, deque[TracerEvent]] = {} + self._event_count = 0 + + def _get_history( + self, + pid: int, + ) -> deque[TracerEvent]: + """ + Get or create the event deque for a PID + """ + if pid not in self._history: + self._history[pid] = deque(maxlen=MAX_EVENTS_PER_PID) + return self._history[pid] + + def _prune_history(self, pid: int) -> None: + """ + Remove stale events outside the correlation window + """ + if pid not in self._history: + return + + cutoff = time.monotonic() - CORRELATION_WINDOW_SEC + hist = self._history[pid] + + while hist and hist[0].extra.get("_mono_time", 0) < cutoff: + hist.popleft() + + if not hist: + del self._history[pid] + + def _sweep_stale(self) -> None: + """ + Remove all PIDs with only expired events + """ + cutoff = time.monotonic() - CORRELATION_WINDOW_SEC + stale_pids = [ + pid for pid, hist in self._history.items() + if not hist or hist[-1].extra.get("_mono_time", 0) < cutoff + ] + for pid in stale_pids: + del self._history[pid] + + def evaluate( + self, + event: TracerEvent, + ) -> TracerEvent: + """ + Run all detection rules against an event + """ + event.extra["_mono_time"] = time.monotonic() + + det = self._check_stateless(event) + if det is None: + det = self._check_stateful(event) + + hist = self._get_history(event.pid) + hist.append(event) + self._prune_history(event.pid) + + self._event_count += 1 + if self._event_count % SWEEP_INTERVAL == 0: + self._sweep_stale() + + if det is not None: + return _apply_detection(event, det) + return event + + def _check_stateless( + self, + event: TracerEvent, + ) -> DetectionRule | None: + """ + Check single-event detection rules + """ + if event.event_type == "setuid": + if event.target_uid == 0 and event.uid != 0: + return DETECTION_RULES["D001"] + + if event.event_type == "openat": + filename = event.filename + + if _path_matches(filename, SENSITIVE_READ_PATHS): + if (event.uid != 0 and not _is_write_flags(event.flags)): + return DETECTION_RULES["D002"] + + if _path_contains(filename, CREDENTIAL_PATHS): + if event.comm not in CREDENTIAL_ACCESS_ALLOWLIST: + return DETECTION_RULES["D003"] + + if _is_write_flags(event.flags): + if _path_matches(filename, PERSISTENCE_CRON_PATHS): + return DETECTION_RULES["D007"] + + if _path_matches(filename, PERSISTENCE_SYSTEMD_PATHS): + return DETECTION_RULES["D008"] + + if _path_matches(filename, LOG_PATHS): + if event.flags & O_TRUNC: + return DETECTION_RULES["D009"] + + if event.event_type == "unlinkat": + if _path_matches(event.filename, LOG_PATHS): + return DETECTION_RULES["D009"] + + if event.event_type == "ptrace": + if event.ptrace_request in ( + PTRACE_ATTACH, + PTRACE_SEIZE, + PTRACE_SETREGS, + ): + return DETECTION_RULES["D004"] + + if event.event_type == "init_module": + return DETECTION_RULES["D005"] + + if event.event_type == "mount": + return DETECTION_RULES["D010"] + + return None + + def _check_stateful( + self, + event: TracerEvent, + ) -> DetectionRule | None: + """ + Check multi-event correlation rules + """ + if event.event_type == "execve": + if event.comm not in SHELL_BINARIES: + return None + + hist = self._get_history(event.pid) + has_connect = any(e.event_type == "connect" for e in hist) + + if not has_connect: + ppid_hist = self._history.get(event.ppid) + if ppid_hist: + has_connect = any(e.event_type == "connect" + for e in ppid_hist) + + if has_connect: + return DETECTION_RULES["D006"] + + if event.event_type == "connect": + hist = self._get_history(event.pid) + has_shell = any( + e.event_type == "execve" and e.comm in SHELL_BINARIES + for e in hist) + if has_shell: + return DETECTION_RULES["D006"] + + return None diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c new file mode 100644 index 00000000..68670669 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c @@ -0,0 +1,97 @@ +// ©AngelaMos | 2026 +// file_tracer.c + +#include +#include + +#define EVENT_OPENAT 3 +#define EVENT_UNLINKAT 4 +#define EVENT_RENAMEAT2 5 +#define FILENAME_LEN 256 + +struct event { + u64 timestamp_ns; + u32 pid; + u32 ppid; + u32 uid; + u32 gid; + u32 event_type; + u32 flags; + char comm[TASK_COMM_LEN]; + char filename[FILENAME_LEN]; + u32 addr_v4; + u16 port; + u16 protocol; + u32 target_uid; + u32 target_gid; + u32 ptrace_request; + u32 target_pid; +}; + +BPF_RINGBUF_OUTPUT(events, 1 << 18); + +static __always_inline void fill_base(struct event *e, u32 etype) { + u64 pid_tgid = bpf_get_current_pid_tgid(); + u64 uid_gid = bpf_get_current_uid_gid(); + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + + e->timestamp_ns = bpf_ktime_get_ns(); + e->pid = pid_tgid >> 32; + e->uid = uid_gid & 0xFFFFFFFF; + e->gid = uid_gid >> 32; + e->event_type = etype; + e->flags = 0; + + bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid), + &task->real_parent->tgid); + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + __builtin_memset(e->filename, 0, sizeof(e->filename)); + + e->addr_v4 = 0; + e->port = 0; + e->protocol = 0; + e->target_uid = 0; + e->target_gid = 0; + e->ptrace_request = 0; + e->target_pid = 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_openat) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_OPENAT); + bpf_probe_read_user_str(e->filename, sizeof(e->filename), + args->filename); + e->flags = args->flags; + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_unlinkat) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_UNLINKAT); + bpf_probe_read_user_str(e->filename, sizeof(e->filename), + args->pathname); + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_renameat2) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_RENAMEAT2); + bpf_probe_read_user_str(e->filename, sizeof(e->filename), + args->newname); + + events.ringbuf_submit(e, 0); + return 0; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c new file mode 100644 index 00000000..34dff556 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c @@ -0,0 +1,121 @@ +// ©AngelaMos | 2026 +// network_tracer.c + +#include +#include +#include +#include + +#define EVENT_CONNECT 6 +#define EVENT_ACCEPT4 7 +#define EVENT_BIND 8 +#define EVENT_LISTEN 9 +#define FILENAME_LEN 256 + +struct event { + u64 timestamp_ns; + u32 pid; + u32 ppid; + u32 uid; + u32 gid; + u32 event_type; + u32 flags; + char comm[TASK_COMM_LEN]; + char filename[FILENAME_LEN]; + u32 addr_v4; + u16 port; + u16 protocol; + u32 target_uid; + u32 target_gid; + u32 ptrace_request; + u32 target_pid; +}; + +BPF_RINGBUF_OUTPUT(events, 1 << 18); + +static __always_inline void fill_base(struct event *e, u32 etype) { + u64 pid_tgid = bpf_get_current_pid_tgid(); + u64 uid_gid = bpf_get_current_uid_gid(); + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + + e->timestamp_ns = bpf_ktime_get_ns(); + e->pid = pid_tgid >> 32; + e->uid = uid_gid & 0xFFFFFFFF; + e->gid = uid_gid >> 32; + e->event_type = etype; + e->flags = 0; + + bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid), + &task->real_parent->tgid); + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + __builtin_memset(e->filename, 0, sizeof(e->filename)); + + e->addr_v4 = 0; + e->port = 0; + e->protocol = 0; + e->target_uid = 0; + e->target_gid = 0; + e->ptrace_request = 0; + e->target_pid = 0; +} + +static __always_inline int parse_sockaddr( + struct event *e, const void *uaddr +) { + struct sockaddr_in sa = {}; + bpf_probe_read_user(&sa, sizeof(sa), uaddr); + + if (sa.sin_family == AF_INET) { + e->addr_v4 = sa.sin_addr.s_addr; + e->port = __builtin_bswap16(sa.sin_port); + e->protocol = AF_INET; + } + + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_connect) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_CONNECT); + parse_sockaddr(e, args->uservaddr); + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_accept4) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_ACCEPT4); + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_bind) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_BIND); + parse_sockaddr(e, args->umyaddr); + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_listen) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_LISTEN); + + events.ringbuf_submit(e, 0); + return 0; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c new file mode 100644 index 00000000..4f61d6d4 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// privilege_tracer.c + +#include +#include + +#define EVENT_SETUID 10 +#define EVENT_SETGID 11 +#define FILENAME_LEN 256 + +struct event { + u64 timestamp_ns; + u32 pid; + u32 ppid; + u32 uid; + u32 gid; + u32 event_type; + u32 flags; + char comm[TASK_COMM_LEN]; + char filename[FILENAME_LEN]; + u32 addr_v4; + u16 port; + u16 protocol; + u32 target_uid; + u32 target_gid; + u32 ptrace_request; + u32 target_pid; +}; + +BPF_RINGBUF_OUTPUT(events, 1 << 18); + +static __always_inline void fill_base(struct event *e, u32 etype) { + u64 pid_tgid = bpf_get_current_pid_tgid(); + u64 uid_gid = bpf_get_current_uid_gid(); + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + + e->timestamp_ns = bpf_ktime_get_ns(); + e->pid = pid_tgid >> 32; + e->uid = uid_gid & 0xFFFFFFFF; + e->gid = uid_gid >> 32; + e->event_type = etype; + e->flags = 0; + + bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid), + &task->real_parent->tgid); + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + __builtin_memset(e->filename, 0, sizeof(e->filename)); + + e->addr_v4 = 0; + e->port = 0; + e->protocol = 0; + e->target_uid = 0; + e->target_gid = 0; + e->ptrace_request = 0; + e->target_pid = 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_setuid) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_SETUID); + e->target_uid = args->uid; + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_setgid) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_SETGID); + e->target_gid = args->gid; + + events.ringbuf_submit(e, 0); + return 0; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c new file mode 100644 index 00000000..07c759ae --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// process_tracer.c + +#include +#include + +#define EVENT_EXECVE 1 +#define EVENT_CLONE 2 +#define FILENAME_LEN 256 + +struct event { + u64 timestamp_ns; + u32 pid; + u32 ppid; + u32 uid; + u32 gid; + u32 event_type; + u32 flags; + char comm[TASK_COMM_LEN]; + char filename[FILENAME_LEN]; + u32 addr_v4; + u16 port; + u16 protocol; + u32 target_uid; + u32 target_gid; + u32 ptrace_request; + u32 target_pid; +}; + +BPF_RINGBUF_OUTPUT(events, 1 << 18); + +static __always_inline void fill_base(struct event *e, u32 etype) { + u64 pid_tgid = bpf_get_current_pid_tgid(); + u64 uid_gid = bpf_get_current_uid_gid(); + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + + e->timestamp_ns = bpf_ktime_get_ns(); + e->pid = pid_tgid >> 32; + e->uid = uid_gid & 0xFFFFFFFF; + e->gid = uid_gid >> 32; + e->event_type = etype; + e->flags = 0; + + bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid), + &task->real_parent->tgid); + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + __builtin_memset(e->filename, 0, sizeof(e->filename)); + + e->addr_v4 = 0; + e->port = 0; + e->protocol = 0; + e->target_uid = 0; + e->target_gid = 0; + e->ptrace_request = 0; + e->target_pid = 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_execve) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_EXECVE); + bpf_probe_read_user_str(e->filename, sizeof(e->filename), + args->filename); + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_clone) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_CLONE); + + events.ringbuf_submit(e, 0); + return 0; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c new file mode 100644 index 00000000..354c989b --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c @@ -0,0 +1,94 @@ +// ©AngelaMos | 2026 +// system_tracer.c + +#include +#include + +#define EVENT_PTRACE 12 +#define EVENT_MOUNT 13 +#define EVENT_INIT_MODULE 14 +#define FILENAME_LEN 256 + +struct event { + u64 timestamp_ns; + u32 pid; + u32 ppid; + u32 uid; + u32 gid; + u32 event_type; + u32 flags; + char comm[TASK_COMM_LEN]; + char filename[FILENAME_LEN]; + u32 addr_v4; + u16 port; + u16 protocol; + u32 target_uid; + u32 target_gid; + u32 ptrace_request; + u32 target_pid; +}; + +BPF_RINGBUF_OUTPUT(events, 1 << 18); + +static __always_inline void fill_base(struct event *e, u32 etype) { + u64 pid_tgid = bpf_get_current_pid_tgid(); + u64 uid_gid = bpf_get_current_uid_gid(); + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + + e->timestamp_ns = bpf_ktime_get_ns(); + e->pid = pid_tgid >> 32; + e->uid = uid_gid & 0xFFFFFFFF; + e->gid = uid_gid >> 32; + e->event_type = etype; + e->flags = 0; + + bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid), + &task->real_parent->tgid); + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + __builtin_memset(e->filename, 0, sizeof(e->filename)); + + e->addr_v4 = 0; + e->port = 0; + e->protocol = 0; + e->target_uid = 0; + e->target_gid = 0; + e->ptrace_request = 0; + e->target_pid = 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_ptrace) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_PTRACE); + e->ptrace_request = args->request; + e->target_pid = args->pid; + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_mount) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_MOUNT); + bpf_probe_read_user_str(e->filename, sizeof(e->filename), + args->dev_name); + + events.ringbuf_submit(e, 0); + return 0; +} + +TRACEPOINT_PROBE(syscalls, sys_enter_init_module) { + struct event *e = events.ringbuf_reserve(sizeof(*e)); + if (!e) + return 0; + + fill_base(e, EVENT_INIT_MODULE); + + events.ringbuf_submit(e, 0); + return 0; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py new file mode 100644 index 00000000..cca2a579 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py @@ -0,0 +1,129 @@ +""" +©AngelaMos | 2026 +loader.py +""" +from __future__ import annotations + +import os +import signal +import sys +from collections.abc import Callable +from typing import Any + +from .config import ( + EBPF_DIR, + MIN_KERNEL_MAJOR, + MIN_KERNEL_MINOR, + TracerType, +) + +TRACER_FILES: dict[str, str] = { + "process": "process_tracer.c", + "file": "file_tracer.c", + "network": "network_tracer.c", + "privilege": "privilege_tracer.c", + "system": "system_tracer.c", +} + + +def check_privileges() -> None: + """ + Verify the process has root privileges + """ + if os.geteuid() != 0: + sys.stderr.write("Error: eBPF tracing requires root privileges.\n" + "Run with: sudo uv run ebpf-tracer\n") + sys.exit(1) + + +def check_kernel_version() -> None: + """ + Verify the kernel version supports ring buffers + """ + release = os.uname().release + parts = release.split(".") + major = int(parts[0]) + minor = int(parts[1].split("-")[0]) + + if (major < MIN_KERNEL_MAJOR + or (major == MIN_KERNEL_MAJOR and minor < MIN_KERNEL_MINOR)): + sys.stderr.write(f"Error: Kernel {release} detected. " + f"Requires {MIN_KERNEL_MAJOR}." + f"{MIN_KERNEL_MINOR}+ for ring buffer.\n") + sys.exit(1) + + +def _resolve_tracers(tracer_type: TracerType, ) -> list[str]: + """ + Determine which tracer files to load + """ + if tracer_type == "all": + return list(TRACER_FILES.keys()) + return [tracer_type] + + +class TracerLoader: + """ + Loads and manages eBPF programs via BCC + """ + + def __init__( + self, + tracer_type: TracerType, + callback: Callable[..., None], + ) -> None: + """ + Initialize the loader with tracer selection + """ + self._bpf_objects: list[Any] = [] + self._tracer_type = tracer_type + self._callback = callback + self._running = False + + def load(self) -> None: + """ + Compile and load all selected eBPF programs + """ + from bcc import BPF # type: ignore[import-untyped] + + tracers = _resolve_tracers(self._tracer_type) + + for name in tracers: + filename = TRACER_FILES[name] + src_path = EBPF_DIR / filename + c_text = src_path.read_text() + + bpf = BPF(text=c_text) + bpf["events"].open_ring_buffer(self._callback) + self._bpf_objects.append(bpf) + + def poll(self) -> None: + """ + Start polling all ring buffers for events + """ + self._running = True + original_sigint = signal.getsignal(signal.SIGINT) + original_sigterm = signal.getsignal(signal.SIGTERM) + + def _handle_stop(signum: int, frame: Any) -> None: + self._running = False + + signal.signal(signal.SIGINT, _handle_stop) + signal.signal(signal.SIGTERM, _handle_stop) + + try: + while self._running: + for bpf in self._bpf_objects: + bpf.ring_buffer_poll(timeout=100) + finally: + signal.signal(signal.SIGINT, original_sigint) + signal.signal(signal.SIGTERM, original_sigterm) + self.cleanup() + + def cleanup(self) -> None: + """ + Detach all eBPF programs and free resources + """ + for bpf in self._bpf_objects: + bpf.cleanup() + self._bpf_objects.clear() diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py new file mode 100644 index 00000000..921840cf --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py @@ -0,0 +1,160 @@ +""" +©AngelaMos | 2026 +main.py +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer +from rich.console import Console + +from .config import OutputFormat, Severity, TracerType +from .detector import DetectionEngine +from .loader import ( + TracerLoader, + check_kernel_version, + check_privileges, +) +from .processor import ( + enrich_event, + parse_raw_event, + should_include, +) +from .renderer import ( + FileRenderer, + TableRenderer, + create_renderer, +) + +app = typer.Typer( + name="ebpf-tracer", + help="Real-time syscall tracing with eBPF", + add_completion=False, +) + +console = Console() + +VERSION = "1.0.0" + + +def _version_callback(value: bool) -> None: + """ + Print version and exit + """ + if value: + console.print(f"ebpf-tracer v{VERSION}") + raise typer.Exit() + + +@app.command() +def trace( + format: OutputFormat = typer.Option( + "live", + "--format", + "-f", + help="Output format", + ), + severity: Severity = typer.Option( + "LOW", + "--severity", + "-s", + help="Minimum severity level", + ), + pid: int | None = typer.Option( + None, + "--pid", + "-p", + help="Filter by PID", + ), + comm: str | None = typer.Option( + None, + "--comm", + "-c", + help="Filter by process name", + ), + tracer_type: TracerType = typer.Option( + "all", + "--type", + "-t", + help="Event category filter", + ), + no_enrich: bool = typer.Option( + False, + "--no-enrich", + help="Disable /proc enrichment", + ), + output: Path | None = typer.Option( + None, + "--output", + "-o", + help="Also write events to file", + ), + detections_only: bool = typer.Option( + False, + "--detections", + help="Show only detection alerts", + ), + version: bool = typer.Option( + False, + "--version", + callback=_version_callback, + is_eager=True, + help="Show version", + ), +) -> None: + """ + Start the eBPF security tracer + """ + check_privileges() + check_kernel_version() + + detector = DetectionEngine() + renderer = create_renderer(format) + + file_renderer: FileRenderer | None = None + if output is not None: + file_renderer = FileRenderer(output) + + def on_event(ctx: Any, data: Any, size: int) -> None: + event = parse_raw_event(ctx, data, size) + + if not no_enrich: + event = enrich_event(event) + + event = detector.evaluate(event) + + if not should_include( + event, + severity, + pid, + comm, + tracer_type, + detections_only, + ): + return + + renderer.render(event) + + if file_renderer is not None: + file_renderer.render(event) + + console.print("[bold green]eBPF Security Tracer[/] " + f"v{VERSION}") + console.print(f"Format: {format} | " + f"Min severity: {severity} | " + f"Type: {tracer_type}") + console.print("Press Ctrl+C to stop\n") + + loader = TracerLoader(tracer_type, on_event) + + try: + loader.load() + loader.poll() + finally: + if isinstance(renderer, TableRenderer): + renderer.finalize() + if file_renderer is not None: + file_renderer.close() + console.print("\n[bold red]Tracer stopped[/]") diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py new file mode 100644 index 00000000..f48ca289 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py @@ -0,0 +1,232 @@ +""" +©AngelaMos | 2026 +processor.py +""" +from __future__ import annotations + +import ctypes +import pwd +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .config import ( + EVENT_TYPE_CATEGORIES, + EVENT_TYPE_NAMES, + MAX_FILENAME_LEN, + SEVERITY_ORDER, + TASK_COMM_LEN, + Severity, + TracerType, +) + + +class RawEvent(ctypes.Structure): + """ + Mirrors the C struct event layout from eBPF programs + """ + _fields_ = [ + ("timestamp_ns", ctypes.c_uint64), + ("pid", ctypes.c_uint32), + ("ppid", ctypes.c_uint32), + ("uid", ctypes.c_uint32), + ("gid", ctypes.c_uint32), + ("event_type", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("comm", ctypes.c_char * TASK_COMM_LEN), + ("filename", ctypes.c_char * MAX_FILENAME_LEN), + ("addr_v4", ctypes.c_uint32), + ("port", ctypes.c_uint16), + ("protocol", ctypes.c_uint16), + ("target_uid", ctypes.c_uint32), + ("target_gid", ctypes.c_uint32), + ("ptrace_request", ctypes.c_uint32), + ("target_pid", ctypes.c_uint32), + ] + + +@dataclass +class TracerEvent: + """ + Processed event with enriched metadata + """ + timestamp: datetime + event_type: str + category: str + pid: int + ppid: int + uid: int + gid: int + username: str + comm: str + filename: str + addr_v4: str + port: int + protocol: int + target_uid: int + target_gid: int + ptrace_request: int + target_pid: int + flags: int + severity: Severity = "LOW" + detection: str | None = None + detection_id: str | None = None + mitre_id: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + +def _decode_comm(raw: bytes) -> str: + """ + Decode a null-terminated comm field + """ + return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + + +def _decode_filename(raw: bytes) -> str: + """ + Decode a null-terminated filename field + """ + return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + + +def _ipv4_to_str(addr: int) -> str: + """ + Convert a 32-bit network-order IPv4 address to string + """ + if addr == 0: + return "" + return ".".join(str((addr >> (i * 8)) & 0xFF) for i in range(4)) + + +_UID_CACHE: dict[int, str] = {} + + +def _resolve_username(uid: int) -> str: + """ + Resolve UID to username with caching + """ + if uid in _UID_CACHE: + return _UID_CACHE[uid] + + try: + name = pwd.getpwuid(uid).pw_name + except KeyError: + name = str(uid) + + _UID_CACHE[uid] = name + return name + + +def _boot_time_ns() -> int: + """ + Read system boot time for timestamp conversion + """ + stat_path = Path("/proc/stat") + if not stat_path.exists(): + return 0 + + for line in stat_path.read_text().splitlines(): + if line.startswith("btime"): + return int(line.split()[1]) * 1_000_000_000 + return 0 + + +_BOOT_NS = _boot_time_ns() + + +def _ktime_to_datetime(ktime_ns: int) -> datetime: + """ + Convert kernel monotonic timestamp to wall clock + """ + epoch_ns = _BOOT_NS + ktime_ns + return datetime.fromtimestamp(epoch_ns / 1_000_000_000, + tz=timezone.utc) + + +def parse_raw_event( + ctx: Any, + data: Any, + size: int, +) -> TracerEvent: + """ + Convert raw ring buffer bytes to a TracerEvent + """ + raw = ctypes.cast(data, ctypes.POINTER(RawEvent)).contents + + etype = raw.event_type + type_name = EVENT_TYPE_NAMES.get(etype, f"unknown_{etype}") + category = EVENT_TYPE_CATEGORIES.get(etype, "unknown") + + return TracerEvent( + timestamp=_ktime_to_datetime(raw.timestamp_ns), + event_type=type_name, + category=category, + pid=raw.pid, + ppid=raw.ppid, + uid=raw.uid, + gid=raw.gid, + username=_resolve_username(raw.uid), + comm=_decode_comm(raw.comm), + filename=_decode_filename(raw.filename), + addr_v4=_ipv4_to_str(raw.addr_v4), + port=raw.port, + protocol=raw.protocol, + target_uid=raw.target_uid, + target_gid=raw.target_gid, + ptrace_request=raw.ptrace_request, + target_pid=raw.target_pid, + flags=raw.flags, + ) + + +def _resolve_parent_comm(ppid: int) -> str: + """ + Read parent process name from /proc + """ + comm_path = Path(f"/proc/{ppid}/comm") + try: + return comm_path.read_text().strip() + except (FileNotFoundError, PermissionError): + return "" + + +def enrich_event(event: TracerEvent) -> TracerEvent: + """ + Add additional context from /proc filesystem + """ + parent_comm = _resolve_parent_comm(event.ppid) + if parent_comm: + event.extra["parent_comm"] = parent_comm + return event + + +def should_include( + event: TracerEvent, + min_severity: Severity, + pid_filter: int | None, + comm_filter: str | None, + tracer_type: TracerType, + detections_only: bool, +) -> bool: + """ + Determine if an event passes all active filters + """ + if detections_only and event.detection is None: + return False + + sev_val = SEVERITY_ORDER.get(event.severity, 0) + min_val = SEVERITY_ORDER.get(min_severity, 0) + if sev_val < min_val: + return False + + if pid_filter is not None and event.pid != pid_filter: + return False + + if (comm_filter is not None and event.comm != comm_filter): + return False + + if tracer_type != "all" and event.category != tracer_type: + return False + + return True diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py new file mode 100644 index 00000000..c5cf6771 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py @@ -0,0 +1,264 @@ +""" +©AngelaMos | 2026 +renderer.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import IO, Any, TextIO + +from rich.console import Console +from rich.table import Table +from rich.text import Text + +from .config import ( + SEVERITY_COLORS, + OutputFormat, +) +from .processor import TracerEvent + + +def _event_to_dict(event: TracerEvent) -> dict[str, Any]: + """ + Serialize a TracerEvent to a JSON-compatible dict + """ + d: dict[str, Any] = { + "timestamp": event.timestamp.isoformat(), + "event_type": event.event_type, + "category": event.category, + "pid": event.pid, + "ppid": event.ppid, + "uid": event.uid, + "username": event.username, + "comm": event.comm, + "severity": event.severity, + } + + if event.filename: + d["filename"] = event.filename + + if event.addr_v4: + d["dest_ip"] = event.addr_v4 + d["dest_port"] = event.port + + if event.event_type in ("setuid", "setgid"): + d["target_uid"] = event.target_uid + d["target_gid"] = event.target_gid + + if event.event_type == "ptrace": + d["ptrace_request"] = event.ptrace_request + d["target_pid"] = event.target_pid + + if event.detection: + d["detection"] = event.detection + d["detection_id"] = event.detection_id + d["mitre_id"] = event.mitre_id + + parent_comm = event.extra.get("parent_comm") + if parent_comm: + d["parent_comm"] = parent_comm + + return d + + +class JsonRenderer: + """ + Outputs one JSON object per line + """ + + def __init__( + self, + stream: TextIO = sys.stdout, + ) -> None: + """ + Initialize with output stream + """ + self._stream = stream + + def render(self, event: TracerEvent) -> None: + """ + Write a single event as JSON + """ + d = _event_to_dict(event) + self._stream.write(json.dumps(d) + "\n") + self._stream.flush() + + +class LiveRenderer: + """ + Color-coded streaming output using Rich + """ + + def __init__( + self, + console: Console | None = None, + ) -> None: + """ + Initialize with Rich console + """ + self._console = console or Console() + + def render(self, event: TracerEvent) -> None: + """ + Print a color-coded event line + """ + ts = event.timestamp.strftime("%H:%M:%S") + color = SEVERITY_COLORS.get(event.severity, "white") + + sev = Text(f"{event.severity:8s}", style=color) + + detail = self._format_detail(event) + + line = Text() + line.append(f"[{ts}] ") + line.append_text(sev) + line.append(f" {event.event_type:14s} ") + line.append(f"pid={event.pid} " + f"comm={event.comm} ") + if detail: + line.append(detail) + + if event.detection: + det_text = Text( + f" [{event.detection}]", + style="bold magenta", + ) + line.append_text(det_text) + + self._console.print(line) + + def _format_detail( + self, + event: TracerEvent, + ) -> str: + """ + Build detail string based on event type + """ + if event.filename: + return event.filename + if event.addr_v4: + return f"{event.addr_v4}:{event.port}" + if event.target_uid: + return f"uid->{event.target_uid}" + if event.ptrace_request: + return (f"req={event.ptrace_request} " + f"target={event.target_pid}") + return "" + + +class TableRenderer: + """ + Periodic table summaries using Rich + """ + + def __init__( + self, + console: Console | None = None, + ) -> None: + """ + Initialize with Rich console and event buffer + """ + self._console = console or Console() + self._buffer: list[TracerEvent] = [] + self._flush_count = 20 + + def render(self, event: TracerEvent) -> None: + """ + Buffer events and flush as table periodically + """ + self._buffer.append(event) + if len(self._buffer) >= self._flush_count: + self._flush() + + def _flush(self) -> None: + """ + Render buffered events as a Rich table + """ + if not self._buffer: + return + + table = Table( + title="eBPF Security Events", + show_lines=False, + ) + table.add_column("Time", width=8) + table.add_column("Severity", width=8) + table.add_column("Type", width=12) + table.add_column("PID", width=7) + table.add_column("Comm", width=15) + table.add_column("Detail", min_width=20) + table.add_column("Detection", width=18) + + for ev in self._buffer: + color = SEVERITY_COLORS.get(ev.severity, "white") + ts = ev.timestamp.strftime("%H:%M:%S") + detail = "" + if ev.filename: + detail = ev.filename + elif ev.addr_v4: + detail = f"{ev.addr_v4}:{ev.port}" + + table.add_row( + ts, + Text(ev.severity, style=color), + ev.event_type, + str(ev.pid), + ev.comm, + detail, + ev.detection or "", + ) + + self._console.print(table) + self._buffer.clear() + + def finalize(self) -> None: + """ + Flush remaining events on shutdown + """ + self._flush() + + +class FileRenderer: + """ + Append JSON events to a file + """ + + def __init__(self, path: Path) -> None: + """ + Initialize with output file path + """ + self._path = path + self._fh: IO[str] | None = None + + def render(self, event: TracerEvent) -> None: + """ + Append a single event as JSON to the file + """ + if self._fh is None: + self._fh = open(self._path, "a") + + d = _event_to_dict(event) + self._fh.write(json.dumps(d) + "\n") + self._fh.flush() + + def close(self) -> None: + """ + Close the output file handle + """ + if self._fh is not None: + self._fh.close() + self._fh = None + + +def create_renderer( + fmt: OutputFormat, ) -> JsonRenderer | LiveRenderer | TableRenderer: + """ + Factory for the appropriate renderer + """ + if fmt == "json": + return JsonRenderer() + if fmt == "table": + return TableRenderer() + return LiveRenderer() diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json b/PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json new file mode 100644 index 00000000..a045e543 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json @@ -0,0 +1,92 @@ +[ + { + "timestamp_ns": 1000000000, + "pid": 1234, + "ppid": 1000, + "uid": 1000, + "gid": 1000, + "event_type": 1, + "ret_val": 0, + "comm": "bash", + "filename": "/usr/bin/ls", + "addr_v4": 0, + "port": 0, + "protocol": 0, + "target_uid": 0, + "target_gid": 0, + "ptrace_request": 0, + "target_pid": 0 + }, + { + "timestamp_ns": 2000000000, + "pid": 1234, + "ppid": 1000, + "uid": 1000, + "gid": 1000, + "event_type": 3, + "ret_val": 0, + "comm": "python3", + "filename": "/etc/shadow", + "addr_v4": 0, + "port": 0, + "protocol": 0, + "target_uid": 0, + "target_gid": 0, + "ptrace_request": 0, + "target_pid": 0 + }, + { + "timestamp_ns": 3000000000, + "pid": 5678, + "ppid": 5600, + "uid": 1000, + "gid": 1000, + "event_type": 10, + "ret_val": 0, + "comm": "exploit", + "filename": "", + "addr_v4": 0, + "port": 0, + "protocol": 0, + "target_uid": 0, + "target_gid": 0, + "ptrace_request": 0, + "target_pid": 0 + }, + { + "timestamp_ns": 4000000000, + "pid": 9999, + "ppid": 9900, + "uid": 0, + "gid": 0, + "event_type": 6, + "ret_val": 0, + "comm": "nc", + "filename": "", + "addr_v4": 167772161, + "port": 4444, + "protocol": 2, + "target_uid": 0, + "target_gid": 0, + "ptrace_request": 0, + "target_pid": 0 + }, + { + "timestamp_ns": 5000000000, + "pid": 7777, + "ppid": 7700, + "uid": 0, + "gid": 0, + "event_type": 12, + "ret_val": 0, + "comm": "injector", + "filename": "", + "addr_v4": 0, + "port": 0, + "protocol": 0, + "target_uid": 0, + "target_gid": 0, + "ptrace_request": 16, + "target_pid": 1234 + } +] diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py new file mode 100644 index 00000000..7a5433be --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py @@ -0,0 +1,61 @@ +""" +©AngelaMos | 2026 +conftest.py +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from src.processor import TracerEvent + + +@pytest.fixture() +def make_event(): + """ + Factory for creating TracerEvent instances in tests + """ + + def _make( + event_type: str = "execve", + category: str = "process", + pid: int = 1000, + ppid: int = 999, + uid: int = 1000, + gid: int = 1000, + comm: str = "test", + filename: str = "", + addr_v4: str = "", + port: int = 0, + protocol: int = 0, + target_uid: int = 0, + target_gid: int = 0, + ptrace_request: int = 0, + target_pid: int = 0, + flags: int = 0, + severity: str = "LOW", + ) -> TracerEvent: + return TracerEvent( + timestamp=datetime.now(tz=timezone.utc), + event_type=event_type, + category=category, + pid=pid, + ppid=ppid, + uid=uid, + gid=gid, + username="testuser", + comm=comm, + filename=filename, + addr_v4=addr_v4, + port=port, + protocol=protocol, + target_uid=target_uid, + target_gid=target_gid, + ptrace_request=ptrace_request, + target_pid=target_pid, + flags=flags, + severity=severity, + ) + + return _make diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py new file mode 100644 index 00000000..649a272c --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py @@ -0,0 +1,474 @@ +""" +©AngelaMos | 2026 +test_detector.py +""" +from __future__ import annotations + +from src.detector import DetectionEngine + + +class TestPrivilegeEscalation: + """ + Tests for D001 privilege escalation detection + """ + + def test_setuid_zero_by_nonroot(self, make_event): + """ + Detects setuid(0) from non-root UID + """ + engine = DetectionEngine() + event = make_event( + event_type="setuid", + category="privilege", + uid=1000, + target_uid=0, + ) + result = engine.evaluate(event) + assert result.detection == "Privilege Escalation" + assert result.severity == "CRITICAL" + assert result.detection_id == "D001" + + def test_setuid_zero_by_root_ignored(self, make_event): + """ + Allows setuid(0) when already root + """ + engine = DetectionEngine() + event = make_event( + event_type="setuid", + category="privilege", + uid=0, + target_uid=0, + ) + result = engine.evaluate(event) + assert result.detection is None + + def test_setuid_nonzero_ignored(self, make_event): + """ + Allows setuid to non-root UIDs + """ + engine = DetectionEngine() + event = make_event( + event_type="setuid", + category="privilege", + uid=1000, + target_uid=1001, + ) + result = engine.evaluate(event) + assert result.detection is None + + +class TestSensitiveFileRead: + """ + Tests for D002 sensitive file read detection + """ + + def test_shadow_read_nonroot(self, make_event): + """ + Detects /etc/shadow access by non-root + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + uid=1000, + filename="/etc/shadow", + ) + result = engine.evaluate(event) + assert result.detection == "Sensitive File Read" + assert result.severity == "MEDIUM" + + def test_shadow_read_root_ignored(self, make_event): + """ + Allows /etc/shadow access by root + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + uid=0, + filename="/etc/shadow", + ) + result = engine.evaluate(event) + assert result.detection is None + + def test_gshadow_read_nonroot(self, make_event): + """ + Detects /etc/gshadow access by non-root + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + uid=1000, + filename="/etc/gshadow", + ) + result = engine.evaluate(event) + assert result.detection == "Sensitive File Read" + + def test_shadow_write_not_read_detection(self, make_event): + """ + Write to sensitive file does not trigger read detection + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + uid=1000, + filename="/etc/shadow", + flags=1, + ) + result = engine.evaluate(event) + assert result.detection != "Sensitive File Read" + + +class TestSSHKeyAccess: + """ + Tests for D003 SSH key access detection + """ + + def test_ssh_private_key(self, make_event): + """ + Detects SSH private key file access + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + filename="/home/user/.ssh/id_rsa", + ) + result = engine.evaluate(event) + assert result.detection == "SSH Key Access" + assert result.mitre_id == "T1552.004" + + def test_authorized_keys(self, make_event): + """ + Detects authorized_keys access + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + filename="/root/.ssh/authorized_keys", + ) + result = engine.evaluate(event) + assert result.detection == "SSH Key Access" + + def test_sshd_authorized_keys_ignored(self, make_event): + """ + Allows sshd to read authorized_keys + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + comm="sshd", + filename="/root/.ssh/authorized_keys", + ) + result = engine.evaluate(event) + assert result.detection is None + + def test_ssh_agent_key_ignored(self, make_event): + """ + Allows ssh-agent to access private keys + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + comm="ssh-agent", + filename="/home/user/.ssh/id_ed25519", + ) + result = engine.evaluate(event) + assert result.detection is None + + +class TestProcessInjection: + """ + Tests for D004 ptrace-based injection detection + """ + + def test_ptrace_attach(self, make_event): + """ + Detects PTRACE_ATTACH calls + """ + engine = DetectionEngine() + event = make_event( + event_type="ptrace", + category="system", + ptrace_request=16, + target_pid=1234, + ) + result = engine.evaluate(event) + assert result.detection == "Process Injection" + assert result.mitre_id == "T1055.008" + + def test_ptrace_setregs(self, make_event): + """ + Detects PTRACE_SETREGS calls + """ + engine = DetectionEngine() + event = make_event( + event_type="ptrace", + category="system", + ptrace_request=13, + target_pid=1234, + ) + result = engine.evaluate(event) + assert result.detection == "Process Injection" + + def test_ptrace_traceme_ignored(self, make_event): + """ + Ignores PTRACE_TRACEME (normal debugging) + """ + engine = DetectionEngine() + event = make_event( + event_type="ptrace", + category="system", + ptrace_request=0, + target_pid=0, + ) + result = engine.evaluate(event) + assert result.detection is None + + +class TestKernelModule: + """ + Tests for D005 kernel module load detection + """ + + def test_init_module(self, make_event): + """ + Detects init_module calls + """ + engine = DetectionEngine() + event = make_event( + event_type="init_module", + category="system", + ) + result = engine.evaluate(event) + assert result.detection == "Kernel Module Load" + assert result.severity == "HIGH" + + +class TestPersistence: + """ + Tests for D007/D008 persistence detection + """ + + def test_cron_write(self, make_event): + """ + Detects writes to cron directories + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + filename="/etc/cron.d/backdoor", + flags=1, + ) + result = engine.evaluate(event) + assert result.detection == "Persistence via Cron" + + def test_systemd_write(self, make_event): + """ + Detects writes to systemd unit directories + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + filename="/etc/systemd/system/evil.service", + flags=1, + ) + result = engine.evaluate(event) + assert result.detection == "Persistence via Systemd" + + def test_cron_read_ignored(self, make_event): + """ + Allows reads from cron directories + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + filename="/etc/cron.d/something", + flags=0, + ) + result = engine.evaluate(event) + assert result.detection is None + + +class TestLogTampering: + """ + Tests for D009 log tampering detection + """ + + def test_log_truncation(self, make_event): + """ + Detects log file truncation + """ + engine = DetectionEngine() + event = make_event( + event_type="openat", + category="file", + filename="/var/log/auth.log", + flags=512, + ) + result = engine.evaluate(event) + assert result.detection == "Log Tampering" + + def test_log_deletion(self, make_event): + """ + Detects log file deletion + """ + engine = DetectionEngine() + event = make_event( + event_type="unlinkat", + category="file", + filename="/var/log/syslog", + ) + result = engine.evaluate(event) + assert result.detection == "Log Tampering" + + +class TestReverseShell: + """ + Tests for D006 reverse shell correlation detection + """ + + def test_connect_then_shell(self, make_event): + """ + Detects shell spawn after network connect + """ + engine = DetectionEngine() + + connect_event = make_event( + event_type="connect", + category="network", + pid=2000, + addr_v4="10.0.0.1", + port=4444, + ) + engine.evaluate(connect_event) + + shell_event = make_event( + event_type="execve", + category="process", + pid=2000, + comm="bash", + filename="/bin/bash", + ) + result = engine.evaluate(shell_event) + assert result.detection == "Reverse Shell" + assert result.severity == "CRITICAL" + + def test_shell_without_connect(self, make_event): + """ + Normal shell execution is not flagged + """ + engine = DetectionEngine() + event = make_event( + event_type="execve", + category="process", + pid=3000, + comm="bash", + filename="/bin/bash", + ) + result = engine.evaluate(event) + assert result.detection is None + + def test_connect_then_nonshell(self, make_event): + """ + Non-shell execution after connect is not flagged + """ + engine = DetectionEngine() + + connect_event = make_event( + event_type="connect", + category="network", + pid=4000, + addr_v4="10.0.0.1", + port=80, + ) + engine.evaluate(connect_event) + + exec_event = make_event( + event_type="execve", + category="process", + pid=4000, + comm="curl", + filename="/usr/bin/curl", + ) + result = engine.evaluate(exec_event) + assert result.detection is None + + def test_shell_then_connect(self, make_event): + """ + Detects network connect after shell execution + """ + engine = DetectionEngine() + + shell_event = make_event( + event_type="execve", + category="process", + pid=5000, + comm="bash", + filename="/bin/bash", + ) + engine.evaluate(shell_event) + + connect_event = make_event( + event_type="connect", + category="network", + pid=5000, + addr_v4="10.0.0.1", + port=4444, + ) + result = engine.evaluate(connect_event) + assert result.detection == "Reverse Shell" + assert result.severity == "CRITICAL" + + def test_nonshell_then_connect(self, make_event): + """ + Connect after non-shell exec is not flagged + """ + engine = DetectionEngine() + + exec_event = make_event( + event_type="execve", + category="process", + pid=6000, + comm="curl", + filename="/usr/bin/curl", + ) + engine.evaluate(exec_event) + + connect_event = make_event( + event_type="connect", + category="network", + pid=6000, + addr_v4="10.0.0.1", + port=80, + ) + result = engine.evaluate(connect_event) + assert result.detection is None + + +class TestMount: + """ + Tests for D010 suspicious mount detection + """ + + def test_mount_detected(self, make_event): + """ + Detects mount syscalls + """ + engine = DetectionEngine() + event = make_event( + event_type="mount", + category="system", + filename="/dev/sda1", + ) + result = engine.evaluate(event) + assert result.detection == "Suspicious Mount" + assert result.severity == "HIGH" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py new file mode 100644 index 00000000..3964db04 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py @@ -0,0 +1,163 @@ +""" +©AngelaMos | 2026 +test_processor.py +""" +from __future__ import annotations + +from src.processor import ( + _decode_comm, + _decode_filename, + _ipv4_to_str, + should_include, +) + + +class TestDecodeComm: + """ + Tests for comm field decoding + """ + + def test_normal_string(self): + """ + Decodes a normal null-terminated comm + """ + raw = b"bash\x00\x00\x00\x00" + assert _decode_comm(raw) == "bash" + + def test_full_length(self): + """ + Handles comm at max length without null + """ + raw = b"long_process_nm" + assert _decode_comm(raw) == "long_process_nm" + + def test_empty(self): + """ + Handles empty comm + """ + raw = b"\x00" * 16 + assert _decode_comm(raw) == "" + + +class TestDecodeFilename: + """ + Tests for filename field decoding + """ + + def test_normal_path(self): + """ + Decodes a normal filepath + """ + raw = b"/usr/bin/ls\x00" + assert _decode_filename(raw) == "/usr/bin/ls" + + def test_empty(self): + """ + Handles empty filename + """ + raw = b"\x00" * 256 + assert _decode_filename(raw) == "" + + +class TestIpv4ToStr: + """ + Tests for IPv4 address conversion + """ + + def test_localhost(self): + """ + Converts 127.0.0.1 in network byte order + """ + assert _ipv4_to_str(0x0100007F) == "127.0.0.1" + + def test_ten_network(self): + """ + Converts 10.0.0.1 in network byte order + """ + assert _ipv4_to_str(0x0100000A) == "10.0.0.1" + + def test_zero(self): + """ + Returns empty string for zero address + """ + assert _ipv4_to_str(0) == "" + + +class TestShouldInclude: + """ + Tests for event filtering logic + """ + + def test_passes_all_defaults(self, make_event): + """ + Event passes with default filters + """ + event = make_event() + assert should_include(event, "LOW", None, None, "all", False) + + def test_severity_filter(self, make_event): + """ + Filters events below minimum severity + """ + event = make_event(severity="LOW") + assert not should_include(event, "MEDIUM", None, None, "all", + False) + + def test_severity_passes(self, make_event): + """ + Passes events at or above minimum severity + """ + event = make_event(severity="HIGH") + assert should_include(event, "MEDIUM", None, None, "all", False) + + def test_pid_filter_match(self, make_event): + """ + Passes when PID matches filter + """ + event = make_event(pid=1234) + assert should_include(event, "LOW", 1234, None, "all", False) + + def test_pid_filter_mismatch(self, make_event): + """ + Filters when PID does not match + """ + event = make_event(pid=1234) + assert not should_include(event, "LOW", 5678, None, "all", False) + + def test_comm_filter_match(self, make_event): + """ + Passes when comm matches filter + """ + event = make_event(comm="bash") + assert should_include(event, "LOW", None, "bash", "all", False) + + def test_comm_filter_mismatch(self, make_event): + """ + Filters when comm does not match + """ + event = make_event(comm="bash") + assert not should_include(event, "LOW", None, "python", "all", + False) + + def test_type_filter(self, make_event): + """ + Filters events outside requested category + """ + event = make_event(category="process") + assert not should_include(event, "LOW", None, None, "network", + False) + + def test_detections_only_with_detection(self, make_event): + """ + Passes events with detections in detections mode + """ + event = make_event() + event.detection = "Test Detection" + assert should_include(event, "LOW", None, None, "all", True) + + def test_detections_only_without_detection(self, make_event): + """ + Filters events without detections in detections mode + """ + event = make_event() + assert not should_include(event, "LOW", None, None, "all", True) diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py new file mode 100644 index 00000000..eb1313d3 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py @@ -0,0 +1,140 @@ +""" +©AngelaMos | 2026 +test_renderer.py +""" +from __future__ import annotations + +import io +import json + +from src.renderer import JsonRenderer, _event_to_dict + + +class TestEventToDict: + """ + Tests for event serialization + """ + + def test_basic_fields(self, make_event): + """ + Serializes core event fields + """ + event = make_event( + event_type="execve", + pid=1234, + comm="bash", + ) + d = _event_to_dict(event) + assert d["event_type"] == "execve" + assert d["pid"] == 1234 + assert d["comm"] == "bash" + assert "timestamp" in d + + def test_filename_included(self, make_event): + """ + Includes filename when present + """ + event = make_event(filename="/usr/bin/ls") + d = _event_to_dict(event) + assert d["filename"] == "/usr/bin/ls" + + def test_filename_excluded_when_empty(self, make_event): + """ + Omits filename when empty + """ + event = make_event(filename="") + d = _event_to_dict(event) + assert "filename" not in d + + def test_network_fields(self, make_event): + """ + Includes network fields for connect events + """ + event = make_event( + event_type="connect", + addr_v4="10.0.0.1", + port=4444, + ) + d = _event_to_dict(event) + assert d["dest_ip"] == "10.0.0.1" + assert d["dest_port"] == 4444 + + def test_detection_fields(self, make_event): + """ + Includes detection metadata when present + """ + event = make_event() + event.detection = "Test Rule" + event.detection_id = "D999" + event.mitre_id = "T1234" + d = _event_to_dict(event) + assert d["detection"] == "Test Rule" + assert d["detection_id"] == "D999" + assert d["mitre_id"] == "T1234" + + def test_no_detection_fields_when_none(self, make_event): + """ + Omits detection fields when no detection + """ + event = make_event() + d = _event_to_dict(event) + assert "detection" not in d + + def test_setuid_zero_serialized(self, make_event): + """ + Includes target_uid even when value is zero + """ + event = make_event( + event_type="setuid", + target_uid=0, + ) + d = _event_to_dict(event) + assert "target_uid" in d + assert d["target_uid"] == 0 + + def test_ptrace_fields_serialized(self, make_event): + """ + Includes ptrace fields for ptrace events + """ + event = make_event( + event_type="ptrace", + ptrace_request=16, + target_pid=1234, + ) + d = _event_to_dict(event) + assert d["ptrace_request"] == 16 + assert d["target_pid"] == 1234 + + +class TestJsonRenderer: + """ + Tests for JSON output rendering + """ + + def test_outputs_valid_json(self, make_event): + """ + Produces valid JSON output + """ + buf = io.StringIO() + renderer = JsonRenderer(stream=buf) + event = make_event(event_type="execve", pid=42, comm="ls") + renderer.render(event) + + output = buf.getvalue().strip() + parsed = json.loads(output) + assert parsed["pid"] == 42 + assert parsed["comm"] == "ls" + + def test_one_line_per_event(self, make_event): + """ + Each event is a single line + """ + buf = io.StringIO() + renderer = JsonRenderer(stream=buf) + renderer.render(make_event(pid=1)) + renderer.render(make_event(pid=2)) + + lines = buf.getvalue().strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0])["pid"] == 1 + assert json.loads(lines[1])["pid"] == 2 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock b/PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock new file mode 100644 index 00000000..6488acde --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock @@ -0,0 +1,459 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "ebpf-security-tracer" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "rich" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "yapf" }, +] + +[package.metadata] +requires-dist = [ + { name = "rich", specifier = ">=14.0.0" }, + { name = "typer", specifier = ">=0.21.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.0" }, + { name = "pytest", specifier = ">=9.0.0" }, + { name = "ruff", specifier = ">=0.14.0" }, + { name = "yapf", specifier = ">=0.43.0" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "yapf" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, +] diff --git a/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md b/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md deleted file mode 100644 index e4545b73..00000000 --- a/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md +++ /dev/null @@ -1,591 +0,0 @@ -# Credential Enumeration Audit - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans -> to implement this plan task-by-task. - -**Goal:** Address all gaps identified in the audit. - -**Architecture:** All changes are modifications to existing files unless noted. - -**Tech Stack:** Nim 2.2+, Docker, Bash (Justfile) - ---- - -## Impression - -Solid architecture for a Nim CLI tool — clean type hierarchy, consistent -`{.push raises: [].}` discipline, well-structured collector pattern. The -bones are genuinely good. But two of the command-detection patterns silently -match nothing, the terminal box renderer computes stats it never prints, -and the only test mechanism (Docker) can't actually build because the -Justfile passes the wrong build context. The tool scans 7 credential -categories competently but misses several high-value targets (.netrc, -npm/pip tokens, Terraform, Vault) that a real post-access operator would -check first. - -## Project Assessment - -**Type:** Rule-based credential detection CLI tool (post-access) -**Primary Axis:** Completeness — weighted 65/35 over code quality -**Why:** A scanner's value is directly proportional to what it catches. -Missing a credential category is a harder failure than a rendering bug. - -## Findings - -### Finding 1: Docker test build context is wrong — entire test pipeline broken -**Severity:** CRITICAL -**Axis:** Code Quality -**Files:** Justfile:88-89, tests/docker/Dockerfile:1-12 - -**Issue:** The Justfile recipe `docker-build` runs -`docker build -t credenum-test tests/docker`, setting the build context to -`tests/docker/`. But the Dockerfile's first stage copies `src/`, `config.nims`, -and `credential-enumeration.nimble` from the build context root — none of which -exist under `tests/docker/`. The build fails immediately with -"COPY failed: file not found in build context." - -**Proof:** The Dockerfile contains: -```dockerfile -COPY src/ src/ -COPY config.nims . -COPY credential-enumeration.nimble . -``` -With context `tests/docker/`, Docker looks for `tests/docker/src/`, -`tests/docker/config.nims`, `tests/docker/credential-enumeration.nimble`. -None exist — `find tests/docker/ -name "config.nims"` returns nothing. -The only test mechanism for this project has never run successfully with -this Justfile recipe. - -**Proof Check:** Confidence: HIGH — Docker build context semantics are deterministic; -this is not a maybe. - -**Fix:** -`Justfile:88-89` — change the docker-build recipe to use the project root as context: -```just -[group('test')] -docker-build: - docker build -t credenum-test -f tests/docker/Dockerfile . -``` -And update `docker-test` accordingly (it depends on docker-build, so no change needed -there since it just `docker run`s the image). - -**Test:** -```bash -just docker-build -``` - ---- - -### Finding 2: matchesCommandPattern has case mismatch — 2/7 patterns are dead code -**Severity:** CRITICAL -**Axis:** Code Quality -**Files:** src/collectors/history.nim:38-54, src/config.nim:120-128 - -**Issue:** `matchesCommandPattern` lowercases the input line (`line.toLowerAscii()`) -then searches for pattern fragments that contain uppercase characters. Two patterns -are affected: - -- `"curl.*-H.*[Aa]uthoriz"` splits into `["curl", "-H", "[Aa]uthoriz"]` — - `-H` (uppercase) will never be found in a lowercased string, and - `[Aa]uthoriz` is treated as a literal (not a character class) -- `"wget.*--header.*[Aa]uthoriz"` splits into `["wget", "--header", "[Aa]uthoriz"]` — - `[Aa]uthoriz` is literal and will never appear in real history - -This means `curl -H "Authorization: Bearer ..."` commands in shell history -are silently missed — one of the most common credential-leaking patterns. - -**Proof:** Trace through `matchesCommandPattern` with input -`curl -H "Authorization: Bearer token" https://api.example.com`: -1. `lower` = `curl -h "authorization: bearer token" https://api.example.com` -2. Pattern `"curl.*-H.*[Aa]uthoriz"` → parts = `["curl", "-H", "[Aa]uthoriz"]` -3. `lower.find("curl")` → found at 0 -4. `lower.find("-H")` → NOT FOUND (lowercase string has `-h`, not `-H`) -5. `allFound = false` → returns false - -The pattern never matches. The planted test data in `.bash_history` line 4 -has `curl -H "Authorization: ..."` which should trigger this pattern but -the validate.sh check labeled "Sensitive command" passes only because -OTHER patterns (like `sshpass`, `mysql.*-p`) produce matches. - -**Proof Check:** Confidence: HIGH — Nim's `find` is case-sensitive by default; -this is deterministic. - -**Fix:** -`src/config.nim:120-128` — lowercase all pattern fragments: -```nim -HistoryCommandPatterns* = [ - "curl.*-h.*authoriz", - "curl.*-u ", - "wget.*--header.*authoriz", - "wget.*--password", - "mysql.*-p", - "psql.*password", - "sshpass" -] -``` - -**Test:** -Add a Docker test assertion that specifically validates curl -H Authorization -detection. After fix, run `just docker-test`. - ---- - -### Finding 3: Module header stats computed but never rendered -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** src/output/terminal.nim:40-57 - -**Issue:** `renderModuleHeader` computes a `stats` string containing -the finding count and duration, but the padding calculation -`padLen - stats.len + stats.len` simplifies to just `padLen` — then -writes padding spaces without ever writing `stats` to stdout. -The finding count and per-module duration are silently dropped from output. - -**Proof:** The arithmetic: -```nim -let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset -let padLen = 76 - name.len - desc.len - 5 -if padLen > 0: - stdout.write " ".repeat(padLen - stats.len + stats.len) # = " ".repeat(padLen) -stdout.writeLine " " & BoxVertical -``` -`stats` is never passed to `stdout.write`. The line is equivalent to -`stdout.write " ".repeat(padLen)` followed by the box border — no stats -anywhere. - -**Proof Check:** Confidence: HIGH — the variable is computed and never -appears in any write call in the function. - -**Fix:** -`src/output/terminal.nim:51-55` — compute visual width (excluding ANSI codes), -pad to fill the box, then write stats: -```nim -proc visualLen(s: string): int = - var i = 0 - while i < s.len: - if s[i] == '\e': - while i < s.len and s[i] != 'm': - inc i - inc i - else: - inc result - inc i - -proc renderModuleHeader(name: string, desc: string, findingCount: int, durationMs: int64) = - try: - stdout.writeLine boxLine(78) - stdout.write BoxVertical & " " - stdout.write ColorBold & ColorCyan - stdout.write name.toUpperAscii() - stdout.write ColorReset - stdout.write ColorDim - stdout.write " " & Arrow & " " & desc - stdout.write ColorReset - - let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset - let usedWidth = 2 + name.len + 3 + desc.len - let statsVisual = visualLen(stats) - let padLen = 78 - usedWidth - statsVisual - 2 - if padLen > 0: - stdout.write " ".repeat(padLen) - stdout.write stats - stdout.writeLine " " & BoxVertical - stdout.writeLine boxMid(78) - except CatchableError: - discard -``` - -**Test:** -```bash -just run --target /tmp | head -20 -``` -Verify module headers show "N findings (Xms)" right-aligned. - ---- - -### Finding 4: Terminal box right-border alignment broken for variable content -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** src/output/terminal.nim:60-84, 98-126 - -**Issue:** `renderFinding` writes descriptions and paths of arbitrary length -then appends `" " & BoxVertical` with no padding to reach column 78. Long -descriptions push past the box. Short ones leave the right border floating -at different positions. Same issue in `renderSummary` — hardcoded -`" ".repeat(69)` and `" ".repeat(20)` assume fixed content widths that -vary with finding counts, module counts, and durations. - -**Proof:** A finding with path `/home/user/.config/google-chrome/Default/Login Data` -(49 chars) plus permissions `[0644]` plus modified timestamp is ~90+ chars of -content in a 78-char box. The right `BoxVertical` gets pushed to column ~95. -A finding with path `/home/user/.pgpass` (18 chars) leaves the right border -at ~column 50. - -**Proof Check:** Confidence: HIGH — the code has zero width calculation before -writing the trailing BoxVertical. - -**Fix:** -Create a `padWrite` helper that calculates visual width of content written so -far and pads to fill the 78-char box before writing the closing border. -Apply it to `renderFinding`, `renderSummary`, and `renderModuleErrors`. -Truncate content that would exceed box width. - -In `src/output/terminal.nim`, add the `visualLen` proc from Finding 3 -(shared), then refactor each line that writes content + BoxVertical: -```nim -proc padToBox(content: string, boxWidth: int = 78) = - let vLen = visualLen(content) - let pad = boxWidth - vLen - 1 - if pad > 0: - stdout.write " ".repeat(pad) - stdout.writeLine BoxVertical -``` - -Then each finding line becomes: -```nim -var line = BoxVertical & " " & sevBadge(f.severity) & " " & f.description -stdout.write line -padToBox(line) -``` - -Apply this pattern consistently to all content rows in the terminal renderer. - -**Test:** -```bash -just docker-test -``` -Visual inspection of terminal output — all right borders should align at column 78. - ---- - -### Finding 5: scanGitCredentials reports svHigh for empty credential files -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** src/collectors/git.nim:11-39 - -**Issue:** If `.git-credentials` exists but is empty or contains no valid URLs, -`credCount` stays at 0 but the function still creates a finding with -"Plaintext Git credential store with 0 entries" at severity svHigh -(or svCritical if world-readable). An empty file is not a high-severity -credential exposure. - -**Proof:** Trace through `scanGitCredentials` with an empty `.git-credentials`: -1. `safeFileExists` returns true -2. `readFileLines` returns `@[]` -3. Loop runs zero iterations, `credCount = 0` -4. Code falls through to create credential and finding with `svHigh` -5. Report shows "Plaintext Git credential store with 0 entries" as HIGH - -**Proof Check:** Confidence: HIGH — there is no guard checking `credCount > 0` -before creating the finding. - -**Fix:** -`src/collectors/git.nim` — add early return after counting: -```nim -if credCount == 0: - return -``` -Insert after the for-loop that counts credentials (after line 22), before -the credential/finding construction. - -**Test:** -Create an empty `.git-credentials` file, run scanner, verify no git finding -appears. - ---- - -### Finding 6: `just test` references non-existent test_all.nim -**Severity:** MAJOR -**Axis:** Code Quality -**Files:** Justfile:84-85 - -**Issue:** The Justfile `test` recipe runs `nim c -r tests/test_all.nim`, -but this file does not exist. There are no unit tests in the project. -The only testing is Docker-based integration testing (validate.sh), which -itself is broken (Finding 1). - -**Proof:** `test -f tests/test_all.nim` returns non-zero. The `tests/` -directory contains only `docker/`. - -**Proof Check:** Confidence: HIGH — file does not exist. - -**Fix:** -Create `tests/test_all.nim` with unit tests for each collector's core logic. -At minimum, test: -- `isPrivateKey` with various key headers -- `isEncrypted` with encrypted/unencrypted markers -- `matchesSecretPattern` with positive and negative cases -- `matchesCommandPattern` (after fixing Finding 2) with all 7 patterns -- `redactValue` edge cases -- `permissionSeverity` logic -- `parseModules` from CLI parsing - -These should be fast, in-process tests that don't require Docker or -real credential files. - -**Test:** -```bash -just test -``` - ---- - -### Finding 7: Missing credential categories — .netrc, npm/pip tokens, Terraform, Vault, GitHub CLI -**Severity:** MAJOR -**Axis:** Completeness -**Files:** src/config.nim, src/collectors/apptoken.nim - -**Issue:** The tool covers 7 categories but misses several high-value -credential stores that a post-access operator would check: - -| Missing Target | Path | Why It Matters | -|---|---|---| -| `.netrc` | `~/.netrc` | Universal HTTP auth store; Heroku, Artifactory, many tools | -| `.npmrc` | `~/.npmrc` | npm registry auth tokens (`_authToken=`) | -| `.pypirc` | `~/.pypirc` | PyPI upload tokens | -| GitHub CLI | `~/.config/gh/hosts.yml` | GitHub OAuth tokens | -| Terraform | `~/.terraform.d/credentials.tfrc.json` | Terraform Cloud API tokens | -| Vault | `~/.vault-token` | HashiCorp Vault root/user tokens | -| `~/.config/helm/repositories.yaml` | Helm chart repo credentials | -| `~/.config/rclone/rclone.conf` | Cloud storage credentials (S3, GCS, etc.) | - -Industry comparison: LaZagne (closest post-access tool) covers 20+ -credential categories on Linux alone. `truffleHog` detects 700+ secret -patterns. This tool's 7 categories leave real coverage gaps. - -**Proof:** `grep -r "netrc\|npmrc\|pypirc\|vault-token\|terraform\|gh/hosts" src/` -returns zero matches. - -**Proof Check:** Confidence: HIGH — the files are either scanned or they're not. - -**Fix:** -Add constants to `src/config.nim`: -```nim -const - NetrcFile* = ".netrc" - NpmrcFile* = ".npmrc" - PypircFile* = ".pypirc" - GhCliHosts* = ".config/gh/hosts.yml" - TerraformCreds* = ".terraform.d/credentials.tfrc.json" - VaultTokenFile* = ".vault-token" - HelmRepos* = ".config/helm/repositories.yaml" - RcloneConf* = ".config/rclone/rclone.conf" -``` - -Add scanning logic to `src/collectors/apptoken.nim` — each is a simple -file-exists-and-check-contents pattern, consistent with existing -`scanDbCredFiles` approach. `.netrc` deserves content parsing (look for -`password` or `login` tokens). `.npmrc` should check for `_authToken=`. -`.pypirc` should check for `password` under `[pypi]` section. - -**Test:** -Add planted files to `tests/docker/planted/` and assertions to `validate.sh`. - ---- - -### Finding 8: matchesExclude uses substring matching, not glob patterns -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/base.nim:90-94 - -**Issue:** `matchesExclude` checks `if pattern in path` — plain substring. -An exclude pattern of `"env"` would exclude `/home/user/.venv/something`, -`/home/user/environment/data`, and the intended `.env` file. The CLI help -says `--exclude ` suggesting glob behavior, but the implementation -is substring containment. - -**Proof:** `matchesExclude("/home/user/.venv/lib/site.py", @["env"])` -returns `true`, excluding a Python virtualenv file that has nothing to do -with environment secrets. - -**Proof Check:** Confidence: HIGH — `in` is Nim's substring containment -operator for strings. - -**Fix:** -`src/collectors/base.nim:90-94` — use `std/os.extractFilename` and simple -glob matching, or at minimum document that patterns are substrings. Better -fix: use Nim's `std/strutils.contains` with path-segment awareness: -```nim -proc matchesExclude*(path: string, patterns: seq[string]): bool = - let name = path.extractFilename() - for pattern in patterns: - if pattern in name or pattern in path.splitPath().head: - return true -``` - -Or implement basic glob support with `*` matching. - -**Test:** -Unit test that `.venv/lib/site.py` is NOT excluded by pattern `".env"`. - ---- - -### Finding 9: JSON renderJson silently discards file-write errors -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/output/json.nim:72-85 - -**Issue:** When `--output ` specifies an invalid path (read-only dir, -nonexistent parent), `writeFile` throws, the exception is caught and -discarded. The JSON is then also written to stdout, but if stdout is -redirected and also fails, both errors are silently swallowed. The user -gets zero indication that their requested output file was not created. - -**Proof:** Run `credenum --format json --output /root/nope.json` as -non-root — the file write fails silently, output goes only to stdout. -If stdout is piped to a broken pipe, both writes fail and the user -sees nothing. - -**Proof Check:** Confidence: MEDIUM — the stdout fallback usually works, -so the practical impact is limited to the file path case. - -**Fix:** -`src/output/json.nim:77-80` — write a warning to stderr on file write failure: -```nim -except CatchableError as e: - try: - stderr.writeLine "Warning: could not write to " & outputPath & ": " & e.msg - except CatchableError: - discard -``` - -**Test:** -```bash -just run --format json --output /dev/full 2>&1 | grep "Warning" -``` - ---- - -### Finding 10: redactLine strips leading quote but keeps trailing quote -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/history.nim:15-28 - -**Issue:** `redactLine` strips a leading `"` or `'` from the value via -`value[1 .. ^1]`, but `^1` is the last index in Nim (inclusive), so -this removes only the first character. Input `"secret"` becomes -`secret"` — the trailing quote survives into the redacted preview. - -**Proof:** Input line `export API_KEY="mysecret"`: -1. `eqIdx` = 14 (position of `=`) -2. `value` = `"mysecret"` (after strip) -3. `value.startsWith("\"")` → true -4. `cleanValue` = `value[1 .. ^1]` = `mysecret"` (trailing quote kept) -5. `redactValue("mysecret\"", 4)` = `myse****"` - -**Proof Check:** Confidence: HIGH — `^1` is the last character in Nim slice -notation; this is deterministic. - -**Fix:** -`src/collectors/history.nim:24-26`: -```nim -let cleanValue = if (value.startsWith("\"") and value.endsWith("\"")) or - (value.startsWith("'") and value.endsWith("'")): - value[1 ..< ^1] -else: - value -``` - -Note: `^1` in `[1 ..< ^1]` excludes the last character (half-open range). - -**Test:** -Unit test: `redactLine("export KEY=\"secret\"")` should produce `KEY=secr**` -with no trailing quote. - ---- - -### Finding 11: isRelative computed but unused in Firefox profile parsing -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/browser.nim:11-48 - -**Issue:** The `scanFirefox` proc parses `IsRelative=0` from profiles.ini -and stores it in `isRelative`, but this variable is never read. Profile -path resolution uses `profile.startsWith("/")` instead. The variable is -dead code from an abandoned design path. - -**Proof:** `isRelative` is set on lines 23 and 37, but never appears in -any conditional or expression after the parsing loop. - -**Proof Check:** Confidence: HIGH — grep for `isRelative` in browser.nim -shows only assignments, zero reads. - -**Fix:** -`src/collectors/browser.nim` — remove the `isRelative` variable entirely -(lines 23, 37). The `startsWith("/")` check on line 43 is sufficient for -Linux path detection. - -**Test:** -```bash -just check -``` -Verify compilation succeeds with no warnings about unused variable. - ---- - -### Finding 12: Azure scanner adds directory finding unconditionally -**Severity:** MINOR -**Axis:** Code Quality -**Files:** src/collectors/cloud.nim:140-144 - -**Issue:** `scanAzure` always adds an svInfo finding for the Azure CLI -directory after checking for specific token files. If token cache findings -were already added, this creates redundant noise. If no tokens were found, -a bare directory finding at svInfo adds very little value. - -**Proof:** If `~/.azure/` exists with `accessTokens.json`, the output shows: -1. "Azure token cache" at svMedium — useful -2. "Azure CLI configuration directory" at svInfo — noise, adds nothing - -**Proof Check:** Confidence: MEDIUM — it's noise, not incorrect data. Could -argue the directory finding is useful as a "this user has Azure CLI installed" -signal, but only if no token files were found. - -**Fix:** -`src/collectors/cloud.nim:140-144` — only add the directory finding if no -token files were found: -```nim -if result.findings.len == 0 or - result.findings[^1].category != catCloud: - result.findings.add(makeFinding( - azDir, - "Azure CLI configuration directory", - catCloud, svInfo - )) -``` - -Better: track whether any Azure-specific findings were added and only emit -the directory finding as a fallback. - -**Test:** -Docker test — verify Azure directory finding only appears when no token -findings exist. - ---- - -## Self-Interrogation - -Looking at these 12 findings as a whole: - -- **Did I miss a dimension?** The tool has no rate-limiting or size-limiting on - file reads. `readFileContent` reads entire files into memory. A malicious - (or just large) `.bash_history` of several GB would cause OOM. But the - history scanner has `MaxHistoryLines = 50000` via `readFileLines`, which - mitigates this for its use case. Other collectors reading full files - (git config, kubeconfig) are typically small. Not worth a finding. - -- **Are any findings weak?** Finding 12 (Azure directory) is the weakest — - it's a UX preference, not a bug. Keeping it as MINOR is appropriate. - Finding 11 (dead variable) is real but trivial. Everything MAJOR and above - is solid. - -- **Completeness check:** The tool has 7 modules covering the major - categories but Finding 7 lists 8 specific credential stores that any - practitioner would expect. The `.netrc` omission alone is notable since - it's been the standard Unix credential store since the 1980s. - -## Summary - -**Total Findings:** 12 (2 critical, 5 major, 5 minor) -**Code Quality Findings:** 11 -**Completeness Findings:** 1 diff --git a/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md b/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md deleted file mode 100644 index 959bca97..00000000 --- a/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md +++ /dev/null @@ -1,257 +0,0 @@ -# Credential Enumeration Tool — Design Spec - -## Overview - -A post-access credential enumeration tool written in Nim that scans Linux systems for exposed secrets across 7 categories. Compiles to a single static binary with zero dependencies — drop on target, run, get a structured report of every credential file, its exposure level, and severity rating. - -**Language:** Nim 2.2.x -**Binary name:** `credenum` -**Architecture:** Modular collector pattern — one module per credential category, common interface, central runner - ---- - -## Core Types (`src/types.nim`) - -- **Severity** — enum: `info`, `low`, `medium`, `high`, `critical` -- **Category** — enum: `browser`, `ssh`, `cloud`, `history`, `keyring`, `git`, `apptoken` -- **Credential** — discovered credential data (source, credential type, value or redacted preview, metadata) -- **Finding** — a single discovery (path, category, severity, description, optional Credential, file permissions, timestamps) -- **CollectorResult** — `seq[Finding]` + collector metadata (name, duration, errors encountered) -- **HarvestConfig** — runtime configuration (target home dir, enabled modules, exclude patterns, output format, flags) -- **Report** — all collector results + summary stats + timestamp + target info - -**Severity assignment rules:** -- Critical: plaintext credentials in world-readable files -- High: unprotected private keys, plaintext credential stores -- Medium: overly permissive file permissions on credential files -- Low: credential files exist but properly permissioned -- Info: enumeration data (host lists, profile counts, existence checks) - ---- - -## Collector Modules - -Each module exports `proc collect(config: HarvestConfig): CollectorResult`. The runner calls each in sequence. No inheritance needed — just a common return type and a seq of collector procs populated at init. - -### 1. Browser Credential Store Scanner (`src/collectors/browser.nim`) -- Firefox: locate profiles via `profiles.ini`, check `logins.json`, `cookies.sqlite`, `key4.db` -- Chromium: locate `Login Data`, `Cookies`, `Web Data` SQLite databases -- Report: file locations, permissions, entry counts, last-modified timestamps -- Flag world-readable/group-readable databases as critical -- Detection + metadata level (no decryption) - -### 2. SSH Key & Config Auditor (`src/collectors/ssh.nim`) -- Scan `~/.ssh/` for private keys (RSA, Ed25519, ECDSA, non-standard filenames) -- Read key headers to determine passphrase protection (encrypted PEM vs unencrypted) -- Flag unprotected keys as high severity -- Check permissions (keys=600, directory=700) -- Parse `~/.ssh/config` — enumerate hosts, identify weak settings -- Read `authorized_keys` and `known_hosts` for enumeration - -### 3. Cloud Provider Config Scanner (`src/collectors/cloud.nim`) -- AWS: `~/.aws/credentials`, `~/.aws/config` — count profiles, identify static vs session keys -- GCP: `~/.config/gcloud/` — application default credentials, service account keys -- Azure: `~/.azure/` — access tokens, profile info -- Kubernetes: `~/.kube/config` — enumerate contexts, clusters, auth methods -- Permission checks, flag anything broader than owner-only - -### 4. Shell History & Environment Scanner (`src/collectors/history.nim`) -- Read `.bash_history`, `.zsh_history`, `.fish_history` -- Pattern match for inline secrets: KEY=, SECRET=, TOKEN=, PASSWORD= exports, DB connection strings, curl/wget with auth headers -- Scan for `.env` files in home directory tree -- Report: file, line region, redacted preview - -### 5. Keyring & Password Store Scanner (`src/collectors/keyring.nim`) -- GNOME Keyring: `~/.local/share/keyrings/` -- KDE Wallet: `~/.local/share/kwalletd/` -- KeePass/KeePassXC: search for `.kdbx` files -- pass (password-store): `~/.password-store/` -- Bitwarden: `~/.config/Bitwarden/` local vault data -- Report locations, file sizes, permissions, last modified - -### 6. Git Credential Store Scanner (`src/collectors/git.nim`) -- `~/.git-credentials` — plaintext storage (high severity) -- `~/.gitconfig` — check `credential.helper` setting -- Search for credential cache socket files -- Check for GitHub/GitLab PATs in config files - -### 7. Application Token Scanner (`src/collectors/apptoken.nim`) -- Slack: `~/.config/Slack/` session/cookie storage -- Discord: `~/.config/discord/` token storage -- VS Code: `~/.config/Code/` stored secrets -- Database configs: `~/.pgpass`, `~/.my.cnf`, Redis configs -- MQTT broker configs, common application credential files - ---- - -## CLI Interface - -``` -credenum [flags] - -Flags: - --target Target user home directory (default: current user) - --modules Comma-separated module list (default: all) - --exclude Glob patterns for paths to skip - --format Output format: terminal, json, both (default: terminal) - --output Write JSON output to file - --dry-run List paths that would be scanned without reading - --quiet Suppress banner and progress, output findings only - --verbose Show all scanned paths, not just findings -``` - -**CLI parsing:** `std/parseopt` (stdlib, no dependencies) - ---- - -## Terminal Output Design - -Hacker-aesthetic terminal output: -- ASCII art banner with tool name and version -- Box-drawing characters for section borders -- Color-coded severity badges (critical=red, high=magenta, medium=yellow, low=cyan, info=dim) -- Clean table formatting for findings -- Summary footer with totals by severity, modules scanned, duration -- Progress indicators showing which module is currently scanning - ---- - -## Output Formats - -### Terminal (ANSI) -Colored, formatted output designed for interactive use. Banner, per-module sections, severity badges, summary. - -### JSON -Structured report: -```json -{ - "metadata": { "timestamp": "...", "target": "...", "version": "...", "duration_ms": 0 }, - "modules": [ - { - "name": "ssh", - "findings": [ - { - "category": "ssh", - "severity": "high", - "path": "/home/user/.ssh/id_rsa", - "description": "Unprotected private key (no passphrase)", - "permissions": "0644", - "modified": "2026-01-15T10:30:00Z" - } - ], - "duration_ms": 12, - "errors": [] - } - ], - "summary": { "critical": 2, "high": 5, "medium": 8, "low": 3, "info": 12 } -} -``` - ---- - -## Build & Distribution - -### Static binary via musl -- `config.nims` configures musl-gcc for fully static Linux binaries -- Zero runtime dependencies - -### Cross-compilation -- x86_64-linux (primary) -- aarch64-linux (ARM64) -- Uses zig cc for cross-compilation -- Justfile tasks: `just build-x86`, `just build-arm64` - -### Build modes -- `just build` — debug build with all checks -- `just release` — optimized static binary (`-d:release -d:lto --opt:size`) -- `just release-small` — stripped + UPX compressed - -### Justfile tasks -- `just build` / `just release` / `just release-small` -- `just test` — run unit tests -- `just docker-test` — build + run in Docker test environment -- `just fmt` — format with nph -- `just clean` - ---- - -## Docker Test Environment - -**`tests/docker/Dockerfile`** — Ubuntu-based container planting fake credentials across all 7 categories: - -- SSH: test key pairs (some protected, some not), various permissions -- Browser: mock Firefox profile with dummy `logins.json`, mock Chromium dirs -- Cloud: fake AWS credentials, dummy GCP service account JSON, mock kubeconfig -- History: seeded `.bash_history`/`.zsh_history` with fake tokens -- Keyrings: mock `.kdbx`, mock `pass` store -- Git: `.git-credentials` with dummy entries -- App tokens: mock Slack/Discord/VS Code configs, `.pgpass`, `.my.cnf` - -All values are obviously fake (`AKIA_FAKE_ACCESS_KEY_12345`). - -`just docker-test` builds, runs credenum inside, validates all findings discovered with correct severity. - ---- - -## Project Structure - -``` -credential-enumeration/ -├── src/ -│ ├── harvester.nim # Entry point, CLI parsing -│ ├── config.nim # Constants, paths, patterns, severities -│ ├── types.nim # Core types -│ ├── runner.nim # Execute collectors, aggregate results -│ ├── output/ -│ │ ├── terminal.nim # ANSI terminal output with hacker aesthetic -│ │ └── json.nim # JSON serialization -│ └── collectors/ -│ ├── base.nim # Collector registration -│ ├── browser.nim -│ ├── ssh.nim -│ ├── cloud.nim -│ ├── history.nim -│ ├── keyring.nim -│ ├── git.nim -│ └── apptoken.nim -├── tests/ -│ └── docker/ -│ ├── Dockerfile -│ └── planted/ # Mock credential files -├── learn/ -│ ├── 00-OVERVIEW.md -│ ├── 01-CONCEPTS.md -│ ├── 02-ARCHITECTURE.md -│ ├── 03-IMPLEMENTATION.md -│ └── 04-CHALLENGES.md -├── config.nims # Build config (static linking, cross-compile) -├── credential-enumeration.nimble # Package manifest -├── Justfile -├── install.sh -├── README.md -├── LICENSE -└── .gitignore -``` - ---- - -## Learn Folder - -- **00-OVERVIEW.md** — What credential enumeration is, why it matters, prerequisites, quick start -- **01-CONCEPTS.md** — Linux credential storage locations, file permission model, where apps store secrets and why defaults are insecure. Real-world breach references. -- **02-ARCHITECTURE.md** — Modular collector design, data flow, why Nim for security tooling -- **03-IMPLEMENTATION.md** — Code walkthrough: core types, collector pattern, CLI parsing, output formatting, Nim type system and modules -- **04-CHALLENGES.md** — Extensions: new collectors, encrypted output, network enumeration, framework integration - ---- - -## What This Project Teaches - -- Linux credential storage locations across browsers, SSH, cloud tools, shells, keyrings, Git, and applications -- File permission models and their security implications -- Nim programming: static compilation, module system, type system, FFI potential -- Why Nim is adopted in the security assessment community (small static binaries, C-level performance) -- Modular tool architecture with common interfaces -- Building visually polished CLI tools -- Docker-based testing for security tools -- Cross-compilation and static linking for portable binaries From d3fa5ea1326d10ea135a74e0542826e2b6db2626 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 11 Apr 2026 05:38:49 -0400 Subject: [PATCH 16/30] fix: update READMEs for ebpf tracer & dlp scanner Add ASCII art, project number badges, justfile tip, and learn module links. Add missing justfile to dlp-scanner. Update main README with source code links and bump project count to 23/67. --- .../linux-ebpf-security-tracer/README.md | 188 ++++-------------- PROJECTS/intermediate/dlp-scanner/README.md | 7 +- PROJECTS/intermediate/dlp-scanner/justfile | 27 +++ README.md | 10 +- 4 files changed, 80 insertions(+), 152 deletions(-) create mode 100644 PROJECTS/intermediate/dlp-scanner/justfile diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/README.md b/PROJECTS/beginner/linux-ebpf-security-tracer/README.md index c562ea44..b6ceabcc 100644 --- a/PROJECTS/beginner/linux-ebpf-security-tracer/README.md +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/README.md @@ -1,68 +1,52 @@ -# Linux eBPF Security Tracer +```ruby +███████╗██████╗ ██████╗ ███████╗ ████████╗██████╗ █████╗ ██████╗███████╗██████╗ +██╔════╝██╔══██╗██╔══██╗██╔════╝ ╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗ +█████╗ ██████╔╝██████╔╝█████╗ ██║ ██████╔╝███████║██║ █████╗ ██████╔╝ +██╔══╝ ██╔══██╗██╔═══╝ ██╔══╝ ██║ ██╔══██╗██╔══██║██║ ██╔══╝ ██╔══██╗ +███████╗██████╔╝██║ ██║ ██║ ██║ ██║██║ ██║╚██████╗███████╗██║ ██║ +╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ +``` -Real-time syscall tracing tool using eBPF for security observability. Monitors process execution, file access, network connections, privilege changes, and system operations to detect suspicious behavior patterns. +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2322-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/linux-ebpf-security-tracer) +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) +[![C](https://img.shields.io/badge/C-eBPF-A8B9CC?style=flat&logo=c&logoColor=black)](https://ebpf.io) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) -## Features +> Real-time syscall tracing tool using eBPF for security observability — monitors process execution, file access, network connections, privilege changes, and system operations to detect suspicious behavior. -- Real-time syscall monitoring via eBPF tracepoints +*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Real-time syscall monitoring via eBPF tracepoints (process, file, network, privilege, system) - 10 built-in detection rules mapped to MITRE ATT&CK techniques -- Correlated event analysis (reverse shell detection, privilege escalation) +- Correlated event analysis for multi-step attacks (reverse shell detection, privilege escalation chains) - Multiple output formats: live color-coded stream, JSON, table summary - Configurable severity filtering (LOW, MEDIUM, HIGH, CRITICAL) -- Process, file, network, privilege, and system event categories -- Event enrichment from /proc filesystem +- Event enrichment from /proc filesystem (parent process, username) - Clean signal handling and eBPF program cleanup -## Prerequisites - -- Linux kernel 5.8+ (ring buffer support) -- Root privileges (required for eBPF) -- Python 3.10+ -- BCC (BPF Compiler Collection) with Python bindings - ## Quick Start ```bash -# Install system dependencies and Python packages ./install.sh - -# Start tracing all syscalls sudo uv run ebpf-tracer - -# JSON output, only MEDIUM+ severity -sudo uv run ebpf-tracer -f json -s MEDIUM - -# Only network events -sudo uv run ebpf-tracer -t network - -# Only show detection alerts -sudo uv run ebpf-tracer --detections - -# Filter by process name -sudo uv run ebpf-tracer -c nginx - -# Write events to file while streaming -sudo uv run ebpf-tracer -o events.jsonl ``` +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + ## Usage -``` -ebpf-tracer [OPTIONS] - -Options: - -f, --format Output format: json, table, live [default: live] - -s, --severity Minimum severity: LOW, MEDIUM, [default: LOW] - HIGH, CRITICAL - -p, --pid Filter by specific PID - -c, --comm Filter by process name - -t, --type Event category: process, file, [default: all] - network, privilege, system, all - --no-enrich Disable /proc enrichment - -o, --output Also write events to file - --detections Show only detection alerts - --version Show version - --help Show help +```bash +sudo uv run ebpf-tracer # trace all syscalls (live mode) +sudo uv run ebpf-tracer -f json -s MEDIUM # JSON output, MEDIUM+ severity +sudo uv run ebpf-tracer -t network # only network events +sudo uv run ebpf-tracer --detections # only show detection alerts +sudo uv run ebpf-tracer -c nginx # filter by process name +sudo uv run ebpf-tracer -o events.jsonl # write events to file while streaming ``` ## Detection Rules @@ -80,106 +64,18 @@ Options: | D009 | Log Tampering | MEDIUM | T1070.002 | Log file deletion/truncation | | D010 | Suspicious Mount | HIGH | T1611 | mount syscall | -## Architecture +## Learn -``` -User Space -┌─────────┐ ┌──────────────┐ ┌─────────────────┐ -│ CLI │──▶│ Event Engine │──▶│ Output Renderer │ -│ (Typer) │ │ (Processor + │ │ (JSON / Table / │ -│ │ │ Detector) │ │ Live Stream) │ -└─────────┘ └──────┬───────┘ └─────────────────┘ - │ - ┌──────┴───────┐ - │ BPF Loader │ - │ (BCC/Python)│ - └──────┬───────┘ -─────────────────────┼────────────────────────────── -Kernel Space │ - ┌──────┴───────┐ - │ Ring Buffer │ - └──────┬───────┘ - ┌───────────────┼───────────────────┐ - │ eBPF C Tracepoint Programs │ - │ ┌─────────┐┌────────┐┌─────────┐ │ - │ │ Process ││ File ││ Network │ │ - │ └─────────┘└────────┘└─────────┘ │ - │ ┌──────────┐┌────────┐ │ - │ │Privilege ││ System │ │ - │ └──────────┘└────────┘ │ - └───────────────────────────────────┘ -``` +This project includes step-by-step learning materials covering security theory, architecture, and implementation. -## Monitored Syscalls - -| Category | Syscalls | Purpose | -|----------|----------|---------| -| Process | execve, clone | New process creation | -| File | openat, unlinkat, renameat2 | File access and manipulation | -| Network | connect, accept4, bind, listen | Network activity | -| Privilege | setuid, setgid | Privilege changes | -| System | ptrace, mount, init_module | System-level operations | - -## Project Structure - -``` -src/ -├── main.py # CLI entrypoint (Typer) -├── config.py # Constants, event types, detection rules -├── loader.py # BCC program loader and ring buffer setup -├── processor.py # Event parsing, enrichment, filtering -├── detector.py # Detection engine with stateless and stateful rules -├── renderer.py # Output formatters (JSON, live, table) -└── ebpf/ - ├── process_tracer.c # execve, clone tracepoints - ├── file_tracer.c # openat, unlinkat, renameat2 tracepoints - ├── network_tracer.c # connect, accept4, bind, listen tracepoints - ├── privilege_tracer.c # setuid, setgid tracepoints - └── system_tracer.c # ptrace, mount, init_module tracepoints -``` - -## Example Output - -### Live Mode (default) - -``` -[14:30:01] LOW execve pid=1234 comm=bash /usr/bin/curl -[14:30:01] CRITICAL connect pid=1234 comm=nc 10.0.0.1:4444 [Reverse Shell] -[14:30:02] MEDIUM openat pid=5678 comm=python3 /etc/shadow [Sensitive File Read] -[14:30:03] HIGH init_module pid=9012 comm=insmod [Kernel Module Load] -``` - -### JSON Mode - -```json -{"timestamp":"2026-04-08T14:30:01+00:00","event_type":"connect","pid":1234,"comm":"nc","severity":"CRITICAL","detection":"Reverse Shell","mitre_id":"T1059.004","dest_ip":"10.0.0.1","dest_port":4444} -``` - -## Development - -```bash -# Install dev dependencies -uv sync - -# Run unit tests -just test - -# Lint -just lint - -# Format -just format -``` - -## How It Works - -1. **eBPF C programs** attach to kernel tracepoints for specific syscalls -2. When a traced syscall fires, the eBPF program captures event data (PID, UID, filename, etc.) and pushes it to a shared ring buffer -3. **Python (BCC)** polls the ring buffer and deserializes events via ctypes -4. The **processor** enriches events with data from /proc (parent process, username) -5. The **detection engine** evaluates each event against stateless rules (single-event patterns) and stateful rules (correlated event sequences) -6. The **renderer** outputs events in the selected format with severity-based color coding +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | eBPF theory and security observability | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | ## License -MIT +AGPL 3.0 diff --git a/PROJECTS/intermediate/dlp-scanner/README.md b/PROJECTS/intermediate/dlp-scanner/README.md index 4bcedf04..f6b1cf6f 100644 --- a/PROJECTS/intermediate/dlp-scanner/README.md +++ b/PROJECTS/intermediate/dlp-scanner/README.md @@ -7,7 +7,7 @@ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ``` -[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-intermediate-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/dlp-scanner) +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2323-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/dlp-scanner) [![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) [![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) @@ -31,6 +31,11 @@ bash install.sh dlp-scan file ./data ``` +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + ## Usage ```bash diff --git a/PROJECTS/intermediate/dlp-scanner/justfile b/PROJECTS/intermediate/dlp-scanner/justfile new file mode 100644 index 00000000..577732af --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/justfile @@ -0,0 +1,27 @@ +# ©AngelaMos | 2026 +# justfile + +default: + @just --list + +lint: + uv run ruff check . + uv run mypy src/ + +format: + uv run yapf -r -i src/ tests/ + +check-format: + uv run yapf -r -d src/ tests/ + +test: + uv run pytest tests/ -m "unit" + +test-all: + uv run pytest tests/ + +run *ARGS: + uv run dlp-scan {{ARGS}} + +install: + bash install.sh diff --git a/README.md b/README.md index e792ecc3..2d1ccbbe 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@

View Complete Projects:

-

Currently building: project #22

+

Currently building: project #24

--- @@ -70,7 +70,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[Base64 Encoder/Decoder](./SYNOPSES/beginner/Base64.Encoder.Decoder.md)**
Multi-format encoding tool | ![1h](https://img.shields.io/badge/⏱️_2h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Base64/32 encoding • URL encoding • Auto-detection
[Source Code](./PROJECTS/beginner/base64-tool) \| [Docs](./PROJECTS/beginner/base64-tool/learn) | | **[Linux CIS Hardening Auditor](./SYNOPSES/beginner/Linux.CIS.Hardening.Auditor.md)**
CIS benchmark compliance checker | ![3-4h](https://img.shields.io/badge/⏱️_6--8h-blue) ![Bash](https://img.shields.io/badge/Bash-4EAA25?logo=gnubash&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | CIS benchmarks • System hardening • Compliance scoring • Shell scripting
[Learn More](./SYNOPSES/beginner/Linux.CIS.Hardening.Auditor.md) | | **[Systemd Persistence Scanner](./SYNOPSES/beginner/Systemd.Persistence.Scanner.md)**
Hunt Linux persistence mechanisms | ![2-3h](https://img.shields.io/badge/⏱️_6--8h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Persistence techniques • Systemd internals • Cron analysis • Threat hunting
[Learn More](./SYNOPSES/beginner/Systemd.Persistence.Scanner.md) | -| **[Linux eBPF Security Tracer](./SYNOPSES/beginner/Linux.eBPF.Security.Tracer.md)**
Real-time syscall tracing with eBPF | ![2-3h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C](https://img.shields.io/badge/C-A8B9CC?logo=c&logoColor=black) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | eBPF programs • Syscall tracing • BCC framework • Security observability
[Learn More](./SYNOPSES/beginner/Linux.eBPF.Security.Tracer.md) | +| **[Linux eBPF Security Tracer](./PROJECTS/beginner/linux-ebpf-security-tracer)**
Real-time syscall tracing with eBPF | ![2-3h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C](https://img.shields.io/badge/C-A8B9CC?logo=c&logoColor=black) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | eBPF programs • Syscall tracing • BCC framework • Security observability
[Source Code](./PROJECTS/beginner/linux-ebpf-security-tracer) \| [Docs](./PROJECTS/beginner/linux-ebpf-security-tracer/learn) | | **[Trojan Application Builder](./SYNOPSES/beginner/Trojan.Application.Builder.md)**
Educational malware lifecycle demo | ![2-3h](https://img.shields.io/badge/⏱️_8--10h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Trojan anatomy • Data exfiltration • File encryption • Attack lifecycle
[Learn More](./SYNOPSES/beginner/Trojan.Application.Builder.md) | | **[DNS Sinkhole](./SYNOPSES/beginner/DNS.Sinkhole.md)**
Pi-hole-style malware domain blocker | ![3-4h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | DNS protocol • Blocklist management • Query logging • Network defense
[Learn More](./SYNOPSES/beginner/DNS.Sinkhole.md) | | **[Firewall Rule Engine](./PROJECTS/beginner/firewall-rule-engine)**
Parse and validate iptables/nftables rules | ![2-3h](https://img.shields.io/badge/⏱️_6--8h-blue) ![V](https://img.shields.io/badge/V-5D87BF?logo=v&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Firewall internals • Rule parsing • iptables/nftables • V language
[Source Code](./PROJECTS/beginner/firewall-rule-engine) \| [Docs](./PROJECTS/beginner/firewall-rule-engine/learn) | @@ -96,7 +96,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[Self-Hosted Shodan Clone](./SYNOPSES/intermediate/Self.Hosted.Shodan.Clone.md)**
Internet-connected device search engine | ![3-5d](https://img.shields.io/badge/⏱️_3--5d-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | Service fingerprinting • Network scanning • OSINT • Search engine design
[Learn More](./SYNOPSES/intermediate/Self.Hosted.Shodan.Clone.md) | | **[JA3/JA4 TLS Fingerprinting Tool](./SYNOPSES/intermediate/JA3.JA4.TLS.Fingerprinting.Tool.md)**
Fingerprint TLS clients by handshake | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Rust](https://img.shields.io/badge/Rust-000000?logo=rust&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | TLS handshake analysis • JA3/JA4 hashing • Bot detection • Malware C2 identification
[Learn More](./SYNOPSES/intermediate/JA3.JA4.TLS.Fingerprinting.Tool.md) | | **[Mobile App Security Analyzer](./SYNOPSES/intermediate/Mobile.App.Security.Analyzer.md)**
Decompile and analyze mobile apps | ![3-5d](https://img.shields.io/badge/⏱️_3--5d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | APK/IPA analysis • Reverse engineering • OWASP Mobile
[Learn More](./SYNOPSES/intermediate/Mobile.App.Security.Analyzer.md) | -| **[DLP Scanner](./SYNOPSES/intermediate/DLP.Scanner.md)**
Data Loss Prevention for files, DBs, and traffic | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | PII detection • GDPR/HIPAA compliance • Pattern matching • Data classification
[Learn More](./SYNOPSES/intermediate/DLP.Scanner.md) | +| **[DLP Scanner](./PROJECTS/intermediate/dlp-scanner)**
Data Loss Prevention for files, DBs, and traffic | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | PII detection • GDPR/HIPAA compliance • Pattern matching • Data classification
[Source Code](./PROJECTS/intermediate/dlp-scanner) \| [Docs](./PROJECTS/intermediate/dlp-scanner/learn) | | **[Lua/Nginx Edge Backend](./SYNOPSES/intermediate/Lua.Nginx.Edge.Backend.md)**
Full CRUD backend via Lua in Nginx | ![3-5d](https://img.shields.io/badge/⏱️_3--5d-blue) ![Lua](https://img.shields.io/badge/Lua-2C2D72?logo=lua&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | Edge computing • OpenResty • Lua scripting • WAF • JWT at the edge
[Learn More](./SYNOPSES/intermediate/Lua.Nginx.Edge.Backend.md) | | **[Privesc Playground](./SYNOPSES/intermediate/Privesc.Playground.md)**
20+ privilege escalation paths to exploit | ![3-5d](https://img.shields.io/badge/⏱️_3--5d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | SUID exploitation • Sudo abuse • Cron hijacking • GTFOBins • Capability abuse
[Learn More](./SYNOPSES/intermediate/Privesc.Playground.md) | | **[SBOM Generator & Vulnerability Matcher](./SYNOPSES/intermediate/SBOM.Generator.md)**
Software Bill of Materials with CVE matching | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | SPDX/CycloneDX formats • Dependency analysis • CVE databases • EO 14028 compliance
[Learn More](./SYNOPSES/intermediate/SBOM.Generator.md) | @@ -141,4 +141,4 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr ## License -AGPL 3.0 \ No newline at end of file +AGPL 3.0 From 34465a1255f714d7b66fff5b50106c35a0c31b43 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 11 Apr 2026 05:52:55 -0400 Subject: [PATCH 17/30] fix: CI nim action, sbom badge, and main README links Replace broken jiro4989/setup-nim-action@v2 with direct choosenim install. Add ebpf tracer and dlp scanner to lint matrix. Update SBOM generator badge to Project #24 and add source/docs links in main README. Bump project count to 24/67. --- .github/workflows/lint.yml | 12 +++++++++--- .../sbom-generator-vulnerability-matcher/README.md | 2 +- README.md | 6 +++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 54d4d787..f8845ecd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -62,6 +62,12 @@ jobs: - name: ai-threat-detection-backend type: ruff path: PROJECTS/advanced/ai-threat-detection/backend + - name: linux-ebpf-security-tracer + type: ruff + path: PROJECTS/beginner/linux-ebpf-security-tracer + - name: dlp-scanner + type: ruff + path: PROJECTS/intermediate/dlp-scanner # Biome (frontend) - name: bug-bounty-platform-frontend type: biome @@ -154,9 +160,9 @@ jobs: # Nim Setup - name: Setup Nim if: matrix.type == 'nim' - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: '2.2.x' + run: | + curl https://nim-lang.org/choosenim/init.sh -sSf | bash -s -- -y + echo "$HOME/.nimble/bin" >> $GITHUB_PATH - name: Install nph if: matrix.type == 'nim' diff --git a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md index b421da58..35987ad3 100644 --- a/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md +++ b/PROJECTS/intermediate/sbom-generator-vulnerability-matcher/README.md @@ -7,7 +7,7 @@ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ``` -[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2340-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/sbom-generator-vulnerability-matcher) +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2324-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/sbom-generator-vulnerability-matcher) [![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev) [![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) diff --git a/README.md b/README.md index 2d1ccbbe..b95ed1df 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@

View Complete Projects:

-

Currently building: project #24

+

Currently building: project #25

--- @@ -99,7 +99,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[DLP Scanner](./PROJECTS/intermediate/dlp-scanner)**
Data Loss Prevention for files, DBs, and traffic | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | PII detection • GDPR/HIPAA compliance • Pattern matching • Data classification
[Source Code](./PROJECTS/intermediate/dlp-scanner) \| [Docs](./PROJECTS/intermediate/dlp-scanner/learn) | | **[Lua/Nginx Edge Backend](./SYNOPSES/intermediate/Lua.Nginx.Edge.Backend.md)**
Full CRUD backend via Lua in Nginx | ![3-5d](https://img.shields.io/badge/⏱️_3--5d-blue) ![Lua](https://img.shields.io/badge/Lua-2C2D72?logo=lua&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | Edge computing • OpenResty • Lua scripting • WAF • JWT at the edge
[Learn More](./SYNOPSES/intermediate/Lua.Nginx.Edge.Backend.md) | | **[Privesc Playground](./SYNOPSES/intermediate/Privesc.Playground.md)**
20+ privilege escalation paths to exploit | ![3-5d](https://img.shields.io/badge/⏱️_3--5d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | SUID exploitation • Sudo abuse • Cron hijacking • GTFOBins • Capability abuse
[Learn More](./SYNOPSES/intermediate/Privesc.Playground.md) | -| **[SBOM Generator & Vulnerability Matcher](./SYNOPSES/intermediate/SBOM.Generator.md)**
Software Bill of Materials with CVE matching | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | SPDX/CycloneDX formats • Dependency analysis • CVE databases • EO 14028 compliance
[Learn More](./SYNOPSES/intermediate/SBOM.Generator.md) | +| **[SBOM Generator & Vulnerability Matcher](./PROJECTS/intermediate/sbom-generator-vulnerability-matcher)**
Software Bill of Materials with CVE matching | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | SPDX/CycloneDX formats • Dependency analysis • CVE databases • EO 14028 compliance
[Source Code](./PROJECTS/intermediate/sbom-generator-vulnerability-matcher) \| [Docs](./PROJECTS/intermediate/sbom-generator-vulnerability-matcher/learn) | | **[Subdomain Takeover Scanner](./SYNOPSES/intermediate/Subdomain.Takeover.Scanner.md)**
Detect dangling DNS records | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | DNS enumeration • CNAME analysis • Cloud resource claiming • Bug bounty
[Learn More](./SYNOPSES/intermediate/Subdomain.Takeover.Scanner.md) | | **[GraphQL Security Tester](./SYNOPSES/intermediate/GraphQL.Security.Tester.md)**
Automated GraphQL vulnerability testing | ![2-4d](https://img.shields.io/badge/⏱️_2--4d-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | Introspection attacks • Query depth DoS • Authorization bypass • Batching abuse
[Learn More](./SYNOPSES/intermediate/GraphQL.Security.Tester.md) | | **[Docker Security Audit](./PROJECTS/intermediate/docker-security-audit)**
CIS Docker Benchmark scanner | ![1-2d](https://img.shields.io/badge/⏱️_1--2d-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Intermediate](https://img.shields.io/badge/●_Intermediate-yellow) | CIS benchmarks • Container security • Multiple output formats
[Source Code](./PROJECTS/intermediate/docker-security-audit) \| [Docs](./PROJECTS/intermediate/docker-security-audit/learn) | From 4c5bce05c17429772eaa33564a78022223adcfdf Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 11 Apr 2026 05:56:43 -0400 Subject: [PATCH 18/30] add: AGPL 3.0 license files to hash-cracker, ebpf tracer, dlp scanner --- PROJECTS/beginner/hash-cracker/LICENSE | 661 ++++++++++++++++++ .../linux-ebpf-security-tracer/LICENSE | 661 ++++++++++++++++++ PROJECTS/intermediate/dlp-scanner/LICENSE | 661 ++++++++++++++++++ 3 files changed, 1983 insertions(+) create mode 100644 PROJECTS/beginner/hash-cracker/LICENSE create mode 100644 PROJECTS/beginner/linux-ebpf-security-tracer/LICENSE create mode 100644 PROJECTS/intermediate/dlp-scanner/LICENSE diff --git a/PROJECTS/beginner/hash-cracker/LICENSE b/PROJECTS/beginner/hash-cracker/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/beginner/hash-cracker/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/LICENSE b/PROJECTS/beginner/linux-ebpf-security-tracer/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/intermediate/dlp-scanner/LICENSE b/PROJECTS/intermediate/dlp-scanner/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/intermediate/dlp-scanner/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From c79d17c8ffb8fbdf19ad2b361723a25235470a21 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 11 Apr 2026 07:27:25 -0400 Subject: [PATCH 19/30] fix: remove unused imports and merge comparison in dlp-scanner Remove unused TextChunk, DetectorMatch, make_flow_key imports and merge repeated DNS_PORT equality check into set membership test. --- .../src/dlp_scanner/scanners/file_scanner.py | 5 +---- .../src/dlp_scanner/scanners/network_scanner.py | 12 +++++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py index b83ac5a1..2046cbc6 100644 --- a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py @@ -35,10 +35,7 @@ from dlp_scanner.extractors.structured import ( XmlExtractor, YamlExtractor, ) -from dlp_scanner.models import ( - ScanResult, - TextChunk, -) +from dlp_scanner.models import ScanResult from dlp_scanner.scoring import match_to_finding diff --git a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py index 2ebc54a6..936b9212 100644 --- a/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py +++ b/PROJECTS/intermediate/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py @@ -10,7 +10,6 @@ from pathlib import Path import structlog from dlp_scanner.config import ScanConfig -from dlp_scanner.detectors.base import DetectorMatch from dlp_scanner.detectors.registry import DetectorRegistry from dlp_scanner.models import ( Finding, @@ -22,10 +21,7 @@ from dlp_scanner.network.exfiltration import ( ExfilIndicator, detect_base64_payload, ) -from dlp_scanner.network.flow_tracker import ( - FlowTracker, - make_flow_key, -) +from dlp_scanner.network.flow_tracker import FlowTracker from dlp_scanner.network.pcap import read_pcap from dlp_scanner.network.protocols import ( DNS_PORT, @@ -131,8 +127,10 @@ class NetworkScanner: if ( packet.protocol == "udp" and ( - packet.src_port == DNS_PORT - or packet.dst_port == DNS_PORT + DNS_PORT in ( + packet.src_port, + packet.dst_port, + ) ) ): self._process_dns_packet( From bcf2ca34aeaa75b1226b7ff74132a252b1c3d124 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:37:05 +0000 Subject: [PATCH 20/30] chore(deps): bump pillow in /PROJECTS/beginner/metadata-scrubber-tool Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.0 to 12.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/12.1.0...12.2.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.2.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../beginner/metadata-scrubber-tool/uv.lock | 184 +++++++++--------- 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/PROJECTS/beginner/metadata-scrubber-tool/uv.lock b/PROJECTS/beginner/metadata-scrubber-tool/uv.lock index 85747fe8..d9a0810c 100644 --- a/PROJECTS/beginner/metadata-scrubber-tool/uv.lock +++ b/PROJECTS/beginner/metadata-scrubber-tool/uv.lock @@ -517,100 +517,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, - { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, - { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] From a7410bc34ea629468a96441fd5ca7415e402f5b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:45:45 +0000 Subject: [PATCH 21/30] chore(deps): bump pillow Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.1 to 12.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/12.1.1...12.2.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.2.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../ai-threat-detection/backend/uv.lock | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/PROJECTS/advanced/ai-threat-detection/backend/uv.lock b/PROJECTS/advanced/ai-threat-detection/backend/uv.lock index 069f2d8d..d8b4334e 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/uv.lock +++ b/PROJECTS/advanced/ai-threat-detection/backend/uv.lock @@ -1775,35 +1775,35 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] From 07ad8379eedab2f7da774f9bf6448401e8e8e230 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:47:13 +0000 Subject: [PATCH 22/30] chore(deps): bump pillow Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.1 to 12.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/12.1.1...12.2.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.2.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../network-traffic-analyzer/python/uv.lock | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock b/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock index afb416b0..220eae4d 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock +++ b/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock @@ -450,35 +450,35 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] From 93849e855ccda29507cf599cbf945ee93896433a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:37:32 +0000 Subject: [PATCH 23/30] chore(deps): bump pytest in /PROJECTS/beginner/base64-tool Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/beginner/base64-tool/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PROJECTS/beginner/base64-tool/uv.lock b/PROJECTS/beginner/base64-tool/uv.lock index f183fd78..6ccd1587 100644 --- a/PROJECTS/beginner/base64-tool/uv.lock +++ b/PROJECTS/beginner/base64-tool/uv.lock @@ -356,7 +356,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -365,9 +365,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 00e0141b41b32e58fd4d943c0e30b9463ccf05cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:59:57 +0000 Subject: [PATCH 24/30] chore(deps): bump pytest in /PROJECTS/beginner/dns-lookup Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/beginner/dns-lookup/uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PROJECTS/beginner/dns-lookup/uv.lock b/PROJECTS/beginner/dns-lookup/uv.lock index 8c6134bd..d3e24442 100644 --- a/PROJECTS/beginner/dns-lookup/uv.lock +++ b/PROJECTS/beginner/dns-lookup/uv.lock @@ -86,7 +86,7 @@ wheels = [ [[package]] name = "dnslookup-cli" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "dnspython" }, @@ -272,7 +272,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -281,9 +281,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 5eb71369e3284fb6b73cc5168b3dd34af454598a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:06:39 +0000 Subject: [PATCH 25/30] chore(deps): bump pytest Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/beginner/network-traffic-analyzer/python/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock b/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock index afb416b0..223ecae2 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock +++ b/PROJECTS/beginner/network-traffic-analyzer/python/uv.lock @@ -553,7 +553,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -562,9 +562,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 128531011233133a0afc47bab50a5c7e3c5a7f61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:08:08 +0000 Subject: [PATCH 26/30] chore(deps): bump pytest Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../advanced/ai-threat-detection/backend/pyproject.toml | 2 +- PROJECTS/advanced/ai-threat-detection/backend/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml b/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml index 8834f5f3..821a5948 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml +++ b/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=9.0.2", + "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "aiosqlite>=0.22.1", "ruff>=0.15.0", diff --git a/PROJECTS/advanced/ai-threat-detection/backend/uv.lock b/PROJECTS/advanced/ai-threat-detection/backend/uv.lock index 069f2d8d..40bff797 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/uv.lock +++ b/PROJECTS/advanced/ai-threat-detection/backend/uv.lock @@ -169,7 +169,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, { name = "pylint-pydantic", marker = "extra == 'dev'", specifier = ">=0.4.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "redis", extras = ["hiredis"], specifier = ">=7.1.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, @@ -2073,7 +2073,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2082,9 +2082,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 74a2dd18f4ad74331b0c933d40ecdf75690846e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:00:49 +0000 Subject: [PATCH 27/30] chore(deps): bump axios Bumps [axios](https://github.com/axios/axios) from 1.13.6 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../ai-threat-detection/frontend/package.json | 2 +- .../frontend/pnpm-lock.yaml | 46 ++++++------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/package.json b/PROJECTS/advanced/ai-threat-detection/frontend/package.json index ddddc732..4e4230c9 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/package.json +++ b/PROJECTS/advanced/ai-threat-detection/frontend/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.20", - "axios": "^1.13.4", + "axios": "^1.15.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", diff --git a/PROJECTS/advanced/ai-threat-detection/frontend/pnpm-lock.yaml b/PROJECTS/advanced/ai-threat-detection/frontend/pnpm-lock.yaml index 99ae0b2d..67ce40e5 100644 --- a/PROJECTS/advanced/ai-threat-detection/frontend/pnpm-lock.yaml +++ b/PROJECTS/advanced/ai-threat-detection/frontend/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^5.90.20 version: 5.90.21(react@19.2.4) axios: - specifier: ^1.13.4 - version: 1.13.6 + specifier: ^1.15.0 + version: 1.15.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -192,28 +192,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.4': resolution: {integrity: sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.4': resolution: {integrity: sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.4': resolution: {integrity: sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.4': resolution: {integrity: sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==} @@ -357,42 +353,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -451,28 +441,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} @@ -587,8 +573,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} babel-runtime@5.8.38: resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} @@ -768,8 +754,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -977,28 +963,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1124,8 +1106,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} qified@0.6.0: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} @@ -1199,6 +1182,7 @@ packages: rolldown-vite@7.2.5: resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} engines: {node: ^20.19.0 || >=22.12.0} + deprecated: Use 7.3.1 for migration purposes. For the most recent updates, migrate to Vite 8 once you're ready. hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -1891,11 +1875,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.6: + axios@1.15.0: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -2055,7 +2039,7 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} form-data@4.0.5: dependencies: @@ -2329,7 +2313,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} qified@0.6.0: dependencies: From bb0ad647aed9f706d0ccfeca4081a1a59e81dffa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:01:10 +0000 Subject: [PATCH 28/30] chore(deps): bump pytest in /PROJECTS/beginner/keylogger Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.4.1 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.4.1...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/beginner/keylogger/pyproject.toml | 2 +- PROJECTS/beginner/keylogger/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PROJECTS/beginner/keylogger/pyproject.toml b/PROJECTS/beginner/keylogger/pyproject.toml index b1afa218..33187f12 100644 --- a/PROJECTS/beginner/keylogger/pyproject.toml +++ b/PROJECTS/beginner/keylogger/pyproject.toml @@ -25,7 +25,7 @@ macos = [ ] dev = [ - "pytest==8.4.1", + "pytest==9.0.3", "ruff==0.14.14", "mypy==1.19.1", "pylint==4.0.4", diff --git a/PROJECTS/beginner/keylogger/uv.lock b/PROJECTS/beginner/keylogger/uv.lock index 85efbf95..665136df 100644 --- a/PROJECTS/beginner/keylogger/uv.lock +++ b/PROJECTS/beginner/keylogger/uv.lock @@ -145,7 +145,7 @@ requires-dist = [ { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, { name = "pynput", specifier = "==1.8.1" }, { name = "pyobjc-framework-cocoa", marker = "extra == 'macos'", specifier = "==12.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.3" }, { name = "pywin32", marker = "extra == 'windows'", specifier = "==311" }, { name = "requests", specifier = "==2.33.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }, @@ -433,7 +433,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.1" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -442,9 +442,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 5b2b7e86a48e4063b63e91e7f282e0dbc4c3c298 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:01:27 +0000 Subject: [PATCH 29/30] chore(deps): bump pytest in /PROJECTS/beginner/caesar-cipher Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/beginner/caesar-cipher/uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PROJECTS/beginner/caesar-cipher/uv.lock b/PROJECTS/beginner/caesar-cipher/uv.lock index ba122b26..6eefaa10 100644 --- a/PROJECTS/beginner/caesar-cipher/uv.lock +++ b/PROJECTS/beginner/caesar-cipher/uv.lock @@ -12,7 +12,7 @@ wheels = [ ] [[package]] -name = "caesar-cipher" +name = "caesar-salad-cipher" version = "0.1.0" source = { editable = "." } dependencies = [ @@ -417,7 +417,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -426,9 +426,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From 0faca40468dbe0031444a16bd8aa784cdc03abc8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:01:29 +0000 Subject: [PATCH 30/30] chore(deps): bump pytest in /PROJECTS/advanced/api-rate-limiter Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- PROJECTS/advanced/api-rate-limiter/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PROJECTS/advanced/api-rate-limiter/uv.lock b/PROJECTS/advanced/api-rate-limiter/uv.lock index 5927d289..54f6ee9d 100644 --- a/PROJECTS/advanced/api-rate-limiter/uv.lock +++ b/PROJECTS/advanced/api-rate-limiter/uv.lock @@ -889,7 +889,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -898,9 +898,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]]