diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d4552..d8ac629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Install shellcheck run: sudo apt-get update -qq && sudo apt-get install -y shellcheck - name: Lint shell scripts - run: shellcheck -S warning banner.sh seckit.sh scan_repos.sh scan_skill.sh + run: shellcheck -S warning banner.sh seckit.sh scan_repos.sh scan_skill.sh fix_deps.sh psscriptanalyzer: name: PSScriptAnalyzer diff --git a/CLAUDE.md b/CLAUDE.md index c0eb436..44e61e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ These rules apply to AI coding agents working in this repository ## Before you commit (definition of green) - Run `bash check.sh` and make it print `RESULT: green` before committing or reporting work as done. It runs the same gates CI enforces: bash syntax, - `shellcheck` (banner.sh, seckit.sh, scan_repos.sh, scan_skill.sh, check.sh), + `shellcheck` (banner.sh, seckit.sh, scan_repos.sh, scan_skill.sh, fix_deps.sh, check.sh), `PSScriptAnalyzer` (all `*.ps1`, recursive), and YAML parse of the CI files. - A red `check.sh` means a red PR. Fix it; do not hand work back while it fails. - Keep the shellcheck file list in `check.sh` and `.github/workflows/ci.yml` in diff --git a/README.md b/README.md index 51e375b..585b545 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ pwsh ./seckit.ps1 # Windows | **doctor** | Reports which scanners and clients (jq, yq, gh, az, gitleaks, semgrep, checkov, osv-scanner, trufflehog, pre-commit) are installed and which are missing. | | **install** | Installs every missing scanner and client via brew/winget/scoop/pipx/npm. No admin rights required — all installs target user space. Run this once on a fresh machine. Missing scanners are skipped gracefully at scan time, so you can scan immediately with whatever is available. | | **scan** | Sweeps a folder of repos for vulnerable dependencies, code/IaC flaws, malware and secrets. Pick all scanners or a subset (osv, gitleaks, trufflehog, semgrep, checkov, socket). | +| **fix** | Applies safe (same-major) fixes for vulnerable npm dependencies. Lockfile-only by default: never runs install scripts, never touches `node_modules`, never commits. Prints before/after counts and lists what still needs a major upgrade. `--dry-run` previews, `--install` also updates `node_modules`. Other ecosystems are reported as not supported yet. | | **scan-skill** | Statically vets an AI agent skill or MCP server (directory, `.zip` or git URL) before you install it: prompt-injection, data exfiltration, credential theft, supply-chain RCE, obfuscation, over-broad agency and MCP tool poisoning. Never executes the target; prints a 0-100 risk verdict and a markdown report. | | **harden** | Drops pre-commit, gitleaks, SECURITY.md, CODEOWNERS, dependabot, CodeQL, the SecKit CI scan workflow and PR templates into a repo so the next commit is clean. Also writes the AI-assistant guardrails that keep secrets out of context: Claude `settings.json` deny rules, a GitHub Copilot content-exclusion file, and a `.cursorignore`. On Node repos it also sets `ignore-scripts=true` in `.npmrc` to block install-time supply-chain worms (see below). | | **agent** | Installs the SecKit prompt as a Claude subagent, Copilot chat mode, Cursor rule or `AGENTS.md` section so any AI assistant runs the same playbook. | diff --git a/audit.ps1 b/audit.ps1 index 5f1b4f7..46f3bfd 100644 --- a/audit.ps1 +++ b/audit.ps1 @@ -93,6 +93,15 @@ function Test-GitHubRepoPolicy($Owner, $Repo) { $vaEnabled = $LASTEXITCODE -eq 0 Add-Result 'required' 'security.vulnerability_alerts' 'enabled' $(if ($vaEnabled) {'enabled'} else {'disabled'}) $(if ($vaEnabled) {'pass'} else {'fail'}) + # Dependabot security updates (automatic fix PRs): {"enabled":bool,"paused":bool}. + $asfState = 'unknown' + try { + $asf = (& gh api "repos/$Owner/$Repo/automated-security-fixes" 2>$null) | ConvertFrom-Json + if ($asf.enabled -eq $true) { $asfState = if ($asf.paused -eq $true) { 'paused' } else { 'enabled' } } + elseif ($asf.enabled -eq $false) { $asfState = 'disabled' } + } catch { $asfState = 'unknown' } + Decide 'required' 'security.dependabot_security_updates' 'enabled' $asfState + $sa = $repoObj.security_and_analysis $secScan = if ($sa -and $sa.secret_scanning) { $sa.secret_scanning.status } else { 'unknown' } $pushProt = if ($sa -and $sa.secret_scanning_push_protection) { $sa.secret_scanning_push_protection.status } else { 'unknown' } diff --git a/audit.sh b/audit.sh index f6501a5..0d31a94 100755 --- a/audit.sh +++ b/audit.sh @@ -94,6 +94,12 @@ audit_github_repo() { if [[ "$va_status" == "204" ]]; then record required "security.vulnerability_alerts" "enabled" "enabled" pass else record required "security.vulnerability_alerts" "enabled" "disabled" fail; fi + # Dependabot security updates (automatic fix PRs): {"enabled":bool,"paused":bool}. + local asf asf_state + asf="$(gh api "repos/$owner/$repo/automated-security-fixes" 2>/dev/null)" || asf='{}' + asf_state="$(jq -r 'if .enabled == true then (if .paused == true then "paused" else "enabled" end) elif .enabled == false then "disabled" else "unknown" end' <<< "$asf")" + match "$asf_state" "enabled" && record required "security.dependabot_security_updates" "enabled" "$asf_state" pass || record required "security.dependabot_security_updates" "enabled" "$asf_state" fail + sec_state="$(jq -r '.security_and_analysis.secret_scanning.status // "unknown"' <<< "$repo_json")" push_state="$(jq -r '.security_and_analysis.secret_scanning_push_protection.status // "unknown"' <<< "$repo_json")" cs_state="$(jq -r '.security_and_analysis.code_scanning_default_setup.status // "unknown"' <<< "$repo_json" 2>/dev/null || echo unknown)" diff --git a/check.sh b/check.sh index 7a6fbbb..21820f9 100644 --- a/check.sh +++ b/check.sh @@ -14,7 +14,7 @@ set -uo pipefail cd "$(dirname "$0")" || exit 2 # Keep this list in sync with the shellcheck step in ci.yml. -SH_FILES=(banner.sh seckit.sh scan_repos.sh scan_skill.sh check.sh) +SH_FILES=(banner.sh seckit.sh scan_repos.sh scan_skill.sh fix_deps.sh check.sh) fail=0 note() { printf '\n== %s ==\n' "$1"; } diff --git a/docs/README.md b/docs/README.md index d4815d9..eb863b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -177,6 +177,22 @@ Global level writes `~/.config/git/ignore`, sets `git config --global core.excludesfile` (only if unset), and adds Claude deny rules to `~/.claude/settings.json` (or `~/.claude/settings.seckit.json` to merge). +## Fix vulnerable dependencies + +```bash +seckit fix . --dry-run # preview: before/after counts, versions that would change +seckit fix . # rewrite package-lock.json only (no scripts, no node_modules) +seckit fix . --install # also update node_modules (still --ignore-scripts) +``` + +Scope is npm (`package-lock.json`); other lockfiles are listed as not supported +yet. Only same-major updates are applied: `osv-scanner fix` first when +installed, `npm audit fix --package-lock-only --ignore-scripts` after it (and +as the fallback when osv-scanner fails). Anything that needs a major upgrade is +listed with its current and fixed version. SecKit never commits, pushes or +branches; review the diff, run `npm ci --ignore-scripts` and your tests, then +commit. Exit code is 1 while any high/critical vulnerability remains. + ## Audit a remote repo or org `seckit audit` is the read-only posture check. It calls `gh` for GitHub and `az @@ -204,7 +220,8 @@ every call is a `GET`. `seckit enforce` writes the settings flagged by `audit`. It is dry-run by default and prints every command it would run; pass `--apply` (Bash) or `-Apply` (PowerShell) to actually write. It enables branch protection on the -default branch, turns on vulnerability alerts + secret scanning + push +default branch, turns on vulnerability alerts + Dependabot security updates + +secret scanning + push protection + private vulnerability reporting, clamps workflow permissions to read-only with no PR approval, and drops any missing `SECURITY.md`, `CODEOWNERS`, dependabot, CodeQL or PR template files into the cwd. @@ -322,6 +339,7 @@ The log is written to `~/.seckit/logs/debug-.log` and captures `set - | `seckit.sh` / `seckit.ps1` | the dispatcher | | `scan_repos.sh` / `scan_repos.ps1` | the repo scanner | | `mcp.sh` / `mcp.ps1` | the MCP server manager | +| `fix_deps.sh` / `fix_deps.ps1` | safe (same-major) npm vulnerability fixes | | `audit.sh` / `audit.ps1` | the read-only GitHub / ADO posture audit | | `enforce.sh` / `enforce.ps1` | writes the settings flagged by `audit` | | `banner.sh` | the animated startup banner | diff --git a/fix_deps.ps1 b/fix_deps.ps1 new file mode 100644 index 0000000..45401b1 --- /dev/null +++ b/fix_deps.ps1 @@ -0,0 +1,182 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Apply safe (same-major) fixes for vulnerable dependencies. + +.DESCRIPTION + Scope: npm (package-lock.json). Other ecosystems are listed as not supported. + + Default mode is lockfile-only: package-lock.json (and package.json when npm + must widen a range) is rewritten, node_modules is not touched, and no + install script ever runs. Only non-breaking (same-major) updates are made; + whatever still needs a major upgrade is listed for a human to decide. + + Strategy per lockfile: osv-scanner fix --strategy=in-place + --upgrade-config=minor --no-introduce when osv-scanner is installed, then + npm audit fix --package-lock-only --ignore-scripts (also the fallback when + osv-scanner fails, e.g. on lockfiles with file: dependencies). + + Never commits, pushes or creates branches. Review the diff yourself. + Exit codes: 0 no high/critical left, 1 high/critical remain, 2 usage error + or npm missing. + +.PARAMETER Root + Directory to search for lockfiles (default: current directory). + +.PARAMETER DryRun + Show what would change; the target is never written. + +.PARAMETER Install + Also update node_modules (npm audit fix, still --ignore-scripts). + +.EXAMPLE + ./fix_deps.ps1 . -DryRun + ./fix_deps.ps1 ~/Git/app +#> +[CmdletBinding()] +param( + [string]$Root = (Get-Location).Path, + [switch]$DryRun, + [switch]$Install +) +$ErrorActionPreference = 'Continue' + +function Have($name) { [bool](Get-Command $name -ErrorAction SilentlyContinue) } + +if (-not (Test-Path $Root -PathType Container)) { Write-Host "Not a directory: $Root" -ForegroundColor Red; exit 2 } +$Root = (Resolve-Path $Root).Path +$skipDirs = '[\\/](node_modules|\.git|vendor|\.venv)[\\/]' + +function Get-Lockfile($Names) { + Get-ChildItem -Path $Root -Recurse -File -Force -ErrorAction SilentlyContinue | + Where-Object { $Names -contains $_.Name -and $_.FullName -notmatch $skipDirs } | + Sort-Object FullName +} +function Get-RelativePath($Path) { [IO.Path]::GetRelativePath($Root, $Path) } + +$other = 'yarn.lock', 'pnpm-lock.yaml', 'requirements.txt', 'poetry.lock', 'Pipfile.lock', 'go.mod', + 'Cargo.lock', 'Gemfile.lock', 'composer.lock', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'packages.lock.json' +foreach ($f in Get-Lockfile $other) { + Write-Host "not supported yet: $(Get-RelativePath $f.FullName) (fix by hand; seckit scan still reports it)" -ForegroundColor Yellow +} + +$locks = @(Get-Lockfile @('package-lock.json')) +if ($locks.Count -eq 0) { + Write-Host "No package-lock.json under $Root. Nothing to fix." + if (Test-Path (Join-Path $Root 'package.json')) { Write-Host 'Generate one first: npm install --package-lock-only --ignore-scripts' -ForegroundColor DarkGray } + exit 0 +} +if (-not (Have 'npm')) { Write-Host 'npm is required to fix npm dependencies (install Node.js).' -ForegroundColor Red; exit 2 } + +# npm audit JSON for the lockfile in $Dir (no install, no scripts). +function Get-Audit($Dir) { + Push-Location $Dir + try { $raw = (& npm audit --json --package-lock-only 2>$null) -join "`n" } finally { Pop-Location } + try { return $raw | ConvertFrom-Json -AsHashtable } catch { return @{} } +} +function Get-Count($A) { + $v = if ($A.metadata) { $A.metadata.vulnerabilities } else { $null } + if (-not $v) { return 'unknown (npm audit failed)' } + "$($v.critical) critical, $($v.high) high, $($v.moderate) moderate, $($v.low) low" +} +function Get-CritHigh($A) { + if (-not ($A.metadata -and $A.metadata.vulnerabilities)) { return 1 } + [int]$A.metadata.vulnerabilities.critical + [int]$A.metadata.vulnerabilities.high +} +function Read-Lock($Path) { Get-Content $Path -Raw | ConvertFrom-Json -AsHashtable } + +$left = 0; $i = 0 +$tmp = Join-Path ([IO.Path]::GetTempPath()) "seckit-fix-$PID" +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +try { + foreach ($lock in $locks) { + $i++ + $dir = $lock.DirectoryName + Write-Host '' + Write-Host "npm $(Get-RelativePath $lock.FullName)" -ForegroundColor White + + $before = Get-Audit $dir + Write-Host " before: $(Get-Count $before)" + $lockBefore = Read-Lock $lock.FullName + + $work = $dir + if ($DryRun) { + $pkg = Get-Content (Join-Path $dir 'package.json') -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json -AsHashtable -ErrorAction SilentlyContinue + if ($pkg -and $pkg.workspaces) { Write-Host ' -DryRun does not support npm workspaces yet; skipped' -ForegroundColor Yellow; $left += Get-CritHigh $before; continue } + $work = Join-Path $tmp "work$i" + New-Item -ItemType Directory -Force -Path $work | Out-Null + Copy-Item (Join-Path $dir 'package.json'), $lock.FullName -Destination $work + if (Test-Path (Join-Path $dir '.npmrc')) { Copy-Item (Join-Path $dir '.npmrc') -Destination $work } + } + + Push-Location $work + try { + if ($Install -and -not $DryRun) { + Write-Host ' npm audit fix --ignore-scripts (updates node_modules)' -ForegroundColor DarkGray + & npm audit fix --ignore-scripts --no-fund *> $null + } else { + if (Have 'osv-scanner') { + Write-Host ' osv-scanner fix --strategy=in-place --upgrade-config=minor --no-introduce' -ForegroundColor DarkGray + $osvOut = & osv-scanner fix -L (Join-Path $work 'package-lock.json') --strategy=in-place --upgrade-config=minor --no-introduce 2>&1 + if ($LASTEXITCODE -ne 0) { Write-Host " osv-scanner fix failed ($(@($osvOut)[-1])); falling back to npm" -ForegroundColor Yellow } + } + Write-Host ' npm audit fix --package-lock-only --ignore-scripts' -ForegroundColor DarkGray + & npm audit fix --package-lock-only --ignore-scripts --no-fund *> $null + } + } finally { Pop-Location } + + $after = Get-Audit $work + Write-Host " after: $(Get-Count $after)" + + $lockAfter = Read-Lock (Join-Path $work 'package-lock.json') + $pa = if ($lockBefore.packages) { $lockBefore.packages } else { @{} } + $pb = if ($lockAfter.packages) { $lockAfter.packages } else { @{} } + $changes = @(@($pa.Keys) + @($pb.Keys) | Where-Object { $_ } | Sort-Object -Unique | ForEach-Object { + $o = if ($pa[$_]) { $pa[$_].version } else { '-' } + $n = if ($pb[$_]) { $pb[$_].version } else { '-' } + if ($o -ne $n) { "$($_ -replace '^.*node_modules/', '') $o -> $n" } + }) + if ($changes.Count) { + Write-Host $(if ($DryRun) { ' would update:' } else { ' updated:' }) + $changes | ForEach-Object { Write-Host " $_" } + } else { Write-Host ' no safe update available' } + + $rem = @() + if ($after.vulnerabilities) { + foreach ($k in $after.vulnerabilities.Keys) { + $v = $after.vulnerabilities[$k] + $node = if ($v.nodes) { @($v.nodes)[0] } else { "node_modules/$k" } + $cur = if ($pb[$node]) { $pb[$node].version } else { '?' } + $fx = $v.fixAvailable + $rem += if ($fx -is [System.Collections.IDictionary]) { + "$k@$cur [$($v.severity)] -> $($fx.name)@$($fx.version)" + $(if ($fx.isSemVerMajor) { ' (major upgrade)' } else { '' }) + } elseif ($fx -eq $true) { + "$k@$cur [$($v.severity)] -> fix in range, run with -Install or npm install" + } else { "$k@$cur [$($v.severity)] -> no fixed version published" } + } + } + if ($rem.Count) { + Write-Host ' still vulnerable (needs a major upgrade or has no fix):' -ForegroundColor Yellow + $rem | Sort-Object -Unique | ForEach-Object { Write-Host " $_" } + } + $left += Get-CritHigh $after + + if (-not $DryRun -and (Have 'git')) { + & git -C $dir rev-parse --git-dir *> $null + if ($LASTEXITCODE -eq 0) { & git -C $dir diff --stat -- package.json package-lock.json | ForEach-Object { Write-Host " $_" } } + } + } +} finally { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } + +Write-Host '' +if ($DryRun) { + Write-Host 'Dry run: nothing was written. Re-run without -DryRun to apply.' -ForegroundColor DarkGray +} else { + Write-Host 'Next steps (SecKit never commits or pushes):' -ForegroundColor White + Write-Host ' 1. Review the diff: git diff -- package.json package-lock.json' + if (-not $Install) { Write-Host ' 2. Install from lock: npm ci --ignore-scripts' } + Write-Host ' 3. Run your build and tests, then commit the change yourself.' +} +if ($left -gt 0) { Write-Host "$left high/critical vulnerabilit(ies) remain." -ForegroundColor Red; exit 1 } +Write-Host 'No high/critical vulnerabilities remain.' -ForegroundColor Green +exit 0 diff --git a/fix_deps.sh b/fix_deps.sh new file mode 100644 index 0000000..3b146a4 --- /dev/null +++ b/fix_deps.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# +# fix_deps.sh - apply safe (same-major) fixes for vulnerable dependencies. +# +# Usage: +# ./fix_deps.sh [DIR] [--dry-run] [--install] +# +# DIR directory to search for lockfiles (default: current directory) +# --dry-run show what would change; the target is never written +# --install also update node_modules (npm audit fix, still --ignore-scripts) +# +# Scope: npm (package-lock.json). Other ecosystems are listed as not supported. +# +# Default mode is lockfile-only: package-lock.json (and package.json when npm +# must widen a range) is rewritten, node_modules is not touched, and no +# install script ever runs. Only non-breaking (same-major) updates are made; +# whatever still needs a major upgrade is listed for a human to decide. +# +# Strategy per lockfile: `osv-scanner fix --strategy=in-place +# --upgrade-config=minor --no-introduce` when osv-scanner is installed, then +# `npm audit fix --package-lock-only --ignore-scripts` (also the fallback when +# osv-scanner fails, e.g. on lockfiles with file: dependencies). +# +# Never commits, pushes or creates branches. Review the diff yourself. +# +# Exit codes: 0 no high/critical left, 1 high/critical remain, +# 2 usage error or npm missing. +# +set -uo pipefail + +ROOT="$PWD" +DRY=0 +INSTALL=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + --install) INSTALL=1 ;; + -h|--help) sed -n '2,27p' "$0"; exit 0 ;; + --*) echo "Unknown flag: $arg" >&2; exit 2 ;; + *) ROOT="$arg" ;; + esac +done +[[ -d "$ROOT" ]] || { echo "Not a directory: $ROOT" >&2; exit 2; } +ROOT="$(cd "$ROOT" && pwd)" + +if [[ -t 1 ]]; then + BOLD=$'\033[1m'; RED=$'\033[31m'; GRN=$'\033[32m'; YEL=$'\033[33m'; DIM=$'\033[2m'; RST=$'\033[0m' +else + BOLD=; RED=; GRN=; YEL=; DIM=; RST= +fi +have() { command -v "$1" >/dev/null 2>&1; } + +have jq || { echo "${RED}jq is required (brew install jq).${RST}" >&2; exit 2; } + +PRUNE=( \( -name node_modules -o -name .git -o -name vendor -o -name .venv \) -prune -o ) + +# ---------- Not supported yet ---------------------------------------------- +while IFS= read -r f; do + echo "${YEL}not supported yet:${RST} ${f#"$ROOT"/} ${DIM}(fix by hand; seckit scan still reports it)${RST}" +done < <(find "$ROOT" "${PRUNE[@]}" -type f \( -name yarn.lock -o -name pnpm-lock.yaml \ + -o -name requirements.txt -o -name poetry.lock -o -name Pipfile.lock -o -name go.mod \ + -o -name Cargo.lock -o -name Gemfile.lock -o -name composer.lock -o -name pom.xml \ + -o -name build.gradle -o -name build.gradle.kts -o -name packages.lock.json \) -print | sort) + +LOCKS=() +while IFS= read -r f; do LOCKS+=("$f"); done \ + < <(find "$ROOT" "${PRUNE[@]}" -type f -name package-lock.json -print | sort) + +if (( ${#LOCKS[@]} == 0 )); then + echo "No package-lock.json under $ROOT. Nothing to fix." + [[ -f "$ROOT/package.json" ]] && echo "${DIM}Generate one first: npm install --package-lock-only --ignore-scripts${RST}" + exit 0 +fi +have npm || { echo "${RED}npm is required to fix npm dependencies (install Node.js).${RST}" >&2; exit 2; } + +# ---------- Helpers --------------------------------------------------------- +# npm audit JSON for the lockfile in $1 (no install, no scripts). +audit_json() { (cd "$1" && npm audit --json --package-lock-only 2>/dev/null) || true; } + +# "C critical, H high, M moderate, L low" from audit JSON on stdin. npm audit +# prints {"error":...} (exit 0) when the registry is unreachable: that is +# "unknown", never "0 critical", so a failed audit cannot read as clean. +counts() { + local out + out="$(jq -r '.metadata.vulnerabilities | objects | + "\(.critical // 0) critical, \(.high // 0) high, \(.moderate // 0) moderate, \(.low // 0) low"' 2>/dev/null)" + echo "${out:-unknown (npm audit failed)}" +} +# critical + high; 1 when the audit failed (fail closed). +crit_high() { + local n + n="$(jq -r '.metadata.vulnerabilities | objects | (.critical // 0) + (.high // 0)' 2>/dev/null)" + [[ "$n" =~ ^[0-9]+$ ]] && echo "$n" || echo 1 +} + +# What still needs a human: name, current version, severity, and the fix npm knows. +remaining() { # $1 = audit json file, $2 = lockfile + jq -r --slurpfile lock "$2" ' + .vulnerabilities // {} | to_entries[] | .value as $v | + ($v.nodes[0] // ("node_modules/" + .key)) as $node | + ($lock[0].packages[$node].version // "?") as $cur | + if ($v.fixAvailable | type) == "object" then + "\(.key)@\($cur) [\($v.severity)] -> \($v.fixAvailable.name)@\($v.fixAvailable.version)" + + (if $v.fixAvailable.isSemVerMajor then " (major upgrade)" else "" end) + elif $v.fixAvailable == true then + "\(.key)@\($cur) [\($v.severity)] -> fix in range, run with --install or npm install" + else + "\(.key)@\($cur) [\($v.severity)] -> no fixed version published" + end' "$1" 2>/dev/null | sort -u +} + +# Lockfile version changes between $1 (before) and $2 (after). +version_diff() { + jq -rn --slurpfile a "$1" --slurpfile b "$2" ' + ($a[0].packages // {}) as $pa | ($b[0].packages // {}) as $pb | + [($pa | keys[]), ($pb | keys[])] | unique[] | select(. != "") as $k | + ($pa[$k].version // "-") as $o | ($pb[$k].version // "-") as $n | + select($o != $n) | "\($k | sub("^.*node_modules/"; "")) \($o) -> \($n)"' +} + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# ---------- Fix each lockfile ---------------------------------------------- +LEFT=0 +i=0 +for lock in "${LOCKS[@]}"; do + i=$((i+1)) + dir="$(dirname "$lock")" + rel="${lock#"$ROOT"/}" + echo + echo "${BOLD}npm${RST} $rel" + + audit_json "$dir" > "$TMP/before.json" + echo " before: $(counts < "$TMP/before.json")" + cp "$lock" "$TMP/lock-before.json" + + # Dry run works on a copy so the target is never written. + work="$dir" + if (( DRY )); then + if jq -e '.workspaces' "$dir/package.json" >/dev/null 2>&1; then + echo " ${YEL}--dry-run does not support npm workspaces yet; skipped${RST}" + LEFT=$((LEFT + $(crit_high < "$TMP/before.json"))) + continue + fi + work="$TMP/work$i"; mkdir -p "$work" + cp "$dir/package.json" "$lock" "$work/" + [[ -f "$dir/.npmrc" ]] && cp "$dir/.npmrc" "$work/" + fi + + if (( INSTALL && ! DRY )); then + echo " ${DIM}npm audit fix --ignore-scripts (updates node_modules)${RST}" + (cd "$work" && npm audit fix --ignore-scripts --no-fund >/dev/null 2>"$TMP/npm.err") || true + else + if have osv-scanner; then + echo " ${DIM}osv-scanner fix --strategy=in-place --upgrade-config=minor --no-introduce${RST}" + if ! osv-scanner fix -L "$work/package-lock.json" --strategy=in-place \ + --upgrade-config=minor --no-introduce >"$TMP/osv.log" 2>&1; then + echo " ${YEL}osv-scanner fix failed ($(tail -1 "$TMP/osv.log")); falling back to npm${RST}" + fi + fi + echo " ${DIM}npm audit fix --package-lock-only --ignore-scripts${RST}" + (cd "$work" && npm audit fix --package-lock-only --ignore-scripts --no-fund >/dev/null 2>"$TMP/npm.err") || true + fi + grep -q 'ERR!' "$TMP/npm.err" 2>/dev/null && echo " ${YEL}npm: $(grep 'ERR!' "$TMP/npm.err" | head -1)${RST}" + + audit_json "$work" > "$TMP/after.json" + echo " after: $(counts < "$TMP/after.json")" + + changes="$(version_diff "$TMP/lock-before.json" "$work/package-lock.json")" + if [[ -n "$changes" ]]; then + if (( DRY )); then echo " would update:"; else echo " updated:"; fi + printf '%s\n' "$changes" | sed 's/^/ /' + else + echo " no safe update available" + fi + + left="$(remaining "$TMP/after.json" "$work/package-lock.json")" + if [[ -n "$left" ]]; then + echo " ${YEL}still vulnerable (needs a major upgrade or has no fix):${RST}" + printf '%s\n' "$left" | sed 's/^/ /' + fi + + n="$(crit_high < "$TMP/after.json")" + LEFT=$((LEFT + n)) + + if (( ! DRY )) && git -C "$dir" rev-parse --git-dir >/dev/null 2>&1; then + stat="$(git -C "$dir" diff --stat -- package.json package-lock.json)" + [[ -n "$stat" ]] && printf '%s\n' "$stat" | sed 's/^/ /' + fi +done + +# ---------- Next steps ----------------------------------------------------- +echo +if (( DRY )); then + echo "${DIM}Dry run: nothing was written. Re-run without --dry-run to apply.${RST}" +else + echo "${BOLD}Next steps${RST} (SecKit never commits or pushes):" + echo " 1. Review the diff: git diff -- package.json package-lock.json" + (( INSTALL )) || echo " 2. Install from lock: npm ci --ignore-scripts" + echo " 3. Run your build and tests, then commit the change yourself." +fi +if (( LEFT > 0 )); then + echo "${RED}${LEFT} high/critical vulnerabilit(ies) remain.${RST}" + exit 1 +fi +echo "${GRN}No high/critical vulnerabilities remain.${RST}" +exit 0 diff --git a/seckit.ps1 b/seckit.ps1 index 25ac512..a3a6246 100644 --- a/seckit.ps1 +++ b/seckit.ps1 @@ -3,7 +3,7 @@ .SYNOPSIS seckit - a small, portable security kit you carry between machines. .DESCRIPTION - Commands: (no args) interactive menu, install, doctor, scan, harden, agent, + Commands: (no args) interactive menu, install, doctor, scan, fix, harden, agent, mcp, audit, enforce, reminders, startup, help. .EXAMPLE ./seckit.ps1 doctor @@ -284,6 +284,7 @@ seckit - a small, portable security kit you carry between machines. seckit install install any missing scanners (scoop / pipx / npm) seckit doctor check that the scanners are installed seckit scan [DIR] sweep repos for vuln deps, code/IaC flaws, malware, secrets + seckit fix [DIR] apply safe (same-major) npm fixes (-DryRun, -Install) seckit harden [DIR] drop pre-commit + gitleaks + repo posture files seckit agent install the SecKit agent prompt for Claude/Copilot/Cursor seckit mcp manage MCP servers (security + enterprise packs) @@ -306,6 +307,7 @@ function Invoke-Menu { Write-Host ' 1) doctor check your tools are installed' Write-Host ' 2) install install any missing scanners' Write-Host ' 3) scan sweep repos for trouble' + Write-Host ' f) fix apply safe (same-major) npm vulnerability fixes' Write-Host ' 4) harden drop pre-commit + gitleaks + posture files in a repo' Write-Host ' 5) agent install the SecKit AI-agent prompt' Write-Host ' 6) mcp manage MCP servers (security + enterprise)' @@ -325,6 +327,15 @@ function Invoke-Menu { & (Join-Path $Here 'scan_repos.ps1') $dir } } + { $_ -in 'f', 'fix' } { + $dir = Read-Host 'Directory to fix [.] (b to back)' + if ($dir -notin 'b', 'back') { + if (-not $dir) { $dir = '.' } + $fy = Read-Host 'Dry run first? [Y/n]' + if ($fy -in 'n', 'N') { & (Join-Path $Here 'fix_deps.ps1') $dir } + else { & (Join-Path $Here 'fix_deps.ps1') $dir -DryRun } + } + } { $_ -in '4', 'harden' } { $dir = Read-Host 'Repo to harden [.] (b to back)' if ($dir -notin 'b', 'back') { if (-not $dir) { $dir = '.' }; Invoke-Harden -RepoPath $dir } @@ -418,6 +429,16 @@ switch ($Command) { } & (Join-Path $Here 'scan_repos.ps1') @scanArgs } + 'fix' { + $fixArgs = @{} + foreach ($tok in @($Rest)) { + if ("$tok" -in '-DryRun', '--dry-run') { $fixArgs.DryRun = $true } + elseif ("$tok" -in '-Install', '--install') { $fixArgs.Install = $true } + elseif ($tok) { $fixArgs.Root = "$tok" } + } + & (Join-Path $Here 'fix_deps.ps1') @fixArgs + exit $LASTEXITCODE + } 'harden' { $repo = if ($Rest.Count -gt 0) { [string]$Rest[0] } else { '.' } $yes = $Rest -contains '-Yes' -or $Rest -contains '--yes' diff --git a/seckit.sh b/seckit.sh index 9d55448..3e22ebd 100644 --- a/seckit.sh +++ b/seckit.sh @@ -6,6 +6,7 @@ # seckit install install any missing scanners + clients (brew/npm/pipx) # seckit doctor check that scanners + clients are installed # seckit scan [DIR] sweep local repos for vulns, malicious packages, secrets +# seckit fix [DIR] apply safe (same-major) npm vulnerability fixes, lockfile-only # seckit bug report an error or unexpected behaviour on GitHub # seckit scan-skill vet an AI skill / MCP server before you install it # seckit harden [DIR] drop AI-agent guardrails + PR/CODEOWNERS into a repo @@ -345,6 +346,7 @@ ${BOLD}COMMANDS${RST} ${BOLD}install${RST} install missing scanners + clients (brew/npm/pipx) ${BOLD}doctor${RST} check that all scanners + clients are installed ${BOLD}scan${RST} [DIR] sweep repos for vulns, malicious packages, secrets + ${BOLD}fix${RST} [DIR] apply safe (same-major) npm fixes (--dry-run, --install) ${BOLD}scan-skill${RST} vet an AI skill / MCP server before installing ${BOLD}harden${RST} [DIR] drop AI-agent guardrails + PR/CODEOWNERS into a repo ${BOLD}agent${RST} install the SecKit agent prompt (claude|copilot|cursor) @@ -361,6 +363,7 @@ ${BOLD}COMMANDS${RST} ${BOLD}EXAMPLES${RST} seckit doctor check tool health seckit scan . scan the current directory + seckit fix . --dry-run preview safe dependency fixes seckit scan-skill @some/mcp vet an MCP server seckit harden . harden the current repo seckit update update to the latest version @@ -430,6 +433,7 @@ cmd_menu() { echo " ${GRN}2${RST}) install ${DIM}install any missing scanners + clients${RST}" echo " ${GRN}3${RST}) scan ${DIM}sweep local repos for vulns, malware, secrets${RST}" echo " ${GRN}s${RST}) scan-skill ${DIM}vet an AI skill / MCP server before installing${RST}" + echo " ${GRN}f${RST}) fix ${DIM}apply safe (same-major) npm vulnerability fixes${RST}" echo " ${GRN}4${RST}) harden ${DIM}AI guardrails + PR/CODEOWNERS + dependabot${RST}" echo " ${GRN}5${RST}) agent ${DIM}install the SecKit agent for Claude/Copilot/Cursor${RST}" echo " ${GRN}6${RST}) mcp ${DIM}install MCP servers (security + enterprise packs)${RST}" @@ -476,6 +480,13 @@ cmd_menu() { ;; 4|harden) cmd_harden ;; 5|agent) cmd_agent install ;; + f|fix) + printf 'Directory to fix [%s.%s] (b to back): ' "$DIM" "$RST"; read -r dir + [[ "$dir" == b || "$dir" == back ]] && continue + dir="${dir:-.}"; dir="${dir/#\~/$HOME}" + printf 'Dry run first? [%sY%s/n]: ' "$BOLD" "$RST"; read -r fy + if [[ "$fy" == n || "$fy" == N ]]; then bash "$HERE/fix_deps.sh" "$dir" + else bash "$HERE/fix_deps.sh" "$dir" --dry-run; fi ;; 6|mcp) echo "${BOLD}MCP${RST} - pick an action" echo " ${GRN}1${RST}) list ${DIM}show every server, grouped by pack${RST}" @@ -883,6 +894,7 @@ case "$cmd" in install|setup) cmd_install "$@" ;; scan) exec bash "$HERE/scan_repos.sh" "$@" ;; scan-skill|skill) exec bash "$HERE/scan_skill.sh" "$@" ;; + fix) exec bash "$HERE/fix_deps.sh" "$@" ;; harden|guard|init) cmd_harden "$@" ;; agent) cmd_agent "$@" ;; mcp) exec bash "$HERE/mcp.sh" "$@" ;;