diff --git a/.gitignore b/.gitignore index 4f53639..ffc32a5 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ credentials.json # Tests # =================== test-results/ +playwright-report/ +apps/docs/playwright-report/ # =================== # Agents diff --git a/AGENTS.md b/AGENTS.md index 0366da7..04a30f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,7 +266,7 @@ See `.agents/README.md` for usage instructions. ## Version Information -- **Repository**: https://github.com/miccy/dont-be-shy-hulud +- **Repository**: https://github.com/miccy/worms-ctrl - **License**: MIT - **Maintainer**: @miccy - **Status**: Active development (public release, seeking contributors) diff --git a/CHANGELOG.md b/CHANGELOG.md index 135edf2..e65136a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,97 @@ # Changelog + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Path validation** — Added `validatePath()` utility to scanner to reject null bytes and empty/whitespace paths. +- **Threat database** — Added `xz-utils-2024.json` threat profile to `packages/ioc/threats`. +- **E2E Test Robustness** — Added escaping of regex special characters in E2E assertions for docs navigation. + +### Changed +- **Scanner types** — `LockfileEntry.resolved` property made optional to accommodate bundlers/resolvers without integrity metadata. +- **Documentation standardization** — Updated references in `bunfig-secure.toml`, `pnpm-workspace-secure.yaml`, and scripts to canonical `worms-ctrl` names. +- **Scan Scripts** — Enhanced `comprehensive-scan.sh` output with clearer action instructions and `full-audit.sh` with the verified URL. +- **Documentation**: Updated IOC database repository link to point to the new `worms-ctrl` repository in both English and Czech `ioc-database.md` reference pages. +- **Dependencies**: Pinned `@types/bun` to `^1.3.13` in the `kb` package. + +### Security +- **SSRF Hardening** — `isPrivateIp` now correctly parses IPv4-mapped IPv6 addresses, drops full `169.254.0.0/16` subnet, and `fetch` prevents arbitrary redirects (`redirect: 'manual'`). + +## [2.0.0] - 2026-05-04 + +### ⚠️ Breaking Changes + +- **Stricter threat schema validation** — `sha256` fields in `file_artifacts` now require a valid 64-character hex hash or `null` (empty strings are rejected). IOC and remediation string arrays now reject empty strings. +- **pnpm parser output** — `resolved` and `integrity` are now separate fields (previously `integrity` was conflated into `resolved`). + +### Added + +- **SSRF protection** — `ingest.ts` now blocks fetches to private/internal networks, adds fetch timeouts (15s), and enforces response size limits (2 MB). +- **Alias descriptor parsing** — Bun and pnpm parsers now correctly handle npm alias descriptors (`alias@npm:real@1.2.3`) by extracting the alias name. +- **Per-entry threat loading** — Both `packages/ioc/index.js` and `packages/scanner/src/threats.ts` now load threat files individually, so one malformed JSON file doesn't drop the entire catalog. +- **Threat shape validation** — `readThreatObject()` in the scanner now validates the parsed JSON shape before indexing, preventing crashes on malformed threat files. +- **Dynamic test discovery** — `threats.validate.test.ts` now validates all JSON files in the threats directory automatically instead of a hardcoded list. +- **Path traversal security** — Added `validatePath()` utility to scanner with null byte and empty path rejection. + +### Fixed + +- **Bun lockfile deduplication** — Scanner now prefers `bun.lock` over `bun.lockb` when both exist, preventing duplicate findings. +- **npm parser** — Skips workspace/link entries and root package (`""` key) in v3 lockfiles. +- **Threat loader resilience** — `readdirSync` wrapped in try/catch to handle TOCTOU race after `existsSync`. +- **`toFindingSeverity` switch** — Added default case to prevent `undefined` return for unexpected severity values. +- **CLI output error handling** — `writeFileSync` failures now return a user-friendly error message instead of an unhandled exception. +- **Empty hash indicators** — Replaced `sha256: ""` with `sha256: null` in `event-stream-2018`, `ctx-2022`, and `xz-utils-2024` threat profiles. +- **Node ESM compatibility** — `packages/ioc/index.js` now uses `fs.readFileSync` for JSON loading instead of bare import assertions. +- **Playwright config** — Added `stdout`/`stderr` pipe configuration for better CI debugging. +- **Redundant wrapper** — Removed `parseNpmLock()` wrapper in `scan.ts` (calls `parseNpmLockfile()` directly). +- **Top-level await** — Added documentation comment explaining intentional eager threat database initialization. + +### Changed + +- **Version bump** — `1.5.2` → `2.0.0` + +### Added + +- **Grant-ready threat catalog** — Added structured threat objects for `event-stream`, `node-ipc`, `ua-parser-js`, `ctx`, and `xz-utils` under `packages/ioc/threats/`. +- **Threat catalog expansion** — Added `axios-2026`, `shai-hulud-2025`, and `teampcp-2026` threat objects plus fixture data for npm and PyPI compromise scenarios. +- **AI ingestion skeleton** — Added `packages/engine/src/ingest.ts`, `prompt.ts`, and `validate.ts` for JSON-mode threat extraction with graceful OpenAI fallback behavior and Zod validation. +- **Scanner validation flow** — Added scanner and engine validation plumbing for npm lock parsing, injection findings, and schema-based threat extraction support. +- **Threat regression tests** — Added fixture-based `bun:test` coverage for malicious version matching, phantom dependency detection, threat schema validation, and clean baseline scans. +- **Grant demo artifact** — Added a SARIF demo output for the axios compromise fixture under `examples/axios-compromise.sarif`. + +### Security + +- **Report Sanitization** — Implemented a centralized `redactSensitiveData` function in the Playwright HTML report to strip emails, tokens, secrets, and local paths from AI prompts. +- **Payload Removal** — Removed the embedded base64 ZIP payload from the HTML report to prevent secret leakage and encourage CI artifact usage. + +### Fixed + +- **E2E Stability** — Corrected invalid Playwright assertions in `apps/docs` to use `toBeVisible()` and removed silent failures in table rendering tests. +- **Git Hygiene** — Added `playwright-report/` to `.gitignore` to prevent committing generated test artifacts. + +### Changed + +- **Lockfile coverage** — Completed pnpm and Bun parser support and wired both into scanner dispatch. +- **Threat-aware detection** — Scanner now cross-references `packages/ioc/threats/*.json` for known malicious versions and only emits injection findings for known phantom dependency IOCs. +- **Python package coverage** — Added basic `requirements.txt` parsing so PyPI threat entries can be matched by the scanner. +- **CLI flow** — Reworked the main CLI entry point to support `--format`, `--output`, and `--threats`, and to run through the scanner package end-to-end. +- **Documentation** — Rewrote the root README and added `cs/README.md` with grant-focused positioning, AI architecture, quick start, and threat database coverage. +- **Threat schema** — Upgraded `ThreatObject` to use structured IOC and reference objects, expanded ecosystem coverage for Linux/system incidents, and migrated the bundled threat catalog to the new format. +- **CLI packaging** — Added a self-contained CLI bundle build with bundled threat catalog assets so published installs no longer depend on monorepo-only source paths. + +- **E2E Test Robustness** — Updated documentation tests to assert element visibility and existence unconditionally, preventing silent regressions in table rendering and page navigation. +- **Threat Database Accuracy** — Added missing `oneday-test` malicious version and updated empty SHA256 fields to `null` with explanatory notes in `node-ipc-2022` and `ua-parser-js-2021` threat profiles. +- **IOC helper consistency** — Made archived threat/profile helper lookups return synchronously instead of mixing plain objects with dynamic-import promises. +- **Parser resilience** — Fixed scoped `pnpm` key parsing, restored `bun.lockb` fallback after text-lock read failures, and added safer `bun pm ls` timeout/error handling. +- **Scanner output** — Fixed the `low` severity summary line formatting, removed duplicate verbose location output, and reject empty `--output=` CLI values with a clear error. +- **Regression coverage** — Added tests for structured threat validation, publish-safe CLI argument parsing, pnpm/Bun parser edge cases, text formatter output, and synchronous IOC helper returns. + ## [1.5.2] - 2026-04-21 ### Changed @@ -21,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Scanner Output** — Symmetrized naming of formatters (`formatJson`, `formatText`, `formatSarif`) and fixed broken exports in `@worms-ctrl/scanner`. - **KB Engine** — Fixed asynchronous generator logic in `chunker.ts` and removed dead exports from `@worms-ctrl/kb`. - **Process Suspension Safety** — Fixed remediation playbook phase mapping and type definitions for safe malware containment. +- **`safe-suspend` Parameter Fix** — Removed unused `_dryRun` parameter and implemented its logic in `packages/remediation/src/scripts/safe-suspend.ts`. ### Added diff --git a/README.md b/README.md index 1dcb357..0fd00f0 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,90 @@ -# 🪱 worms-ctrl +# wormsCTRL ![worms-ctrl Banner](packages/assets/banner.png) -> **Universal Supply Chain Audit Tool & Threat Knowledge Base** -> Defending against registry-native worms, malicious packages, and CI/CD compromises. +> Universal Supply Chain Audit Tool & Threat Knowledge Base for npm and adjacent open-source ecosystems. -[![npm version](https://img.shields.io/npm/v/@worms-ctrl/core?color=cb3837&logo=npm)](https://www.npmjs.com/package/@worms-ctrl/core) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) +[![Open Source](https://img.shields.io/badge/open%20source-public%20benefit-blue)](https://github.com/miccy/worms-ctrl) +[![Bun](https://img.shields.io/badge/runtime-Bun-black)](https://bun.sh) -## 🛡️ The Future of Supply Chain Defense +Supply-chain attacks are growing because modern applications inherit trust from thousands of transitive packages, maintainer accounts, CI runners, and release pipelines they do not directly control. A single compromised publisher, malicious install script, or poisoned upstream artifact can now reach thousands of downstream builds in hours, which leaves solo developers and small teams with enterprise-grade risk but without enterprise-grade detection coverage. `wormsCTRL` exists to close that gap with auditable lockfile scanning, structured threat intelligence, and an AI-assisted ingestion pipeline that turns public advisories into machine-readable defensive knowledge. -The evolution of software supply chain attacks has reached a critical point. Incidents like the [Shai-Hulud 2.0 npm worm](packages/ioc/archived/shai-hulud/) or the TeamPCP CI/CD compromise demonstrate the rise of self-propagating malware that leverages the trust of package registries to exfiltrate sensitive data. +## Architecture -**worms-ctrl** is an automated auditing tool and comprehensive Knowledge Base designed to: -1. **Act as an Incident Response Tool:** Providing immediate audit capabilities and remediation steps when under attack. -2. **Serve as a Threat Database:** Documenting historical threats (like Shai-Hulud) with exact Indicators of Compromise (IoCs). -3. **Be AI-Agent Ready:** Exposing machine-readable Threat Models (JSON/YAML) that Autonomous Security Agents can consume to update defense mechanisms in real-time. +```mermaid +flowchart LR + A["Scanner
packages/scanner"] --> B["Engine
packages/engine"] + B --> C["Threat KB
packages/ioc + packages/kb"] + C --> D["CLI
apps/cli"] +``` ---- +- `Scanner` parses lockfiles from npm, Yarn, pnpm, and Bun to surface suspicious packages. +- `Engine` converts advisories and blog posts into structured threat objects. +- `Threat KB` stores documented incidents as JSON and exposes them for automation and RAG. +- `CLI` makes the workflow usable in local dev, CI, and incident-response contexts. -## ⚡ Quick Start +## Quick Start ```bash -# Install the universal scanner globally -npm install -g @worms-ctrl/cli +bun install +npx worms-ctrl scan . +``` -# Run an audit against your current project using all known threat definitions -npx worms-ctrl scan +Optional formats: -# View the Knowledge Base of tracked threats -npx worms-ctrl threats +```bash +npx worms-ctrl scan . --format json +npx worms-ctrl scan . --format sarif --output wormsctrl.sarif +npx worms-ctrl scan . --threats ``` -## 🧠 Threat Knowledge Base Architecture +## AI Integration -worms-ctrl treats threats as structured data. Inside `packages/ioc/`, you will find JSON representations of known supply chain attacks. +`wormsCTRL` treats AI as a defensive extraction layer, not as an opaque security oracle. -### Example: The Shai-Hulud Threat Object -When a new threat is detected via our intelligence feeds (e.g., Socket.dev, OSV, Phylum), an AI Agent can automatically generate a Threat Profile: +- `packages/engine/src/ingest.ts` accepts either raw advisory text or a blog/advisory URL. +- The engine sends the material to the OpenAI API using a constrained JSON-mode extraction prompt. +- The extracted payload is validated with Zod in `packages/engine/src/validate.ts`. +- Only schema-valid threat objects are returned for downstream storage or review. +- If `OPENAI_API_KEY` is not set, ingestion fails safely and returns `null` instead of throwing. -```json -{ - "id": "shai-hulud-2.0", - "name": "Shai-Hulud 2.0", - "ecosystem": "npm", - "severity": "CRITICAL", - "status": "ARCHIVED", - "description": "A destructive npm supply-chain worm targeting developers and CI/CD pipelines." -} +Example environment: + +```bash +export OPENAI_API_KEY=your_key_here +export OPENAI_MODEL=gpt-4o-mini ``` -## 🗺️ Roadmap & Integration -- [x] Refactor core architecture from static scripts to dynamic JSON Threat Object ingestion. -- [x] Archive Shai-Hulud 2.0 as the first documented threat. -- [ ] Integrate real-time webhook ingestion from Socket.dev & Phylum APIs. -- [ ] Launch the `wormsCTRL` public Knowledge Base web portal. -- [ ] Implement AI Agent workflow for automatic Threat Object generation from Twitter/Mastodon threat intel. +## Threat Database + +Current documented entries include: + +| Threat ID | Ecosystem | Severity | Attack Vector | Summary | +| --- | --- | --- | --- | --- | +| `event-stream-2018` | npm | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | maintainer compromise | Introduced `flatmap-stream` to target Copay wallet builds. | +| `node-ipc-2022` | npm | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | protestware | Overwrote files and dropped `WITH-LOVE-FROM-AMERICA.txt`. | +| `ua-parser-js-2021` | npm | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | maintainer account hijack | Delivered credential theft and crypto-mining payloads. | +| `ctx-2022` | pypi | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | account takeover | Exfiltrated environment variables to a Heroku endpoint. | +| `xz-utils-2024` | linux | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | upstream release compromise | Backdoored `liblzma` via malicious upstream tarballs. | +| `shai-hulud-2025` | npm | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | self-replicating registry worm | Injected `bundle.js`, scanned for credentials, and republished infected packages. | +| `axios-2026` | npm | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | maintainer account compromise + phantom dependency | Published malicious axios releases plus `plain-crypto-js` RAT delivery. | +| `teampcp-2026` | pypi | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | stolen OIDC Trusted Publisher token + direct registry push | Pushed malicious `litellm` and `telnyx` releases via compromised publishing identity. | + +Threat records live in [`packages/ioc/threats`](packages/ioc/threats) and are designed to be both human-readable and automation-friendly. + +## What Ships Today ---- +- Lockfile parsing for `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `bun.lockb`, and basic `requirements.txt` pins. +- Injection detection with text, JSON, and SARIF output modes. +- Structured incident entries for major real-world supply-chain attacks. +- AI ingestion skeleton for converting advisories into reusable threat objects. +- Implemented parser logic, injection finding generation, and schema validation for threat objects. -## 🤝 Contributing -We welcome contributions from security researchers! If you've analyzed a new malicious package campaign, please submit a PR adding a new Threat Object to our `packages/ioc` directory. +## Grant Context -See [CONTRIBUTING.md](CONTRIBUTING.md) for details. +Built as part of an OpenAI Cybersecurity Grant Program submission. Goal: democratize supply chain defense for solo developers and small teams. ---- +## License -*Formerly known as dont-be-shy-hulud.* +MIT. This repository is intentionally kept permissive and public for defensive reuse, research, and community contribution. diff --git a/SECURITY.md b/SECURITY.md index f633eee..429f2bc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ Instead, please report them via one of the following methods: ### Private Security Advisory (Preferred) -1. Go to the [Security Advisories page](https://github.com/miccy/dont-be-shy-hulud/security/advisories) +1. Go to the [Security Advisories page](https://github.com/miccy/worms-ctrl/security/advisories) 2. Click "New draft security advisory" 3. Fill in the details diff --git a/apps/cli/bin/cli.js b/apps/cli/bin/cli.js index 0366310..d629c69 100755 --- a/apps/cli/bin/cli.js +++ b/apps/cli/bin/cli.js @@ -1,95 +1,32 @@ #!/usr/bin/env node -/** - * 🪱 worms-ctrl CLI - * Supply Chain Attack Detection & Knowledge Base Toolkit - */ - -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import chalk from 'chalk' +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const ROOT = join(__dirname, '..') - -const c = (color, text) => { - const colorMap = { - red: chalk.red, - green: chalk.green, - yellow: chalk.yellow, - blue: chalk.blue, - magenta: chalk.magenta, - cyan: chalk.cyan, - bright: chalk.bold, - } - return (colorMap[color] || chalk.white)(text) -} +const bundledCliPath = resolve(__dirname, '../dist/cli.js') +const sourceCliPath = resolve(__dirname, '../../../packages/scanner/src/cli.ts') -const banner = ` -${c('magenta', ' ╭─────────────────────────────────────────────────╮')} -${c('magenta', ' │')} ${c('cyan', '🪱 worms-ctrl')} ${c('magenta', '│')} -${c('magenta', ' │')} ${c('yellow', 'Supply chain attack detection & audit tool')} ${c('magenta', '│')} -${c('magenta', ' ╰─────────────────────────────────────────────────╯')} -` +let entrypointPath = bundledCliPath -function _getVersion() { - try { - const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')) - return pkg.version || '0.0.0' - } catch { - return '0.0.0' +if (!existsSync(entrypointPath)) { + if (!existsSync(sourceCliPath)) { + console.error('[worms-ctrl] CLI bundle is missing. Run the build step before publishing.') + process.exit(1) } -} - -function loadThreats() { - const threatsPath = join(ROOT, '..', '..', 'packages', 'ioc', 'archived') - if (!existsSync(threatsPath)) return [] - const threats = [] - const dirs = readdirSync(threatsPath, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - - for (const dir of dirs) { - try { - const model = JSON.parse(readFileSync(join(threatsPath, dir, 'threat-model.json'), 'utf-8')) - threats.push(model) - } catch (_e) { - // Skip - } + if (!process.versions.bun) { + console.error( + '[worms-ctrl] Source fallback requires Bun. Run `bun ./apps/cli/bin/cli.js` or build the CLI first.' + ) + process.exit(1) } - return threats -} - -function main() { - const args = process.argv.slice(2) - const command = args[0] || 'help' - console.log(banner) - - if (command === 'scan') { - console.log(c('cyan', '🔍 Initializing worms-ctrl universal scanner...')) - console.log(c('yellow', 'Loading threat definitions...')) - const threats = loadThreats() - console.log(`Loaded ${threats.length} threat models.`) - console.log(c('green', 'Ready for universal supply chain audit. (Logic to be implemented)')) - } else if (command === 'threats') { - console.log(c('bright', '📊 KNOWN THREATS KNOWLEDGE BASE')) - const threats = loadThreats() - threats.forEach((t) => { - console.log(`${c('red', t.name)} [${t.status}] - ${t.severity}`) - console.log(` ${t.description}`) - }) - } else { - console.log(` -${c('bright', 'COMMANDS:')} - ${c('cyan', 'scan')} Scan directory using all loaded threat definitions - ${c('cyan', 'threats')} List all tracked and archived threats - ${c('cyan', 'help')} Show this help message -`) - } + entrypointPath = sourceCliPath } -main() +const { runCli } = await import(pathToFileURL(entrypointPath).href) +const exitCode = await runCli(process.argv.slice(2)) +process.exit(exitCode) diff --git a/apps/cli/build.mjs b/apps/cli/build.mjs new file mode 100644 index 0000000..456442b --- /dev/null +++ b/apps/cli/build.mjs @@ -0,0 +1,43 @@ +import { spawnSync } from 'node:child_process' +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const rootDir = resolve(__dirname, '../..') +const scannerCliEntry = resolve(rootDir, 'packages/scanner/src/cli.ts') +const threatSourceDir = resolve(rootDir, 'packages/ioc/threats') +const distDir = resolve(__dirname, 'dist') +const bundledCliPath = resolve(distDir, 'cli.js') +const bundledThreatsPath = resolve(distDir, 'threats') + +if (!existsSync(scannerCliEntry)) { + throw new Error(`Scanner CLI entrypoint not found: ${scannerCliEntry}`) +} + +if (!existsSync(threatSourceDir)) { + throw new Error(`Threat catalog directory not found: ${threatSourceDir}`) +} + +rmSync(distDir, { recursive: true, force: true }) +mkdirSync(distDir, { recursive: true }) + +const buildResult = spawnSync( + 'bun', + ['build', scannerCliEntry, '--target=node', '--format=esm', '--outfile', bundledCliPath], + { + cwd: rootDir, + stdio: 'inherit', + } +) + +if (buildResult.error) { + throw buildResult.error +} + +if ((buildResult.status ?? 1) !== 0) { + process.exit(buildResult.status ?? 1) +} + +cpSync(threatSourceDir, bundledThreatsPath, { recursive: true }) diff --git a/apps/cli/package.json b/apps/cli/package.json index a3feb23..bf224a0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -6,9 +6,14 @@ "bin": { "worms-ctrl": "./bin/cli.js" }, + "files": [ + "bin", + "dist" + ], "scripts": { - "dev": "node bin/cli.js", - "build": "echo 'No build step needed'", + "dev": "bun ./bin/cli.js", + "build": "node ./build.mjs", + "prepack": "npm run build", "lint": "biome check .", "typecheck": "tsc --noEmit" }, @@ -16,7 +21,7 @@ "chalk": "latest", "cli-progress": "latest", "commander": "latest", - "ora": "latest" + "ora": "^9.4.0" }, "devDependencies": { "@types/cli-progress": "latest", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index c6a1c8e..9d57d6f 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/docs/package.json b/apps/docs/package.json index 88942d9..d171aa4 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,15 +16,15 @@ "test:ui": "playwright test --ui" }, "dependencies": { - "@astrojs/check": "^0.9.8", - "@astrojs/starlight": "^0.38.3", + "@astrojs/check": "^0.9.9", + "@astrojs/starlight": "^0.38.4", "@astrojs/starlight-tailwind": "^5.0.0", "@pagefind/default-ui": "^1.5.2", "@tailwindcss/vite": "^4.2.4", - "astro": "^6.1.8", + "astro": "^6.2.1", "astro-embed": "^0.13.0", "astro-font": "^1.1.0", - "marked": "^18.0.2", + "marked": "^18.0.3", "sharp": "^0.34.4", "vite": "^8.0.9" }, diff --git a/apps/docs/playwright-report/index.html b/apps/docs/playwright-report/index.html deleted file mode 100644 index 0d749eb..0000000 --- a/apps/docs/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/apps/docs/playwright.config.ts b/apps/docs/playwright.config.ts index fbeb371..627fd37 100644 --- a/apps/docs/playwright.config.ts +++ b/apps/docs/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './tests', + testMatch: ['**/*.e2e.ts', '**/*.spec.ts'], fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/apps/docs/src/components/override-components/Search.astro b/apps/docs/src/components/override-components/Search.astro index 32dc365..13f41ac 100644 --- a/apps/docs/src/components/override-components/Search.astro +++ b/apps/docs/src/components/override-components/Search.astro @@ -36,7 +36,7 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') -
+
{ /* TODO: Make the layout of this button flexible to accommodate different word lengths. Currently hard-coded for English: “Cancel” */ } @@ -264,17 +264,18 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') .dialog-frame { position: relative; overflow: auto; - flex-direction: column; + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: "search cancel" "results results"; flex-grow: 1; gap: 1rem; padding: 1rem; } button[data-close-modal] { - position: absolute; + grid-area: cancel; z-index: 1; align-items: center; - align-self: flex-end; height: calc(64px * var(--pagefind-ui-scale)); padding: 0.25rem; border: 0; @@ -283,6 +284,12 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') color: var(--sl-color-text-accent); } + .search-container, + #starlight__search, + #starlight__search .pagefind-ui { + display: contents; + } + #starlight__search { --pagefind-ui-primary: var(--sl-color-text); --pagefind-ui-text: var(--sl-color-gray-2); @@ -291,7 +298,6 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') --pagefind-ui-border: var(--sl-color-gray-5); --pagefind-ui-border-width: 1px; --pagefind-ui-tag: var(--sl-color-gray-5); - --sl-search-cancel-space: 5rem; } :root[data-theme="light"] #starlight__search { @@ -299,10 +305,6 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') } @media (min-width: 50rem) { - #starlight__search { - --sl-search-cancel-space: 0px; - } - dialog { margin: 4rem auto auto; border-radius: 0.5rem; @@ -315,6 +317,8 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') .dialog-frame { padding: 1.5rem; + grid-template-columns: 1fr; + grid-template-areas: "search" "results"; } } } @@ -357,6 +361,10 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') ); } + #starlight__search .pagefind-ui__form { + grid-area: search; + } + #starlight__search .pagefind-ui__form::before { --pagefind-ui-text: var(--sl-color-gray-1); opacity: 1; @@ -365,7 +373,7 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') #starlight__search .pagefind-ui__search-input { color: var(--sl-color-white); font-weight: 400; - width: calc(100% - var(--sl-search-cancel-space)); + width: 100%; } #starlight__search input:focus { @@ -373,7 +381,7 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') } #starlight__search .pagefind-ui__search-clear { - inset-inline-end: var(--sl-search-cancel-space); + inset-inline-end: 0; width: calc(60px * var(--pagefind-ui-scale)); padding: 0; background-color: transparent; @@ -394,6 +402,10 @@ if ((project as { trailingSlash?: string })?.trailingSlash === 'never') height: 100%; } + #starlight__search .pagefind-ui__results { + grid-area: results; + } + #starlight__search .pagefind-ui__results > * + * { margin-top: var(--sl-search-result-spacing); } diff --git a/apps/docs/test-loader.ts b/apps/docs/test-loader.ts deleted file mode 100644 index de3bf02..0000000 --- a/apps/docs/test-loader.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { glob } from 'astro/loaders' -import { dirname, join } from 'path' -import { fileURLToPath } from 'url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const rootDir = join(__dirname, '../..') - -console.log('Current dir:', __dirname) -console.log('Root dir:', rootDir) -console.log('Packages dir:', join(rootDir, 'packages/docs-content/en')) diff --git a/apps/docs/test-results/.last-run.json b/apps/docs/test-results/.last-run.json deleted file mode 100644 index 344ea9e..0000000 --- a/apps/docs/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "interrupted", - "failedTests": [] -} \ No newline at end of file diff --git a/apps/docs/tests/docs.spec.ts b/apps/docs/tests/docs.e2e.ts similarity index 92% rename from apps/docs/tests/docs.spec.ts rename to apps/docs/tests/docs.e2e.ts index 9bd91a8..fa03592 100644 --- a/apps/docs/tests/docs.spec.ts +++ b/apps/docs/tests/docs.e2e.ts @@ -83,8 +83,14 @@ test.describe('Documentation Site', () => { // Click on a sidebar link const nextLink = page.locator('a:has-text("Installation"), a:has-text("Quick Start")') - if ((await nextLink.count()) > 0) { - await nextLink.first().click() + await expect(nextLink.first()).toBeVisible() + const expectedHref = await nextLink.first().getAttribute('href') + await nextLink.first().click() + + if (expectedHref) { + const escapedHref = expectedHref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + await expect(page).toHaveURL(new RegExp(escapedHref)) + } else { await expect(page).not.toHaveURL('/getting-started/introduction/') } }) @@ -115,10 +121,7 @@ test.describe('Documentation Site', () => { test('should render tables', async ({ page }) => { await page.goto('/reference/ioc-database/') const table = page.locator('table') - // Tables may not be on every page - if ((await table.count()) > 0) { - await expect(table.first()).toBeVisible() - } + await expect(table.first()).toBeVisible() }) }) diff --git a/apps/docs/tests/verify-search.spec.ts b/apps/docs/tests/verify-search.spec.ts new file mode 100644 index 0000000..c54321a --- /dev/null +++ b/apps/docs/tests/verify-search.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Search Cancel Button Layout', () => { + test('should have flexible cancel button on mobile', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/'); + + const searchButton = page.locator('button[data-open-modal]'); + await searchButton.click(); + + const dialog = page.locator('dialog'); + await expect(dialog).toBeVisible(); + + const cancelButton = page.locator('button[data-close-modal]'); + await expect(cancelButton).toBeVisible(); + + // Check original width + const originalBox = await cancelButton.boundingBox(); + expect(originalBox, 'Cancel button should be visible and have a bounding box').not.toBeNull(); + console.log(`Original cancel button width: ${originalBox?.width}`); + + // Inject a long label + await cancelButton.evaluate(node => { + node.textContent = 'Moc dlouhé tlačítko pro zrušení'; + }); + + const newBox = await cancelButton.boundingBox(); + expect(newBox, 'Cancel button bounding box should not be null').not.toBeNull(); + console.log(`New cancel button width: ${newBox?.width}`); + + expect(newBox!.width).toBeGreaterThan(originalBox?.width || 0); + + await page.screenshot({ path: testInfo.outputPath('search-modal-mobile-long-cancel.png') }); + }); + + test('should look correct on desktop', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/'); + + const searchButton = page.locator('button[data-open-modal]'); + await searchButton.click(); + + const dialog = page.locator('dialog'); + await expect(dialog).toBeVisible(); + + const cancelButton = page.locator('button[data-close-modal]'); + await expect(cancelButton).toBeHidden(); // Hidden on desktop in original code (md:sl-hidden) + + await page.screenshot({ path: testInfo.outputPath('search-modal-desktop.png') }); + }); +}); diff --git a/bun.lock b/bun.lock index 988ee82..12fceb4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,13 @@ "": { "name": "dont-be-shy-hulud", "devDependencies": { - "@biomejs/biome": "^2.4.12", - "turbo": "2.9.6", + "@biomejs/biome": "^2.4.14", + "turbo": "2.9.8", }, }, "apps/cli": { "name": "@worms-ctrl/cli", - "version": "1.5.1", + "version": "1.5.2", "bin": { "worms-ctrl": "./bin/cli.js", }, @@ -19,7 +19,7 @@ "chalk": "latest", "cli-progress": "latest", "commander": "latest", - "ora": "latest", + "ora": "^9.4.0", }, "devDependencies": { "@types/cli-progress": "latest", @@ -29,17 +29,17 @@ }, "apps/docs": { "name": "@worms-ctrl/docs", - "version": "1.5.1", + "version": "1.5.2", "dependencies": { - "@astrojs/check": "^0.9.8", - "@astrojs/starlight": "^0.38.3", + "@astrojs/check": "^0.9.9", + "@astrojs/starlight": "^0.38.4", "@astrojs/starlight-tailwind": "^5.0.0", "@pagefind/default-ui": "^1.5.2", "@tailwindcss/vite": "^4.2.4", - "astro": "^6.1.8", + "astro": "^6.2.1", "astro-embed": "^0.13.0", "astro-font": "^1.1.0", - "marked": "^18.0.2", + "marked": "^18.0.3", "sharp": "^0.34.4", "vite": "^8.0.9", }, @@ -52,54 +52,60 @@ }, "packages/assets": { "name": "@worms-ctrl/assets", - "version": "1.5.1", + "version": "1.5.2", }, "packages/docs-content": { "name": "@worms-ctrl/docs-content", - "version": "1.5.1", + "version": "1.5.2", }, "packages/engine": { "name": "@worms-ctrl/engine", - "version": "0.1.0", + "version": "1.5.2", + "dependencies": { + "zod": "^4.4.2", + }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3", }, }, "packages/ioc": { "name": "@worms-ctrl/ioc", - "version": "1.5.1", + "version": "1.5.2", "devDependencies": { "typescript": "^6.0.3", }, }, "packages/kb": { "name": "@worms-ctrl/kb", - "version": "0.1.0", + "version": "1.5.2", "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3", }, }, "packages/remediation": { "name": "@worms-ctrl/remediation", - "version": "0.1.0", + "version": "1.5.2", "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3", }, }, "packages/scanner": { "name": "@worms-ctrl/scanner", - "version": "0.1.0", + "version": "1.5.2", + "dependencies": { + "js-yaml": "^4.1.1", + }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3", }, }, "packages/scripts": { "name": "@worms-ctrl/scripts", - "version": "1.5.1", + "version": "1.5.2", "bin": { "hulud-detect": "./detect.sh", "hulud-audit": "./full-audit.sh", @@ -108,7 +114,7 @@ }, "packages/wiki-sync": { "name": "@worms-ctrl/wiki-sync", - "version": "1.5.1", + "version": "1.5.2", "dependencies": { "glob": "^13.0.6", "gray-matter": "^4.0.3", @@ -119,6 +125,9 @@ }, }, }, + "overrides": { + "vite": "8.0.10", + }, "packages": { "@astro-community/astro-embed-baseline-status": ["@astro-community/astro-embed-baseline-status@0.2.2", "", { "dependencies": { "@astro-community/astro-embed-utils": "^0.2.0" } }, "sha512-07TBEb+xQWWZfMuoHohcZv/r2VSB80/1xN5iLhzSqavLmdsMyebEnbc6tvw3yMkxvX9IBLduNA5SxvVkpmowNQ=="], @@ -140,13 +149,13 @@ "@astro-community/astro-embed-youtube": ["@astro-community/astro-embed-youtube@0.5.10", "", { "dependencies": { "lite-youtube-embed": "^0.3.4" } }, "sha512-hVlx77KQLjKzElVQnrU5znQ5/E60keVSAPrhuWvQQHuqva5auJtt8YBpOThkwDMuEKXjQybEF1/3C07RZ8MAOQ=="], - "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], + "@astrojs/check": ["@astrojs/check@0.9.9", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg=="], - "@astrojs/compiler": ["@astrojs/compiler@3.0.1", "", {}, "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA=="], + "@astrojs/compiler": ["@astrojs/compiler@4.0.0", "", {}, "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA=="], - "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.9.0", "", { "dependencies": { "picomatch": "^4.0.4" } }, "sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg=="], - "@astrojs/language-server": ["@astrojs/language-server@2.16.6", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.7", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.16", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-b64bWT74Vq/ORcSqW7TdIjjpB6hcl+Ei/lMANIUaAGlLPiYNtPTRI/j2tzvugT+LoVwfJtE2Ukq/t2OGCyEtfQ=="], "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.1.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ=="], @@ -156,7 +165,7 @@ "@astrojs/sitemap": ["@astrojs/sitemap@3.7.2", "", { "dependencies": { "sitemap": "^9.0.0", "stream-replace-string": "^2.0.0", "zod": "^4.3.6" } }, "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA=="], - "@astrojs/starlight": ["@astrojs/starlight@0.38.3", "", { "dependencies": { "@astrojs/markdown-remark": "^7.0.0", "@astrojs/mdx": "^5.0.0", "@astrojs/sitemap": "^3.7.1", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.41.6", "bcp-47": "^2.1.0", "hast-util-from-html": "^2.0.1", "hast-util-select": "^6.0.2", "hast-util-to-string": "^3.0.0", "hastscript": "^9.0.0", "i18next": "^23.11.5", "js-yaml": "^4.1.0", "klona": "^2.0.6", "magic-string": "^0.30.17", "mdast-util-directive": "^3.0.0", "mdast-util-to-markdown": "^2.1.0", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.3.0", "rehype": "^13.0.1", "rehype-format": "^5.0.0", "remark-directive": "^3.0.0", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "vfile": "^6.0.2" }, "peerDependencies": { "astro": "^6.0.0" } }, "sha512-kDlJPlUDdQFWYmyFM2yUPo66yws7v067AEK+/rQjjoVyqehL3DabuOJuy6UJFFTFyGbHxYcBms/ITEgdW7tphw=="], + "@astrojs/starlight": ["@astrojs/starlight@0.38.4", "", { "dependencies": { "@astrojs/markdown-remark": "^7.0.0", "@astrojs/mdx": "^5.0.0", "@astrojs/sitemap": "^3.7.1", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.41.6", "bcp-47": "^2.1.0", "hast-util-from-html": "^2.0.1", "hast-util-select": "^6.0.2", "hast-util-to-string": "^3.0.0", "hastscript": "^9.0.0", "i18next": "^23.11.5", "js-yaml": "^4.1.0", "klona": "^2.0.6", "magic-string": "^0.30.17", "mdast-util-directive": "^3.0.0", "mdast-util-to-markdown": "^2.1.0", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.3.0", "rehype": "^13.0.1", "rehype-format": "^5.0.0", "remark-directive": "^3.0.0", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "vfile": "^6.0.2" }, "peerDependencies": { "astro": "^6.0.0" } }, "sha512-TGFIr2aVC+gcZCPQzJOO4ZnA/yL3jRnsUDcKlVdEhxhxaOQnWr9lZ9MRScg9zU6uh3HVeZAmmjkLCdTlHdcaZA=="], "@astrojs/starlight-tailwind": ["@astrojs/starlight-tailwind@5.0.0", "", { "peerDependencies": { "@astrojs/starlight": ">=0.38.0", "tailwindcss": "^4.0.0" } }, "sha512-VivF+bWg++4ma/ffr5sgHsd/ONtGdVJIKAaRZ6jmL4yqxy7bviu59MGNi5aW3nd8psP9i/aivBTrpwGxRM1XyA=="], @@ -192,23 +201,23 @@ "@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="], - "@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.14", "@biomejs/cli-darwin-x64": "2.4.14", "@biomejs/cli-linux-arm64": "2.4.14", "@biomejs/cli-linux-arm64-musl": "2.4.14", "@biomejs/cli-linux-x64": "2.4.14", "@biomejs/cli-linux-x64-musl": "2.4.14", "@biomejs/cli-win32-arm64": "2.4.14", "@biomejs/cli-win32-x64": "2.4.14" }, "bin": { "biome": "bin/biome" } }, "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.14", "", { "os": "win32", "cpu": "x64" }, "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA=="], "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], @@ -232,7 +241,7 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -364,7 +373,7 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ=="], @@ -384,37 +393,37 @@ "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -510,21 +519,21 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-zU1P95ygDpsQ+2QHh7CVTqvYwi9UBlhKWzoIyUnP3vUoge7H9SQEzrd8dj+XcTrslAp9Db3vIBcXtMVoTEYDnA=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nKRFI5ZhCGUi4eXNlrojzWcT/CehMj0raot1WE4lw5qf66ZxZHbRbBqcwNEy+ZLY7RkJJRY+TaU89fuj3BcgGg=="], - "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], + "@turbo/linux-64": ["@turbo/linux-64@2.9.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Wf/kQpVDCaWM3P5d6lKvJnqjYn/ofUBGbT4h4vRFrdC4N6B/nsun03S2kQNJJMXpXg39woeS4CI367RMU3/OAg=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-v6S3HuKVoa9CEx16IxKj1i/+crxXx22A9O80zW1350zyUlcX0T/zLOxVf1k+ruK/7ssXnDJVg8uSYOxlYRedlA=="], - "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], + "@turbo/windows-64": ["@turbo/windows-64@2.9.8", "", { "os": "win32", "cpu": "x64" }, "sha512-JaefWOJNBazDylAn3f+lLB34XMNu8nEBbgPRP/Ewysg81cBubGfcyyyzpQOGVuMwfaqdNAE/kitG7w3AbJn9/g=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-Or6ljjB4TiiwCdVKDYWew0SokQ9kep5zruL8P3nbum9WdkH5XA41rQID4Ulc215Z+R3DrB+qXSHPsJjU3/n2ng=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/cli-progress": ["@types/cli-progress@3.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA=="], @@ -626,7 +635,7 @@ "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "astro": ["astro@6.1.8", "", { "dependencies": { "@astrojs/compiler": "^3.0.1", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.1.0", "@astrojs/telemetry": "3.3.1", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-6fT9M12U3fpi13DiPavNKDIoBflASTSxmKTEe+zXhWtlebQuOqfOnIrMWyRmlXp+mgDsojmw+fVFG9LUTzKSog=="], + "astro": ["astro@6.2.1", "", { "dependencies": { "@astrojs/compiler": "^4.0.0", "@astrojs/internal-helpers": "0.9.0", "@astrojs/markdown-remark": "7.1.1", "@astrojs/telemetry": "3.3.1", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.5", "vfile": "^6.0.3", "vite": "^7.3.2", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-3g1sYNly+QAkuO5ErNEQBYvsxorNDSCUNIeStBs+kcXGchvKQl1Q9EuDNOvSg010XLlHJFLVFZs9LV18Jjp4Hg=="], "astro-auto-import": ["astro-auto-import@0.5.1", "", { "dependencies": { "acorn": "^8.15.0" }, "peerDependencies": { "astro": "^5.0.0-beta || ^6.0.0-alpha" } }, "sha512-7YZKVA7LE5nLkopOM+KIHqnh6g2CfHrysj2JUXNBrC3FppHH42RSNBM7mgsEgaq2lgHVDt7hsDQIA0JKTwIN8A=="], @@ -652,7 +661,7 @@ "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -954,7 +963,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@18.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg=="], + "marked": ["marked@18.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA=="], "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], @@ -1104,7 +1113,7 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], - "ora": ["ora@9.3.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" } }, "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw=="], + "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], @@ -1130,7 +1139,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], @@ -1210,7 +1219,7 @@ "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], - "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], @@ -1240,7 +1249,7 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "stdin-discarder": ["stdin-discarder@0.3.1", "", {}, "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA=="], + "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], "stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="], @@ -1268,7 +1277,7 @@ "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1278,7 +1287,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], + "turbo": ["turbo@2.9.8", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.8", "@turbo/darwin-arm64": "2.9.8", "@turbo/linux-64": "2.9.8", "@turbo/linux-arm64": "2.9.8", "@turbo/windows-64": "2.9.8", "@turbo/windows-arm64": "2.9.8" }, "bin": { "turbo": "bin/turbo" } }, "sha512-REEB2rVTVDTf4hav1gJ5dIsGylWZrNonvjXFtk1dCi8gND3PhZtnYkyry1bra/Fo+iP6ctTEZbg6vWfdfHq/1A=="], "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], @@ -1316,7 +1325,7 @@ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], "unist-util-visit-children": ["unist-util-visit-children@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA=="], @@ -1332,7 +1341,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="], + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], @@ -1380,7 +1389,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], @@ -1392,35 +1401,31 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.4.2", "", {}, "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@astro-community/astro-embed-link-preview/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], - "@astrojs/check/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@astrojs/internal-helpers/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], - "@astrojs/markdown-remark/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - - "@astrojs/mdx/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], - "@astrojs/yaml2ts/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@astrojs/sitemap/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@atcute/identity/@atcute/lexicons": ["@atcute/lexicons@1.3.0", "", { "dependencies": { "@atcute/uint8array": "^1.1.1", "@atcute/util-text": "^1.2.0", "@standard-schema/spec": "^1.1.0", "esm-env": "^1.2.2" } }, "sha512-Eq5y+9onnCXNVUlNiMf31beSXHKqptB7lUo/68YbhlmxdaR7ooywHmahya9goP5AsmlYEA1z+dRPXIDAa9O7cg=="], - "@expressive-code/core/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - "@expressive-code/plugin-shiki/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "@mdx-js/mdx/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "@mdx-js/mdx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@mdx-js/mdx/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@rollup/pluginutils/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1440,9 +1445,9 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "astro/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + "astro/@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.1.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.9.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA=="], - "astro/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], + "astro/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "astro-auto-import/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -1456,8 +1461,18 @@ "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "hast-util-raw/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "hast-util-select/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "mdast-util-definitions/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "mdast-util-to-hast/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "mdast-util-to-markdown/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "micromark-extension-mdxjs/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "ofetch/ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], @@ -1468,6 +1483,10 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "remark-smartypants/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "retext-smartypants/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "sitemap/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], @@ -1478,11 +1497,9 @@ "typescript-auto-import-cache/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - - "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "unist-util-remove-position/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], - "vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "volar-service-typescript/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -1490,6 +1507,8 @@ "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], + "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "@atcute/identity/@atcute/lexicons/@atcute/util-text": ["@atcute/util-text@1.3.0", "", { "dependencies": { "unicode-segmenter": "^0.14.5" } }, "sha512-njN7Zkul8gWq91NH93ToAnQ/IDgU7lUixZXgI3WyoDvgPFsuPQ+/u4LChNeD56rDFAbrP6m69dfA8gtVOQTMyg=="], @@ -1510,8 +1529,6 @@ "@types/sax/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "astro/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], diff --git a/cs/README.md b/cs/README.md new file mode 100644 index 0000000..bfbb05b --- /dev/null +++ b/cs/README.md @@ -0,0 +1,90 @@ +# wormsCTRL + +![worms-ctrl Banner](../packages/assets/banner.png) + +> Univerzální nástroj pro audit supply chainu a threat knowledge base pro npm a příbuzné open-source ekosystémy. + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](../LICENSE) +[![Open Source](https://img.shields.io/badge/open%20source-public%20benefit-blue)](https://github.com/miccy/worms-ctrl) +[![Bun](https://img.shields.io/badge/runtime-Bun-black)](https://bun.sh) + +Supply-chain útoků přibývá, protože moderní aplikace dědí důvěru z tisíců transitive balíčků, maintainer účtů, CI runnerů a release pipeline, které tým přímo neřídí. Jeden kompromitovaný publisher, škodlivý install script nebo otrávený upstream artifact tak dnes dokáže během hodin zasáhnout tisíce downstream buildů. `wormsCTRL` se snaží tenhle problém zmenšit pomocí auditovatelných lockfile scanů, strukturované threat intelligence a AI-assisted ingestion pipeline, která převádí veřejné advisory do strojově čitelných obranných znalostí. + +## Architektura + +```mermaid +flowchart LR + A["Scanner
packages/scanner"] --> B["Engine
packages/engine"] + B --> C["Threat KB
packages/ioc + packages/kb"] + C --> D["CLI
apps/cli"] +``` + +- `Scanner` parsuje lockfily z npm, Yarnu, pnpm i Bunu a hledá podezřelé balíčky. +- `Engine` převádí advisory a blog posty na strukturované threat objekty. +- `Threat KB` ukládá incidenty jako JSON a zpřístupňuje je pro automatizaci i RAG. +- `CLI` dává celý workflow k dispozici lokálně, v CI i při incident response. + +## Rychlý start + +```bash +bun install +npx worms-ctrl scan . +``` + +Volitelné formáty: + +```bash +npx worms-ctrl scan . --format json +npx worms-ctrl scan . --format sarif --output wormsctrl.sarif +npx worms-ctrl scan . --threats +``` + +## AI integrace + +`wormsCTRL` používá AI jako obrannou extrakční vrstvu, ne jako neprůhlednou bezpečnostní autoritu. + +- `packages/engine/src/ingest.ts` přijímá buď surový text advisory, nebo URL článku/advisory. +- Engine odešle materiál do OpenAI API přes omezený JSON-mode prompt. +- Výstup se validuje přes Zod v `packages/engine/src/validate.ts`. +- Do dalšího zpracování projdou jen threat objekty, které odpovídají schématu. +- Pokud není nastavené `OPENAI_API_KEY`, ingestion skončí bezpečně a vrátí `null`. + +Ukázka prostředí: + +```bash +export OPENAI_API_KEY=your_key_here +export OPENAI_MODEL=gpt-4o-mini +``` + +## Threat databáze + +Aktuálně zdokumentované záznamy: + +| Threat ID | Ekosystém | Závažnost | Attack vector | Shrnutí | +| --- | --- | --- | --- | --- | +| `event-stream-2018` | npm | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | maintainer compromise | Přes `flatmap-stream` cílil na buildy Copay walletu. | +| `node-ipc-2022` | npm | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | protestware | Přepisoval soubory a vytvářel `WITH-LOVE-FROM-AMERICA.txt`. | +| `ua-parser-js-2021` | npm | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | maintainer account hijack | Nasazoval credential theft a crypto-mining payloady. | +| `ctx-2022` | pypi | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | account takeover | Exfiltroval environment proměnné na Heroku endpoint. | +| `xz-utils-2024` | linux | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | upstream release compromise | Backdoor v `liblzma` přes škodlivé upstream tarbally. | +| `shai-hulud-2025` | npm | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | self-replicating registry worm | Injectoval `bundle.js`, skenoval kredenciály a republishoval infikované balíčky. | +| `axios-2026` | npm | ![CRITICAL](https://img.shields.io/badge/severity-CRITICAL-red) | maintainer account compromise + phantom dependency | Publikoval škodlivé axios releasy a RAT přes `plain-crypto-js`. | +| `teampcp-2026` | pypi | ![HIGH](https://img.shields.io/badge/severity-HIGH-orange) | stolen OIDC Trusted Publisher token + direct registry push | Protlačil škodlivé releasy `litellm` a `telnyx` přes kompromitovanou publikační identitu. | + +Threat záznamy jsou v [`packages/ioc/threats`](../packages/ioc/threats) a jsou navržené tak, aby byly čitelné pro člověka i použitelné pro automatizaci. + +## Co je hotové + +- Parsování `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `bun.lockb` a základních pinů v `requirements.txt`. +- Injection detection s textovým, JSON a SARIF výstupem. +- Strukturované incidenty pro reálné supply-chain útoky. +- AI ingestion skeleton pro převod advisory do znovupoužitelných threat objektů. +- Implementována logika parserů, generování injection findingů a validace schémat. + +## Kontext grantu + +Vytvořeno jako součást přihlášky do OpenAI Cybersecurity Grant Programu. Cíl: demokratizovat obranu supply chainu pro solo developery a malé týmy. + +## Licence + +MIT. Repo zůstává záměrně permissive a veřejné kvůli obrannému reuse, výzkumu a komunitním příspěvkům. diff --git a/examples/axios-compromise.sarif b/examples/axios-compromise.sarif new file mode 100644 index 0000000..224c720 --- /dev/null +++ b/examples/axios-compromise.sarif @@ -0,0 +1,104 @@ +{ + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "wormsCTRL Scanner", + "version": "2.0.0", + "informationUri": "https://github.com/miccy/worms-ctrl", + "rules": [ + { + "id": "WCTRL/scan/malicious-package", + "name": "malicious package", + "shortDescription": { + "text": "Known malicious version: axios@1.14.1 matches axios-2026 (maintainer account compromise + phantom dependency)" + }, + "fullDescription": { + "text": "Known malicious version: axios@1.14.1 matches axios-2026 (maintainer account compromise + phantom dependency)" + }, + "help": { + "text": "Remove axios@1.14.1, rebuild from a known-good dependency set, and follow the axios-2026 remediation guidance." + }, + "properties": { + "tags": [ + "critical", + "malicious-package" + ] + } + }, + { + "id": "WCTRL/scan/injected-package", + "name": "injection", + "shortDescription": { + "text": "Phantom dependency: plain-crypto-js@4.2.1 — not declared in package.json, matches known IOC" + }, + "fullDescription": { + "text": "Phantom dependency: plain-crypto-js@4.2.1 — not declared in package.json, matches known IOC" + }, + "help": { + "text": "Investigate the parent dependency chain for axios-2026, remove the phantom package, and rebuild from a trusted lockfile." + }, + "properties": { + "tags": [ + "critical", + "injection" + ] + } + } + ] + } + }, + "results": [ + { + "ruleId": "WCTRL/scan/malicious-package", + "level": "error", + "message": { + "text": "Known malicious version: axios@1.14.1 matches axios-2026 (maintainer account compromise + phantom dependency)" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "tests/fixtures/axios-compromise/package-lock.json" + }, + "region": { + "startLine": 1 + } + } + } + ], + "properties": { + "package": "axios", + "severity": "critical", + "type": "malicious-package" + } + }, + { + "ruleId": "WCTRL/scan/injected-package", + "level": "error", + "message": { + "text": "Phantom dependency: plain-crypto-js@4.2.1 — not declared in package.json, matches known IOC" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "tests/fixtures/axios-compromise/package-lock.json" + }, + "region": { + "startLine": 1 + } + } + } + ], + "properties": { + "package": "plain-crypto-js", + "severity": "critical", + "type": "injection" + } + } + ] + } + ] +} diff --git a/package.json b/package.json index e2f91aa..bccb2c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@worms-ctrl/core", - "version": "1.5.2", + "version": "2.0.0", "private": true, "description": "🪱 npm supply chain attack detection toolkit — Shai-Hulud 2.0 detection, defense & remediation", "bin": { @@ -60,10 +60,13 @@ "homepage": "https://github.com/miccy/worms-ctrl#readme", "packageManager": "bun@1.1.38", "engines": { - "node": ">=18" + "node": ">=20.19" + }, + "overrides": { + "vite": "8.0.10" }, "devDependencies": { - "@biomejs/biome": "^2.4.12", - "turbo": "2.9.6" + "@biomejs/biome": "^2.4.14", + "turbo": "2.9.8" } } diff --git a/packages/configs/bunfig-secure.toml b/packages/configs/bunfig-secure.toml index 336ca90..119b231 100644 --- a/packages/configs/bunfig-secure.toml +++ b/packages/configs/bunfig-secure.toml @@ -1,5 +1,5 @@ # Secure Bun configuration for protection against supply chain attacks -# See: https://github.com/miccy/dont-be-shy-hulud +# See: https://github.com/miccy/worms-ctrl # # IMPORTANT: Bun's .npmrc ignore-scripts=true is NOT reliable! # Always use: bun install --ignore-scripts diff --git a/packages/configs/pnpm-workspace-secure.yaml b/packages/configs/pnpm-workspace-secure.yaml index 785d05e..b379c0c 100644 --- a/packages/configs/pnpm-workspace-secure.yaml +++ b/packages/configs/pnpm-workspace-secure.yaml @@ -1,5 +1,5 @@ # Secure pnpm workspace configuration for protection against supply chain attacks -# See: https://github.com/miccy/dont-be-shy-hulud +# See: https://github.com/miccy/worms-ctrl # # Place this file as pnpm-workspace.yaml in your monorepo root # Combine with .npmrc settings for maximum protection diff --git a/packages/configs/renovate-defense.json b/packages/configs/renovate-defense.json index b1ab349..133d459 100644 --- a/packages/configs/renovate-defense.json +++ b/packages/configs/renovate-defense.json @@ -39,10 +39,10 @@ "This package was compromised in the November 2025 npm supply chain attack.", "Before enabling updates, verify:", "1. Package has been remediated by maintainers", - "2. Check [IOC database](https://github.com/miccy/dont-be-shy-hulud/blob/main/ioc/malicious-packages.json)", + "2. Check [IOC database](https://github.com/miccy/worms-ctrl/tree/main/packages/ioc)", "3. Review package changelog for security fixes", "", - "See: https://github.com/miccy/dont-be-shy-hulud" + "See: https://github.com/miccy/worms-ctrl" ] }, { diff --git a/packages/docs-content/cs/getting-started/installation.md b/packages/docs-content/cs/getting-started/installation.md index bc796de..c003322 100644 --- a/packages/docs-content/cs/getting-started/installation.md +++ b/packages/docs-content/cs/getting-started/installation.md @@ -23,10 +23,10 @@ npx hulud harden ```bash # npm -npm install -g dont-be-shy-hulud +npm install -g worms-ctrl # bun -bun add -g dont-be-shy-hulud +bun add -g worms-ctrl # Pak použijte hulud scan . @@ -36,10 +36,10 @@ hulud scan . ```bash # npm -npm install --save-dev dont-be-shy-hulud +npm install --save-dev worms-ctrl # bun -bun add -d dont-be-shy-hulud +bun add -d worms-ctrl ``` Pak přidejte do `package.json`: @@ -58,10 +58,10 @@ Pak přidejte do `package.json`: ```bash # Stáhnout image -docker pull ghcr.io/miccy/dont-be-shy-hulud:latest +docker pull ghcr.io/miccy/worms-ctrl:latest # Spustit sken -docker run -v $(pwd):/app ghcr.io/miccy/dont-be-shy-hulud scan /app +docker run -v $(pwd):/app ghcr.io/miccy/worms-ctrl scan /app ``` ## Ověření Instalace diff --git a/packages/docs-content/cs/index.mdx b/packages/docs-content/cs/index.mdx index 470e051..7ab73f1 100644 --- a/packages/docs-content/cs/index.mdx +++ b/packages/docs-content/cs/index.mdx @@ -24,7 +24,7 @@ hero: npx hulud scan . # Nebo použijte shell skript přímo -curl -sSL https://raw.githubusercontent.com/miccy/dont-be-shy-hulud/main/scripts/detect.sh | bash +curl -sSL https://raw.githubusercontent.com/miccy/worms-ctrl/main/packages/scripts/detect.sh | bash ``` ## Klíčové IOC soubory diff --git a/packages/docs-content/cs/meta/AGENTS.md b/packages/docs-content/cs/meta/AGENTS.md index 5a80ab4..add5afc 100644 --- a/packages/docs-content/cs/meta/AGENTS.md +++ b/packages/docs-content/cs/meta/AGENTS.md @@ -25,7 +25,7 @@ Tento repozitář poskytuje: ## Struktura repozitáře ``` -dont-be-shy-hulud/ +worms-ctrl/ ├── docs/ │ ├── en/ # Anglická dokumentace │ └── cs/ # Česká dokumentace @@ -178,7 +178,7 @@ Viz `.agents/README.md` pro instrukce použití. ## Informace o verzi -- **Repozitář**: https://github.com/miccy/dont-be-shy-hulud +- **Repozitář**: https://github.com/miccy/worms-ctrl - **License**: MIT - **Maintainer**: @miccy - **Status**: Aktivní vývoj (příprava na public release) diff --git a/packages/docs-content/cs/meta/README.md b/packages/docs-content/cs/meta/README.md index c39f819..8ef861f 100644 --- a/packages/docs-content/cs/meta/README.md +++ b/packages/docs-content/cs/meta/README.md @@ -5,11 +5,11 @@ > **Praktický průvodce detekcí a ochranou proti npm supply-chain útokům** > Zaměřeno na Shai-Hulud 2.0 (listopad 2025) a podobné hrozby -[![npm version](https://img.shields.io/npm/v/dont-be-shy-hulud?color=cb3837&logo=npm)](https://www.npmjs.com/package/dont-be-shy-hulud) +[![npm version](https://img.shields.io/npm/v/worms-ctrl?color=cb3837&logo=npm)](https://www.npmjs.com/package/worms-ctrl) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](../LICENSE) -[![GitHub release](https://img.shields.io/github/v/release/miccy/dont-be-shy-hulud?include_prereleases&label=Release)](https://github.com/miccy/dont-be-shy-hulud/releases) +[![GitHub release](https://img.shields.io/github/v/release/miccy/worms-ctrl?include_prereleases&label=Release)](https://github.com/miccy/worms-ctrl/releases) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](../CONTRIBUTING.md) -![CodeRabbit PR Reviews](https://img.shields.io/coderabbit/prs/github/miccy/dont-be-shy-hulud?utm_source=oss&utm_medium=github&utm_campaign=miccy%2Fdont-be-shy-hulud&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) +![CodeRabbit PR Reviews](https://img.shields.io/coderabbit/prs/github/miccy/worms-ctrl?utm_source=oss&utm_medium=github&utm_campaign=miccy%2Fworms-ctrl&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) ## ⚠️ KRITICKÉ: Varování Dead Man's Switch @@ -100,8 +100,8 @@ npx hulud --help # Nápověda ### Alternativa: Klon a spuštění ```bash -git clone https://github.com/miccy/dont-be-shy-hulud.git -cd dont-be-shy-hulud +git clone https://github.com/miccy/worms-ctrl.git +cd worms-ctrl ./scripts/detect.sh /cesta/k/projektu ``` @@ -417,9 +417,9 @@ Projekt obsahuje předpřipravené VS Code tasks. Stiskněte `Cmd+Shift+P` -> `T Máte dotazy, našli jste nové IOCs, nebo chcete sdílet zkušenosti? -- **[GitHub Discussions](https://github.com/miccy/dont-be-shy-hulud/discussions)** — Ptát se, sdílet nálezy, získat pomoc -- **[Nahlásit bezpečnostní problém](https://github.com/miccy/dont-be-shy-hulud/security/advisories/new)** — Pro citlivé bezpečnostní zprávy -- **[Otevřít Issue](https://github.com/miccy/dont-be-shy-hulud/issues)** — Bug reporty a feature requesty +- **[GitHub Discussions](https://github.com/miccy/worms-ctrl/discussions)** — Ptát se, sdílet nálezy, získat pomoc +- **[Nahlásit bezpečnostní problém](https://github.com/miccy/worms-ctrl/security/advisories/new)** — Pro citlivé bezpečnostní zprávy +- **[Otevřít Issue](https://github.com/miccy/worms-ctrl/issues)** — Bug reporty a feature requesty ## 📚 Reference diff --git a/packages/docs-content/cs/meta/SECURITY.md b/packages/docs-content/cs/meta/SECURITY.md index a5ac99e..d5066db 100644 --- a/packages/docs-content/cs/meta/SECURITY.md +++ b/packages/docs-content/cs/meta/SECURITY.md @@ -17,7 +17,7 @@ Místo toho je prosím nahlaste jedním z následujících způsobů: ### Soukromé bezpečnostní upozornění (Preferováno) -1. Přejděte na stránku [Security Advisories](https://github.com/miccy/dont-be-shy-hulud/security/advisories) +1. Přejděte na stránku [Security Advisories](https://github.com/miccy/worms-ctrl/security/advisories) 2. Klikněte na "New draft security advisory" 3. Vyplňte podrobnosti diff --git a/packages/docs-content/cs/reference/cli.md b/packages/docs-content/cs/reference/cli.md index 9b36bc4..bc28f8e 100644 --- a/packages/docs-content/cs/reference/cli.md +++ b/packages/docs-content/cs/reference/cli.md @@ -15,8 +15,8 @@ lastUpdated: 2025-12-05 # Spuštění bez instalace npx hulud -# Nebo globální instalace -npm install -g dont-be-shy-hulud +# Nebo globální instalace (toto nainstaluje CLI nástroj pod názvem `hulud`) +npm install -g worms-ctrl ``` ## Příkazy diff --git a/packages/docs-content/cs/reference/ioc-database.md b/packages/docs-content/cs/reference/ioc-database.md index 4657f31..df8e517 100644 --- a/packages/docs-content/cs/reference/ioc-database.md +++ b/packages/docs-content/cs/reference/ioc-database.md @@ -67,7 +67,7 @@ npm → bun (Bun spuštěný během npm install) ## Kompromitované Balíčky (Ukázka) -> ⚠️ Toto je částečný seznam. Viz [kompletní IOC databáze](https://github.com/miccy/dont-be-shy-hulud/tree/main/packages/ioc) pro úplný seznam. +> ⚠️ Toto je částečný seznam. Viz [kompletní IOC databáze](https://github.com/miccy/worms-ctrl/tree/main/packages/ioc) pro úplný seznam. | Balíček | Postižené Verze | Týdenní Stažení | | ---------------------- | --------------- | --------------- | diff --git a/packages/docs-content/getting-started/installation.md b/packages/docs-content/getting-started/installation.md index a514fc0..171972b 100644 --- a/packages/docs-content/getting-started/installation.md +++ b/packages/docs-content/getting-started/installation.md @@ -22,9 +22,9 @@ npx hulud scan --system ## Global Installation ```bash -npm install -g dont-be-shy-hulud +npm install -g worms-ctrl # or -bun add -g dont-be-shy-hulud +bun add -g worms-ctrl ``` Then use anywhere: @@ -39,9 +39,9 @@ hulud scan ~/projects Add to your project: ```bash -npm install --save-dev dont-be-shy-hulud +npm install --save-dev worms-ctrl # or -bun add -d dont-be-shy-hulud +bun add -d worms-ctrl ``` Add to `package.json` scripts: @@ -66,8 +66,8 @@ docker run --rm -v $(pwd):/target ghcr.io/miccy/hulud-scanner Or build locally: ```bash -git clone https://github.com/miccy/dont-be-shy-hulud.git -cd dont-be-shy-hulud +git clone https://github.com/miccy/worms-ctrl.git +cd worms-ctrl docker build -t hulud-scanner . docker run --rm -v $(pwd):/target hulud-scanner ``` @@ -78,7 +78,7 @@ If you only need the shell scripts: ```bash # Download detect.sh -curl -O https://raw.githubusercontent.com/miccy/dont-be-shy-hulud/main/scripts/detect.sh +curl -O https://raw.githubusercontent.com/miccy/worms-ctrl/main/packages/scripts/detect.sh chmod +x detect.sh ./detect.sh ``` diff --git a/packages/docs-content/getting-started/quickstart.md b/packages/docs-content/getting-started/quickstart.md index 9233616..34cd525 100644 --- a/packages/docs-content/getting-started/quickstart.md +++ b/packages/docs-content/getting-started/quickstart.md @@ -21,15 +21,15 @@ npx hulud scan . ## Option 2: Direct Script ```bash -curl -sSL https://raw.githubusercontent.com/miccy/dont-be-shy-hulud/main/scripts/detect.sh | bash +curl -sSL https://raw.githubusercontent.com/miccy/worms-ctrl/main/packages/scripts/detect.sh | bash ``` ## Option 3: Clone & Run ```bash -git clone https://github.com/miccy/dont-be-shy-hulud.git -cd dont-be-shy-hulud -./scripts/detect.sh +git clone https://github.com/miccy/worms-ctrl.git +cd worms-ctrl +./packages/scripts/detect.sh ``` ## What the Scan Checks diff --git a/packages/docs-content/reference/cli.md b/packages/docs-content/reference/cli.md index 9b61bfb..0b77b5c 100644 --- a/packages/docs-content/reference/cli.md +++ b/packages/docs-content/reference/cli.md @@ -15,8 +15,8 @@ lastUpdated: 2025-12-05 # Run without installing npx hulud -# Or install globally -npm install -g dont-be-shy-hulud +# Or install globally (this installs the hulud CLI tool) +npm install -g worms-ctrl ``` ## Commands diff --git a/packages/docs-content/reference/ioc-database.md b/packages/docs-content/reference/ioc-database.md index f2a991e..6719317 100644 --- a/packages/docs-content/reference/ioc-database.md +++ b/packages/docs-content/reference/ioc-database.md @@ -67,7 +67,7 @@ npm → bun (Bun spawned during npm install) ## Compromised Packages (Sample) -> ⚠️ This is a partial list. See [full IOC database](https://github.com/miccy/dont-be-shy-hulud/tree/main/packages/ioc) for complete list. +> ⚠️ This is a partial list. See [full IOC database](https://github.com/miccy/worms-ctrl/tree/main/packages/ioc) for complete list. | Package | Affected Versions | Weekly Downloads | | ---------------------- | ----------------- | ---------------- | diff --git a/packages/engine/package.json b/packages/engine/package.json index b78128f..4051207 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -12,9 +12,11 @@ "lint": "biome check .", "lint:fix": "biome check --write ." }, - "dependencies": {}, + "dependencies": { + "zod": "^4.4.2" + }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3" } } diff --git a/packages/engine/src/__tests__/threats.validate.test.ts b/packages/engine/src/__tests__/threats.validate.test.ts new file mode 100644 index 0000000..b0b27f9 --- /dev/null +++ b/packages/engine/src/__tests__/threats.validate.test.ts @@ -0,0 +1,26 @@ +import { readdirSync } from 'node:fs' +import { describe, expect, test } from 'bun:test' +import { join, resolve } from 'node:path' +import { validateThreatObject } from '../validate.js' + +const THREATS_DIR = resolve(import.meta.dir, '../../../../packages/ioc/threats') + +describe('threat catalog validation', () => { + const filenames = readdirSync(THREATS_DIR).filter((f) => f.endsWith('.json')) + + test('found threat files to validate', () => { + expect(filenames.length).toBeGreaterThan(0) + }) + + for (const filename of filenames) { + test(`${filename} passes ThreatObject schema validation`, async () => { + const content = await Bun.file(join(THREATS_DIR, filename)).text() + try { + const candidate = JSON.parse(content) as unknown + expect(validateThreatObject(candidate), `Validation failed for ${filename}`).not.toBeNull() + } catch (e: any) { + throw new Error(`Failed to parse or validate ${filename}: ${e.message}`) + } + }) + } +}) diff --git a/packages/engine/src/__tests__/validate.test.ts b/packages/engine/src/__tests__/validate.test.ts new file mode 100644 index 0000000..654b1ff --- /dev/null +++ b/packages/engine/src/__tests__/validate.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' +import type { ThreatObject } from '../types.js' +import { threatObjectSchema, validateThreatObject } from '../validate.js' + +describe('validateThreatObject', () => { + test('accepts a valid threat object', () => { + const threat: ThreatObject = { + id: 'ua-parser-js-2021', + name: 'ua-parser-js maintainer account hijack', + ecosystem: 'npm', + severity: 'CRITICAL', + status: 'PATCHED', + year: 2021, + cve: 'CVE-2021-4229', + description: + 'Attackers hijacked the ua-parser-js maintainer account and published malicious versions to npm. The package used install-time code to deliver a miner and credential theft payload.', + attack_vector: 'maintainer account hijack', + indicators_of_compromise: { + package_names: ['ua-parser-js'], + malicious_versions: [ + { package: 'ua-parser-js', version: '0.7.29' }, + { package: 'ua-parser-js', version: '0.8.0' }, + { package: 'ua-parser-js', version: '1.0.0' }, + ], + network_iocs: [{ type: 'ip', value: '159.148.186.228' }], + file_artifacts: [ + { filename: 'preinstall.js', sha256: null, path: 'node_modules/ua-parser-js/preinstall.js' }, + { filename: 'jsextension.exe', sha256: null }, + ], + }, + remediation: { + immediate: [ + 'Upgrade to a fixed version.', + 'Rotate credentials from a clean host.', + 'Inspect systems for malware execution.', + ], + long_term: ['Require MFA for maintainers.', 'Monitor install-time script execution.'], + }, + references: [ + { + title: 'CERT-EU Security Advisory 2021-057', + url: 'https://cert.europa.eu/publications/security-advisories/2021-057/', + }, + { + url: 'https://nvd.nist.gov/vuln/detail/CVE-2021-4229', + }, + ], + } + + expect(validateThreatObject(threat)).toEqual(threat) + }) + + test('accepts empty references when the source has no canonical URLs', () => { + const threat: ThreatObject = { + id: 'test-threat-2024', + name: 'Test threat object', + ecosystem: 'linux', + severity: 'MEDIUM', + status: 'ARCHIVED', + year: 2024, + cve: null, + description: + 'This synthetic threat exists only to verify schema validation behavior. It uses the structured IOC layout and intentionally omits references.', + attack_vector: 'test fixture', + indicators_of_compromise: { + package_names: ['test-package'], + malicious_versions: [{ package: 'test-package', version: '1.0.0' }], + network_iocs: [], + file_artifacts: [{ filename: 'test.bin', sha256: null }], + }, + remediation: { + immediate: ['Remove the package.', 'Inspect affected hosts.', 'Rebuild from a clean state.'], + long_term: ['Add dependency review gates.'], + }, + references: [], + } + + expect(validateThreatObject(threat)).toEqual(threat) + }) + + test('rejects an invalid threat object', () => { + const invalidThreat = { + id: 'Invalid Threat', + ecosystem: 'linux', + severity: 'LOW', + status: 'OPEN', + year: '2024', + remediation: { + immediate: ['Only one step'], + long_term: [], + }, + references: [{ url: 'not-a-url' }], + } + + expect(validateThreatObject(invalidThreat)).toBeNull() + expect(threatObjectSchema.safeParse(invalidThreat).success).toBeFalse() + }) + + test('rejects the legacy IOC array format', () => { + const legacyThreat = { + id: 'legacy-threat-2024', + name: 'Legacy threat object', + ecosystem: 'npm', + severity: 'HIGH', + status: 'PATCHED', + year: 2024, + cve: null, + description: + 'This object uses the old IOC string arrays and should no longer validate after the schema redesign.', + attack_vector: 'test fixture', + indicators_of_compromise: { + package_names: ['legacy-package'], + malicious_versions: ['legacy-package@1.0.0'], + network_iocs: ['example.test'], + file_artifacts: ['legacy.js'], + }, + remediation: { + immediate: ['Remove the package.', 'Rotate credentials.', 'Rebuild the environment.'], + long_term: ['Use structured threat objects.'], + }, + references: ['https://example.test/advisory'], + } + + expect(validateThreatObject(legacyThreat)).toBeNull() + expect(threatObjectSchema.safeParse(legacyThreat).success).toBeFalse() + }) +}) diff --git a/packages/engine/src/extractor/ioc.test.ts b/packages/engine/src/extractor/ioc.test.ts new file mode 100644 index 0000000..7807419 --- /dev/null +++ b/packages/engine/src/extractor/ioc.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from 'bun:test' +import { extractStixBundle, type RawIOC } from './ioc' + +test('extractStixBundle should generate a valid UUIDv4 for the bundle ID', () => { + const iocs: RawIOC[] = [ + { + type: 'hash', + value: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + confidence: 'confirmed', + }, + ] + const raw = extractStixBundle(iocs) + expect(raw).toBeDefined() + const bundle = raw as { id: string } + + // STIX bundle ID should be "bundle--" + expect(bundle.id).toStartWith('bundle--') + const uuid = bundle.id.replace('bundle--', '') + + // UUIDv4 regex + const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + + expect(uuid).toMatch(uuidV4Regex) +}) diff --git a/packages/engine/src/extractor/ioc.ts b/packages/engine/src/extractor/ioc.ts index cfc29e5..d3ce035 100644 --- a/packages/engine/src/extractor/ioc.ts +++ b/packages/engine/src/extractor/ioc.ts @@ -4,6 +4,8 @@ * STIX 2.1 spec: https://docs.oasis-open.org/cti/stix/v2.1/ */ +import { randomUUID } from 'node:crypto' + export interface RawIOC { type: 'domain' | 'hash' | 'url' | 'ip' | 'file' | 'package' | 'email' value: string @@ -133,7 +135,7 @@ export function extractStixBundle(iocs: RawIOC[], malware = 'unknown'): object { const indicators = iocs.map((i) => iocToStix(i, malware)) return { type: 'bundle', - id: `bundle--${Date.now()}`, + id: `bundle--${randomUUID()}`, objects: [ { type: 'marking-definition', diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 1b4848a..facb432 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -2,5 +2,17 @@ * wormsCTRL Engine — AI Agent Pipeline */ +export { ingestAdvisory } from './ingest.js' export { ingest } from './ingestion/index.js' -export type { FeedSource, IOC, ThreatProfile } from './types.js' +export { THREAT_EXTRACTION_SYSTEM_PROMPT } from './prompt.js' +export type { + FeedSource, + IOC, + ThreatFileArtifact, + ThreatMaliciousVersion, + ThreatNetworkIoc, + ThreatObject, + ThreatProfile, + ThreatReference, +} from './types.js' +export { threatObjectSchema, validateThreatObject } from './validate.js' diff --git a/packages/engine/src/ingest.ts b/packages/engine/src/ingest.ts new file mode 100644 index 0000000..0b7530c --- /dev/null +++ b/packages/engine/src/ingest.ts @@ -0,0 +1,217 @@ +import { lookup } from 'node:dns/promises' +import { THREAT_EXTRACTION_SYSTEM_PROMPT } from './prompt.js' +import type { ThreatObject } from './types.js' +import { validateThreatObject } from './validate.js' + +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string | Array<{ type?: string; text?: string }> + refusal?: string + } + }> +} + +function isUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +const ADVISORY_FETCH_TIMEOUT_MS = 15_000 +const ADVISORY_MAX_BODY_BYTES = 2 * 1024 * 1024 // 2 MB + +function isPrivateIp(ip: string): boolean { + let normalized = ip + const ipv4MappedMatch = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(ip) + if (ipv4MappedMatch) { + normalized = ipv4MappedMatch[1] + } + + return ( + normalized === 'localhost' || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized) || // 127.0.0.0/8 + normalized === '::1' || + normalized.startsWith('10.') || + normalized.startsWith('192.168.') || + /^172\.(1[6-9]|2\d|3[01])\./.test(normalized) || + normalized === '0.0.0.0' || + /^169\.254\.\d{1,3}\.\d{1,3}$/.test(normalized) || // Link-local / AWS metadata + /^f[cd][0-9a-f]{2}:/i.test(normalized) || // fc00::/7 + /^fe[89ab][0-9a-f]:/i.test(normalized) // fe80::/10 + ) +} + +/** Block fetches to private/internal networks (SSRF mitigation) */ +function isPrivateUrl(url: string): boolean { + try { + const { hostname } = new URL(url) + if (hostname.endsWith('.local')) return true + return isPrivateIp(hostname.replace(/^\[(.*)\]$/, '$1')) + } catch { + return true + } +} + +function stripHtml(source: string): string { + return source + .replace(/]*>[\s\S]*?<\/script(?:\s+[^>]*)?\s*>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style(?:\s+[^>]*)?\s*>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +async function resolveAdvisoryInput(input: string): Promise { + const trimmedInput = input.trim() + if (!trimmedInput) { + return null + } + + if (!isUrl(trimmedInput)) { + return trimmedInput + } + + if (isPrivateUrl(trimmedInput)) { + console.warn(`[engine] Refusing to fetch private/internal URL: ${trimmedInput}`) + return null + } + + try { + const { hostname } = new URL(trimmedInput) + const resolved = await lookup(hostname) + if (isPrivateIp(resolved.address)) { + console.warn( + `[engine] DNS resolved to private IP (${resolved.address}) - SSRF blocked: ${trimmedInput}` + ) + return null + } + } catch (_err) { + console.warn(`[engine] Failed to resolve hostname for SSRF check: ${trimmedInput}`) + return null + } + + try { + // TODO: Use a custom HTTP agent (e.g., undici dispatcher) to connect directly + // to `resolved.address` to prevent TOCTOU/DNS rebinding attacks. + const response = await fetch(trimmedInput, { + signal: AbortSignal.timeout(ADVISORY_FETCH_TIMEOUT_MS), + redirect: 'manual', // Prevent SSRF bypass via redirects + }) + + if (!response.ok) { + console.warn( + `[engine] Failed to fetch advisory URL: ${response.status} ${response.statusText}` + ) + return null + } + + const contentLength = response.headers.get('content-length') + if (contentLength && Number.parseInt(contentLength, 10) > ADVISORY_MAX_BODY_BYTES) { + console.warn(`[engine] Advisory response too large: ${contentLength} bytes`) + return null + } + + const html = await response.text() + if (html.length > ADVISORY_MAX_BODY_BYTES) { + console.warn(`[engine] Advisory response body exceeds size limit`) + return null + } + + const text = stripHtml(html) + return text ? `Source URL: ${trimmedInput}\n\n${text}` : null + } catch (error) { + console.warn( + `[engine] Failed to fetch advisory URL: ${error instanceof Error ? error.message : 'unknown error'}` + ) + return null + } +} + +function extractMessageContent(payload: ChatCompletionResponse): string | null { + const content = payload.choices?.[0]?.message?.content + if (typeof content === 'string') { + return content + } + + if (Array.isArray(content)) { + return content + .map((item) => item.text ?? '') + .join('') + .trim() + } + + return null +} + +async function requestThreatExtraction(advisoryText: string): Promise { + const apiKey = process.env.OPENAI_API_KEY + if (!apiKey) { + console.warn('[engine] OPENAI_API_KEY is not set; skipping advisory ingestion.') + return null + } + + const model = process.env.OPENAI_MODEL ?? 'gpt-4o-mini' + + try { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: THREAT_EXTRACTION_SYSTEM_PROMPT }, + { role: 'user', content: advisoryText }, + ], + }), + signal: AbortSignal.timeout(30_000), // 30 seconds timeout for LLM + }) + + if (!response.ok) { + console.warn(`[engine] OpenAI API request failed: ${response.status} ${response.statusText}`) + return null + } + + const payload = (await response.json()) as ChatCompletionResponse + const content = extractMessageContent(payload) + if (!content) { + console.warn('[engine] OpenAI API returned an empty response body.') + return null + } + + return JSON.parse(content) as unknown + } catch (error) { + console.warn( + `[engine] Failed to ingest advisory: ${error instanceof Error ? error.message : 'unknown error'}` + ) + return null + } +} + +export async function ingestAdvisory(input: string): Promise { + const advisoryText = await resolveAdvisoryInput(input) + if (!advisoryText) { + return null + } + + const extracted = await requestThreatExtraction(advisoryText) + if (!extracted) { + return null + } + + const validated = validateThreatObject(extracted) + if (!validated) { + console.warn('[engine] Extracted advisory payload did not match the threat schema.') + return null + } + + return validated +} diff --git a/packages/engine/src/prompt.ts b/packages/engine/src/prompt.ts new file mode 100644 index 0000000..d9d2fe7 --- /dev/null +++ b/packages/engine/src/prompt.ts @@ -0,0 +1,34 @@ +export const THREAT_EXTRACTION_SYSTEM_PROMPT = `You are a software supply-chain threat analyst. + +Extract a single threat object from the provided advisory text and return JSON only. + +Rules: +- Output must be valid JSON and must contain exactly these top-level keys: + id, name, ecosystem, severity, status, year, cve, description, attack_vector, + indicators_of_compromise, remediation, references +- name must be a concise title under 80 characters (e.g., "Supply chain malicious update") +- attack_vector must be one of: dependency-confusion, typosquatting, account-takeover, malicious-update, credential-phishing, or a short freeform description +- Use one of these ecosystem values only: npm, pypi, cargo, rubygems, linux +- Use one of these severity values only: CRITICAL, HIGH, MEDIUM +- Use one of these status values only: ACTIVE, PATCHED, ARCHIVED +- id must be lowercase kebab-case +- year must be a four-digit integer +- cve must be a string or null +- description must be 2 to 3 factual sentences +- indicators_of_compromise must be an object with exactly these keys: + - package_names: array of strings + - malicious_versions: array of objects with { package: string, version: string, reason?: string } + - network_iocs: array of objects with { type: "ip" | "domain" | "url", value: string } + - file_artifacts: array of objects with { filename: string, sha256: string, path?: string } +- remediation must contain immediate and long_term arrays +- immediate should contain 3 to 5 concise action items +- long_term should contain 3 to 7 high-level, actionable plan items (each item can optionally include an estimated timeframe and owner) +- references must be an array of objects with { url: string, title?: string } +- references should contain canonical source URLs when present in the material +- If there are no references, return references: [] +- If a field is unknown, use an empty array or null where appropriate, but do not omit keys +- Output only JSON following this schema +- Do not add markdown fences, prose, or explanations +- Keep the output defensive, factual, and specific to the supplied material + +Return JSON.` diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 7ab9ba8..e2035d8 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -1,5 +1,60 @@ export type FeedSource = 'osv' | 'socket' | 'github' | 'phylum' | 'npm-replicate' | 'rss' +export type ThreatEcosystem = 'npm' | 'pypi' | 'cargo' | 'rubygems' | 'linux' + +export type ThreatProfileEcosystem = ThreatEcosystem | 'gem' | 'maven' | 'nuget' + +export type ThreatSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' +export type ThreatProfileSeverity = ThreatSeverity | 'LOW' + +export type ThreatStatus = 'ACTIVE' | 'PATCHED' | 'ARCHIVED' +export type ThreatProfileStatus = ThreatStatus | 'UNDER_REVIEW' + +export interface ThreatMaliciousVersion { + package: string + version: string + reason?: string +} + +export interface ThreatNetworkIoc { + type: 'ip' | 'domain' | 'url' + value: string +} + +export interface ThreatFileArtifact { + filename: string + sha256: string | null + path?: string +} + +export interface ThreatReference { + url: string + title?: string +} + +export interface ThreatObject { + id: string + name: string + ecosystem: ThreatEcosystem + severity: ThreatSeverity + status: ThreatStatus + year: number + cve: string | null + description: string + attack_vector: string + indicators_of_compromise: { + package_names: string[] + malicious_versions: ThreatMaliciousVersion[] + network_iocs: ThreatNetworkIoc[] + file_artifacts: ThreatFileArtifact[] + } + remediation: { + immediate: string[] + long_term: string[] + } + references: ThreatReference[] +} + export interface IOC { type: 'domain' | 'hash' | 'url' | 'process' | 'file' | 'package' value: string @@ -9,9 +64,9 @@ export interface IOC { export interface ThreatProfile { id: string name: string - ecosystem: 'npm' | 'pypi' | 'maven' | 'gem' | 'cargo' | 'nuget' - severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' - status: 'ACTIVE' | 'UNDER_REVIEW' | 'ARCHIVED' + ecosystem: ThreatProfileEcosystem + severity: ThreatProfileSeverity + status: ThreatProfileStatus iocs: IOC[] ttp?: string[] // MITRE ATT&CK IDs references: string[] diff --git a/packages/engine/src/validate.ts b/packages/engine/src/validate.ts new file mode 100644 index 0000000..eb467ca --- /dev/null +++ b/packages/engine/src/validate.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import type { ThreatObject } from './types.js' + +const threatReferenceSchema = z.object({ + url: z.string().url(), + title: z.string().min(1).optional(), +}) + +const threatMaliciousVersionSchema = z.object({ + package: z.string().min(1), + version: z.string().min(1), + reason: z.string().min(1).optional(), +}) + +const threatNetworkIocSchema = z.object({ + type: z.enum(['ip', 'domain', 'url']), + value: z.string().min(1), +}) + +const threatFileArtifactSchema = z.object({ + filename: z.string().min(1), + sha256: z + .string() + .regex(/^[0-9a-f]{64}$/i) + .nullable(), + path: z.string().min(1).optional(), +}) + +export const threatObjectSchema = z.object({ + id: z + .string() + .min(1) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + name: z.string().min(1), + ecosystem: z.enum(['npm', 'pypi', 'cargo', 'rubygems', 'linux']), + severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM']), + status: z.enum(['ACTIVE', 'PATCHED', 'ARCHIVED']), + year: z.number().int().min(1970).max(2100), + cve: z.string().min(1).nullable(), + description: z.string().min(20), + attack_vector: z.string().min(1), + indicators_of_compromise: z.object({ + package_names: z.array(z.string().min(1)), + malicious_versions: z.array(threatMaliciousVersionSchema), + network_iocs: z.array(threatNetworkIocSchema), + file_artifacts: z.array(threatFileArtifactSchema), + }), + remediation: z.object({ + immediate: z.array(z.string().min(1)).min(1).max(10), + long_term: z.array(z.string().min(1)).min(1), + }), + references: z.array(threatReferenceSchema), +}) + +export function validateThreatObject(candidate: unknown): ThreatObject | null { + const result = threatObjectSchema.safeParse(candidate) + return result.success ? (result.data as ThreatObject) : null +} diff --git a/packages/engine/tests/osv.test.ts b/packages/engine/tests/osv.test.ts new file mode 100644 index 0000000..fe090d5 --- /dev/null +++ b/packages/engine/tests/osv.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' +import { type OsvPackage, osvToThreatProfile } from '../src/ingestion/osv' + +describe('osvToThreatProfile', () => { + const baseOsv: OsvPackage = { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '2023-01-01T00:00:00Z', + published: '2023-01-01T00:00:00Z', + summary: 'A vulnerability summary', + affected: [ + { + package: { + name: 'test-package', + ecosystem: 'npm', + }, + }, + ], + } + + test('converts basic OSV record correctly', () => { + const result = osvToThreatProfile(baseOsv) as unknown as Record + expect(result.id).toBe(baseOsv.id) + expect(result.name).toBe('test-package') + expect(result.ecosystem).toBe('npm') + expect(result.severity).toBe('LOW') // Default score 0 -> LOW + expect(result.status).toBe('UNDER_REVIEW') + expect(result.modified).toBe(baseOsv.modified) + expect(result.published).toBe(baseOsv.published) + }) + + test('maps CVSS_V3 scores to severity correctly', () => { + const testCases = [ + { score: '9.5', expected: 'CRITICAL' }, + { score: '9.0', expected: 'CRITICAL' }, + { score: '8.9', expected: 'HIGH' }, + { score: '7.0', expected: 'HIGH' }, + { score: '6.9', expected: 'MEDIUM' }, + { score: '4.0', expected: 'MEDIUM' }, + { score: '3.9', expected: 'LOW' }, + { score: '0.0', expected: 'LOW' }, + { score: 'N/A', expected: 'LOW' }, + ] + + for (const { score, expected } of testCases) { + const osv = { + ...baseOsv, + severity: [{ type: 'CVSS_V3', score }], + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.severity).toBe(expected) + } + }) + + test('handles non-CVSS_V3 severity types by defaulting to score 0', () => { + const osv = { + ...baseOsv, + severity: [{ type: 'CVSS_V2', score: '10.0' }], + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.severity).toBe('LOW') + }) + + test('handles missing severity array', () => { + const osv = { + ...baseOsv, + severity: undefined, + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.severity).toBe('LOW') + }) + + test('uses name and ecosystem from first affected package', () => { + const osv: OsvPackage = { + ...baseOsv, + affected: [ + { package: { name: 'pkg1', ecosystem: 'pypi' } }, + { package: { name: 'pkg2', ecosystem: 'npm' } }, + ], + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.name).toBe('pkg1') + expect(result.ecosystem).toBe('pypi') + }) + + test('handles missing package information by using ID and default ecosystem', () => { + const osv: OsvPackage = { + ...baseOsv, + affected: [], + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.name).toBe(osv.id) + expect(result.ecosystem).toBe('npm') + }) + + test('handles missing summary', () => { + const osv: OsvPackage = { + ...baseOsv, + summary: undefined, + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.description).toBe('') + }) + + test('maps references correctly', () => { + const osv: OsvPackage = { + ...baseOsv, + references: [ + { type: 'ADVISORY', url: 'https://example.com/advisory' }, + { type: 'WEB', url: 'https://example.com/web' }, + ], + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.references).toEqual([ + { type: 'ADVISORY', url: 'https://example.com/advisory' }, + { type: 'WEB', url: 'https://example.com/web' }, + ]) + }) + + test('handles missing references', () => { + const osv: OsvPackage = { + ...baseOsv, + references: undefined, + } + const result = osvToThreatProfile(osv) as unknown as Record + expect(result.references).toEqual([]) + }) +}) diff --git a/packages/ioc/__tests__/index.test.ts b/packages/ioc/__tests__/index.test.ts new file mode 100644 index 0000000..4d28609 --- /dev/null +++ b/packages/ioc/__tests__/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { getIocIdentifiers, getThreatProfile } from '../index.js' + +describe('IOC catalog helpers', () => { + test('return archived shai-hulud data synchronously', () => { + const threatProfile = getThreatProfile('shai-hulud-2.0') + const iocIdentifiers = getIocIdentifiers('shai-hulud-2.0') + + expect(threatProfile).not.toBeNull() + expect(iocIdentifiers).not.toBeNull() + expect(threatProfile).not.toBeInstanceOf(Promise) + expect(iocIdentifiers).not.toBeInstanceOf(Promise) + }) +}) diff --git a/packages/ioc/index.js b/packages/ioc/index.js index b439970..e4d787d 100644 --- a/packages/ioc/index.js +++ b/packages/ioc/index.js @@ -1,3 +1,18 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Legacy archived threat data loaded via fs for Node ESM compatibility +// (JSON import assertions are not universally supported) +const ROOT_DIR = dirname(fileURLToPath(import.meta.url)) + +const shaiHulud2 = JSON.parse( + readFileSync(join(ROOT_DIR, 'archived/shai-hulud/threat-model.json'), 'utf8') +) +const shaiHulud2Iocs = JSON.parse( + readFileSync(join(ROOT_DIR, 'archived/shai-hulud/iocs.json'), 'utf8') +) + /** * @worms-ctrl/ioc - Threat Knowledge Base API * @@ -10,15 +25,56 @@ * @see archived/ — Public-safe threat profiles (in git) */ -export { default as shaiHulud2Iocs } from './archived/shai-hulud/iocs.json' -export { default as shaiHulud2 } from './archived/shai-hulud/threat-model.json' +export { shaiHulud2, shaiHulud2Iocs } + +const THREATS_DIR = join(ROOT_DIR, 'threats') + +let cachedThreatCatalog = null + +/** + * Load threat catalog with per-entry error handling. + * One malformed JSON file does not prevent other valid threats from loading. + * @param {boolean} [forceReload=false] + */ +function loadThreatCatalog(forceReload = false) { + if (cachedThreatCatalog && !forceReload) { + return cachedThreatCatalog + } + + let entries + try { + entries = readdirSync(THREATS_DIR).filter((entry) => entry.endsWith('.json')) + } catch { + cachedThreatCatalog = [] + return cachedThreatCatalog + } + + const threats = [] + for (const entry of entries) { + try { + const content = readFileSync(join(THREATS_DIR, entry), 'utf8') + const parsed = JSON.parse(content) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && 'id' in parsed && 'status' in parsed) { + threats.push(parsed) + } else { + console.warn(`[ioc] Skipping invalid threat file ${entry}: missing required fields or not an object`) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error' + console.warn(`[ioc] Skipping malformed threat file ${entry}: ${message}`) + } + } + + cachedThreatCatalog = threats + return threats +} /** * List all archived threats (public-safe metadata only) * @returns {Array<{id: string, name: string, ecosystem: string, severity: string, status: string}>} */ export function getArchivedThreats() { - return [ + const threats = [ { id: 'shai-hulud-2.0', name: 'Shai-Hulud 2.0', @@ -26,7 +82,23 @@ export function getArchivedThreats() { severity: 'CRITICAL', status: 'ARCHIVED', }, + ...loadThreatCatalog() + .filter((threat) => threat.status === 'ARCHIVED') + .map((threat) => ({ + id: threat.id, + name: threat.name, + ecosystem: threat.ecosystem, + severity: threat.severity, + status: threat.status, + })), ] + + const seen = new Set() + return threats.filter((t) => { + if (seen.has(t.id)) return false + seen.add(t.id) + return true + }) } /** @@ -35,20 +107,32 @@ export function getArchivedThreats() { * @returns {object|null} */ export function getThreatProfile(id) { - const map = { - 'shai-hulud-2.0': () => import('./archived/shai-hulud/threat-model.json'), + if (id === 'shai-hulud-2.0') { + return shaiHulud2 } - return map[id]?.() ?? null + + const threat = loadThreatCatalog().find((entry) => entry.id === id) + return threat ?? null } /** * Get IOC identifiers for a threat (public-safe names only) - * @param {string} threatId + * @param {string|object} threatOrId * @returns {object|null} */ -export function getIocIdentifiers(threatId) { +export function getIocIdentifiers(threatOrId) { + // If a full threat object was provided, extract its indicators directly + if (typeof threatOrId === 'object' && threatOrId !== null) { + return threatOrId.iocs ?? threatOrId.indicators_of_compromise ?? null + } + const map = { - 'shai-hulud-2.0': () => import('./archived/shai-hulud/iocs.json'), + 'shai-hulud-2.0': shaiHulud2Iocs, } - return map[threatId]?.() ?? null + const mapped = map[threatOrId] + if (mapped) return mapped + + // Fallback to searching the catalog if an ID was provided + const profile = getThreatProfile(threatOrId) + return profile?.iocs ?? profile?.indicators_of_compromise ?? null } diff --git a/packages/ioc/threats/axios-2026.json b/packages/ioc/threats/axios-2026.json new file mode 100644 index 0000000..3e467b9 --- /dev/null +++ b/packages/ioc/threats/axios-2026.json @@ -0,0 +1,80 @@ +{ + "id": "axios-2026", + "name": "axios phantom dependency RAT compromise", + "ecosystem": "npm", + "severity": "CRITICAL", + "status": "ACTIVE", + "year": 2026, + "cve": null, + "description": "In 2026, attackers compromised the axios npm maintainer account and published malicious axios releases together with a phantom dependency named plain-crypto-js. The injected postinstall chain delivered a cross-platform RAT, attempted anti-forensics through self-destruct behavior, and replaced itself with clean decoy content to hinder incident response.", + "attack_vector": "maintainer account compromise + phantom dependency", + "indicators_of_compromise": { + "package_names": ["axios", "plain-crypto-js"], + "malicious_versions": [ + { + "package": "axios", + "version": "1.14.1", + "reason": "compromised axios release" + }, + { + "package": "axios", + "version": "0.30.4", + "reason": "compromised axios release" + }, + { + "package": "plain-crypto-js", + "version": "4.2.1", + "reason": "phantom dependency injected via compromised axios release" + } + ], + "network_iocs": [ + { + "type": "domain", + "value": "sfrclak[.]com" + }, + { + "type": "ip", + "value": "142.11.206.72" + } + ], + "file_artifacts": [ + { + "filename": "package.json", + "sha256": null, + "path": "node_modules/plain-crypto-js/package.json" + }, + { + "filename": "index.js", + "sha256": null, + "path": "node_modules/plain-crypto-js/index.js" + } + ] + }, + "remediation": { + "immediate": [ + "Remove axios 1.14.1 and 0.30.4 plus plain-crypto-js 4.2.1 from affected lockfiles, caches, and build images.", + "Treat developer workstations and CI runners that installed the malicious releases as potentially backdoored because the payload delivered a RAT.", + "Rotate npm, GitHub, cloud, and CI credentials from a separate clean host after containment.", + "Rebuild dependencies from a known-good lockfile and review install telemetry for undeclared phantom dependency resolution." + ], + "long_term": [ + "Require publisher MFA, scoped automation tokens, and provenance enforcement for high-impact npm packages.", + "Alert on undeclared dependency additions and unexpected postinstall activity during dependency updates.", + "Mirror critical dependencies internally and gate updates with policy checks before production rollout." + ] + }, + "references": [ + { + "url": "https://www.trendmicro.com/en_us/research/26/c/axios-npm-package-compromised.html" + }, + { + "url": "https://cloud.google.com/blog/topics/threat-intelligence/north-korea-threat-actor-targets-axios-npm-package" + }, + { + "url": "https://www.datadoghq.com/security/advisories/axios-2026-compromise" + }, + { + "url": "https://www.huntress.com/blog/supply-chain-compromise-axios-npm-package" + } + ] +} diff --git a/packages/ioc/threats/ctx-2022.json b/packages/ioc/threats/ctx-2022.json new file mode 100644 index 0000000..8f1b6b5 --- /dev/null +++ b/packages/ioc/threats/ctx-2022.json @@ -0,0 +1,48 @@ +{ + "id": "ctx-2022", + "name": "ctx PyPI takeover and env exfiltration", + "ecosystem": "pypi", + "severity": "HIGH", + "status": "ARCHIVED", + "year": 2022, + "cve": null, + "description": "In May 2022, the dormant PyPI package ctx was taken over and republished with malicious code that collected environment variables at runtime. The payload base64-encoded secrets such as cloud credentials and exfiltrated them to a Heroku endpoint, making the incident a clear credential theft supply-chain attack rather than a benign package update.", + "attack_vector": "account takeover", + "indicators_of_compromise": { + "package_names": ["ctx"], + "malicious_versions": [ + { "package": "ctx", "version": "0.1.2", "reason": "republished malicious release" }, + { "package": "ctx", "version": "0.2.2" }, + { "package": "ctx", "version": "0.2.6" } + ], + "network_iocs": [{ "type": "domain", "value": "anti-theft-web.herokuapp.com" }], + "file_artifacts": [ + { + "filename": "__init__.py", + "sha256": null, + "path": "ctx/__init__.py" + } + ] + }, + "remediation": { + "immediate": [ + "Remove ctx from affected environments and investigate all installs from May 14, 2022 through May 24, 2022.", + "Rotate any credentials exposed via environment variables, especially cloud access keys.", + "Audit outbound requests for anti-theft-web.herokuapp.com and related exfiltration attempts.", + "Rebuild Python environments from a clean dependency set with verified hashes." + ], + "long_term": [ + "Use pinned hashes and trusted internal mirrors for Python dependencies.", + "Monitor abandoned packages for sudden ownership or release activity.", + "Adopt dependency review gates that flag runtime access to environment variables." + ] + }, + "references": [ + { + "url": "https://python-security.readthedocs.io/pypi-vuln/index-2022-05-24-ctx-domain-takeover.html" + }, + { "url": "https://security.snyk.io/vuln/SNYK-PYTHON-CTX-2847242" }, + { "url": "https://www.sonatype.com/blog/pypi-package-ctx-compromised-are-you-at-risk" }, + { "url": "https://www.datadoghq.com/security/advisories/ctx-2022-compromise" } + ] +} diff --git a/packages/ioc/threats/event-stream-2018.json b/packages/ioc/threats/event-stream-2018.json new file mode 100644 index 0000000..63c03bd --- /dev/null +++ b/packages/ioc/threats/event-stream-2018.json @@ -0,0 +1,67 @@ +{ + "type": "bundle", + "id": "bundle--3d0c41df-9d58-45da-9b59-4d8727284f18", + "spec_version": "2.1", + "objects": [ + { + "type": "vulnerability", + "id": "vulnerability--event-stream-2018", + "spec_version": "2.1", + "created": "2018-11-26T00:00:00.000Z", + "modified": "2018-11-26T00:00:00.000Z", + "name": "event-stream maintainer compromise", + "description": "In late 2018, the popular npm package event-stream was compromised after a new maintainer introduced the malicious flatmap-stream dependency into event-stream@3.3.6. The payload was tailored to identify Copay wallet builds and steal cryptocurrency from targeted users rather than indiscriminately executing on every consumer of the package.", + "external_references": [ + { "url": "https://github.com/dominictarr/event-stream/issues/116" }, + { "url": "https://security.snyk.io/vuln/SNYK-JS-EVENTSTREAM-72638", "source_name": "snyk" }, + { "url": "https://devblogs.microsoft.com/devops/blocking-malicious-versions-of-event-stream-and-flatmap-stream-packages/" }, + { "url": "https://www.datadoghq.com/security/advisories/event-stream-2018" } + ], + "osv": { + "id": "event-stream-2018", + "published": "2018-11-26T00:00:00Z", + "modified": "2018-11-26T00:00:00Z", + "summary": "event-stream maintainer compromise", + "details": "In late 2018, the popular npm package event-stream was compromised...", + "affected": [ + { + "package": { + "ecosystem": "npm", + "name": "event-stream" + }, + "versions": ["3.3.6"] + }, + { + "package": { + "ecosystem": "npm", + "name": "flatmap-stream" + }, + "versions": ["0.1.0", "0.1.1", "0.1.2"] + } + ] + } + }, + { + "type": "indicator", + "id": "indicator--flatmap-stream-index", + "spec_version": "2.1", + "created": "2018-11-26T00:00:00.000Z", + "modified": "2018-11-26T00:00:00.000Z", + "name": "flatmap-stream/index.js", + "indicator_types": ["malicious-activity"], + "pattern": "[file:name = 'index.js' AND file:parent_directory_ref.path = 'node_modules/flatmap-stream']", + "pattern_type": "stix", + "valid_from": "2018-11-26T00:00:00Z" + }, + { + "type": "relationship", + "id": "relationship--1", + "spec_version": "2.1", + "created": "2018-11-26T00:00:00.000Z", + "modified": "2018-11-26T00:00:00.000Z", + "relationship_type": "indicates", + "source_ref": "indicator--flatmap-stream-index", + "target_ref": "vulnerability--event-stream-2018" + } + ] +} diff --git a/packages/ioc/threats/node-ipc-2022.json b/packages/ioc/threats/node-ipc-2022.json new file mode 100644 index 0000000..78424a7 --- /dev/null +++ b/packages/ioc/threats/node-ipc-2022.json @@ -0,0 +1,58 @@ +{ + "id": "node-ipc-2022", + "name": "node-ipc protestware incident", + "ecosystem": "npm", + "severity": "HIGH", + "status": "PATCHED", + "year": 2022, + "cve": "CVE-2022-23812", + "description": "In March 2022, node-ipc releases introduced protestware that targeted systems geolocated to Russia or Belarus and overwrote files with heart characters. Later releases and related packages such as peacenotwar also dropped WITH-LOVE-FROM-AMERICA.txt files onto affected systems, expanding the incident beyond a simple proof of concept.", + "attack_vector": "protestware", + "indicators_of_compromise": { + "package_names": ["node-ipc", "peacenotwar", "oneday-test"], + "malicious_versions": [ + { "package": "node-ipc", "version": "10.1.1" }, + { "package": "node-ipc", "version": "10.1.2" }, + { "package": "node-ipc", "version": "9.2.2" }, + { "package": "node-ipc", "version": "11.0.0" }, + { "package": "peacenotwar", "version": "1.0.0" }, + { "package": "oneday-test", "version": "0.0.6" } + ], + "network_iocs": [], + "file_artifacts": [ + { "filename": "WITH-LOVE-FROM-AMERICA.txt", "sha256": null, "note": "hash unavailable" }, + { + "filename": "index.cjs", + "sha256": null, + "note": "hash unavailable", + "path": "node_modules/peacenotwar/index.cjs" + }, + { + "filename": "index.js", + "sha256": null, + "note": "hash unavailable", + "path": "node_modules/oneday-test/index.js" + } + ] + }, + "remediation": { + "immediate": [ + "Upgrade node-ipc to 10.1.3 or a later vetted release and remove peacenotwar artifacts.", + "Restore overwritten files from backups if any affected systems were executed in targeted regions.", + "Search developer workstations and CI images for WITH-LOVE-FROM-AMERICA.txt and related package traces.", + "Rebuild node_modules and lockfiles from a known-good state with scripts reviewed." + ], + "long_term": [ + "Gate dependency updates with policy checks for maintainer-driven behavioral changes.", + "Mirror and approve high-risk packages internally before broad rollout.", + "Add integrity and behavioral scanning for protestware and destructive install-time logic." + ] + }, + "references": [ + { "url": "https://security.snyk.io/vuln/SNYK-JS-NODEIPC-2426370" }, + { "url": "https://nvd.nist.gov/vuln/detail/CVE-2022-23812" }, + { + "url": "https://www.bleepingcomputer.com/news/security/big-sabotage-famous-npm-package-deletes-files-to-protest-ukraine-war/" + } + ] +} diff --git a/packages/ioc/threats/shai-hulud-2025.json b/packages/ioc/threats/shai-hulud-2025.json new file mode 100644 index 0000000..282243b --- /dev/null +++ b/packages/ioc/threats/shai-hulud-2025.json @@ -0,0 +1,66 @@ +{ + "id": "shai-hulud-2025", + "name": "Shai-Hulud self-replicating npm worm", + "ecosystem": "npm", + "severity": "CRITICAL", + "status": "ACTIVE", + "year": 2025, + "cve": null, + "description": "In 2025, the Shai-Hulud campaign used the compromised npm maintainer techsupportrxnt to launch a self-replicating registry worm across more than 180 packages. The worm abused NpmModule.updatePackage to inject bundle.js into publish artifacts, republished infected packages, ran Trufflehog credential scans on developer machines, and exfiltrated stolen secrets to GitHub before propagating further.", + "attack_vector": "self-replicating registry worm", + "indicators_of_compromise": { + "package_names": ["debug", "ansi-styles"], + "malicious_versions": [ + { + "package": "debug", + "version": "4.4.2", + "reason": "self-replicating worm release" + }, + { + "package": "ansi-styles", + "version": "6.2.2", + "reason": "self-replicating worm release" + } + ], + "network_iocs": [], + "file_artifacts": [ + { + "filename": "bundle.js", + "sha256": null, + "path": "node_modules//bundle.js" + }, + { + "filename": ".truffler-cache", + "sha256": null, + "path": "~/.truffler-cache" + }, + { + "filename": "discussion.yaml", + "sha256": null, + "path": ".github/workflows/discussion.yaml" + } + ] + }, + "remediation": { + "immediate": [ + "Freeze suspected worm processes safely and remove compromised package versions from developer hosts and CI runners.", + "Rotate npm, GitHub, cloud, and CI credentials because the worm executed Trufflehog-style secret discovery on infected machines.", + "Search repositories and caches for bundle.js, discussion.yaml backdoors, and unexpected republish activity linked to compromised maintainers.", + "Rebuild affected workspaces from clean lockfiles and verify package integrity before re-enabling release automation." + ], + "long_term": [ + "Require publisher hardening, provenance checks, and release approval for high-trust packages.", + "Monitor for bulk republish activity, credential-scanning behavior, and GitHub workflow backdoors in dependency updates.", + "Enforce internal mirrors and staged rollout policies for critical third-party package changes." + ] + }, + "references": [ + { + "url": "https://krebsonsecurity.com/2025/09/self-replicating-worm-hits-180-software-packages/" + }, + { + "url": "https://thehackernews.com/2025/09/40-npm-packages-compromised-in-supply-chain-attack.html" + }, + { "url": "https://www.datadoghq.com/security/advisories/shai-hulud-2025" } + ] +} diff --git a/packages/ioc/threats/teampcp-2026.json b/packages/ioc/threats/teampcp-2026.json new file mode 100644 index 0000000..2b697a0 --- /dev/null +++ b/packages/ioc/threats/teampcp-2026.json @@ -0,0 +1,59 @@ +{ + "id": "teampcp-2026", + "name": "TeamPCP trusted publisher supply-chain campaign", + "ecosystem": "pypi", + "severity": "HIGH", + "status": "ACTIVE", + "year": 2026, + "cve": null, + "description": "In March 2026, the TeamPCP campaign abused a stolen OIDC Trusted Publisher token to push malicious releases directly to public registries. Confirmed PyPI compromises included litellm 1.82.7 and 1.82.8 plus telnyx 4.87.1 and 4.87.2, while reporting linked the same operator set to secondary npm targeting and registry abuse patterns.", + "attack_vector": "stolen OIDC Trusted Publisher token + direct registry push", + "indicators_of_compromise": { + "package_names": ["litellm", "telnyx"], + "malicious_versions": [ + { + "package": "litellm", + "version": "1.82.7", + "reason": "malicious PyPI release published on March 24, 2026" + }, + { + "package": "litellm", + "version": "1.82.8", + "reason": "malicious PyPI release published on March 24, 2026" + }, + { + "package": "telnyx", + "version": "4.87.1", + "reason": "malicious PyPI release published on March 27, 2026" + }, + { + "package": "telnyx", + "version": "4.87.2", + "reason": "malicious PyPI release published on March 27, 2026" + } + ], + "network_iocs": [], + "file_artifacts": [] + }, + "remediation": { + "immediate": [ + "Remove compromised litellm and telnyx versions from Python environments, caches, and build artifacts.", + "Rotate Trusted Publisher credentials, OIDC federation settings, and downstream secrets exposed by affected release workflows.", + "Audit PyPI release provenance, CI identities, and direct registry push events from March 24 to March 27, 2026.", + "Rebuild Python environments from a known-good requirements set with pinned hashes and validated provenance." + ], + "long_term": [ + "Require short-lived OIDC credentials with least-privilege publish scopes and environment protection rules.", + "Alert on direct registry pushes that bypass expected Trusted Publisher workflows or provenance metadata.", + "Use internal package mirrors and signed release policies for high-impact dependencies." + ] + }, + "references": [ + { + "url": "https://securitylabs.datadoghq.com/articles/litellm-compromised-pypi-teampcp-supply-chain-campaign/" + }, + { + "url": "https://www.helpnetsecurity.com/2026/03/27/teampcp-telnyx-supply-chain-compromise/" + } + ] +} diff --git a/packages/ioc/threats/ua-parser-js-2021.json b/packages/ioc/threats/ua-parser-js-2021.json new file mode 100644 index 0000000..3a69a35 --- /dev/null +++ b/packages/ioc/threats/ua-parser-js-2021.json @@ -0,0 +1,47 @@ +{ + "id": "ua-parser-js-2021", + "name": "ua-parser-js maintainer account hijack", + "ecosystem": "npm", + "severity": "CRITICAL", + "status": "PATCHED", + "year": 2021, + "cve": "CVE-2021-4229", + "description": "On October 22, 2021, attackers hijacked the ua-parser-js maintainer account and published malicious versions 0.7.29, 0.8.0, and 1.0.0 to npm. The compromise used preinstall logic to deploy a cryptominer on Linux and a password-stealing trojan on Windows while harvesting credentials and sensitive environment data from infected systems.", + "attack_vector": "maintainer account hijack", + "indicators_of_compromise": { + "package_names": ["ua-parser-js"], + "malicious_versions": [ + { "package": "ua-parser-js", "version": "0.7.29" }, + { "package": "ua-parser-js", "version": "0.8.0" }, + { "package": "ua-parser-js", "version": "1.0.0" } + ], + "network_iocs": [ + { "type": "ip", "value": "159.148.186.228" }, + { "type": "domain", "value": "citationsherbe.at" } + ], + "file_artifacts": [ + { "filename": "preinstall.js", "sha256": null }, + { "filename": "jsextension", "sha256": null }, + { "filename": "jsextension.exe", "sha256": null }, + { "filename": "sdd.dll", "sha256": null } + ] + }, + "remediation": { + "immediate": [ + "Upgrade to ua-parser-js 0.7.30, 0.8.1, 1.0.1, or newer and remove compromised artifacts.", + "Treat affected systems as fully compromised and rotate credentials from a separate clean host.", + "Inspect build logs and endpoint telemetry for preinstall.js, jsextension, or sdd.dll execution.", + "Reimage or deeply triage hosts that installed the malicious versions during the exposure window." + ], + "long_term": [ + "Require MFA and publisher security controls for package maintainer accounts.", + "Alert on unexpected preinstall or postinstall execution in CI and developer environments.", + "Continuously monitor for malicious package version spikes in high-download dependencies." + ] + }, + "references": [ + { "url": "https://cert.europa.eu/publications/security-advisories/2021-057/" }, + { "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-4229" }, + { "url": "https://github.com/faisalman/ua-parser-js/issues/536" } + ] +} diff --git a/packages/ioc/threats/xz-utils-2024.json b/packages/ioc/threats/xz-utils-2024.json new file mode 100644 index 0000000..73f44f7 --- /dev/null +++ b/packages/ioc/threats/xz-utils-2024.json @@ -0,0 +1,56 @@ +{ + "id": "xz-utils-2024", + "name": "xz-utils upstream release backdoor", + "ecosystem": "linux", + "severity": "CRITICAL", + "status": "PATCHED", + "year": 2024, + "cve": "CVE-2024-3094", + "description": "In March 2024, malicious code was discovered in upstream xz-utils release tarballs 5.6.0 and 5.6.1, introducing a backdoor into the liblzma build process. The compromise modified liblzma in ways that could affect OpenSSH authentication flows on certain Linux distributions, making it one of the most serious recent open-source supply-chain incidents.", + "attack_vector": "upstream release compromise", + "indicators_of_compromise": { + "package_names": ["xz", "xz-utils", "liblzma"], + "malicious_versions": [ + { "package": "xz-utils", "version": "5.6.0" }, + { "package": "xz-utils", "version": "5.6.1" } + ], + "network_iocs": [], + "file_artifacts": [ + { + "filename": "build-to-host.m4", + "sha256": null, + "path": "m4/build-to-host.m4" + }, + { + "filename": "bad-3-corrupt_lzma2.xz", + "sha256": null, + "path": "tests/files/bad-3-corrupt_lzma2.xz" + }, + { + "filename": "good-large_compressed.lzma", + "sha256": null, + "path": "tests/files/good-large_compressed.lzma" + } + ] + }, + "remediation": { + "immediate": [ + "Downgrade or replace xz/liblzma 5.6.0 and 5.6.1 with known-good packages from your distro vendor.", + "Identify and isolate systems running affected testing or rolling-release builds.", + "Rebuild images and containers from trusted repositories after package rollback.", + "Review SSH access logs and authentication anomalies on potentially exposed hosts." + ], + "long_term": [ + "Require provenance verification for upstream source archives, not only Git tags.", + "Prefer vendor-vetted packages and staged rollouts for critical system libraries.", + "Continuously monitor upstream dependency maintainership and release pipeline anomalies." + ] + }, + "references": [ + { "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-3094" }, + { + "url": "https://www.cisa.gov/news-events/alerts/2024/03/29/reported-supply-chain-compromise-affecting-xz-utils-data-compression-library-cve-2024-3094" + }, + { "url": "https://www.rapid7.com/blog/post/2024/04/01/etr-backdoored-xz-utils-cve-2024-3094/" } + ] +} diff --git a/packages/kb/package.json b/packages/kb/package.json index 7d73a3e..0aec938 100644 --- a/packages/kb/package.json +++ b/packages/kb/package.json @@ -13,7 +13,7 @@ "lint:fix": "biome check --write ." }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3" } } diff --git a/packages/kb/src/chunker.ts b/packages/kb/src/chunker.ts index 4a58ca6..0cf0ae0 100644 --- a/packages/kb/src/chunker.ts +++ b/packages/kb/src/chunker.ts @@ -151,7 +151,7 @@ export function chunkFile(filePath: string): Chunk[] { * Returns flat list of all chunks with stable IDs. */ export async function* chunkDir(dir: string): AsyncGenerator { - const { readdirSync, statSync, readFileSync } = await import('node:fs') + const { readdirSync, statSync } = await import('node:fs') const { resolve } = await import('node:path') function* walk(d: string): Generator { diff --git a/packages/remediation/package.json b/packages/remediation/package.json index 69158c4..cedbd80 100644 --- a/packages/remediation/package.json +++ b/packages/remediation/package.json @@ -13,7 +13,7 @@ "lint:fix": "biome check --write ." }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3" } } diff --git a/packages/remediation/src/scripts/safe-suspend.ts b/packages/remediation/src/scripts/safe-suspend.ts index 4aa4cc4..0e09119 100644 --- a/packages/remediation/src/scripts/safe-suspend.ts +++ b/packages/remediation/src/scripts/safe-suspend.ts @@ -8,11 +8,17 @@ const MALICIOUS_SIGNATURES = ['bun_environment.js', 'setup_bun.js', 'trufflehog', '.truffler-cache'] -export async function safeSuspend(_dryRun = false): Promise { +export async function safeSuspend(dryRun = false): Promise { console.log('[safe-suspend] Scanning for malicious processes...') for (const sig of MALICIOUS_SIGNATURES) { // Placeholder: actual pgrep implementation - console.log(`[safe-suspend] Would freeze processes matching: ${sig}`) + if (dryRun) { + console.log(`[safe-suspend] [DRY-RUN] Would freeze processes matching: ${sig}`) + } else { + console.log(`[safe-suspend] Freezing processes matching: ${sig}`) + // TODO/FIXME: implement the actual suspension using child_process (pgrep + kill -STOP) + throw new Error('suspension not implemented') + } } } diff --git a/packages/scanner/package.json b/packages/scanner/package.json index 07cc5e5..2711731 100644 --- a/packages/scanner/package.json +++ b/packages/scanner/package.json @@ -12,9 +12,11 @@ "lint": "biome check .", "lint:fix": "biome check --write ." }, - "dependencies": {}, + "dependencies": { + "js-yaml": "^4.1.1" + }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", "typescript": "^6.0.3" } } diff --git a/packages/scanner/src/__tests__/cli-output.test.ts b/packages/scanner/src/__tests__/cli-output.test.ts new file mode 100644 index 0000000..1c0bb78 --- /dev/null +++ b/packages/scanner/src/__tests__/cli-output.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { runCli } from '../cli.js' +import { renderText } from '../output/text.js' +import type { ScanResult } from '../types.js' + +let originalConsoleError = console.error + +afterEach(() => { + console.error = originalConsoleError +}) + +describe('runCli', () => { + test('rejects an empty --output= value', async () => { + const errors: string[] = [] + originalConsoleError = console.error + console.error = ((...args: unknown[]) => { + errors.push(args.map((arg) => String(arg)).join(' ')) + }) as typeof console.error + + const exitCode = await runCli(['scan', '--output=']) + + expect(exitCode).toBe(1) + expect(errors).toHaveLength(1) + expect(errors[0]).toContain('Missing value for --output.') + }) +}) + +describe('renderText', () => { + test('prints the low summary line with indentation and only one verbose location block', () => { + const result: ScanResult = { + scanned: '/tmp/project', + lockfiles: ['/tmp/project/package-lock.json'], + findings: [ + { + type: 'injection', + severity: 'low', + package: 'flatmap-stream', + message: 'Suspicious package found.', + location: 'lockfile: flatmap-stream@0.1.1', + recommendation: 'Investigate the dependency source.', + }, + ], + timestamp: '2026-04-21T17:00:00.000Z', + duration_ms: 42, + } + + const output = renderText(result, true) + const locationMatches = output.match(/lockfile: flatmap-stream@0\.1\.1/g) ?? [] + + expect(output).toContain(` \x1b[90m1 low\x1b[0m`) + expect(output).not.toContain('\x1b[0m\x1b[0m') + expect(locationMatches).toHaveLength(1) + }) +}) diff --git a/packages/scanner/src/__tests__/parsers.test.ts b/packages/scanner/src/__tests__/parsers.test.ts new file mode 100644 index 0000000..affbf54 --- /dev/null +++ b/packages/scanner/src/__tests__/parsers.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { parseNpmLockfile } from '../parsers/npm.js' +import { parsePnpmLockfile } from '../parsers/pnpm.js' + +const createdDirs: string[] = [] + +function createTempProject(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'wormsctrl-parser-')) + createdDirs.push(tempDir) + return tempDir +} + +afterEach(() => { + mock.restore() + while (createdDirs.length > 0) { + const tempDir = createdDirs.pop() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + } +}) + +describe('parseNpmLockfile', () => { + test('parses a minimal npm v3 lockfile', () => { + const tempDir = createTempProject() + + writeFileSync( + join(tempDir, 'package-lock.json'), + JSON.stringify( + { + lockfileVersion: 3, + packages: { + '': { + name: 'fixture-project', + version: '1.0.0', + }, + 'node_modules/lodash': { + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + }, + }, + }, + null, + 2 + ) + ) + + const packages = parseNpmLockfile(tempDir) + + expect(packages).toHaveLength(1) + expect(packages[0]).toEqual({ + name: 'lodash', + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + engines: undefined, + requires: undefined, + lockfileVersion: 3, + source: join(tempDir, 'package-lock.json'), + }) + }) +}) + +describe('parsePnpmLockfile', () => { + test("parses a scoped package key that uses '@' as the version separator", () => { + const tempDir = createTempProject() + + writeFileSync( + join(tempDir, 'pnpm-lock.yaml'), + `lockfileVersion: '9.0' +packages: + '@scope/demo@1.2.3': + resolution: + integrity: sha512-demo +` + ) + + const packages = parsePnpmLockfile(tempDir) + + expect(packages).toEqual([ + { + name: '@scope/demo', + version: '1.2.3', + resolved: undefined, + integrity: 'sha512-demo', + engines: undefined, + requires: undefined, + lockfileVersion: '9.0', + }, + ]) + }) +}) + +describe('parseBunLockfile', () => { + test('falls back to bun.lockb parsing when reading bun.lock throws', async () => { + const tempDir = createTempProject() + + mkdirSync(join(tempDir, 'bun.lock')) + writeFileSync(join(tempDir, 'bun.lockb'), '') + + mock.module('node:child_process', () => ({ + spawnSync: () => ({ + error: undefined, + status: 0, + stdout: 'fixture@workspace\n└── left-pad@1.3.0\n', + }), + })) + + const { parseBunLockfile } = await import('../parsers/bun.js') + const packages = parseBunLockfile(tempDir) + + expect(packages).toEqual([ + { + name: 'left-pad', + version: '1.3.0', + lockfileVersion: 'pm-ls', + }, + ]) + }) +}) diff --git a/packages/scanner/src/__tests__/scan.test.ts b/packages/scanner/src/__tests__/scan.test.ts new file mode 100644 index 0000000..f81a782 --- /dev/null +++ b/packages/scanner/src/__tests__/scan.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { detectInjection } from '../detectors/injection.js' +import { parseNpmLockfile } from '../parsers/npm.js' + +const createdDirs: string[] = [] + +function createTempProject(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'wormsctrl-scan-')) + createdDirs.push(tempDir) + return tempDir +} + +afterEach(() => { + while (createdDirs.length > 0) { + const tempDir = createdDirs.pop() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + } +}) + +describe('detectInjection', () => { + test('reports known phantom dependency IOCs and malicious versions from the threat DB', () => { + const tempDir = createTempProject() + + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify( + { + name: 'fixture-project', + version: '1.0.0', + dependencies: { + axios: '1.14.1', + }, + }, + null, + 2 + ) + ) + + writeFileSync( + join(tempDir, 'package-lock.json'), + JSON.stringify( + { + lockfileVersion: 3, + packages: { + '': { + name: 'fixture-project', + version: '1.0.0', + }, + 'node_modules/axios': { + version: '1.14.1', + }, + 'node_modules/plain-crypto-js': { + version: '4.2.1', + }, + }, + }, + null, + 2 + ) + ) + + const packages = parseNpmLockfile(tempDir) + const findings = detectInjection(packages, tempDir) + + expect( + findings.some( + (finding) => finding.type === 'malicious-package' && finding.package === 'axios' + ) + ).toBe(true) + expect( + findings.some( + (finding) => finding.type === 'injection' && finding.package === 'plain-crypto-js' + ) + ).toBe(true) + }) +}) diff --git a/packages/scanner/src/__tests__/threats.test.ts b/packages/scanner/src/__tests__/threats.test.ts new file mode 100644 index 0000000..a0bea71 --- /dev/null +++ b/packages/scanner/src/__tests__/threats.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test' +import { join, resolve } from 'node:path' +import { scan } from '../scan.js' + +const FIXTURES_DIR = resolve(import.meta.dir, '../../../../tests/fixtures') + +describe('threat-backed scanner detections', () => { + test('flags axios malicious release and phantom dependency IOC', async () => { + const result = await scan(join(FIXTURES_DIR, 'axios-compromise')) + + expect(result.findings.length).toBeGreaterThanOrEqual(2) + + // Malicious version finding + expect( + result.findings.some( + (finding) => + finding.severity === 'critical' && finding.message.includes('axios') + ) + ).toBe(true) + + // Phantom dependency finding + expect( + result.findings.some( + (finding) => finding.message.includes('plain-crypto-js') + ) + ).toBe(true) + }) + + test('flags shai-hulud malicious versions', async () => { + const result = await scan(join(FIXTURES_DIR, 'shai-hulud-worm')) + + expect(result.findings.length).toBeGreaterThanOrEqual(1) + expect(result.findings.some((finding) => finding.severity === 'critical')).toBe(true) + }) + + test('flags teampcp malicious requirements pin', async () => { + const result = await scan(join(FIXTURES_DIR, 'teampcp-litellm')) + + expect(result.findings.length).toBeGreaterThanOrEqual(1) + expect( + result.findings.some( + (finding) => + finding.severity === 'high' && + finding.message.includes('litellm@1.82.7') && + finding.message.includes('teampcp-2026') + ) + ).toBe(true) + }) + + test('does not flag clean baseline fixture', async () => { + const result = await scan(join(FIXTURES_DIR, 'clean-baseline')) + + expect(result.findings).toHaveLength(0) + }) +}) diff --git a/packages/scanner/src/cli.ts b/packages/scanner/src/cli.ts new file mode 100644 index 0000000..d9988de --- /dev/null +++ b/packages/scanner/src/cli.ts @@ -0,0 +1,244 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, extname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatJson } from './output/json.js' +import { formatSarif } from './output/sarif.js' +import { formatText } from './output/text.js' +import { scan } from './scan.js' +import type { ScanResult } from './types.js' + +type OutputFormat = 'json' | 'sarif' | 'text' + +interface ThreatSummary { + id: string + name: string + severity: string + status: string + year: number +} + +interface ParsedScanArgs { + format: OutputFormat + output?: string + showThreatCount: boolean + target: string +} + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const THREATS_DIR_CANDIDATES = [ + resolve(__dirname, 'threats'), + resolve(__dirname, '../../ioc/threats'), + resolve(__dirname, '../../../packages/ioc/threats'), +] + +function printUsage(): void { + process.stdout.write(`worms-ctrl + +Usage: + worms-ctrl scan [target] [--format text|json|sarif] [--output path] [--threats] + worms-ctrl threats + worms-ctrl help +`) +} + +function findThreatsDir(): string | null { + for (const candidate of THREATS_DIR_CANDIDATES) { + if (existsSync(candidate)) { + return candidate + } + } + + return null +} + +function loadThreats(): ThreatSummary[] { + const threatsDir = findThreatsDir() + if (!threatsDir) { + return [] + } + + return readdirSync(threatsDir) + .filter((entry: string) => extname(entry) === '.json') + .map((entry: string) => { + try { + return JSON.parse(readFileSync(resolve(threatsDir, entry), 'utf8')) as ThreatSummary + } catch { + return null + } + }) + .filter((threat: ThreatSummary | null): threat is ThreatSummary => threat !== null) +} + +function parseFormat(value: string): OutputFormat | null { + if (value === 'json' || value === 'sarif' || value === 'text') { + return value + } + + return null +} + +function parseScanArgs(args: string[]): ParsedScanArgs { + let format: OutputFormat = 'text' + let output: string | undefined + let showThreatCount = false + let target = '.' + let targetSet = false + + for (let index = 0; index < args.length; index++) { + const arg = args[index] + + if (!arg) { + continue + } + + if (arg === '--threats') { + showThreatCount = true + continue + } + + if (arg === '--format') { + const nextArg = args[index + 1] + const nextFormat = nextArg ? parseFormat(nextArg) : null + if (!nextFormat) { + throw new Error('Invalid value for --format. Use text, json, or sarif.') + } + format = nextFormat + index++ + continue + } + + if (arg.startsWith('--format=')) { + const nextFormat = parseFormat(arg.slice('--format='.length)) + if (!nextFormat) { + throw new Error('Invalid value for --format. Use text, json, or sarif.') + } + format = nextFormat + continue + } + + if (arg === '--output') { + const nextArg = args[index + 1] + if (!nextArg) { + throw new Error('Missing value for --output.') + } + output = nextArg + index++ + continue + } + + if (arg.startsWith('--output=')) { + const value = arg.slice('--output='.length) + if (!value) { + throw new Error('Missing value for --output.') + } + output = value + continue + } + + if (arg.startsWith('--')) { + throw new Error(`Unknown flag: ${arg}`) + } + + if (!targetSet) { + target = arg + targetSet = true + continue + } + + throw new Error(`Unexpected argument: ${arg}`) + } + + return { + format, + output, + showThreatCount, + target, + } +} + +function renderResult(result: ScanResult, format: OutputFormat): string { + switch (format) { + case 'json': + return formatJson(result) + case 'sarif': + return JSON.stringify(formatSarif(result), null, 2) + default: + return formatText(result) + } +} + +function exitCodeForResult(result: ScanResult): number { + return result.findings.some( + (finding) => finding.severity === 'critical' || finding.severity === 'high' + ) + ? 1 + : 0 +} + +function printThreatCatalog(threats: ThreatSummary[]): void { + if (threats.length === 0) { + process.stdout.write('No threat entries loaded.\n') + return + } + + for (const threat of threats.sort((left, right) => right.year - left.year)) { + process.stdout.write( + `- ${threat.name} (${threat.year}) [${threat.severity}] ${threat.status} - ${threat.id}\n` + ) + } +} + +export async function runCli(argv = process.argv.slice(2)): Promise { + const command = argv[0] ?? 'help' + + if (command === 'help' || command === '--help' || command === '-h') { + printUsage() + return 0 + } + + if (command === 'threats') { + printThreatCatalog(loadThreats()) + return 0 + } + + if (command !== 'scan') { + printUsage() + return 1 + } + + let parsedArgs: ParsedScanArgs + try { + parsedArgs = parseScanArgs(argv.slice(1)) + } catch (error) { + console.error( + error instanceof Error ? `[worms-ctrl] ${error.message}` : '[worms-ctrl] Invalid arguments.' + ) + return 1 + } + + const threats = loadThreats() + if (parsedArgs.showThreatCount) { + console.error(`[worms-ctrl] Loaded ${threats.length} threat objects`) + } + + const target = resolve(parsedArgs.target) + const result = await scan(target) + const rendered = renderResult(result, parsedArgs.format) + + if (parsedArgs.output) { + const outputPath = resolve(parsedArgs.output) + try { + writeFileSync(outputPath, `${rendered}\n`) + console.error(`[worms-ctrl] Wrote ${parsedArgs.format} output to ${outputPath}`) + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error' + console.error(`[worms-ctrl] Failed to write output to ${outputPath}: ${message}`) + return 1 + } + } else { + process.stdout.write(`${rendered}\n`) + } + + return exitCodeForResult(result) +} diff --git a/packages/scanner/src/detectors/index.ts b/packages/scanner/src/detectors/index.ts index 49d66e4..5e3248b 100644 --- a/packages/scanner/src/detectors/index.ts +++ b/packages/scanner/src/detectors/index.ts @@ -1,4 +1,4 @@ -import { parseNpmLockfile } from '../parsers/npm.js' +import { parseLockfiles } from '../parsers/index.js' import type { Finding } from '../types.js' import { detectInjection } from './injection.js' @@ -9,7 +9,7 @@ export { detectInjection } */ export function detect(targetDir: string): Finding[] { const findings: Finding[] = [] - const lockPkgs = parseNpmLockfile(targetDir) + const lockPkgs = parseLockfiles(targetDir) findings.push(...detectInjection(lockPkgs, targetDir)) return findings } diff --git a/packages/scanner/src/detectors/injection.ts b/packages/scanner/src/detectors/injection.ts index 56878b2..9d696fd 100644 --- a/packages/scanner/src/detectors/injection.ts +++ b/packages/scanner/src/detectors/injection.ts @@ -1,50 +1,87 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { + getPhantomDependencyMatches, + getThreatVersionMatches, + toFindingSeverity, +} from '../threats.js' import type { Finding } from '../types.js' +import { validatePath } from '../utils.js' + +interface PackageJsonManifest { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record +} /** * Load declared packages from package.json */ -function loadPackageJsonDeps(targetDir: string): Set { +function loadPackageJsonDeps(targetDir: string): Set | null { + validatePath(targetDir) try { - const pkg = JSON.parse(readFileSync(resolve(targetDir, 'package.json'), 'utf-8')) as any + const pkg = JSON.parse( + readFileSync(resolve(targetDir, 'package.json'), 'utf-8') + ) as PackageJsonManifest const declared = new Set() for (const deps of [ pkg.dependencies ?? {}, pkg.devDependencies ?? {}, pkg.peerDependencies ?? {}, + pkg.optionalDependencies ?? {}, ]) { for (const name of Object.keys(deps)) { declared.add(name) } } return declared - } catch { - return new Set() + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error' + console.warn(`[detectInjection] loadPackageJsonDeps failed for ${targetDir}: ${message}`) + return new Set() } } /** - * Detect packages in lockfile that are NOT declared in package.json. + * Detect known phantom dependencies and malicious versions from the threat DB. */ export function detectInjection( - lockfilePackages: Array<{ name: string; version?: string }>, + lockfilePackages: Array<{ name: string; version?: string; source?: string }>, targetDir: string ): Finding[] { + validatePath(targetDir) const findings: Finding[] = [] const declared = loadPackageJsonDeps(targetDir) for (const pkg of lockfilePackages) { - const bareName = pkg.name.replace(/^@[^/]+\//, '') + const version = pkg.version ?? 'unknown' + const location = `${pkg.source ?? 'lockfile'}: ${pkg.name}@${version}` + const threatMatches = pkg.version ? getThreatVersionMatches(pkg.name, pkg.version) : [] + const phantomMatches = + declared && pkg.version && !declared.has(pkg.name) + ? getPhantomDependencyMatches(pkg.name, pkg.version) + : [] + + for (const match of threatMatches) { + findings.push({ + type: 'malicious-package', + severity: toFindingSeverity(match.threat.severity), + package: pkg.name, + message: `Known malicious version: ${pkg.name}@${version} matches ${match.threat.id} (${match.threat.attack_vector})`, + location, + recommendation: `Remove ${pkg.name}@${version}, rebuild from a known-good dependency set, and follow the ${match.threat.id} remediation guidance.`, + }) + } - if (!declared.has(pkg.name) && !declared.has(bareName)) { + for (const match of phantomMatches) { findings.push({ type: 'injection', - severity: 'high', + severity: toFindingSeverity(match.threat.severity), package: pkg.name, - message: `Package "${pkg.name}" found in lockfile but not declared in package.json`, - location: `lockfile: ${pkg.name}@${pkg.version ?? 'unknown'}`, - recommendation: `Remove from package.json or investigate origin.`, + message: `Phantom dependency: ${pkg.name}@${version} — not declared in package.json, matches known IOC`, + location, + recommendation: `Investigate the parent dependency chain for ${match.threat.id}, remove the phantom package, and rebuild from a trusted lockfile.`, }) } } diff --git a/packages/scanner/src/index.ts b/packages/scanner/src/index.ts index 96f7e2e..14e02b5 100644 --- a/packages/scanner/src/index.ts +++ b/packages/scanner/src/index.ts @@ -13,6 +13,7 @@ * ``` */ +export { runCli } from './cli.js' export { detect } from './detectors/index.js' export { parseLockfiles } from './parsers/index.js' export { scan } from './scan.js' diff --git a/packages/scanner/src/io.ts b/packages/scanner/src/io.ts new file mode 100644 index 0000000..770453f --- /dev/null +++ b/packages/scanner/src/io.ts @@ -0,0 +1,8 @@ +export async function readTextFile(path: string): Promise { + if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { + return Bun.file(path).text() + } + + const { readFile } = await import('node:fs/promises') + return readFile(path, 'utf8') +} diff --git a/packages/scanner/src/output/sarif.ts b/packages/scanner/src/output/sarif.ts index 2265e16..96bf688 100644 --- a/packages/scanner/src/output/sarif.ts +++ b/packages/scanner/src/output/sarif.ts @@ -12,13 +12,7 @@ export interface SarifRule { fullDescription?: { text: string } help?: { text: string } helpUri?: string - properties?: Record -} - -interface SarifLocation { - uri: string - line: number - column?: number + properties?: Record } interface SarifResult { @@ -46,6 +40,11 @@ interface SarifRun { results: SarifResult[] } +interface SarifReport { + version: '2.1.0' + runs: SarifRun[] +} + /** Maps wormsCTRL severity to SARIF level */ function severityToSarifLevel(severity: Finding['severity']): 'error' | 'warning' | 'note' { switch (severity) { @@ -61,14 +60,15 @@ function severityToSarifLevel(severity: Finding['severity']): 'error' | 'warning /** Generate SARIF ruleId from finding type */ function ruleId(finding: Finding): string { - const map: Record = { + const map: Record = { injection: 'WCTRL/scan/injected-package', - hashMismatch: 'WCTRL/scan/hash-mismatch', + 'hash-mismatch': 'WCTRL/scan/hash-mismatch', doppelganger: 'WCTRL/scan/doppelganger', - maliciousPackage: 'WCTRL/scan/malicious-package', - suspiciousScript: 'WCTRL/scan/suspicious-script', + 'malicious-package': 'WCTRL/scan/malicious-package', + 'suspicious-script': 'WCTRL/scan/suspicious-script', } - return map[finding.type] ?? 'WCTRL/scan/unknown' + + return map[finding.type] } /** Convert finding location string to SARIF location */ @@ -84,7 +84,7 @@ function parseLocation(location: string): { uri: string; line: number } { /** * Format wormsCTRL scan result as SARIF 2.1.0 JSON. */ -export function formatSarif(result: ScanResult): any { +export function formatSarif(result: ScanResult): SarifReport { const rules = new Map() for (const finding of result.findings) { @@ -142,6 +142,6 @@ export function formatSarif(result: ScanResult): any { } /** Write SARIF to file (placeholder for CLI) */ -export function toSarif(result: ScanResult): any { +export function toSarif(result: ScanResult): SarifReport { return formatSarif(result) } diff --git a/packages/scanner/src/output/text.ts b/packages/scanner/src/output/text.ts index cc389a7..9b2b5db 100644 --- a/packages/scanner/src/output/text.ts +++ b/packages/scanner/src/output/text.ts @@ -60,44 +60,43 @@ function severityLine(severity: Severity): string { } } -/** Print a scan result as human-readable text with colors */ -export function formatText(result: ScanResult, verbose = false): void { - printText(result, verbose) -} +export function renderText(result: ScanResult, verbose = false): string { + const lines: string[] = [] + const writeLine = (line = '') => { + lines.push(line) + } -/** Original print function */ -export function printText(result: ScanResult, verbose = false): void { const { scanned, lockfiles, findings, timestamp, duration_ms } = result const now = new Date(timestamp) const pad = 50 - console.log('') - console.log(`${CYAN}${BOLD} wormsCTRL Scanner${RESET} ${GRAY}${timestamp}${RESET}`) - console.log(`${GRAY}${'─'.repeat(pad)}${RESET}`) - console.log(` ${DIM}Target:${RESET} ${scanned}`) - console.log( + writeLine('') + writeLine(`${CYAN}${BOLD} wormsCTRL Scanner${RESET} ${GRAY}${timestamp}${RESET}`) + writeLine(`${GRAY}${'─'.repeat(pad)}${RESET}`) + writeLine(` ${DIM}Target:${RESET} ${scanned}`) + writeLine( ` ${DIM}Lockfiles:${RESET} ${lockfiles.length} ${lockfiles.length > 0 ? `(${lockfiles.join(', ')})` : '(none found)'}` ) - console.log(` ${DIM}Duration:${RESET} ${duration_ms}ms`) - console.log(` ${DIM}Scan date:${RESET} ${now.toLocaleString()}`) + writeLine(` ${DIM}Duration:${RESET} ${duration_ms}ms`) + writeLine(` ${DIM}Scan date:${RESET} ${now.toLocaleString()}`) const critical = findings.filter((f) => f.severity === 'critical') const high = findings.filter((f) => f.severity === 'high') const medium = findings.filter((f) => f.severity === 'medium') const low = findings.filter((f) => f.severity === 'low') - console.log('') - console.log(` ${DIM}Summary:${RESET}`) - console.log(` ${critical.length > 0 ? RED : GREEN}${critical.length} critical${RESET}`) - console.log(` ${high.length > 0 ? RED : GREEN}${high.length} high${RESET}`) - console.log(` ${medium.length > 0 ? AMBER : GREEN}${medium.length} medium${RESET}`) - console.log(`${GRAY}${low.length} low${RESET}${RESET}`) - console.log('') + writeLine('') + writeLine(` ${DIM}Summary:${RESET}`) + writeLine(` ${critical.length > 0 ? RED : GREEN}${critical.length} critical${RESET}`) + writeLine(` ${high.length > 0 ? RED : GREEN}${high.length} high${RESET}`) + writeLine(` ${medium.length > 0 ? AMBER : GREEN}${medium.length} medium${RESET}`) + writeLine(` ${GRAY}${low.length} low${RESET}`) + writeLine('') if (findings.length === 0) { - console.log(` ${GREEN}${BOLD}✅ No findings${RESET}`) - console.log('') - return + writeLine(` ${GREEN}${BOLD}✅ No findings${RESET}`) + writeLine('') + return lines.join('\n') } const sorted = [...critical, ...high, ...medium, ...low] as Finding[] @@ -107,27 +106,35 @@ export function printText(result: ScanResult, verbose = false): void { const label = severityLabel(finding.severity) const line = severityLine(finding.severity) - if (line) console.log(line) - console.log(` ${icon} ${label} ${finding.message}`) + if (line) writeLine(line) + writeLine(` ${icon} ${label} ${finding.message}`) if (finding.package) { - console.log(` ${DIM}Package:${RESET} ${WHITE}${finding.package}${RESET}`) + writeLine(` ${DIM}Package:${RESET} ${WHITE}${finding.package}${RESET}`) } if (verbose && finding.location) { - console.log(` ${DIM}Location:${RESET} ${WHITE}${finding.location}${RESET}`) + writeLine(` ${DIM}Location:${RESET} ${WHITE}${finding.location}${RESET}`) } if (finding.recommendation) { - console.log(` → ${CYAN}${finding.recommendation}${RESET}`) + writeLine(` → ${CYAN}${finding.recommendation}${RESET}`) } - if (verbose && finding.location) { - console.log('') - console.log(` ${DIM} ${finding.location}${RESET}`) - } - console.log('') + writeLine('') } + + return lines.join('\n') +} + +/** Return a scan result as human-readable text with colors */ +export function formatText(result: ScanResult, verbose = false): string { + return renderText(result, verbose) +} + +/** Original print function */ +export function printText(result: ScanResult, verbose = false): void { + console.log(renderText(result, verbose)) } /** Print only the finding lines (machine-friendly for piping */ diff --git a/packages/scanner/src/parsers/bun.ts b/packages/scanner/src/parsers/bun.ts new file mode 100644 index 0000000..e4a5220 --- /dev/null +++ b/packages/scanner/src/parsers/bun.ts @@ -0,0 +1,247 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import type { LockfilePackage } from '../types.js' + +export interface ParsedPackage extends LockfilePackage {} +const BUN_PM_LS_TIMEOUT_MS = 5_000 + +function resolveBunLockTextPath(targetOrPath: string): string | null { + if (basename(targetOrPath) === 'bun.lock') { + return existsSync(targetOrPath) ? targetOrPath : null + } + if (basename(targetOrPath) === 'bun.lockb') { + const candidate = join(dirname(targetOrPath), 'bun.lock') + return existsSync(candidate) ? candidate : null + } + + const candidate = join(targetOrPath, 'bun.lock') + return existsSync(candidate) ? candidate : null +} + +function parseDescriptor(descriptor: string): { name: string; version: string } | null { + // Handle npm alias descriptors: alias@npm:real@1.2.3 + const npmAliasIdx = descriptor.indexOf('@npm:') + if (npmAliasIdx > 0) { + const aliasName = descriptor.slice(0, npmAliasIdx) + const realPart = descriptor.slice(npmAliasIdx + 5) // after @npm: + const realVersionSep = realPart.lastIndexOf('@') + const version = realVersionSep > 0 ? realPart.slice(realVersionSep + 1) : realPart + return { name: aliasName, version } + } + + const versionSeparator = descriptor.lastIndexOf('@') + if (versionSeparator <= 0) { + return null + } + + return { + name: descriptor.slice(0, versionSeparator), + version: descriptor.slice(versionSeparator + 1), + } +} + +/** Strip JSONC comments and trailing commas so JSON.parse can handle bun.lock */ +function stripJsonc(content: string): string { + let result = '' + let inString = false + let inEscape = false + let inLineComment = false + let inBlockComment = false + + for (let i = 0; i < content.length; i++) { + const c = content[i] + const nextC = content[i + 1] || '' + + if (inLineComment) { + if (c === '\n') { + inLineComment = false + result += c + } + continue + } + + if (inBlockComment) { + if (c === '*' && nextC === '/') { + inBlockComment = false + i++ + } + continue + } + + if (inString) { + if (inEscape) { + inEscape = false + } else if (c === '\\') { + inEscape = true + } else if (c === '"') { + inString = false + } + result += c + continue + } + + // Not in string, not in comment + if (c === '"') { + inString = true + result += c + continue + } + + if (c === '/' && nextC === '/') { + inLineComment = true + i++ + continue + } + + if (c === '/' && nextC === '*') { + inBlockComment = true + i++ + continue + } + + result += c + } + + // Strip trailing commas + return result.replace(/,(\s*[}\]])/g, '$1') +} + +function parseBunTextLockfile(content: string): ParsedPackage[] { + let parsedContent: unknown + + try { + parsedContent = JSON.parse(stripJsonc(content)) + } catch { + return [] + } + + if (!parsedContent || typeof parsedContent !== 'object') { + return [] + } + + const { packages } = parsedContent as { packages?: unknown } + if (!packages || typeof packages !== 'object') { + return [] + } + + const parsedPackages: ParsedPackage[] = [] + + for (const packageEntry of Object.values(packages as Record)) { + if (!Array.isArray(packageEntry)) { + continue + } + + const descriptor = packageEntry[0] + if (typeof descriptor !== 'string') { + continue + } + const parsedDescriptor = parseDescriptor(descriptor) + if (!parsedDescriptor) { + continue + } + + parsedPackages.push({ + ...parsedDescriptor, + lockfileVersion: 1, + }) + } + + return parsedPackages +} + +function parseBunPmLsOutput(output: string): ParsedPackage[] { + const seen = new Set() + const parsedPackages: ParsedPackage[] = [] + + for (const line of output.split('\n')) { + const match = line.match(/[├└]──\s+(.+?)@([^\s]+)$/) + if (!match) { + continue + } + + const name = match[1]?.trim() + const version = match[2]?.trim() + if (!name || !version) { + continue + } + + const key = `${name}@${version}` + if (seen.has(key)) { + continue + } + + seen.add(key) + parsedPackages.push({ + name, + version, + lockfileVersion: 0, + }) + } + + return parsedPackages +} + +function bunProjectDirectory(targetOrPath: string): string { + if (basename(targetOrPath) === 'bun.lock' || basename(targetOrPath) === 'bun.lockb') { + return dirname(targetOrPath) + } + + return targetOrPath +} + +/** Parse Bun lockfiles via text bun.lock or bun pm ls fallback for bun.lockb projects. */ +export function parseBunLockfile(targetDirOrPath: string): ParsedPackage[] { + const textLockPath = resolveBunLockTextPath(targetDirOrPath) + if (textLockPath) { + try { + const parsedFromText = parseBunTextLockfile(readFileSync(textLockPath, 'utf8')) + if (parsedFromText.length > 0) { + return parsedFromText + } + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error' + console.error( + `[scanner:bun] Failed to parse text lockfile ${textLockPath}; falling back to bun.lockb parsing: ${message}` + ) + } + } + + let bunLockBinaryPath = '' + if (basename(targetDirOrPath) === 'bun.lockb') { + bunLockBinaryPath = targetDirOrPath + } else if (basename(targetDirOrPath) === 'bun.lock') { + bunLockBinaryPath = join(dirname(targetDirOrPath), 'bun.lockb') + } else { + bunLockBinaryPath = join(targetDirOrPath, 'bun.lockb') + } + + if (!existsSync(bunLockBinaryPath)) { + return [] + } + + try { + const projectDir = bunProjectDirectory(targetDirOrPath) + const result = spawnSync('bun', ['pm', 'ls', '--all'], { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: BUN_PM_LS_TIMEOUT_MS, + }) + + if (result.error) { + console.error( + `[scanner:bun] Failed to execute bun pm ls for ${bunLockBinaryPath}: ${result.error.message}` + ) + return [] + } + + if (result.status !== 0 || !result.stdout) { + return [] + } + + return parseBunPmLsOutput(result.stdout) + } catch { + return [] + } +} diff --git a/packages/scanner/src/parsers/index.ts b/packages/scanner/src/parsers/index.ts index 43e25bb..2873381 100644 --- a/packages/scanner/src/parsers/index.ts +++ b/packages/scanner/src/parsers/index.ts @@ -1,11 +1,26 @@ +import type { LockfilePackage } from '../types.js' +import { parseBunLockfile } from './bun.js' import { parseNpmLockfile } from './npm.js' +import { parsePnpmLockfile } from './pnpm.js' +import { parseRequirementsFile } from './requirements.js' import { parseYarnLockfile } from './yarn.js' -export { parseNpmLockfile, parseYarnLockfile } +export { + parseBunLockfile, + parseNpmLockfile, + parsePnpmLockfile, + parseRequirementsFile, + parseYarnLockfile, +} /** * Placeholder for unified lockfile parser dispatcher */ -export function parseLockfiles(targetDir: string): any[] { - return [...parseNpmLockfile(targetDir), ...parseYarnLockfile(targetDir)] +export function parseLockfiles(targetDir: string): LockfilePackage[] { + return [ + ...parseNpmLockfile(targetDir), + ...parseYarnLockfile(targetDir), + ...parsePnpmLockfile(targetDir), + ...parseBunLockfile(targetDir), + ] } diff --git a/packages/scanner/src/parsers/js-yaml.d.ts b/packages/scanner/src/parsers/js-yaml.d.ts new file mode 100644 index 0000000..e3403cf --- /dev/null +++ b/packages/scanner/src/parsers/js-yaml.d.ts @@ -0,0 +1,14 @@ +declare module 'js-yaml' { + export interface LoadOptions { + filename?: string + onWarning?: (warning: Error) => void + schema?: unknown + json?: boolean + listener?: (eventType: string, state: unknown) => void + } + + /** @deprecated Unsafe for untrusted input. Use safeLoad or supply a safe schema. */ + export function load(source: string, options?: LoadOptions): unknown + + export function safeLoad(source: string, options?: LoadOptions): unknown +} diff --git a/packages/scanner/src/parsers/npm.ts b/packages/scanner/src/parsers/npm.ts index c221899..61cbfb1 100644 --- a/packages/scanner/src/parsers/npm.ts +++ b/packages/scanner/src/parsers/npm.ts @@ -1,10 +1,20 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import type { LockfileEntry } from '../types.js' +import { existsSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import type { LockfilePackage } from '../types.js' +import { validatePath } from '../utils.js' + +interface NpmLockPackageEntry { + version?: string + resolved?: string + integrity?: string + engines?: Record + requires?: Record + link?: boolean +} interface NpmLockfile { lockfileVersion?: number - packages?: Record + packages?: Record dependencies?: Record< string, { @@ -16,51 +26,69 @@ interface NpmLockfile { > } -export interface ParsedPackage { - name: string - version: string - resolved?: string - engines?: Record - requires?: Record - lockfileVersion: number +export interface ParsedPackage extends LockfilePackage {} + +const NPM_LOCKFILE_NAMES = ['package-lock.json', 'package-lock.v3.json', 'package-lock.v2.json'] + +function resolveNpmLockPath(targetOrPath: string): string | null { + if (NPM_LOCKFILE_NAMES.includes(basename(targetOrPath))) { + return existsSync(targetOrPath) ? targetOrPath : null + } + + for (const name of NPM_LOCKFILE_NAMES) { + const candidate = join(targetOrPath, name) + if (existsSync(candidate)) { + return candidate + } + } + + return null +} + +function packageNameFromPath(pkgPath: string): string { + const segments = pkgPath.split(/node_modules\//).filter(Boolean) + return segments.at(-1) ?? '' } /** Parse package-lock.json (v2/v3) or package-lock.v3.json */ -export function parseNpmLockfile(targetDir: string): ParsedPackage[] { - const paths = [ - join(targetDir, 'package-lock.json'), - join(targetDir, 'package-lock.v3.json'), - join(targetDir, 'package-lock.v2.json'), - ] +export function parseNpmLockfile(targetDirOrPath: string): ParsedPackage[] { + validatePath(targetDirOrPath) + const lockPath = resolveNpmLockPath(targetDirOrPath) + if (!lockPath) { + return [] + } - let content: string = '' - let _lockPath: string = '' - for (const p of paths) { - try { - content = readFileSync(p, 'utf-8') - _lockPath = p - break - } catch { - /* try next */ - } + let content = '' + try { + content = readFileSync(lockPath, 'utf-8') + } catch { + return [] + } + + let lock: NpmLockfile + try { + lock = JSON.parse(content) as NpmLockfile + } catch { + return [] } - if (!content) return [] - const lock: NpmLockfile = JSON.parse(content) const version = lock.lockfileVersion ?? 2 const pkgs: ParsedPackage[] = [] if (version >= 3 && lock.packages) { for (const [pkgPath, entry] of Object.entries(lock.packages)) { - if (!pkgPath) continue - const name = pkgPath.replace(/^node_modules\//, '') + // Skip root package ("") and workspace/link entries + if (!pkgPath || entry.link) continue + const name = packageNameFromPath(pkgPath) + if (!name) continue pkgs.push({ name, - version: (entry as any).version ?? '', - resolved: (entry as any).resolved, - engines: (entry as any).engines, - requires: (entry as any).requires, + version: entry.version ?? '', + resolved: entry.resolved, + engines: entry.engines, + requires: entry.requires, lockfileVersion: version, + source: lockPath, }) } } else if (lock.dependencies) { @@ -72,6 +100,7 @@ export function parseNpmLockfile(targetDir: string): ParsedPackage[] { engines: dep.engines, requires: dep.requires, lockfileVersion: version, + source: lockPath, }) } } diff --git a/packages/scanner/src/parsers/pnpm.ts b/packages/scanner/src/parsers/pnpm.ts new file mode 100644 index 0000000..856eb33 --- /dev/null +++ b/packages/scanner/src/parsers/pnpm.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { load } from 'js-yaml' +import type { LockfilePackage } from '../types.js' + +interface PnpmPackageEntry { + resolution?: { + integrity?: string + tarball?: string + } + engines?: Record + dependencies?: Record +} + +interface PnpmLockfile { + lockfileVersion?: number | string + packages?: Record + snapshots?: Record +} + +export interface ParsedPackage extends LockfilePackage {} + +function logPnpmParseError(lockPath: string, stage: 'read' | 'parse', error: unknown): void { + const message = error instanceof Error ? error.message : 'unknown error' + console.error(`[scanner:pnpm] Failed to ${stage} ${lockPath}: ${message}`) +} + +function resolvePnpmLockPath(targetOrPath: string): string | null { + if (basename(targetOrPath) === 'pnpm-lock.yaml') { + return existsSync(targetOrPath) ? targetOrPath : null + } + + const candidate = join(targetOrPath, 'pnpm-lock.yaml') + return existsSync(candidate) ? candidate : null +} + +function parsePnpmPackageKey(key: string): { name: string; version: string } | null { + const normalized = key.replace(/^\/+/, '').replace(/\([^)]*\)/g, '') + if (!normalized) { + return null + } + + if (normalized.startsWith('@')) { + const scopeSeparator = normalized.indexOf('/', 1) + if (scopeSeparator === -1) { + return null + } + + const versionAtSeparator = normalized.indexOf('@', scopeSeparator + 1) + if (versionAtSeparator !== -1) { + return { + name: normalized.slice(0, versionAtSeparator), + version: normalized.slice(versionAtSeparator + 1).split('_')[0] ?? '', + } + } + + const versionSlashSeparator = normalized.indexOf('/', scopeSeparator + 1) + if (versionSlashSeparator !== -1) { + return { + name: normalized.slice(0, versionSlashSeparator), + version: normalized.slice(versionSlashSeparator + 1).split('_')[0] ?? '', + } + } + + return null + } + + if (normalized.includes('/')) { + const versionSeparator = normalized.indexOf('/') + return { + name: normalized.slice(0, versionSeparator), + version: normalized.slice(versionSeparator + 1).split('_')[0] ?? '', + } + } + + // Handle npm alias keys: alias@npm:real@version + const npmAliasIdx = normalized.indexOf('@npm:') + if (npmAliasIdx > 0) { + const aliasName = normalized.slice(0, npmAliasIdx) + const realPart = normalized.slice(npmAliasIdx + 5) + const realVersionSep = realPart.lastIndexOf('@') + const version = + realVersionSep > 0 ? (realPart.slice(realVersionSep + 1).split('_')[0] ?? '') : '' + return { name: aliasName, version } + } + + const versionSeparator = normalized.lastIndexOf('@') + if (versionSeparator <= 0) { + return null + } + + return { + name: normalized.slice(0, versionSeparator), + version: normalized.slice(versionSeparator + 1).split('_')[0] ?? '', + } +} + +/** Parse pnpm-lock.yaml (v6 and v9 formats) */ +export function parsePnpmLockfile(targetDirOrPath: string): ParsedPackage[] { + const lockPath = resolvePnpmLockPath(targetDirOrPath) + if (!lockPath) { + return [] + } + + let content = '' + try { + content = readFileSync(lockPath, 'utf8') + } catch (error) { + logPnpmParseError(lockPath, 'read', error) + return [] + } + + let lock: PnpmLockfile + try { + lock = (load(content) as PnpmLockfile | undefined) ?? {} + } catch (error) { + logPnpmParseError(lockPath, 'parse', error) + return [] + } + + const packages = lock.packages ?? {} + const parsedPackages: ParsedPackage[] = [] + + for (const [key, entry] of Object.entries(packages)) { + const parsedKey = parsePnpmPackageKey(key) + if (!parsedKey) { + continue + } + + parsedPackages.push({ + ...parsedKey, + resolved: entry.resolution?.tarball, + integrity: entry.resolution?.integrity, + engines: entry.engines, + requires: entry.dependencies, + lockfileVersion: lock.lockfileVersion ?? 'unknown', + }) + } + + return parsedPackages +} diff --git a/packages/scanner/src/parsers/requirements.ts b/packages/scanner/src/parsers/requirements.ts new file mode 100644 index 0000000..2f869ff --- /dev/null +++ b/packages/scanner/src/parsers/requirements.ts @@ -0,0 +1,77 @@ +import { existsSync } from 'node:fs' +import { basename, join } from 'node:path' +import { readTextFile } from '../io.js' +import type { LockfilePackage } from '../types.js' + +const REQUIREMENTS_FILE_NAMES = ['requirements.txt'] + +function resolveRequirementsPath(targetOrPath: string): string | null { + if (REQUIREMENTS_FILE_NAMES.includes(basename(targetOrPath))) { + return existsSync(targetOrPath) ? targetOrPath : null + } + + for (const name of REQUIREMENTS_FILE_NAMES) { + const candidate = join(targetOrPath, name) + if (existsSync(candidate)) { + return candidate + } + } + + return null +} + +function parsePinnedRequirement(line: string): LockfilePackage | null { + const sanitized = line.split('#')[0]?.trim() ?? '' + if ( + sanitized.length === 0 || + sanitized.startsWith('-') || + sanitized.startsWith('--') || + sanitized.startsWith('git+') + ) { + return null + } + + const match = sanitized.match( + /^([A-Za-z0-9_.-]+)(?:\[[^\]]+\])?\s*==\s*([A-Za-z0-9*+_.!:-]+)(?:\s*;.*)?$/ + ) + + if (!match) { + return null + } + + const [, name, version] = match + if (!name || !version) { + return null + } + + return { + name, + version, + } +} + +export async function parseRequirementsFile(targetDirOrPath: string): Promise { + const requirementsPath = resolveRequirementsPath(targetDirOrPath) + if (!requirementsPath) { + return [] + } + + let content = '' + try { + content = await readTextFile(requirementsPath) + } catch { + return [] + } + + // Fold continued lines (trailing backslash) + content = content.replace(/\\\r?\n\s*/g, ' ') + + return content + .split(/\r?\n/) + .map((line) => parsePinnedRequirement(line)) + .filter((pkg): pkg is LockfilePackage => pkg !== null) + .map((pkg) => ({ + ...pkg, + source: requirementsPath, + })) +} diff --git a/packages/scanner/src/parsers/yarn.ts b/packages/scanner/src/parsers/yarn.ts index 124bc92..d11de27 100644 --- a/packages/scanner/src/parsers/yarn.ts +++ b/packages/scanner/src/parsers/yarn.ts @@ -1,11 +1,17 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import type { LockfileEntry } from '../types.js' - -export interface ParsedPackage extends LockfileEntry { - name: string - version: string - lockfileVersion: number +import { existsSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import type { LockfilePackage } from '../types.js' +import { validatePath } from '../utils.js' + +export interface ParsedPackage extends LockfilePackage {} + +function resolveYarnLockPath(targetOrPath: string): string | null { + if (basename(targetOrPath) === 'yarn.lock') { + return existsSync(targetOrPath) ? targetOrPath : null + } + + const candidate = join(targetOrPath, 'yarn.lock') + return existsSync(candidate) ? candidate : null } /** @@ -88,7 +94,12 @@ function parseYarnV2(content: string): ParsedPackage[] { * Main entrance for Yarn lockfile parsing */ export function parseYarnLockfile(targetDir: string): ParsedPackage[] { - const lockPath = join(targetDir, 'yarn.lock') + validatePath(targetDir) + const lockPath = resolveYarnLockPath(targetDir) + if (!lockPath) { + return [] + } + let content: string try { content = readFileSync(lockPath, 'utf8') diff --git a/packages/scanner/src/scan.ts b/packages/scanner/src/scan.ts index b85b6c5..1f5da24 100644 --- a/packages/scanner/src/scan.ts +++ b/packages/scanner/src/scan.ts @@ -1,8 +1,15 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { basename, resolve } from 'node:path' +import { detectInjection } from './detectors/injection.js' +import { formatText } from './output/text.js' +import { parseBunLockfile } from './parsers/bun.js' +import { parseNpmLockfile } from './parsers/npm.js' +import { parsePnpmLockfile } from './parsers/pnpm.js' +import { parseRequirementsFile } from './parsers/requirements.js' import { parseYarnLockfile } from './parsers/yarn.js' -import type { Finding, ScanResult } from './types.js' +import type { Finding, LockfilePackage, ScanResult } from './types.js' +import { validatePath } from './utils.js' // --------------------------------------------------------------------------- // Lockfile discovery @@ -14,15 +21,26 @@ const LOCKFILE_NAMES = [ 'package-lock.v3.json', 'yarn.lock', 'pnpm-lock.yaml', + 'bun.lock', 'bun.lockb', + 'requirements.txt', ] function findLockfiles(target: string): string[] { + validatePath(target) const found: string[] = [] for (const name of LOCKFILE_NAMES) { const path = resolve(target, name) if (existsSync(path)) found.push(path) } + + // Prefer bun.lock over bun.lockb to avoid duplicate analysis when both exist + const hasBunLock = found.some((p) => basename(p) === 'bun.lock') + const hasBunLockb = found.some((p) => basename(p) === 'bun.lockb') + if (hasBunLock && hasBunLockb) { + return found.filter((p) => basename(p) !== 'bun.lockb') + } + return found } @@ -30,133 +48,32 @@ function findLockfiles(target: string): string[] { // Parser dispatch // --------------------------------------------------------------------------- -async function parseLockfile(path: string): Promise { +async function parseLockfile(path: string): Promise { switch (basename(path)) { case 'package-lock.json': case 'package-lock.v2.json': case 'package-lock.v3.json': - return parseNpmLock(path) + return parseNpmLockfile(path) case 'yarn.lock': return parseYarnLockfile(path) + case 'pnpm-lock.yaml': + return parsePnpmLockfile(path) + case 'bun.lock': + case 'bun.lockb': + return parseBunLockfile(path) + case 'requirements.txt': + return parseRequirementsFile(path) default: return [] } } -// --------------------------------------------------------------------------- -// npm (v2 + v3) -// --------------------------------------------------------------------------- - -interface NpmLockPackage { - version: string - resolved?: string - engines?: Record - requires?: Record -} - -interface NpmLockfile { - lockfileVersion?: number - packages?: Record - dependencies?: Record -} - -function parseNpmLock(path: string): any[] { - let content: string - try { - content = readFileSync(path, 'utf8') - } catch { - return [] - } - - let lock: NpmLockfile - try { - lock = JSON.parse(content) - } catch { - return [] - } - - const version = lock.lockfileVersion ?? 2 - const packages: any[] = [] - const seen = new Set() - - if (version >= 3 && lock.packages) { - for (const [pkgPath, entry] of Object.entries(lock.packages)) { - if (!pkgPath || seen.has(pkgPath)) continue - seen.add(pkgPath) - const name = pkgPath.replace(/^node_modules\//, '') - packages.push({ - name, - version: entry.version ?? '', - resolved: entry.resolved, - engines: entry.engines, - requires: entry.requires, - }) - } - } else if (lock.dependencies) { - for (const [name, dep] of Object.entries(lock.dependencies)) { - packages.push({ - name, - version: dep.version ?? '', - resolved: dep.resolved, - engines: dep.engines, - requires: dep.requires, - }) - } - } - - return packages -} - -// --------------------------------------------------------------------------- -// Injection detector -// --------------------------------------------------------------------------- - -function detectInjection( - packages: Array<{ name: string; version: string }>, - target: string -): Finding[] { - let pkgJson: any = {} - try { - pkgJson = JSON.parse(readFileSync(resolve(target, 'package.json'), 'utf8')) - } catch { - return [] - } - - const declared = new Set() - const depSource = [ - pkgJson.dependencies ?? {}, - pkgJson.devDependencies ?? {}, - pkgJson.peerDependencies ?? {}, - ] - - for (const deps of depSource) { - for (const name of Object.keys(deps)) { - declared.add(name) - } - } - - const findings: Finding[] = [] - for (const pkg of packages) { - const bare = pkg.name.startsWith('@') ? (pkg.name.split('/')[1] ?? pkg.name) : pkg.name - if (!declared.has(pkg.name) && !declared.has(bare)) { - findings.push({ - type: 'injection', - severity: 'high', - package: pkg.name, - message: `Package "${pkg.name}" found in lockfile but NOT declared in package.json`, - location: `lockfile: ${pkg.name}@${pkg.version}`, - recommendation: `Investigate dependency path: npm ls ${pkg.name}.`, - }) - } - } - return findings -} - // --------------------------------------------------------------------------- // Main scan // --------------------------------------------------------------------------- export async function scan(target: string): Promise { + validatePath(target) const start = Date.now() const findings: Finding[] = [] @@ -173,7 +90,7 @@ export async function scan(target: string): Promise { } const results = await Promise.all(lockfilePaths.map((p) => parseLockfile(p))) - const packages = results.flat() + const packages = results.flat().filter((pkg) => pkg.name) const injectionFindings = detectInjection(packages, target) findings.push(...injectionFindings) @@ -190,33 +107,11 @@ export async function scan(target: string): Promise { * CLI entry point */ export async function cli(target: string): Promise { - console.log(`[worms-scan] Scanning: ${target}`) const result = await scan(target) - - console.log(` Lockfiles found: ${result.lockfiles.length}`) - console.log(` Findings: ${result.findings.length}`) - console.log(` Duration: ${result.duration_ms}ms`) - - if (result.findings.length === 0) { - console.log(' \u2705 Clean \u2014 no findings') - return 0 - } + process.stdout.write(`${formatText(result)}\n`) const critical = result.findings.filter((f) => f.severity === 'critical').length const high = result.findings.filter((f) => f.severity === 'high').length - if (critical > 0 || high > 0) { - console.log(` \uD83D\uDEA8 CRITICAL: ${critical} critical, ${high} high`) - for (const f of result.findings) { - if (f.severity === 'critical' || f.severity === 'high') { - process.stdout.write( - ` ${f.package ? `[${f.severity}] ${f.package}: ${f.message}\n` : `${f.message}\n`}` - ) - } - } - return 1 - } - - console.log(` \u26A0 ${result.findings.length} medium/low findings`) - return 0 + return critical > 0 || high > 0 ? 1 : 0 } diff --git a/packages/scanner/src/threats.ts b/packages/scanner/src/threats.ts new file mode 100644 index 0000000..44287d4 --- /dev/null +++ b/packages/scanner/src/threats.ts @@ -0,0 +1,175 @@ +import { existsSync, readdirSync } from 'node:fs' +import { dirname, extname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readTextFile } from './io.js' +import type { Severity } from './types.js' + +type ThreatSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' + +interface ThreatMaliciousVersion { + package: string + version: string + reason?: string +} + +interface ThreatObject { + id: string + severity: ThreatSeverity + attack_vector: string + indicators_of_compromise: { + malicious_versions: ThreatMaliciousVersion[] + } +} + +export interface ThreatVersionMatch { + threat: ThreatObject + package: string + version: string + reason?: string +} + +export interface ThreatDatabase { + threats: ThreatObject[] + maliciousVersions: Map + phantomDependencies: Map +} + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const THREATS_DIR_CANDIDATES = [ + resolve(__dirname, 'threats'), + resolve(__dirname, '../../ioc/threats'), + resolve(__dirname, '../../../packages/ioc/threats'), +] + +function normalizePackageVersion(name: string, version: string): string { + return `${name.toLowerCase()}@${version}` +} + +function findThreatsDir(): string | null { + for (const candidate of THREATS_DIR_CANDIDATES) { + if (existsSync(candidate)) { + return candidate + } + } + + return null +} + +function isValidThreatShape(obj: unknown): obj is ThreatObject { + if (!obj || typeof obj !== 'object') return false + const candidate = obj as Record + if (typeof candidate.id !== 'string') return false + if (!['CRITICAL', 'HIGH', 'MODERATE', 'LOW'].includes(candidate.severity as string)) return false + if (typeof candidate.attack_vector !== 'string') return false + const ioc = candidate.indicators_of_compromise + if (!ioc || typeof ioc !== 'object') return false + const iocObj = ioc as Record + if (!Array.isArray(iocObj.malicious_versions)) return false + return iocObj.malicious_versions.every((v: unknown) => { + if (!v || typeof v !== 'object') return false + const vObj = v as Record + return typeof vObj.package === 'string' && typeof vObj.version === 'string' + }) +} + +async function readThreatObject(path: string): Promise { + try { + const content = await readTextFile(path) + const parsed = JSON.parse(content) as unknown + if (!isValidThreatShape(parsed)) { + console.warn(`[threats] Skipping malformed threat file: ${path}`) + return null + } + return parsed + } catch (err) { + console.error(`[threats] Failed to parse/read threat file ${path}:`, err) + return null + } +} + +async function loadThreatDatabaseFromDisk(): Promise { + const emptyDatabase: ThreatDatabase = { + threats: [], + maliciousVersions: new Map(), + phantomDependencies: new Map(), + } + + const threatsDir = findThreatsDir() + if (!threatsDir) { + return emptyDatabase + } + + let threatFiles: string[] + try { + threatFiles = readdirSync(threatsDir) + .filter((entry) => extname(entry) === '.json') + .map((entry) => resolve(threatsDir, entry)) + } catch { + // readdirSync can throw (EACCES, TOCTOU after existsSync) + return emptyDatabase + } + + const threatObjects = await Promise.all(threatFiles.map((path) => readThreatObject(path))) + const threats = threatObjects.filter((threat): threat is ThreatObject => threat !== null) + const maliciousVersions = new Map() + const phantomDependencies = new Map() + + for (const threat of threats) { + for (const version of threat.indicators_of_compromise.malicious_versions) { + const key = normalizePackageVersion(version.package, version.version) + const match: ThreatVersionMatch = { + threat, + package: version.package, + version: version.version, + reason: version.reason, + } + + const currentMatches = maliciousVersions.get(key) ?? [] + currentMatches.push(match) + maliciousVersions.set(key, currentMatches) + + if (version.reason?.toLowerCase().includes('phantom dependency')) { + const currentPhantoms = phantomDependencies.get(key) ?? [] + currentPhantoms.push(match) + phantomDependencies.set(key, currentPhantoms) + } + } + } + + return { + threats, + maliciousVersions, + phantomDependencies, + } +} + +// Top-level await is intentional: ES2022 target, private package. +// Eager disk I/O avoids cascading async changes to the sync getter API. +// If lazy-loading is desired later, use a memoized Promise wrapper. +const threatDatabase = await loadThreatDatabaseFromDisk() + +export function getThreatDatabase(): ThreatDatabase { + return threatDatabase +} + +export function getThreatVersionMatches(name: string, version: string): ThreatVersionMatch[] { + return getThreatDatabase().maliciousVersions.get(normalizePackageVersion(name, version)) ?? [] +} + +export function getPhantomDependencyMatches(name: string, version: string): ThreatVersionMatch[] { + return getThreatDatabase().phantomDependencies.get(normalizePackageVersion(name, version)) ?? [] +} + +export function toFindingSeverity(severity: ThreatSeverity): Severity { + switch (severity) { + case 'CRITICAL': + return 'critical' + case 'HIGH': + return 'high' + case 'MEDIUM': + return 'medium' + default: + return 'medium' + } +} diff --git a/packages/scanner/src/types.ts b/packages/scanner/src/types.ts index 73b079e..494fb06 100644 --- a/packages/scanner/src/types.ts +++ b/packages/scanner/src/types.ts @@ -18,7 +18,16 @@ export interface ScanResult { } export interface LockfileEntry { - resolved: string + /** Intentionally optional: Some package managers (Bun aliases, PNPM workspaces) do not provide a resolved URI */ + resolved?: string integrity?: string engines?: Record } + +export interface LockfilePackage extends LockfileEntry { + name: string + version: string + requires?: Record + lockfileVersion?: number | string + source?: string +} diff --git a/packages/scanner/src/utils.ts b/packages/scanner/src/utils.ts new file mode 100644 index 0000000..5c10d16 --- /dev/null +++ b/packages/scanner/src/utils.ts @@ -0,0 +1,19 @@ +/** + * Validates a path to prevent null byte injection and empty path usage. + * (Note: Absolute paths and directory traversal are allowed as they are required by the scanner.) + * @param path - The path to validate + * @throws {Error} If the path is invalid + */ +export function validatePath(path: string): void { + if (typeof path !== 'string') { + throw new Error('Invalid path: must be a string') + } + + if (path.includes('\0')) { + throw new Error('Invalid path: null byte detected') + } + + if (!path || path.trim() === '') { + throw new Error('Invalid path: path cannot be empty') + } +} diff --git a/packages/scanner/tests/security.test.ts b/packages/scanner/tests/security.test.ts new file mode 100644 index 0000000..259924c --- /dev/null +++ b/packages/scanner/tests/security.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { scan } from '../src/scan' +import { validatePath } from '../src/utils' + +describe('Path Validation', () => { + it('should throw error for null bytes', () => { + expect(() => validatePath('/etc/passwd\0')).toThrow('Invalid path: null byte detected') + }) + + it('should throw error for empty paths', () => { + expect(() => validatePath('')).toThrow('Invalid path: path cannot be empty') + expect(() => validatePath(' ')).toThrow('Invalid path: path cannot be empty') + }) + + it('should not throw for valid paths', () => { + expect(() => validatePath('/tmp/project')).not.toThrow() + expect(() => validatePath('./local/dir')).not.toThrow() + }) +}) + +describe('Scanner Security', () => { + it('should reject malicious paths in scan()', async () => { + try { + await scan('/etc/passwd\0') + expect(true).toBe(false) // Should not reach here + } catch (e) { + expect((e as Error).message).toBe('Invalid path: null byte detected') + } + }) +}) diff --git a/packages/scripts/check-github-repos.sh b/packages/scripts/check-github-repos.sh index 5451c93..6cdbfe4 100755 --- a/packages/scripts/check-github-repos.sh +++ b/packages/scripts/check-github-repos.sh @@ -7,7 +7,7 @@ if [[ "${1:-}" == "--version" ]]; then fi # # check-github-repos.sh - Check GitHub account for compromise -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl # # Requires: gh CLI (https://cli.github.com) # diff --git a/packages/scripts/comprehensive-scan.sh b/packages/scripts/comprehensive-scan.sh index 4bc3224..3e6a8b6 100644 --- a/packages/scripts/comprehensive-scan.sh +++ b/packages/scripts/comprehensive-scan.sh @@ -49,7 +49,7 @@ if [[ ! -x "$DETECTOR_SCRIPT" ]]; then echo -e "${RED}ERROR: detect.sh not found or not executable!${NC}" echo "Expected at: $DETECTOR_SCRIPT" echo "" - echo "Make sure you're running from the dont-be-shy-hulud repository." + echo "Make sure you're running from the wormsCTRL repository." exit 1 fi diff --git a/packages/scripts/detect.sh b/packages/scripts/detect.sh index cb280c0..b4096ab 100755 --- a/packages/scripts/detect.sh +++ b/packages/scripts/detect.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Shai-Hulud 2.0 Detection Script -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl # # Usage: ./detect.sh [path] [--output file] [--verbose] [--ci] [--format json|text] # @@ -181,7 +181,7 @@ if [[ "$OUTPUT_FORMAT" != "json" ]]; then echo "" echo "╔════════════════════════════════════════════════════════════════╗" echo "║ 🪱 SHAI-HULUD 2.0 DETECTION SCRIPT ║" - echo "║ https://github.com/miccy/dont-be-shy-hulud ║" + echo "║ https://github.com/miccy/worms-ctrl ║" echo "╚════════════════════════════════════════════════════════════════╝" echo "" echo "Scanning: $SCAN_PATH" @@ -685,7 +685,7 @@ output_sarif() { "driver": { "name": "Shai-Hulud Detector", "version": "$VERSION", - "informationUri": "https://github.com/miccy/dont-be-shy-hulud", + "informationUri": "https://github.com/miccy/worms-ctrl", "rules": [ { "id": "HULUD001", @@ -699,7 +699,7 @@ output_sarif() { "defaultConfiguration": { "level": "error" }, - "helpUri": "https://github.com/miccy/dont-be-shy-hulud/blob/main/docs/DETECTION.md" + "helpUri": "https://github.com/miccy/worms-ctrl/blob/main/docs/DETECTION.md" }, { "id": "HULUD002", @@ -713,7 +713,7 @@ output_sarif() { "defaultConfiguration": { "level": "error" }, - "helpUri": "https://github.com/miccy/dont-be-shy-hulud/blob/main/docs/DETECTION.md" + "helpUri": "https://github.com/miccy/worms-ctrl/blob/main/docs/DETECTION.md" }, { "id": "HULUD003", @@ -727,7 +727,7 @@ output_sarif() { "defaultConfiguration": { "level": "warning" }, - "helpUri": "https://github.com/miccy/dont-be-shy-hulud/blob/main/docs/GITHUB-HARDENING.md" + "helpUri": "https://github.com/miccy/worms-ctrl/blob/main/docs/GITHUB-HARDENING.md" }, { "id": "HULUD004", @@ -741,7 +741,7 @@ output_sarif() { "defaultConfiguration": { "level": "warning" }, - "helpUri": "https://github.com/miccy/dont-be-shy-hulud/blob/main/docs/DETECTION.md" + "helpUri": "https://github.com/miccy/worms-ctrl/blob/main/docs/DETECTION.md" } ] } diff --git a/packages/scripts/full-audit.sh b/packages/scripts/full-audit.sh index f649e4b..29f5d82 100755 --- a/packages/scripts/full-audit.sh +++ b/packages/scripts/full-audit.sh @@ -1,7 +1,7 @@ #!/bin/bash # # full-audit.sh - Complete security audit for Shai-Hulud 2.0 -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl # # Usage: ./full-audit.sh [path_to_projects] # diff --git a/packages/scripts/harden-npm.sh b/packages/scripts/harden-npm.sh index 9c973a9..d84c56a 100755 --- a/packages/scripts/harden-npm.sh +++ b/packages/scripts/harden-npm.sh @@ -7,7 +7,7 @@ if [[ "$1" == "--version" ]]; then fi # # harden-npm.sh - Hardening npm and bun configuration -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl # # Usage: ./harden-npm.sh [--apply] # @@ -98,7 +98,7 @@ apply_setting "Prefer offline installation" npm config set prefer-offline true echo -e "\n${YELLOW}[2/5] Project .npmrc template${NC}" NPMRC_TEMPLATE='# Shai-Hulud hardened .npmrc -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl # Disable lifecycle scripts ignore-scripts=true @@ -141,7 +141,7 @@ if command -v bun &>/dev/null; then # bunfig.toml template read -r -d '' BUNFIG_TEMPLATE << 'EOF' # Shai-Hulud hardened bunfig.toml -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl [install] # Disable lifecycle scripts diff --git a/packages/scripts/quick-audit.sh b/packages/scripts/quick-audit.sh index 460cefc..fcecae7 100755 --- a/packages/scripts/quick-audit.sh +++ b/packages/scripts/quick-audit.sh @@ -7,7 +7,7 @@ if [[ "${1:-}" == "--version" ]]; then fi # # quick-audit.sh - Quick security audit for Shai-Hulud 2.0 -# https://github.com/miccy/dont-be-shy-hulud +# https://github.com/miccy/worms-ctrl # # Usage: ./quick-audit.sh [path_to_projects] # diff --git a/packages/scripts/release.sh b/packages/scripts/release.sh index 52435ac..1bc51cb 100755 --- a/packages/scripts/release.sh +++ b/packages/scripts/release.sh @@ -79,7 +79,7 @@ fi # 6b. Append comparison link echo -e "\n${BLUE}Appending comparison link...${NC}" -REPO_URL="https://github.com/miccy/dont-be-shy-hulud" +REPO_URL="https://github.com/miccy/worms-ctrl" LINK="[$NEW_VERSION]: $REPO_URL/compare/v$CURRENT_VERSION...v$NEW_VERSION" # Check if link already exists to avoid duplicates @@ -134,7 +134,7 @@ if [[ "$CONFIRM" =~ ^[yY]$ ]]; then echo -e "\n${GREEN}✅ Release branch created!${NC}" echo "" echo "Next steps:" - echo "1. Open a Pull Request: https://github.com/miccy/dont-be-shy-hulud/compare/main...$RELEASE_BRANCH" + echo "1. Open a Pull Request: https://github.com/miccy/worms-ctrl/compare/main...$RELEASE_BRANCH" echo "2. Review and merge the PR into 'main' (Ensure 'Merge commit' or 'Rebase' is used to keep the commit message)" echo "3. Wait for the GitHub Action to automatically create the Release and Tag" else diff --git a/packages/wiki-sync/src/index.ts b/packages/wiki-sync/src/index.ts index d1b3d21..072a3b1 100644 --- a/packages/wiki-sync/src/index.ts +++ b/packages/wiki-sync/src/index.ts @@ -16,7 +16,7 @@ import { glob } from 'glob' import matter from 'gray-matter' const CONTENT_DIR = join(import.meta.dirname, '../../docs-content/en') -const WIKI_DIR = join(import.meta.dirname, '../../../../dont-be-shy-hulud.wiki') +const WIKI_DIR = join(import.meta.dirname, '../../../../wormsCTRL.wiki') interface DocFile { path: string @@ -185,7 +185,7 @@ function generateSidebar(docs: DocFile[], lang: 'en' | 'cs'): string { */ function generateFooter(): string { return `--- -📖 [Documentation](https://hulud.dev) | 🐙 [GitHub](https://github.com/miccy/dont-be-shy-hulud) | 🪱 v1.5.1 +📖 [Documentation](https://hulud.dev) | 🐙 [GitHub](https://github.com/miccy/wormsCTRL) | 🪱 v2.0.0 ` } @@ -201,7 +201,7 @@ async function syncToWiki() { if (!existsSync(WIKI_DIR)) { console.error(`❌ Wiki directory not found: ${WIKI_DIR}`) console.log(' Clone the wiki repo first:') - console.log(' git clone https://github.com/miccy/dont-be-shy-hulud.wiki.git') + console.log(' git clone https://github.com/miccy/wormsCTRL.wiki.git') process.exit(1) } @@ -263,7 +263,7 @@ async function syncToWiki() { console.log(`\n✅ Synced ${written} docs + 3 wiki files`) console.log('\n📌 Next steps:') - console.log(' cd dont-be-shy-hulud.wiki') + console.log(' cd wormsCTRL.wiki') console.log(' git add -A && git commit -m "docs: sync from main repo"') console.log(' git push') } diff --git a/scripts/clean.sh b/scripts/clean.sh index 597cd97..ffd0118 100755 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -7,7 +7,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" -echo "🧹 Global Cleanup for dont-be-shy-hulud" +echo "🧹 Global Cleanup for wormsCTRL" echo "========================================" echo "" diff --git a/socket.yml b/socket.yml index bced4f3..7f08b26 100644 --- a/socket.yml +++ b/socket.yml @@ -12,6 +12,10 @@ projectIgnorePaths: - ".git" - "dist" - "build" + - "tests/fixtures" + # packages/ioc/threats is ignored because it contains threat definition JSONs + # with metadata regarding malicious dependencies, which might trigger false positives. + - "packages/ioc/threats" # Only trigger PR alerts when these files change triggerPaths: diff --git a/tests/fixtures/axios-compromise/package-lock.json b/tests/fixtures/axios-compromise/package-lock.json new file mode 100644 index 0000000..08c635e --- /dev/null +++ b/tests/fixtures/axios-compromise/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "axios-compromise-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "axios-compromise-fixture", + "version": "1.0.0", + "dependencies": { + "axios": "1.14.1" + } + }, + "node_modules/axios": { + "version": "1.14.1", + "dependencies": { + "plain-crypto-js": "4.2.1" + } + }, + "node_modules/plain-crypto-js": { + "version": "4.2.1" + } + } +} diff --git a/tests/fixtures/axios-compromise/package.json b/tests/fixtures/axios-compromise/package.json new file mode 100644 index 0000000..e980045 --- /dev/null +++ b/tests/fixtures/axios-compromise/package.json @@ -0,0 +1,7 @@ +{ + "name": "axios-compromise-fixture", + "version": "1.0.0", + "dependencies": { + "axios": "1.14.1" + } +} diff --git a/tests/fixtures/clean-baseline/package-lock.json b/tests/fixtures/clean-baseline/package-lock.json new file mode 100644 index 0000000..a169813 --- /dev/null +++ b/tests/fixtures/clean-baseline/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "clean-baseline-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clean-baseline-fixture", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.21" + } + }, + "node_modules/lodash": { + "version": "4.17.21" + } + } +} diff --git a/tests/fixtures/clean-baseline/package.json b/tests/fixtures/clean-baseline/package.json new file mode 100644 index 0000000..c64dcc0 --- /dev/null +++ b/tests/fixtures/clean-baseline/package.json @@ -0,0 +1,7 @@ +{ + "name": "clean-baseline-fixture", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.21" + } +} diff --git a/tests/fixtures/shai-hulud-worm/package-lock.json b/tests/fixtures/shai-hulud-worm/package-lock.json new file mode 100644 index 0000000..90eac44 --- /dev/null +++ b/tests/fixtures/shai-hulud-worm/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "shai-hulud-worm-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shai-hulud-worm-fixture", + "version": "1.0.0", + "dependencies": { + "debug": "4.4.2", + "ansi-styles": "6.2.2" + } + }, + "node_modules/debug": { + "version": "4.4.2" + }, + "node_modules/ansi-styles": { + "version": "6.2.2" + } + } +} diff --git a/tests/fixtures/shai-hulud-worm/package.json b/tests/fixtures/shai-hulud-worm/package.json new file mode 100644 index 0000000..c3dde00 --- /dev/null +++ b/tests/fixtures/shai-hulud-worm/package.json @@ -0,0 +1,8 @@ +{ + "name": "shai-hulud-worm-fixture", + "version": "1.0.0", + "dependencies": { + "debug": "4.4.2", + "ansi-styles": "6.2.2" + } +} diff --git a/tests/fixtures/teampcp-litellm/requirements.txt b/tests/fixtures/teampcp-litellm/requirements.txt new file mode 100644 index 0000000..f636b16 --- /dev/null +++ b/tests/fixtures/teampcp-litellm/requirements.txt @@ -0,0 +1 @@ +litellm==1.82.7