diff --git a/.github/workflows/sentinel-pr-bot.yml b/.github/workflows/sentinel-pr-bot.yml index 4cb77a6..1385cfc 100644 --- a/.github/workflows/sentinel-pr-bot.yml +++ b/.github/workflows/sentinel-pr-bot.yml @@ -37,14 +37,16 @@ jobs: - name: Run Sentinel local scan id: scan + shell: bash {0} env: SENTINEL_REPO: ${{ github.repository }} SENTINEL_PR: ${{ github.event.pull_request.number }} SENTINEL_AUTHOR: ${{ github.event.pull_request.user.login }} run: | - node dist/cli/pr_scan.js /tmp/pr_diff.txt > /tmp/sentinel_result.json - echo "FINDINGS=$(jq '.findings | length' /tmp/sentinel_result.json)" >> "$GITHUB_OUTPUT" - echo "DECISION=$(jq -r '.verdict.decision' /tmp/sentinel_result.json)" >> "$GITHUB_OUTPUT" + node dist/cli/pr_scan.js /tmp/pr_diff.txt > /tmp/sentinel_result.json || true + echo "FINDINGS=$(jq '.findings | length // 0' /tmp/sentinel_result.json)" >> "$GITHUB_OUTPUT" + echo "DECISION=$(jq -r '.verdict.decision // "PASS"' /tmp/sentinel_result.json)" >> "$GITHUB_OUTPUT" + echo "ERROR=$(jq -r '.error // "null"' /tmp/sentinel_result.json)" >> "$GITHUB_OUTPUT" - name: Post comment with results id: comment @@ -54,6 +56,17 @@ jobs: const fs = require('fs'); const result = JSON.parse(fs.readFileSync('/tmp/sentinel_result.json', 'utf8')); + const serverUrl = context.serverUrl || 'https://github.com'; + const owner = context.repo.owner; + const repoName = context.repo.repo; + const headSha = context.payload.pull_request.head.sha; + + function fileUrl(file, line) { + const base = `${serverUrl}/${owner}/${repoName}/blob/${headSha}`; + const encoded = file.startsWith('/') ? file.substring(1) : file; + return line ? `${base}/${encoded}#L${line}` : `${base}/${encoded}`; + } + if (result.error) { await github.rest.issues.createComment({ ...context.repo, @@ -86,10 +99,12 @@ jobs: if (highCrit.length > 0) { body += `### ⚠️ HIGH/CRITICAL (${highCrit.length})\n\n`; for (const f of highCrit) { - body += `- **${f.severity}** \`${f.type}\` — ${f.file}:${f.line}\n`; + const link = fileUrl(f.file, f.line); + body += `- **${f.severity}** \`${f.type}\` — [${f.file}:${f.line}](${link})\n`; body += ` > ${f.description}\n`; if (f.snippet) { - body += ` \`\`\`\n${f.snippet}\n\`\`\`\n`; + const truncated = f.snippet.length > 60 ? f.snippet.substring(0, 57) + '...' : f.snippet; + body += ` \`${truncated}\`\n`; } body += '\n'; } @@ -97,20 +112,45 @@ jobs: if (lowMed.length > 0) { body += `### ℹ️ LOW/MEDIUM (${lowMed.length})\n\n`; - const byType = {}; for (const f of lowMed) { - byType[f.type] = (byType[f.type] || 0) + 1; - } - for (const [type, count] of Object.entries(byType)) { - body += `- \`${type}\`: ${count}\n`; + const link = fileUrl(f.file, f.line); + body += `- \`${f.type}\` ${f.severity} — [${f.file}:${f.line}](${link})\n`; } body += '\n'; } - if (highCrit.length === 0) { + // Supply chain section + const supplyChain = result.supplyChain ?? []; + if (supplyChain.length > 0) { + body += `### 📦 Supply Chain Scan\n\n`; + for (const pkg of supplyChain) { + const icon = pkg.verdict === 'MALICIOUS' ? '🔴' : pkg.verdict === 'SUSPICIOUS' ? '🟡' : pkg.verdict === 'SKIPPED' ? '⚪' : '🟢'; + body += `${icon} **\`${pkg.package}\`** — ${pkg.verdict}`; + if (pkg.findings?.length) { + body += ` — ${pkg.findings.length} finding(s)`; + } else { + body += ` — ${pkg.fileCount} files scanned`; + } + body += '\n\n'; + if (pkg.findings?.length > 0) { + for (const f of pkg.findings.slice(0, 3)) { + body += ` - \`${f.type}\` ${f.severity} — ${f.file}:${f.line}\n`; + } + if (pkg.findings.length > 3) { + body += ` - _(+ ${pkg.findings.length - 3} more)_\n`; + } + body += '\n'; + } + } + } + + if (highCrit.length === 0 && supplyChain.length === 0) { body += `### ✅ No threats detected\n\n`; } + body += `#### 💡 Inline Bypass\n`; + body += `Añade \`// sentinel-disable-line RULE\` al final de la línea ofensiva para ignorarla:\n\n`; + body += `\`\`\`js\neval('data'); // sentinel-disable-line UNSAFE_EVAL\n\`\`\`\n\n`; body += `---\n`; body += `_Scanned locally by Sentinel CLI v4 LiteScanner | `; body += `Hash: \`${(result.contentHash ?? '').substring(0, 12)}\`_`; diff --git a/.gitignore b/.gitignore index dac985d..3ceed39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +dist/ *.tsbuildinfo .env* *.db diff --git a/README.md b/README.md index 812bdb1..8027620 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,363 @@ -# Sentinel CLI +# Sentinel CLI (v4.0 "Oracle Lite") -Sentinel Security Oracle — Unified Terminal Security Interface. +**Sentinel Security Oracle -- Unified Terminal Security Interface.** -A supply chain enforcement layer, not a vulnerability scanner. Sentinel does not report what is wrong, it decides if something enters. +A supply-chain enforcement layer and static analysis suite that intercepts dependency installation, audits pull requests, and maintains a local signal vault for temporal drift detection. This is the deliberately degraded ("Lite") distribution of the proprietary Sentinel Cloud engine. The rule set, correlation logic, and integrity verification subsystems are intentionally reduced to protect the Cloud engine's reasoning IP while retaining high-utility local scanning. -## Install +## Table of Contents + +- [Architecture](#architecture) +- [Installation](#installation) +- [CLI Reference](#cli-reference) +- [Threat Detection Model](#threat-detection-model) +- [PR Scanning Pipeline](#pr-scanning-pipeline) +- [Supply Chain Analysis](#supply-chain-analysis) +- [Signal Vault & Temporal Correlation](#signal-vault--temporal-correlation) +- [Integrity Verification](#integrity-verification) +- [Baseline & Drift Detection](#baseline--drift-detection) +- [OS-Level Guard](#os-level-guard) +- [Classified Document Protection](#classified-document-protection) +- [GitHub Actions Integration](#github-actions-integration) +- [License](#license) + +## Architecture + +The CLI is organized into two source trees under `src/`: + +``` +src/ + cli/ -- Command entry points and orchestration + main.ts Commander-based CLI dispatcher + pr_scan.ts Standalone PR scanner for CI/CD pipelines + hub.ts Interactive TUI menu system + gh_bridge.ts GitHub API abstraction layer (via gh CLI) + guard.ts OS-level package manager interception + classify.ts Classified document pre-commit hook + telemetry.ts Performance telemetry output + intelligence/ + signal_vault.ts SQLite-backed signal persistence + memory_manager.ts High-level vault operations + supply_chain_shield.ts npm tarball extraction + SAST scanning + capability_analyzer.ts Finding-to-capability mapper + system_auditor.ts "doctor" command -- local node_modules audit + integrity_manager.ts Host integrity verification + integrity_chain.ts Merkle-chain of CLI boot sessions + baseline_manager.ts System snapshot creation and diffing + core/ + lite/ + lite_scanner.ts Core SAST engine (30 rules, patch parsing) +``` + +### Engine Architecture + +`LiteScanner` is the central detection primitive. It operates on unified diff patches rather than full file trees, making it suitable for both local directory scanning and CI/CD pull request analysis. The scanner applies a deterministic rule set of 30 regular expressions across five detection intents: + +| Intent | Description | +|--------|-------------| +| `MALICIOUS` | Deliberately obfuscated or destructive code patterns | +| `SUSPICIOUS` | Capabilities commonly abused in supply-chain attacks | +| `VULNERABILITY` | Accidentally introduced security weaknesses (XSS, injection) | +| `EXFILTRATION` | Secret, credential, or key exposure in plaintext | +| `NEUTRAL` | Benign but observable behavior (network calls, logging) | + +Rules are categorized by severity (`CRITICAL`, `HIGH`, `MEDIUM`, `LOW`) and include pattern groups for: +- Dynamic code execution (`eval`, `new Function`, obfuscated access) +- OS process spawning (`child_process`, `exec`, `spawn`) +- Network outbound communication +- Environment variable access +- Base64 decoding / potential obfuscation +- DOM injection (XSS) +- Sandbox escape (`vm.runInNewContext`) +- Cloud provider secrets (AWS, GitHub, Stripe, SendGrid, Slack) +- Private keys and JWT tokens +- Database credentials, encryption keys, API keys +- Darknet address references +- Hardcoded passwords and authentication tokens + +## Installation ```bash npm install -g @sentinel/cli ``` -Or run directly: +Requires Node.js >= 18.0.0. The package bundles `better-sqlite3` for local persistence, `commander` for CLI parsing, `picocolors` for terminal output, and `acorn`/`acorn-walk` for AST-level analysis (proprietary rules not included in Lite distribution). + +## CLI Reference + +### `sentinel scan [path] [--json]` + +Scans a local file or directory using LiteScanner's SAST rule set. Without `--json`, outputs human-readable findings grouped by severity. With `--json`, produces a structured output containing host integrity status and all findings. + +The scanner treats the entire target as a single unified diff patch where every line is an addition. This allows the same `scanPatch` codepath to serve both local and PR contexts. ```bash -npx @sentinel/cli scan . +sentinel scan ./src/myfile.js +sentinel scan . --json ``` -## Quick Start +### `sentinel verify-pkg [--details] [--summary]` -```bash -# Scan a directory for threats -sentinel scan ./src +Downloads a package tarball from the npm registry via `npm pack` (no installation), extracts it to a temporary directory, and runs LiteScanner on all `.js`, `.ts`, `.mjs`, and `.cjs` files inside the extracted `package/` directory. -# Audit an npm package without installing +The command outputs: +- Package metadata (name, file count, size, scan time, memory usage) +- npm registry information (description, author, maintainers) +- A verdict classification: `SAFE`, `SUSPICIOUS`, or `MALICIOUS` +- Finding distribution by capability type with severity histograms +- Evidence lines for HIGH/CRITICAL findings + +```bash sentinel verify-pkg dotenv --details +sentinel verify-pkg utilz --summary +``` + +### `sentinel doctor [--deep]` + +Performs a system health audit. Without `--deep`, scans only `package.json` for configuration-level threats. With `--deep`, walks all installed dependencies in `node_modules/` (up to 20 files per package, 2 levels deep) and scans each with LiteScanner. + +### `sentinel integrity` + +Runs a six-point host integrity verification: +1. **Ruleset hash**: SHA-256 of the compiled LiteScanner and SignalVault modules +2. **PATH poisoning**: checks whether suspicious directories (`temp`, `downloads`, `desktop`) appear in the top 3 PATH entries +3. **Vault integrity**: verifies the Signal Vault SQLite file is non-zero and its modification time precedes system clock +4. **Signed manifest**: compares `integrity.json` rulesHash against computed hash of the current code +5. **Environment check**: respects `SENTINEL_UNTRUSTED` flag +6. **Integrity chain**: verifies the Merkle chain of previous boot sessions + +Accepts `--uptime` (show accumulated verified uptime) and `--watch` (live counter, updates every second). + +### `sentinel permissions [package]` + +Maps LiteScanner findings to high-level capability categories: `NETWORK`, `FILESYSTEM`, `PROCESS_EXEC`, `ENV_ACCESS`, `DYNAMIC_EXEC`, `DOM_MANIPULATION`, `CREDENTIAL_LEAK`. Without arguments, audits all installed dependencies. With a package name, audits only that package. + +The mapping is performed by `CapabilityAnalyzer` which applies a risk escalation rule: findings with `MALICIOUS` intent are elevated to `CRITICAL`; findings with `VULNERABILITY` intent are downgraded from `CRITICAL` to `HIGH`. + +### `sentinel memory` + +Manages the local Signal Vault (SQLite database at `~/.sentinel/vault.db`). + +| Option | Behavior | +|--------|----------| +| `--status` | Prints metrics (scans, findings, signals, repos, authors) plus threshold drift analysis and multi-author correlation | +| `--ingest ` | Ingests a Sentinel Cloud JSON report from file | +| `--ingest-dir ` | Batch ingests all JSON reports from a directory | +| `--stdin` | Pipe mode -- accepts JSON via stdin | +| `--paste` | Interactive JSON paste mode (terminate with Ctrl+D/Ctrl+Z on empty line) | +| `--wipe` | Deletes all local history | +| `--threshold ` | Sets signal threshold for drift reports (default 5) | + +### `sentinel hub` + +Launches an interactive TUI with 11 main operations: +1. PR Bot -- batch analysis of all open pull requests across all repositories +2. Workspace selection with per-repo auditing (baseline context scan, PR inspection) +3. System doctor +4. Integrity check +5. Permissions audit +6. Local directory scan +7. Guard management (enable/disable/trust-cache) +8. Classified documents management +9. Signal Vault management +10. Donation page +11. Security policy display + +### `sentinel guard ` + +Injects shell aliases into the user's PowerShell or POSIX profile that intercept `npm`, `pip`, `pip3`, `yarn`, `pnpm`, `cargo`, and `docker` commands. Intercepted commands first route through `supply_chain_shield.scanInstallation()` before proceeding to the native binary. Can be disabled by removing the alias block from the profile. + +### `sentinel baseline [name]` + +Creates or diffs system snapshots. A baseline captures dependency versions, SHA-256 hashes of each package's main entry point, and capability fingerprints. `diff` compares the current state against the saved baseline and reports added, removed, modified, or code-drifted packages. + +### `sentinel install [args...]` + +Security-gated installation path. Routes dependency installation requests through `SupplyChainShield.analyzeBatch()` before delegating to the native package manager. Intended to be the backend of the OS-level Guard intercept. + +### `sentinel env-encrypt ` / `sentinel env-decrypt ` + +Encrypts or decrypts `.env` files using AES-256-CBC. The key is derived via SHA-256 of the `SENTINEL_ENV_KEY` environment variable (fallback: hostname). Outputs to `file.enc` or `file.decrypted`. + +### `sentinel check-classified ` + +Pre-commit hook entry point. Reads the local classified database (`~/.sentinel/classified.json`), compares staged files against the classified list, and blocks the commit if any classified files are detected. + +### `sentinel policies` + +Displays security policy, responsible disclosure procedures, contribution guidelines, code of conduct, versioning policy, and privacy statement. + +### `sentinel guide` + +Displays a comprehensive command reference with tested examples. + +## Threat Detection Model + +### Patch Parsing + +The parser in `pr_scan.ts` splits a unified diff on `diff --git` boundaries. Each file segment is passed to `LiteScanner.scanPatch(filename, patch)` which iterates lines, incrementing a line counter on `+` lines and context lines. Only added lines (`+` prefix) are tested against rules. Deletion lines are ignored -- the scanner models only what a PR introduces, not what it removes. + +### Inline Bypass + +Any line ending with `// sentinel-disable-line RULE_NAME` is exempted from that specific rule. Without a rule name, all 30 rules are bypassed for that line. The directive is parsed after the line is trimmed; the comment `// sentinel-disable-line UNSAFE_EVAL` suppresses the UNSAFE_EVAL rule for that line only. This mechanism is implemented directly in `LiteScanner.scanPatch()` before the rule loop. + +### Verdict Calculation + +Given the set of findings from all files in a PR or scan: -# Check system health -sentinel doctor --deep +``` +CRITICAL severity present -> riskBand = "CRITICAL", decision = "BLOCK", score >= 90 +HIGH severity present -> riskBand = "SUSPICIOUS", decision = "REVIEW", score >= 60 +No HIGH/CRITICAL findings -> riskBand = "SAFE", decision = "PASS", score = 10 +``` + +The verdict is computed in `LiteScanner.auditPR()` and persists alongside the scan record in the Signal Vault. + +### Truth Maintenance + +The `integrity.json` manifest stores a `rulesHash` that must match the computed SHA-256 of the compiled LiteScanner and SignalVault modules. If the CLI binary is modified, the `sentinel integrity` command detects the hash mismatch and flags the runtime as `SUSPECT` or `COMPROMISED`. This is a read-only check; the manifest is only updated by the build process. + +## PR Scanning Pipeline + +The system supports two scanning modalities: + +### Interactive (via `hub.ts`) + +The TUI uses `GitHubBridge` to list repositories and their open PRs, fetch diffs via `gh pr diff`, and run `LiteScanner.scanPatch()` on the aggregated diff. Findings are displayed inline with severity, type, description, and a snippet. Results are persisted to the local Signal Vault for historical tracking. + +### CI/CD (via `pr_scan.ts`) + +The `pr_scan.ts` script is invoked in GitHub Actions: + +1. The workflow checks out the repository, installs dependencies, and compiles TypeScript. +2. `gh pr diff ` fetches the multi-file unified diff. +3. `pr_scan.ts` parses the diff into per-file patches, runs `LiteScanner.auditPR()` across all files, and separately parses the diff for `package.json` changes to identify new dependencies. +4. New dependencies are batch-analyzed via `SupplyChainShield.analyzeBatch()` (max 5 per scan to avoid timeout). +5. Results are emitted as a single JSON object containing the scan ID, all findings (with file, line, type, severity, description, and truncated snippet), supply chain results, and the verdict. +6. The workflow reads the JSON output and posts a formatted comment to the pull request via `gh pr comment`. + +Deep links in the comment use the format `{serverUrl}/{owner}/{repo}/blob/{headSha}/{file}#L{line}` to provide one-click navigation to each finding's exact location. + +## Supply Chain Analysis -# Verify CLI integrity -sentinel integrity +`SupplyChainShield` performs static analysis on npm packages without installing them: -# Launch interactive hub -sentinel hub +1. `npm pack --pack-destination ` downloads the tarball +2. `tar -xzf` extracts to a temporary directory +3. All `.js`/`.ts`/`.mjs`/`.cjs` files are collected via recursive directory walk (skipping hidden files and `node_modules`) +4. Each file is scanned with `LiteScanner.scanPatch()` using the same 30-rule SAST engine +5. Composite verdict: `MALICIOUS` if any `CRITICAL` finding exists, `SUSPICIOUS` if any `HIGH` or `SECRET_*` finding exists, `SAFE` otherwise + +Temporary files are cleaned up in a `finally` block. The `analyzeBatch()` method iterates sequentially (not parallel) to avoid resource contention on GitHub runners. + +## Signal Vault & Temporal Correlation + +The `SignalVault` (backed by SQLite via `better-sqlite3`) persists three entity types: + +- **scans**: Scan session records with repo name, PR number, author, risk score, and risk band +- **findings**: Individual SAST findings linked to a scan via foreign key +- **signals**: Lightweight signal records (repo, author, signal type, weight, file path) also linked to scans + +### Schema + +```sql +scans (id TEXT PRIMARY KEY, repo_name TEXT, pr_number INTEGER, author TEXT, + risk_score REAL, risk_band TEXT, created_at DATETIME) + +findings (id INTEGER PK, scan_id TEXT FK -> scans.id, rule_name TEXT, + severity INTEGER, file_path TEXT, line_number INTEGER, description TEXT) + +signals (id INTEGER PK, repo TEXT, author TEXT, signal_type TEXT, weight REAL, + file_path TEXT, source_scan TEXT FK -> scans.id, created_at DATETIME) ``` -## Documentation +### Temporal Correlation + +When a new scan runs, its finding `type` values are compared against historical signals from the same author over the last 90 days. Correlated signals (same author, same signal type, within the lookback window) are returned alongside the scan verdict. This enables detection of behavioral drift across multiple PRs. + +### Drift Thresholds + +`getThresholdAnalysis()` groups signals by repository and filters those exceeding a configurable threshold (default 5). Each group is classified as `MONITOR` (at threshold), `ELEVATED` (2x threshold), or `ESCALATING` (3+ critical signal types). + +### Multi-Author Correlation + +`getMultiAuthorSignals()` reports repositories where multiple distinct GitHub accounts have contributed signals of the same type, indicating coordinated supply-chain infiltration attempts. + +## Integrity Verification + +The `IntegrityManager` computes a SHA-256 hash of the LiteScanner and SignalVault compiled modules at runtime and compares against the signed `integrity.json` manifest. Additional checks: + +- **PATH poisoning**: Extracts the first 3 PATH entries and tests them against a list of suspicious directory name patterns +- **Clock anomaly**: Compares system clock against SQLite vault file modification time to detect time-drift attacks +- **Signal Vault state**: Flags zero-byte vault files as compromised + +### Integrity Chain + +`IntegrityChain` maintains a Merkle-linked list of boot sessions in the same SQLite database: + +``` +link_hash = SHA256(JSON.stringify({ + session_id, link_number, code_hash, previous_link_hash, started_at, accumulated_seconds +})) +``` + +Each new boot record includes the hash of the previous record (`previous_link_hash`), forming a chain that can be verified in either direction. If the code hash changes between boots or the link hash computation does not match the stored value, the chain status is `BROKEN`. + +Accumulated uptime is the sum of all session durations across the entire chain, providing an integrity-gated "verified uptime" counter. + +## Baseline & Drift Detection + +`BaselineManager` serializes to `~/.sentinel/baselines/.json`: + +```json +{ + "timestamp": "ISO 8601", + "dependencies": { "pkg": "version" }, + "capabilities": { "pkg": ["NETWORK"] }, + "hashes": { "pkg": "sha256" } +} +``` + +`diffBaseline()` compares the current `package.json` dependencies against the baseline and reports three drift classes: + +| Class | Detection Mechanism | +|-------|-------------------| +| New package | Entry in current deps not in baseline | +| Version drift | Version string differs | +| Shadow drift | Version matches but SHA-256 hash differs | + +Shadow drift triggers an escalation: the system labels it as potential code integrity violation and recommends `sentinel doctor --deep`. + +## OS-Level Guard + +`guard.ts` injects into the shell profile (PowerShell or POSIX) a set of function definitions that shadow native package manager commands. Each function: + +1. Calls `SupplyChainShield.scanInstallation()` with the manager name and arguments +2. Only proceeds to the native binary if the scan returns `success: true` +3. Returns the exit code from the native binary + +On Windows PowerShell, the resolution uses `.exe`/`.cmd` suffixes because native executables cannot be shadowed by PowerShell functions without explicit extension. Each function calls the native binary via `& "npm.cmd" $args` after the Sentinel scan gate passes. + +## Classified Document Protection + +`classify.ts` maintains a JSON database (`~/.sentinel/classified.json`) mapping repository paths to arrays of classified file paths (relative to repo root). The pre-commit hook (`installPreCommitHook`) appends to the existing `.git/hooks/pre-commit` script, preserving any pre-existing hook logic. At commit time, `checkClassifiedHook()` runs `git diff --cached --name-only`, cross-references against the classified list, and exits with code 1 (blocking the commit) if any classified file is staged. + +## GitHub Actions Integration + +The workflow at `.github/workflows/sentinel-pr-bot.yml` triggers on `pull_request: [opened, synchronize, reopened]`. Execution flow: + +1. Checkout the merge commit (not the PR head) +2. Set up Node.js 20 +3. Install dependencies (`npm ci`) +4. Compile TypeScript (`npx tsc`) +5. Fetch the diff: `gh pr diff ` piped to a file +6. Run `node dist/cli/pr_scan.js ` with environment variables `SENTINEL_REPO`, `SENTINEL_PR`, `SENTINEL_AUTHOR` +7. Parse the JSON output with `fromJson` +8. Post a formatted comment using `gh pr comment` -Full documentation and CLI reference: https://sentinel-psi-nine.vercel.app/cli +Findings are hyperlinked to the exact file and line via deep links constructed from `github.server_url`, `github.repository`, and the PR head SHA. The workflow uses only GitHub-provided infrastructure and consumes zero external API resources. ## License -BUSSL-1.1 — Business Source License 1.1. See [LICENSE](LICENSE). +BUSSL-1.1 -- Business Source License 1.1. See [LICENSE](LICENSE). Free for non-production and personal use. Production use for security tools requires a license. Changes to GPL v2.0 after 2030-05-20. diff --git a/dist/cli/pr_scan.d.ts b/dist/cli/pr_scan.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/cli/pr_scan.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/cli/pr_scan.js b/dist/cli/pr_scan.js new file mode 100644 index 0000000..def511d --- /dev/null +++ b/dist/cli/pr_scan.js @@ -0,0 +1,149 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const lite_scanner_1 = require("../core/lite/lite_scanner"); +const supply_chain_shield_1 = require("./intelligence/supply_chain_shield"); +const crypto_1 = require("crypto"); +const fs_1 = require("fs"); +function parseUnifiedDiff(raw) { + const clean = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + const parts = clean.split(/(?=^diff --git )/m); + const files = []; + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) + continue; + const m = trimmed.match(/^diff --git a\/\S+ b\/(.+)$/m); + if (m) { + files.push({ filename: m[1].trim(), patch: trimmed }); + } + } + if (files.length === 0 && clean.trim()) { + const m = clean.match(/^\+\+\+ b\/(.+)$/m); + const filename = m ? m[1].trim() : 'PR.diff'; + files.push({ filename, patch: clean }); + } + return files; +} +function parsePackageChanges(files) { + const pkgs = new Set(); + for (const file of files) { + if (!file.filename.endsWith('package.json')) + continue; + const lines = file.patch.split('\n'); + let inDeps = false; + for (const line of lines) { + if (line.startsWith('---') || line.startsWith('@@') || line.startsWith('diff')) + continue; + const content = line.substring(1); + if (content.includes('"dependencies"') || content.includes('"devDependencies"') || content.includes('"peerDependencies"')) { + if (!line.startsWith('-')) + inDeps = true; + continue; + } + if (inDeps && (content.trim() === '}' || content.trim() === '},')) { + inDeps = false; + continue; + } + if (line.startsWith('+') && inDeps) { + const match = content.match(/"(@[^"@\s]+\/[^"@\s]+|[a-z0-9_-][^"@\s]*)"\s*:/); + if (match) { + let pkg = match[1].trim(); + if (pkg.endsWith(',')) + pkg = pkg.slice(0, -1).trim(); + if (pkg) + pkgs.add(pkg); + } + } + } + } + return [...pkgs]; +} +function main() { + return __awaiter(this, void 0, void 0, function* () { + const diffFile = process.argv[2]; + if (!diffFile) { + process.stderr.write('Usage: node pr_scan.js \n'); + process.exit(1); + } + const diff = (0, fs_1.readFileSync)(diffFile, 'utf8'); + const repo = process.env.SENTINEL_REPO || 'unknown'; + const prNumber = parseInt(process.env.SENTINEL_PR || '0', 10); + const author = process.env.SENTINEL_AUTHOR || 'unknown'; + const files = parseUnifiedDiff(diff); + process.stderr.write(`[pr_scan] files=${files.length} diffBytes=${diff.length}\n`); + // SAST scan + const scanner = new lite_scanner_1.LiteScanner(); + const result = yield scanner.auditPR(repo, prNumber, author, files); + // Supply chain scan + const changedPkgs = parsePackageChanges(files); + let supplyChain = []; + if (changedPkgs.length > 0) { + process.stderr.write(`[pr_scan] supplyChain: ${changedPkgs.join(', ')}\n`); + const shield = new supply_chain_shield_1.SupplyChainShield(); + const batchSize = Math.min(changedPkgs.length, 5); + const origLog = console.log; + console.log = () => { }; + const results = yield shield.analyzeBatch(changedPkgs.slice(0, batchSize)); + console.log = origLog; + supplyChain = results.map(r => ({ + package: r.pkg, + verdict: r.verdict, + fileCount: r.fileCount, + scanTimeMs: r.scanTimeMs, + sizeBytes: r.sizeBytes, + findings: r.findings.map(f => ({ + type: f.type, + intent: f.intent, + file: f.file, + line: f.line, + severity: f.severity, + description: f.description, + snippet: f.snippet.substring(0, 200) + })) + })); + if (changedPkgs.length > batchSize) { + supplyChain.push({ + package: `... and ${changedPkgs.length - batchSize} more`, + verdict: 'SKIPPED', + fileCount: 0, + scanTimeMs: 0, + sizeBytes: 0, + findings: [] + }); + } + } + const contentHash = (0, crypto_1.createHash)('sha256').update(diff, 'utf8').digest('hex'); + const output = { + scanId: result.scanId, + findings: result.findings.map(f => ({ + type: f.type, + intent: f.intent, + file: f.file, + line: f.line, + severity: f.severity, + description: f.description, + snippet: f.snippet.substring(0, 200) + })), + filesAnalyzed: files.length, + correlations: result.correlations.length, + verdict: result.verdict, + supplyChain, + contentHash + }; + console.log(JSON.stringify(output)); + }); +} +main().catch(err => { + process.stderr.write(`[pr_scan] ERROR: ${err.stack || err.message}\n`); + console.log(JSON.stringify({ error: err.message })); + process.exit(1); +}); diff --git a/dist/core/lite/lite_scanner.js b/dist/core/lite/lite_scanner.js index 711e27f..615f43a 100644 --- a/dist/core/lite/lite_scanner.js +++ b/dist/core/lite/lite_scanner.js @@ -142,7 +142,12 @@ class LiteScanner { const code = line.substring(1).trim(); if (!code) return; + // Inline disable directives: sentinel-disable-line [RULE] + const disableMatch = code.match(/\/\/\s*sentinel-disable-line(?:\s+(\S+))?$/); + const disabledRules = disableMatch ? (disableMatch[1] ? [disableMatch[1]] : RULES.map(r => r.type)) : []; RULES.forEach(r => { + if (disabledRules.includes(r.type)) + return; if (r.regex.test(code)) { findings.push({ file: filename, @@ -175,41 +180,27 @@ class LiteScanner { const findings = this.scanPatch(file.filename, file.patch); allFindings.push(...findings); } - // 1. Persist signals to local Vault + // 1. Compute verdict from findings + const riskBand = allFindings.some(f => f.severity === 'CRITICAL') ? 'CRITICAL' : + allFindings.some(f => f.severity === 'HIGH') ? 'SUSPICIOUS' : 'SAFE'; + const decision = riskBand === 'CRITICAL' ? 'BLOCK' : riskBand === 'SUSPICIOUS' ? 'REVIEW' : 'PASS'; + const score = riskBand === 'CRITICAL' ? 90 : riskBand === 'SUSPICIOUS' ? 60 : 10; + // 2. Persist scan before signals (signals FK references scans.id) + this.vault.recordScan({ id: scanId, repo, pr, author, score, band: riskBand }); + // 3. Persist signals to local Vault for (const f of allFindings) { - const signal = { + this.vault.recordSignal({ repo, author, signal_type: f.type, weight: f.severity === 'CRITICAL' ? 1.0 : (f.severity === 'HIGH' ? 0.7 : 0.3), file_path: f.file, source_scan: scanId - }; - this.vault.recordSignal(signal); + }); } - // 2. Perform Temporal Correlation (Local Drift) + // 4. Perform Temporal Correlation (Local Drift) const currentTypes = Array.from(new Set(allFindings.map(f => f.type))); const historicalCorrelations = this.vault.getCorrelations(author, currentTypes); - // 3. Local Verdict Logic (Intentionaly Simple) - let riskBand = 'SAFE'; - let decision = 'PASS'; - if (allFindings.some(f => f.severity === 'CRITICAL')) { - riskBand = 'CRITICAL'; - decision = 'BLOCK'; - } - else if (allFindings.some(f => f.severity === 'HIGH') || historicalCorrelations.length > 2) { - riskBand = 'SUSPICIOUS'; - decision = 'REVIEW'; - } - // 4. Persistence - this.vault.recordScan({ - id: scanId, - repo, - pr, - author, - score: riskBand === 'CRITICAL' ? 90 : (riskBand === 'SUSPICIOUS' ? 60 : 10), - band: riskBand - }); return { scanId, findings: allFindings, diff --git a/package-lock.json b/package-lock.json index 1114cbe..943a7d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", + "is-odd": "^3.0.1", "typescript": "^5" }, "engines": { @@ -278,6 +279,29 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/is-number": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz", + "integrity": "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-odd": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz", + "integrity": "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", diff --git a/package.json b/package.json index 874ecee..ba1a784 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", + "is-odd": "^3.0.1", "typescript": "^5" } } diff --git a/src/cli/pr_scan.ts b/src/cli/pr_scan.ts index 27b5661..93bf72b 100644 --- a/src/cli/pr_scan.ts +++ b/src/cli/pr_scan.ts @@ -1,9 +1,11 @@ import { LiteScanner } from '../core/lite/lite_scanner'; +import { SupplyChainShield } from './intelligence/supply_chain_shield'; import { createHash } from 'crypto'; import { readFileSync } from 'fs'; function parseUnifiedDiff(raw: string): { filename: string; patch: string }[] { - const parts = raw.split(/(?=^diff --git )/m); + const clean = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + const parts = clean.split(/(?=^diff --git )/m); const files: { filename: string; patch: string }[] = []; for (const part of parts) { @@ -15,20 +17,56 @@ function parseUnifiedDiff(raw: string): { filename: string; patch: string }[] { } } - if (files.length === 0 && raw.trim()) { - const m = raw.match(/^\+\+\+ b\/(.+)$/m); + if (files.length === 0 && clean.trim()) { + const m = clean.match(/^\+\+\+ b\/(.+)$/m); const filename = m ? m[1].trim() : 'PR.diff'; - files.push({ filename, patch: raw }); + files.push({ filename, patch: clean }); } return files; } +function parsePackageChanges(files: { filename: string; patch: string }[]): string[] { + const pkgs = new Set(); + + for (const file of files) { + if (!file.filename.endsWith('package.json')) continue; + + const lines = file.patch.split('\n'); + let inDeps = false; + + for (const line of lines) { + if (line.startsWith('---') || line.startsWith('@@') || line.startsWith('diff')) continue; + const content = line.substring(1); + + if (content.includes('"dependencies"') || content.includes('"devDependencies"') || content.includes('"peerDependencies"')) { + if (!line.startsWith('-')) inDeps = true; + continue; + } + + if (inDeps && (content.trim() === '}' || content.trim() === '},')) { + inDeps = false; + continue; + } + + if (line.startsWith('+') && inDeps) { + const match = content.match(/"(@[^"@\s]+\/[^"@\s]+|[a-z0-9_-][^"@\s]*)"\s*:/); + if (match) { + let pkg = match[1].trim(); + if (pkg.endsWith(',')) pkg = pkg.slice(0, -1).trim(); + if (pkg) pkgs.add(pkg); + } + } + } + } + + return [...pkgs]; +} + async function main() { const diffFile = process.argv[2]; if (!diffFile) { - const err = { error: 'Usage: node pr_scan.js ' }; - console.log(JSON.stringify(err)); + process.stderr.write('Usage: node pr_scan.js \n'); process.exit(1); } @@ -38,9 +76,52 @@ async function main() { const author = process.env.SENTINEL_AUTHOR || 'unknown'; const files = parseUnifiedDiff(diff); + process.stderr.write(`[pr_scan] files=${files.length} diffBytes=${diff.length}\n`); + + // SAST scan const scanner = new LiteScanner(); const result = await scanner.auditPR(repo, prNumber, author, files); + // Supply chain scan + const changedPkgs = parsePackageChanges(files); + let supplyChain: any[] = []; + + if (changedPkgs.length > 0) { + process.stderr.write(`[pr_scan] supplyChain: ${changedPkgs.join(', ')}\n`); + const shield = new SupplyChainShield(); + const batchSize = Math.min(changedPkgs.length, 5); + const origLog = console.log; + console.log = () => {}; + const results = await shield.analyzeBatch(changedPkgs.slice(0, batchSize)); + console.log = origLog; + supplyChain = results.map(r => ({ + package: r.pkg, + verdict: r.verdict, + fileCount: r.fileCount, + scanTimeMs: r.scanTimeMs, + sizeBytes: r.sizeBytes, + findings: r.findings.map(f => ({ + type: f.type, + intent: f.intent, + file: f.file, + line: f.line, + severity: f.severity, + description: f.description, + snippet: f.snippet.substring(0, 200) + })) + })); + if (changedPkgs.length > batchSize) { + supplyChain.push({ + package: `... and ${changedPkgs.length - batchSize} more`, + verdict: 'SKIPPED', + fileCount: 0, + scanTimeMs: 0, + sizeBytes: 0, + findings: [] + }); + } + } + const contentHash = createHash('sha256').update(diff, 'utf8').digest('hex'); const output = { @@ -57,6 +138,7 @@ async function main() { filesAnalyzed: files.length, correlations: result.correlations.length, verdict: result.verdict, + supplyChain, contentHash }; @@ -64,6 +146,7 @@ async function main() { } main().catch(err => { + process.stderr.write(`[pr_scan] ERROR: ${err.stack || err.message}\n`); console.log(JSON.stringify({ error: err.message })); process.exit(1); }); diff --git a/src/core/lite/lite_scanner.ts b/src/core/lite/lite_scanner.ts index de259ce..5f944a5 100644 --- a/src/core/lite/lite_scanner.ts +++ b/src/core/lite/lite_scanner.ts @@ -115,7 +115,12 @@ export class LiteScanner { const code = line.substring(1).trim(); if (!code) return; + // Inline disable directives: sentinel-disable-line [RULE] + const disableMatch = code.match(/\/\/\s*sentinel-disable-line(?:\s+(\S+))?$/); + const disabledRules = disableMatch ? (disableMatch[1] ? [disableMatch[1]] : RULES.map(r => r.type)) : []; + RULES.forEach(r => { + if (disabledRules.includes(r.type)) return; if (r.regex.test(code)) { findings.push({ file: filename, @@ -150,44 +155,31 @@ export class LiteScanner { allFindings.push(...findings); } - // 1. Persist signals to local Vault + // 1. Compute verdict from findings + const riskBand = allFindings.some(f => f.severity === 'CRITICAL') ? 'CRITICAL' : + allFindings.some(f => f.severity === 'HIGH') ? 'SUSPICIOUS' : 'SAFE'; + const decision = riskBand === 'CRITICAL' ? 'BLOCK' : riskBand === 'SUSPICIOUS' ? 'REVIEW' : 'PASS'; + const score = riskBand === 'CRITICAL' ? 90 : riskBand === 'SUSPICIOUS' ? 60 : 10; + + // 2. Persist scan before signals (signals FK references scans.id) + this.vault.recordScan({ id: scanId, repo, pr, author, score, band: riskBand }); + + // 3. Persist signals to local Vault for (const f of allFindings) { - const signal: ScanSignal = { + this.vault.recordSignal({ repo, author, signal_type: f.type, weight: f.severity === 'CRITICAL' ? 1.0 : (f.severity === 'HIGH' ? 0.7 : 0.3), file_path: f.file, source_scan: scanId - }; - this.vault.recordSignal(signal); + }); } - // 2. Perform Temporal Correlation (Local Drift) + // 4. Perform Temporal Correlation (Local Drift) const currentTypes = Array.from(new Set(allFindings.map(f => f.type))); const historicalCorrelations = this.vault.getCorrelations(author, currentTypes); - // 3. Local Verdict Logic (Intentionaly Simple) - let riskBand = 'SAFE'; - let decision = 'PASS'; - if (allFindings.some(f => f.severity === 'CRITICAL')) { - riskBand = 'CRITICAL'; - decision = 'BLOCK'; - } else if (allFindings.some(f => f.severity === 'HIGH') || historicalCorrelations.length > 2) { - riskBand = 'SUSPICIOUS'; - decision = 'REVIEW'; - } - - // 4. Persistence - this.vault.recordScan({ - id: scanId, - repo, - pr, - author, - score: riskBand === 'CRITICAL' ? 90 : (riskBand === 'SUSPICIOUS' ? 60 : 10), - band: riskBand - }); - return { scanId, findings: allFindings, diff --git a/tests/debug_parse.js b/tests/debug_parse.js new file mode 100644 index 0000000..2561409 --- /dev/null +++ b/tests/debug_parse.js @@ -0,0 +1,21 @@ +const fs = require('fs'); +const diff = fs.readFileSync(process.argv[2], 'utf8'); +console.log('First line:', diff.split('\n')[0]); +console.log('Total lines:', diff.split('\n').length); + +const parts = diff.split(/(?=^diff --git )/m); +console.log('Parts count:', parts.length); + +parts.forEach((p, i) => { + const t = p.trim(); + if (!t) { + console.log(`Part ${i}: empty`); + return; + } + const m = t.match(/^diff --git a\/\S+ b\/(.+)$/m); + if (m) { + console.log(`Part ${i}: filename="${m[1]}" lines=${t.split('\n').length}`); + } else { + console.log(`Part ${i}: NO MATCH firstLine="${t.split('\n')[0]}"`); + } +}); diff --git a/tests/poc-harmless.js b/tests/poc-harmless.js new file mode 100644 index 0000000..c3524bc --- /dev/null +++ b/tests/poc-harmless.js @@ -0,0 +1,14 @@ +function simulation() { + const fakeEnv = process.env.NODE_ENV || 'development'; + console.log('[sim] env check:', fakeEnv); +} + +eval('console.log("simulation marker")'); // sentinel-disable-line UNSAFE_EVAL + +const decoded = Buffer.from('c2ltdWxhdGlvbiBkYXRh', 'base64').toString(); +console.log('[sim] decoded:', decoded); + +const payload = new Function('x', 'return x + 1'); +console.log('[sim] function:', payload(5)); + +module.exports = { simulation }; diff --git a/tests/red-team-sim.js b/tests/red-team-sim.js new file mode 100644 index 0000000..fc7dc67 --- /dev/null +++ b/tests/red-team-sim.js @@ -0,0 +1,33 @@ +const cp = require('child_process'); +const https = require('https'); + +function harmlessMetrics() { + const out = cp.spawnSync('echo', ['simulation']); + return out.stdout.toString(); +} + +function fakeNetwork() { + return new Promise((resolve) => { + const req = https.request('https://httpbin.org/get', (res) => { + resolve({ status: res.statusCode }); + }); + req.end(); + }); +} + +function decryptBuffer() { + const raw = process.env.SENTINEL_SECRET || 'none'; + const decoded = Buffer.from(raw, 'base64'); + return decoded; +} + +eval('console.log("sim-eval")'); // sentinel-disable-line UNSAFE_EVAL + +const vm = require('vm'); +const sandbox = { x: 10 }; +vm.runInNewContext('x += 1', sandbox); + +const fn = new Function('a', 'b', 'return a + b'); +console.log(fn(1, 2), harmlessMetrics()); + +module.exports = { fakeNetwork, harmlessMetrics };