diff --git a/.github/workflows/vuln-remediation-self-test.yml b/.github/workflows/vuln-remediation-self-test.yml new file mode 100644 index 0000000..8e0ccc2 --- /dev/null +++ b/.github/workflows/vuln-remediation-self-test.yml @@ -0,0 +1,45 @@ +name: Vulnerability Remediation Self-Test + +on: + pull_request: + paths: + - '.github/workflows/vuln-remediation.yml' + - '.github/workflows/vuln-remediation-self-test.yml' + - '.github/workflows/vuln-remediation/**' + - 'scripts/vuln_remediation.py' + - 'scripts/test_vuln_remediation.py' + push: + branches: + - main + paths: + - '.github/workflows/vuln-remediation.yml' + - '.github/workflows/vuln-remediation-self-test.yml' + - '.github/workflows/vuln-remediation/**' + - 'scripts/vuln_remediation.py' + - 'scripts/test_vuln_remediation.py' + workflow_dispatch: + inputs: + run-live-remediation: + description: Run the reusable remediation workflow against kernel/security-workflows + type: boolean + required: false + default: false + +jobs: + tests: + name: Remediation helper tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run unit and fixture tests + run: python3 -m unittest scripts.test_vuln_remediation + + self-remediation: + name: Manual self remediation + if: github.event_name == 'workflow_dispatch' && inputs['run-live-remediation'] + uses: ./.github/workflows/vuln-remediation.yml + with: + security-workflows-ref: ${{ github.ref_name }} + secrets: inherit diff --git a/.github/workflows/vuln-remediation.yml b/.github/workflows/vuln-remediation.yml index 37968cf..3348625 100644 --- a/.github/workflows/vuln-remediation.yml +++ b/.github/workflows/vuln-remediation.yml @@ -23,6 +23,11 @@ on: type: string required: false default: '3.11' + security-workflows-ref: + description: Ref of kernel/security-workflows to fetch shared scripts and prompts from + type: string + required: false + default: main jobs: scan: @@ -112,6 +117,19 @@ jobs: name: socket-report path: socket-report.json + - name: Normalize Socket report + run: | + curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/scripts/vuln_remediation.py -o /tmp/vuln_remediation.py + python3 /tmp/vuln_remediation.py normalize \ + --input socket-report.json \ + --output remediation-input.json + + - name: Upload normalized remediation input + uses: actions/upload-artifact@v4 + with: + name: remediation-input + path: remediation-input.json + triage: needs: scan runs-on: ubuntu-latest @@ -119,29 +137,49 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Download scan report + - name: Download normalized remediation input uses: actions/download-artifact@v4 with: - name: socket-report + name: remediation-input - - name: Install Cursor CLI - run: | - curl https://cursor.com/install -fsS | bash - echo "$HOME/.cursor/bin" >> $GITHUB_PATH + - name: Install Socket CLI + run: npm install -g @socketsecurity/cli - - name: Triage alerts + - name: Ask Socket for a fix plan env: - CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_API_TOKEN }} run: | - export DATE="$(date -u +%Y-%m-%d)" - curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/main/.github/workflows/vuln-remediation/triage-prompt.md | envsubst '${GITHUB_REPOSITORY} ${DATE}' | agent -p --model ${{ secrets.CURSOR_PREFERRED_MODEL }} --trust --force --output-format=text + set +e + socket fix . \ + --no-apply-fixes \ + --no-major-updates \ + --show-affected-direct-dependencies \ + --json \ + --output-file socket-fix-plan.json + FIX_PLAN_EXIT=$? + set -e + if [ $FIX_PLAN_EXIT -ne 0 ] || ! jq empty socket-fix-plan.json 2>/dev/null; then + echo "::warning::Socket fix planning failed; manager will defer unplanned or insufficiently described fixes." + echo '{}' > socket-fix-plan.json + fi + + - name: Build remediation context + run: | + curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/scripts/vuln_remediation.py -o /tmp/vuln_remediation.py + python3 /tmp/vuln_remediation.py build-context \ + --input remediation-input.json \ + --fix-plan socket-fix-plan.json \ + --output remediation-context.json + cp remediation-context.json triage-result.json - name: Upload triage results uses: actions/upload-artifact@v4 with: name: triage-result - path: triage-result.json + path: | + triage-result.json + remediation-context.json + socket-fix-plan.json fix: needs: triage @@ -168,11 +206,11 @@ jobs: - name: Check for fixable alerts id: check run: | - if ! [ -f triage-result.json ]; then + if ! [ -f remediation-context.json ]; then echo "skip=true" >> $GITHUB_OUTPUT exit 0 fi - FIX_COUNT=$(jq '[.alerts[] | select(.category == "fix")] | length' triage-result.json 2>/dev/null || echo "0") + FIX_COUNT=$(jq '.fixes | length' remediation-context.json 2>/dev/null || echo "0") if [ "$FIX_COUNT" = "0" ]; then echo "skip=true" >> $GITHUB_OUTPUT else @@ -234,18 +272,83 @@ jobs: env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} GOPRIVATE: github.com/kernel/* - GH_TOKEN: ${{ steps.app-token.outputs.token }} SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_API_TOKEN }} run: | - export DATE="$(date -u +%Y-%m-%d)" - curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/main/.github/workflows/vuln-remediation/fix-prompt.md | envsubst '${GITHUB_REPOSITORY} ${DATE}' | agent -p --model ${{ secrets.CURSOR_PREFERRED_MODEL }} --workspace . --trust --force --output-format=text + FIX_IDS=$(jq -r '.fixes[].id' remediation-context.json | paste -sd, -) + set +e + socket fix . \ + --id "$FIX_IDS" \ + --no-major-updates \ + --json \ + --output-file socket-fix-apply.json + SOCKET_FIX_EXIT=$? + set -e + if [ $SOCKET_FIX_EXIT -ne 0 ]; then + echo "::warning::Socket fix failed; invoking bounded fallback agent." + fi + + if git diff --quiet; then + export DATE="$(date -u +%Y-%m-%d)" + curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/.github/workflows/vuln-remediation/fix-prompt.md | envsubst '${GITHUB_REPOSITORY} ${DATE}' | agent -p --model ${{ secrets.CURSOR_PREFERRED_MODEL }} --workspace . --trust --force --output-format=text + fi + + - name: Validate remediation diff + id: diff + if: steps.check.outputs.skip != 'true' + run: | + curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/scripts/vuln_remediation.py -o /tmp/vuln_remediation.py + git diff --name-only > changed-files.txt + if [ ! -s changed-files.txt ]; then + echo '{"fixed": [], "reverted": [], "summary": "No dependency changes were produced."}' > fix-result.json + echo "has_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + echo "has_changes=true" >> $GITHUB_OUTPUT + python3 /tmp/vuln_remediation.py validate-diff \ + --repo-root . \ + --context remediation-context.json \ + --changed-files changed-files.txt \ + --output diff-validation.json + + - name: Confirm fixes + if: steps.check.outputs.skip != 'true' && steps.diff.outputs.has_changes == 'true' + run: | + set +e + python3 /tmp/vuln_remediation.py confirm \ + --repo-root . \ + --context remediation-context.json \ + --output confirmation-result.json + CONFIRM_EXIT=$? + set -e + python3 /tmp/vuln_remediation.py summarize-fix \ + --context remediation-context.json \ + --confirmation confirmation-result.json \ + --output fix-result.json + exit $CONFIRM_EXIT + + - name: Commit and push validated changes + if: steps.check.outputs.skip != 'true' && steps.diff.outputs.has_changes == 'true' + run: | + if ! jq -e '.fixed | length > 0' fix-result.json >/dev/null; then + echo "No confirmed fixes to commit." + exit 0 + fi + git add --pathspec-from-file=changed-files.txt + git diff --cached --quiet && exit 0 + git commit -m "security: vulnerability remediation ($(date -u +%Y-%m-%d))" + git push --force-with-lease origin security/vuln-remediation - name: Upload fix results if: always() uses: actions/upload-artifact@v4 with: name: fix-result - path: fix-result.json + path: | + fix-result.json + confirmation-result.json + diff-validation.json + changed-files.txt + socket-fix-apply.json if-no-files-found: ignore pr: @@ -272,79 +375,38 @@ jobs: name: fix-result continue-on-error: true + - name: Render PR body + run: | + curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/scripts/vuln_remediation.py -o /tmp/vuln_remediation.py + [ -f triage-result.json ] || echo '{"deferred":[]}' > triage-result.json + [ -f fix-result.json ] || echo '{"fixed":[],"reverted":[]}' > fix-result.json + [ -f confirmation-result.json ] || echo '{"ok":false,"confirmed":[],"failures":[]}' > confirmation-result.json + python3 /tmp/vuln_remediation.py render-pr-body \ + --triage triage-result.json \ + --fix-result fix-result.json \ + --confirmation confirmation-result.json \ + --output pr-body.md + - name: Create or update PR env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - DATE="$(date -u +%Y-%m-%d)" - REPO="${{ github.repository }}" - - FIXED_TABLE="" - DISMISSED_TABLE="" - DEFERRED_TABLE="" - - if [ -f triage-result.json ]; then - ALERT_COUNT=$(jq '.alerts | length' triage-result.json 2>/dev/null || echo "0") - if [ "$ALERT_COUNT" = "0" ]; then - echo "No alerts to report." - exit 0 - fi - - DISMISSED_TABLE=$(jq -r ' - .alerts[] | select(.category == "dismiss") | - "| \(.type) | \(.package) | \(.severity) | \(.reason) |" - ' triage-result.json 2>/dev/null || echo "") - - DEFERRED_TABLE=$(jq -r ' - .alerts[] | select(.category == "defer") | - "| \(.cve // "N/A") | \(.package) | \(.severity) | \(.reason) |" - ' triage-result.json 2>/dev/null || echo "") - fi - - if [ -f fix-result.json ]; then - FIXED_TABLE=$(jq -r ' - .fixed[] | - "| \(.cve) | \(.package) | \(.ecosystem) | \(.old_version) | \(.new_version) | \(.manifest) |" - ' fix-result.json 2>/dev/null || echo "") - - REVERTED=$(jq -r ' - .reverted[] | - "| \(.cve) | \(.package) | \(.ecosystem) | \(.reason) |" - ' fix-result.json 2>/dev/null || echo "") - if [ -n "$REVERTED" ]; then - DEFERRED_TABLE="${DEFERRED_TABLE} - ${REVERTED}" - fi + if [ "${{ needs.fix.result }}" != "success" ]; then + echo "Fix job did not complete successfully; not creating or updating a remediation PR." + exit 0 fi - - if [ -z "$FIXED_TABLE" ] && [ -z "$DISMISSED_TABLE" ] && [ -z "$DEFERRED_TABLE" ]; then - echo "No results to report." + REPO="${{ github.repository }}" + if ! jq -e '.fixed | length > 0' fix-result.json >/dev/null 2>&1; then + echo "No confirmed fixes; not creating a PR." exit 0 fi - BODY="## Vulnerability Remediation — ${DATE} - - ### Fixed - | CVE | Package | Ecosystem | Old Version | New Version | Manifest | - |-----|---------|-----------|-------------|-------------|----------| - ${FIXED_TABLE:-| (none) | | | | | |} - - ### Skipped (non-actionable) - | Alert Type | Package | Severity | Reason | - |------------|---------|----------|--------| - ${DISMISSED_TABLE:-| (none) | | | |} - - ### Deferred (needs human review) - | CVE | Package | Severity | Reason | - |-----|---------|----------|--------| - ${DEFERRED_TABLE:-| (none) | | | |}" - EXISTING_PR=$(gh pr list --repo "$REPO" --head security/vuln-remediation --state open --json number --jq '.[0].number' 2>/dev/null || echo "") if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then - gh pr edit "$EXISTING_PR" --repo "$REPO" --body "$BODY" --add-assignee ulziibay-kernel --add-reviewer ulziibay-kernel + gh pr edit "$EXISTING_PR" --repo "$REPO" --body-file pr-body.md --add-assignee ulziibay-kernel --add-reviewer ulziibay-kernel echo "Updated PR #${EXISTING_PR}" - elif [ -n "$FIXED_TABLE" ]; then + else gh pr create \ --repo "$REPO" \ --head security/vuln-remediation \ @@ -352,8 +414,5 @@ jobs: --title "security: vulnerability remediation" \ --assignee ulziibay-kernel \ --reviewer ulziibay-kernel \ - --body "$BODY" - else - echo "No fixes applied and no existing PR. Logging results only." - echo "$BODY" + --body-file pr-body.md fi diff --git a/.github/workflows/vuln-remediation/fix-prompt.md b/.github/workflows/vuln-remediation/fix-prompt.md index f1be08c..500351a 100644 --- a/.github/workflows/vuln-remediation/fix-prompt.md +++ b/.github/workflows/vuln-remediation/fix-prompt.md @@ -1,8 +1,8 @@ -You are a security engineer applying dependency fixes for known vulnerabilities. +You are a security engineer performing bounded fallback dependency remediation. -IMPORTANT: Start executing shell commands immediately. Do NOT plan or describe what you will do — just do it. Every response must include tool calls. +IMPORTANT: The workflow has already asked Socket to plan and apply fixes. You are only invoked when Socket did not leave a dependency diff. Start by reading the local JSON files and then execute the smallest package-manager command needed. -The GitHub CLI is available as `gh` and authenticated via GH_TOKEN. Git is available with write access. +Git is available only for inspection. You do not have authority to create commits, push branches, create PRs, force-push, or stage files. # Context @@ -11,66 +11,66 @@ The GitHub CLI is available as `gh` and authenticated via GH_TOKEN. Git is avail # Goal -Read the triage results and apply fixes for all alerts classified as `fix`. Build and test after each fix. Write results to `fix-result.json`. +Read `remediation-context.json` and apply minimal dependency-only fixes for the `fixes` entries. Write results to `fix-result.json`. # Step 1 — Read triage results -Run `cat triage-result.json` to read the file. Process only alerts where `category` is `"fix"`. +Run `python3 -m json.tool remediation-context.json` to read the file. Process only entries under `fixes`. If there are no `fix` alerts, write this to `fix-result.json` and exit: ```json -{"fixed": [], "reverted": [], "summary": "No alerts to fix."} +{"fixed": [], "reverted": [], "summary": "No fixes in remediation context."} ``` # Step 2 — Apply fixes The branch `security/vuln-remediation` is already checked out and reset to `origin/main`. Do NOT create or switch branches. -For each `fix` alert, grouped by manifest file, run commands immediately: +For each fix, use the manifest and package from `remediation-context.json`. Prefer exact target versions when present. Do not upgrade unrelated dependencies. ### Go (`go.mod`) `cd` into the directory containing the `go.mod`, then run: ``` -go get @latest +go get @ go mod tidy ``` +If no target version is available, do not guess; record the fix as reverted with reason `No Socket target version available for fallback`. ### npm (`package.json` / `package-lock.json` / `pnpm-lock.yaml`) -`cd` into the directory containing the manifest, then: -- If `package.json` lists the dependency directly, update the version and run `bun install` (or `npm install`). -- If transitive only, run `bun update ` (or `npm update `). +`cd` into the directory containing `package.json`, then inspect `packageManager`. +- `pnpm@...`: run `pnpm update @` and only touch `package.json` / `pnpm-lock.yaml`. +- `bun@...`: run `bun update @` and only touch `package.json` / `bun.lock` or `bun.lockb`. +- `yarn@...`: run `yarn up @` and only touch `package.json` / `yarn.lock`. +- `npm@...` or no package manager: run `npm install @` and only touch `package.json` / `package-lock.json`. + +Never create a lockfile for a different package manager. ### Python (`pyproject.toml` / `requirements.txt`) `cd` into the directory containing the manifest, then: -- Edit the version constraint, then run `uv sync` or `pip install -r requirements.txt`. +- Edit the version constraint to the target version, then run `uv lock`, `uv sync`, `poetry lock`, or `pip install -r requirements.txt` only if that tool is already represented by files in the manifest directory. # Step 3 — Verify each fix -After each dependency bump, run: +After each dependency bump, run the smallest available verification command: 1. **Build**: Check for Makefile with `build` target → `make build`. Otherwise: `go build ./...` or `bun run build`. 2. **Test**: Check for Makefile with `test` target → `make test`. Otherwise: `go test ./...` or `bun test`. If build or test fails due to the upgrade: -1. Revert: `git checkout -- ` then re-run `go mod tidy` / `bun install` +1. Revert only the manifest and lockfiles for that attempted fix 2. Record the alert as `reverted` with the failure reason 3. Continue with the next alert -# Step 4 — Format +# Step 4 — Do not format broadly -Run `bun run format` if the command exists, otherwise skip. +Do not run global formatters or linters that rewrite source files. The workflow validator rejects source churn. -# Step 5 — Commit and push +# Step 5 — Never commit or push -If any fixes succeeded, run: -``` -git add -A -git commit -m "security: vulnerability remediation (${DATE})" -git push -f origin security/vuln-remediation -``` +Do not run `git add`, `git commit`, `git push`, `gh pr`, `git checkout -B`, `git reset`, or `git clean`. # Step 6 — Write output @@ -80,7 +80,9 @@ Write `fix-result.json` with this exact schema: { "fixed": [ { + "id": "CVE-2025-7783", "cve": "CVE-2025-7783", + "ghsa": null, "package": "form-data", "ecosystem": "npm", "old_version": "4.0.0", @@ -102,8 +104,8 @@ Write `fix-result.json` with this exact schema: # Constraints -- Do NOT re-triage alerts — trust the classifications in `triage-result.json` -- Do NOT dismiss or skip `fix` alerts unless build/test fails -- Do NOT create PRs — only push the branch -- Write ONLY `fix-result.json` as output -- Never force-push or modify `main` directly +- Do NOT re-triage alerts; trust `remediation-context.json`. +- Do NOT edit source code, generated binaries, workflow files, markdown docs, or remediation JSON artifacts other than `fix-result.json`. +- Do NOT create, stage, commit, push, or create PRs. +- Do NOT run global formatters. +- Write ONLY `fix-result.json` as output. diff --git a/scripts/test_vuln_remediation.py b/scripts/test_vuln_remediation.py new file mode 100644 index 0000000..a470c7e --- /dev/null +++ b/scripts/test_vuln_remediation.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import vuln_remediation as vr + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +class RemediationFixture(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + subprocess.check_call(["git", "init"], cwd=self.root, stdout=subprocess.DEVNULL) + subprocess.check_call(["git", "config", "user.email", "test@example.com"], cwd=self.root) + subprocess.check_call(["git", "config", "user.name", "Test"], cwd=self.root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def commit_all(self) -> None: + subprocess.check_call(["git", "add", "."], cwd=self.root) + subprocess.check_call(["git", "commit", "-m", "baseline"], cwd=self.root, stdout=subprocess.DEVNULL) + + +class NormalizeAndPolicyTests(unittest.TestCase): + def test_normalizes_socket_cli_alerts_into_flat_alerts(self) -> None: + report = { + "alerts": { + "golang": { + "grpc": { + "v1.79.1": { + "type": "criticalCVE", + "policy": "error", + "url": "https://socket.dev/golang/package/google.golang.org/grpc/overview/1.79.1", + "manifest": ["packages/api/go.mod"], + "cve": "CVE-2026-0001", + "upgradeVersion": "v1.81.1", + } + } + } + } + } + + normalized = vr.normalize_input(report) + + self.assertEqual(normalized["alerts"][0]["package"], "google.golang.org/grpc") + self.assertEqual(normalized["alerts"][0]["ecosystem"], "go") + self.assertEqual(normalized["alerts"][0]["cve"], "CVE-2026-0001") + + def test_manager_defers_missing_identifiers_and_dev_scope(self) -> None: + remediation_input = { + "alerts": [ + {"type": "criticalCVE", "package": "missing-id", "manifest": "go.mod"}, + {"type": "cve", "cve": "CVE-2026-0002", "package": "dev-only", "manifest": "package.json", "dependency_scope": "Development"}, + {"type": "cve", "cve": "CVE-2026-0003", "package": "prod", "manifest": "go.mod"}, + ] + } + fix_plan = { + "type": "only-direct-dependency-upgrades", + "fixes": { + "CVE-2026-0002": {"directDependencies": [{"purl": "pkg:npm/dev-only@1.0.0", "fixedVersion": "1.0.1"}]}, + "CVE-2026-0003": {"directDependencies": [{"purl": "pkg:golang/prod@1.0.0", "fixedVersion": "1.0.1"}]}, + }, + } + + context = vr.build_context(remediation_input, fix_plan) + + self.assertEqual([item["id"] for item in context["fixes"]], ["CVE-2026-0003"]) + self.assertEqual(len(context["deferred"]), 2) + + def test_manager_defers_without_socket_fix_plan(self) -> None: + context = vr.build_context( + {"alerts": [{"type": "cve", "cve": "CVE-2026-0005", "package": "prod", "manifest": "go.mod"}]}, + {}, + ) + + self.assertEqual(context["fixes"], []) + self.assertEqual(context["deferred"][0]["reason"], "Socket did not return a fix plan for this vulnerability.") + + def test_manager_focuses_on_reachable_and_potentially_reachable(self) -> None: + fix_plan = { + "type": "only-direct-dependency-upgrades", + "fixes": { + "CVE-2026-0006": {"directDependencies": [{"purl": "pkg:npm/prod@1.0.0", "fixedVersion": "1.0.1"}]}, + "CVE-2026-0007": {"directDependencies": [{"purl": "pkg:npm/unreachable@1.0.0", "fixedVersion": "1.0.1"}]}, + }, + } + + context = vr.build_context( + { + "alerts": [ + {"type": "cve", "cve": "CVE-2026-0006", "package": "prod", "manifest": "package.json", "reachability": "Potentially Reachable"}, + {"type": "cve", "cve": "CVE-2026-0007", "package": "unreachable", "manifest": "package.json", "reachability": "Unreachable"}, + ] + }, + fix_plan, + ) + + self.assertEqual([item["id"] for item in context["fixes"]], ["CVE-2026-0006"]) + self.assertIn("auto-remediation is limited", context["deferred"][0]["reason"]) + + +class DiffValidatorTests(RemediationFixture): + def test_rejects_infra_binary_artifacts_like_dns_and_unikraft(self) -> None: + write(self.root / "go.mod", "module example.com/infra\n") + self.commit_all() + write(self.root / "dns/dns", "binary-ish") + write(self.root / "unikraft/unikraft", "binary-ish") + os.chmod(self.root / "dns/dns", 0o755) + os.chmod(self.root / "unikraft/unikraft", 0o755) + + result = vr.validate_diff(self.root, ["dns/dns", "unikraft/unikraft"]) + + self.assertFalse(result.ok) + self.assertIn("dns/dns: only dependency manifests and lockfiles may change", result.errors) + self.assertIn("unikraft/unikraft: only dependency manifests and lockfiles may change", result.errors) + + def test_rejects_website_source_churn_and_artifacts(self) -> None: + write(self.root / "package.json", json.dumps({"packageManager": "pnpm@10.30.1", "dependencies": {"react": "19.0.0"}})) + write(self.root / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + self.commit_all() + write(self.root / "app/page.tsx", "export default function Page() { return null }\n") + write(self.root / "fix-result.json", "{}\n") + + result = vr.validate_diff(self.root, ["app/page.tsx", "fix-result.json"]) + + self.assertFalse(result.ok) + self.assertIn("app/page.tsx: only dependency manifests and lockfiles may change", result.errors) + self.assertIn("fix-result.json: remediation artifact must not be committed", result.errors) + + def test_rejects_hypeman_package_manager_mixing(self) -> None: + write(self.root / "package.json", json.dumps({"packageManager": "pnpm@10.30.1", "dependencies": {"vite": "6.0.0"}})) + write(self.root / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + self.commit_all() + write(self.root / "package-lock.json", "{}\n") + + result = vr.validate_diff(self.root, ["package-lock.json"]) + + self.assertFalse(result.ok) + self.assertIn("package-lock.json: lockfile does not match detected package manager pnpm", result.errors) + + def test_rejects_unplanned_direct_dependency_addition(self) -> None: + write(self.root / "package.json", json.dumps({"packageManager": "pnpm@10.30.1", "dependencies": {"vite": "6.0.0"}})) + write(self.root / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + self.commit_all() + write(self.root / "package.json", json.dumps({"packageManager": "pnpm@10.30.1", "dependencies": {"vite": "6.0.0", "left-pad": "1.3.0"}})) + + result = vr.validate_diff(self.root, ["package.json"], {"fixes": [{"allowed_direct_dependencies": ["vite"]}]}) + + self.assertFalse(result.ok) + self.assertIn("package.json: new direct dependencies not present in Socket fix plan: left-pad", result.errors) + + def test_accepts_clean_socket_planned_dependency_change(self) -> None: + write(self.root / "go.mod", "module example.com/app\nrequire google.golang.org/grpc v1.79.1\n") + write(self.root / "go.sum", "google.golang.org/grpc v1.79.1 h1:old\n") + self.commit_all() + write(self.root / "go.mod", "module example.com/app\nrequire google.golang.org/grpc v1.81.1\n") + write(self.root / "go.sum", "google.golang.org/grpc v1.81.1 h1:new\n") + + result = vr.validate_diff(self.root, ["go.mod", "go.sum"]) + + self.assertTrue(result.ok, result.errors) + + +class ConfirmationTests(RemediationFixture): + def test_confirmation_fails_when_old_vulnerable_version_remains(self) -> None: + write(self.root / "packages/api/go.mod", "module example.com/api\nrequire google.golang.org/grpc v1.79.1\n") + self.commit_all() + + result = vr.confirm_fix( + self.root, + { + "fixes": [ + { + "id": "CVE-2026-0004", + "package": "google.golang.org/grpc", + "old_version": "v1.79.1", + "target_version": "v1.81.1", + "manifests": ["packages/api/go.mod"], + } + ] + }, + ) + + self.assertFalse(result["ok"]) + self.assertIn("Old vulnerable version v1.79.1 still present", result["failures"][0]["reason"]) + + def test_confirmation_succeeds_when_old_version_is_removed(self) -> None: + write(self.root / "packages/api/go.mod", "module example.com/api\nrequire google.golang.org/grpc v1.81.1\n") + self.commit_all() + + result = vr.confirm_fix( + self.root, + { + "fixes": [ + { + "id": "CVE-2026-0004", + "package": "google.golang.org/grpc", + "old_version": "v1.79.1", + "target_version": "v1.81.1", + "manifests": ["packages/api/go.mod"], + } + ] + }, + ) + + self.assertTrue(result["ok"], result) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/vuln_remediation.py b/scripts/vuln_remediation.py new file mode 100644 index 0000000..61b7f40 --- /dev/null +++ b/scripts/vuln_remediation.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +"""Socket-centric vulnerability remediation helpers. + +This module is intentionally dependency-free so it can run in GitHub Actions +without bootstrapping a Python environment beyond the system interpreter. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ARTIFACT_NAMES = { + "socket-report.json", + "socket-raw.json", + "socket-extracted.json", + "triage-result.json", + "fix-result.json", + "socket-fix-plan.json", + "remediation-context.json", + "confirmation-result.json", + "changed-files.txt", +} + +GO_DEP_FILES = {"go.mod", "go.sum"} +NODE_DEP_FILES = { + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", +} +PYTHON_DEP_FILES = { + "pyproject.toml", + "poetry.lock", + "uv.lock", + "Pipfile", + "Pipfile.lock", +} + +REQUIREMENTS_RE = re.compile(r"(^|/)requirements[^/]*\.txt$") +MAX_ALLOWED_FILE_SIZE = 5 * 1024 * 1024 + + +def load_json(path: Path, default: Any = None) -> Any: + if not path.exists(): + return default + with path.open() as f: + return json.load(f) + + +def write_json(path: Path, data: Any) -> None: + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def run_git(args: list[str], cwd: Path) -> str | None: + try: + return subprocess.check_output(["git", *args], cwd=cwd, text=True, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + return None + + +def package_name_from_socket_key(ecosystem: str, short_name: str, url: str | None) -> str: + if ecosystem == "golang" and url: + match = re.search(r"/golang/package/(.+?)(?:/overview|[?#]|$)", url) + if match: + return match.group(1) + return short_name + + +def normalize_socket_cli_report(report: dict[str, Any]) -> list[dict[str, Any]]: + alerts: list[dict[str, Any]] = [] + nested = report.get("alerts") or {} + if not isinstance(nested, dict): + return alerts + + for ecosystem, packages in nested.items(): + if not isinstance(packages, dict): + continue + normalized_ecosystem = "go" if ecosystem == "golang" else ecosystem + for package_key, versions in packages.items(): + if not isinstance(versions, dict): + continue + for version, alert in versions.items(): + if not isinstance(alert, dict): + continue + url = alert.get("url") + alerts.append( + { + "source": "socket-cli", + "category": "vulnerability" if "cve" in str(alert.get("type", "")).lower() else "supplyChainRisk", + "type": alert.get("type"), + "action": alert.get("policy"), + "severity": alert.get("severity") or alert.get("policy"), + "ecosystem": normalized_ecosystem, + "package": package_name_from_socket_key(ecosystem, package_key, url), + "version": version, + "manifest": alert.get("manifest") or [], + "cve": alert.get("cve"), + "ghsa": alert.get("ghsa"), + "url": url, + "reachability": alert.get("reachability"), + "dependency_scope": alert.get("dependencyScope"), + "dependency_use": alert.get("dependencyUse"), + "upgrade_version": alert.get("upgradeVersion") or alert.get("fixedVersion"), + } + ) + return alerts + + +def normalize_dashboard_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + alerts: list[dict[str, Any]] = [] + for row in rows: + package_version = row.get("Package") or row.get("Package Name & Version") or "" + package_name, _, version = package_version.rpartition("@") + alerts.append( + { + "source": "socket-dashboard", + "key": row.get("Key"), + "category": row.get("Category"), + "type": row.get("Type"), + "action": row.get("Action"), + "severity": str(row.get("Severity") or "").lower() or None, + "ecosystem": row.get("Ecosystem"), + "package": package_name or package_version, + "version": version or None, + "repository": row.get("Repository"), + "branch": row.get("Branch"), + "manifest": row.get("Manifest") or row.get("Manifest File") or None, + "cve": row.get("CVE") or None, + "ghsa": row.get("GHSA") or None, + "cvss": row.get("CVSS") or None, + "epss": row.get("EPSS") or None, + "dependency_type": row.get("Dependency Type") or None, + "dependency_scope": row.get("Dependency Scope") or None, + "dependency_use": row.get("Dependency Use") or None, + "reachability": row.get("Reachability") or None, + "upgrade_version": row.get("Upgrade Version") or None, + } + ) + return alerts + + +def normalize_input(data: Any) -> dict[str, Any]: + if isinstance(data, dict) and "alerts" in data and isinstance(data["alerts"], dict): + alerts = normalize_socket_cli_report(data) + elif isinstance(data, dict) and "alerts" in data and isinstance(data["alerts"], list): + alerts = data["alerts"] + elif isinstance(data, list): + alerts = normalize_dashboard_rows(data) + else: + alerts = [] + return {"alerts": alerts} + + +def fix_plan_state(plan_entry: dict[str, Any]) -> str: + return str(plan_entry.get("type") or "") + + +def build_context(remediation_input: dict[str, Any], fix_plan: dict[str, Any] | None = None) -> dict[str, Any]: + fix_plan = fix_plan or {} + plan_by_id = fix_plan.get("fixDetails") or fix_plan.get("fixes") or {} + default_plan_state = str(fix_plan.get("type") or "") + items: list[dict[str, Any]] = [] + deferred: list[dict[str, Any]] = [] + + for alert in remediation_input.get("alerts", []): + vuln_id = alert.get("ghsa") or alert.get("cve") + is_cve = "cve" in str(alert.get("type", "")).lower() or bool(vuln_id) + if not is_cve: + deferred.append({**alert, "decision": "defer", "reason": "Non-CVE alert is not handled by dependency remediation."}) + continue + if not vuln_id: + deferred.append({**alert, "decision": "defer", "reason": "Missing CVE/GHSA identifier required for Socket fix planning."}) + continue + + plan_entry = plan_by_id.get(vuln_id) or {} + state = fix_plan_state(plan_entry) or (default_plan_state if plan_entry else "") + if not state: + deferred.append({**alert, "decision": "defer", "reason": "Socket did not return a fix plan for this vulnerability."}) + continue + if state not in {"fixFound", "partialFixFound", "only-direct-dependency-upgrades"}: + deferred.append({**alert, "decision": "defer", "reason": f"Socket fix planner returned {state}."}) + continue + + if str(alert.get("dependency_scope") or "").lower() == "development": + deferred.append({**alert, "decision": "defer", "reason": "Development-scope dependency is reported but not auto-fixed."}) + continue + reachability = str(alert.get("reachability") or "").lower() + if reachability and reachability not in {"reachable", "potentially reachable", "potentially_reachable"}: + deferred.append({**alert, "decision": "defer", "reason": f"Reachability is {alert.get('reachability')}; auto-remediation is limited to reachable or potentially reachable vulnerabilities."}) + continue + + manifest = alert.get("manifest") + if isinstance(manifest, list): + manifests = [m for m in manifest if m] + elif manifest: + manifests = [manifest] + else: + manifests = [] + if not manifests and plan_entry: + fixes = ((plan_entry.get("value") or {}).get("fixDetails") or {}).get("fixes") or [] + for fix in fixes: + manifests.extend(fix.get("manifestFiles") or []) + if not manifests: + deferred.append({**alert, "decision": "defer", "reason": "No manifest path available for fix."}) + continue + + items.append( + { + "decision": "fix", + "id": vuln_id, + "cve": alert.get("cve"), + "ghsa": alert.get("ghsa"), + "package": alert.get("package"), + "ecosystem": alert.get("ecosystem"), + "old_version": alert.get("version") or version_from_plan(plan_entry), + "target_version": alert.get("upgrade_version") or target_version_from_plan(plan_entry), + "manifests": sorted(set(manifests)), + "socket_plan_state": state, + "allowed_direct_dependencies": responsible_direct_dependencies(plan_entry), + } + ) + + return {"fixes": items, "deferred": deferred} + + +def responsible_direct_dependencies(plan_entry: dict[str, Any]) -> list[str]: + details = ((plan_entry.get("value") or {}).get("fixDetails") or {}) + raw = details.get("responsibleDirectDependencies") or {} + names: set[str] = set() + if isinstance(raw, dict): + for key, value in raw.items(): + names.add(purl_to_name(key)) + if isinstance(value, list): + names.update(purl_to_name(v) for v in value) + elif isinstance(value, str): + names.add(purl_to_name(value)) + direct_dependencies = plan_entry.get("directDependencies") or [] + if isinstance(direct_dependencies, list): + for dependency in direct_dependencies: + if isinstance(dependency, dict): + names.add(purl_to_name(dependency.get("purl", ""))) + for transitive in dependency.get("transitiveFixes") or []: + if isinstance(transitive, dict): + names.add(purl_to_name(transitive.get("purl", ""))) + return sorted(n for n in names if n) + + +def target_version_from_plan(plan_entry: dict[str, Any]) -> str | None: + for dependency in plan_entry.get("directDependencies") or []: + if isinstance(dependency, dict): + if dependency.get("fixedVersion"): + return dependency["fixedVersion"] + for transitive in dependency.get("transitiveFixes") or []: + if isinstance(transitive, dict) and transitive.get("fixedVersion"): + return transitive["fixedVersion"] + return None + + +def version_from_plan(plan_entry: dict[str, Any]) -> str | None: + for dependency in plan_entry.get("directDependencies") or []: + if isinstance(dependency, dict) and dependency.get("purl"): + purl = dependency["purl"] + if "@" in purl: + return purl.rsplit("@", 1)[-1] + return None + + +def purl_to_name(value: str) -> str: + value = str(value) + if value.startswith("pkg:"): + value = value.split("/", 1)[-1] + value = value.split("@", 1)[0] + return value + + +def is_dependency_file(path: str) -> bool: + name = Path(path).name + if name in GO_DEP_FILES or name in NODE_DEP_FILES or name in PYTHON_DEP_FILES: + return True + return bool(REQUIREMENTS_RE.search(path)) + + +def package_manager_for_dir(root: Path, directory: Path) -> str | None: + package_json = directory / "package.json" + if package_json.exists(): + try: + package_manager = json.loads(package_json.read_text()).get("packageManager", "") + except json.JSONDecodeError: + package_manager = "" + for manager in ("pnpm", "bun", "yarn", "npm"): + if package_manager.startswith(manager + "@"): + return manager + + locks = { + "pnpm": directory / "pnpm-lock.yaml", + "bun": directory / "bun.lock", + "bunb": directory / "bun.lockb", + "yarn": directory / "yarn.lock", + "npm": directory / "package-lock.json", + } + present = [manager for manager, path in locks.items() if path.exists()] + if "bun" in present or "bunb" in present: + return "bun" + if len(present) == 1: + return present[0] + return None + + +def allowed_node_lock_names(manager: str | None) -> set[str]: + if manager == "pnpm": + return {"package.json", "pnpm-lock.yaml"} + if manager == "bun": + return {"package.json", "bun.lock", "bun.lockb"} + if manager == "yarn": + return {"package.json", "yarn.lock"} + if manager == "npm": + return {"package.json", "package-lock.json", "npm-shrinkwrap.json"} + return NODE_DEP_FILES + + +@dataclass +class ValidationResult: + ok: bool + errors: list[str] + + +def validate_diff(root: Path, changed_files: list[str], context: dict[str, Any] | None = None) -> ValidationResult: + errors: list[str] = [] + context = context or {} + allowed_direct_deps = { + dep + for fix in context.get("fixes", []) + for dep in fix.get("allowed_direct_dependencies", []) + } + + for path in changed_files: + if not path or path.endswith("/"): + continue + rel = Path(path) + name = rel.name + full = root / rel + + if name in ARTIFACT_NAMES: + errors.append(f"{path}: remediation artifact must not be committed") + continue + if not is_dependency_file(path): + errors.append(f"{path}: only dependency manifests and lockfiles may change") + continue + if full.exists(): + try: + mode = full.stat().st_mode + if mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH): + errors.append(f"{path}: executable files are not allowed") + if full.stat().st_size > MAX_ALLOWED_FILE_SIZE: + errors.append(f"{path}: file is too large for dependency remediation") + except OSError as exc: + errors.append(f"{path}: could not stat file: {exc}") + + if name in NODE_DEP_FILES: + manager = package_manager_for_dir(root, full.parent) + if name not in allowed_node_lock_names(manager): + errors.append(f"{path}: lockfile does not match detected package manager {manager or 'unknown'}") + + if name == "package.json": + errors.extend(validate_package_json_direct_deps(root, path, allowed_direct_deps)) + + return ValidationResult(ok=not errors, errors=errors) + + +def validate_package_json_direct_deps(root: Path, path: str, allowed_direct_deps: set[str]) -> list[str]: + current_path = root / path + if not current_path.exists(): + return [] + old = run_git(["show", f"HEAD:{path}"], root) + if old is None: + return [] + try: + before = json.loads(old) + after = json.loads(current_path.read_text()) + except json.JSONDecodeError: + return [f"{path}: invalid package.json"] + + errors: list[str] = [] + for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"): + before_deps = set((before.get(section) or {}).keys()) + after_deps = set((after.get(section) or {}).keys()) + added = after_deps - before_deps + unexpected = sorted(dep for dep in added if dep not in allowed_direct_deps) + if unexpected: + errors.append(f"{path}: new direct dependencies not present in Socket fix plan: {', '.join(unexpected)}") + return errors + + +def confirm_fix(root: Path, context: dict[str, Any]) -> dict[str, Any]: + failures: list[dict[str, str]] = [] + confirmed: list[dict[str, str]] = [] + for fix in context.get("fixes", []): + old_version = fix.get("old_version") + package = fix.get("package") + if not old_version or not package: + failures.append({"id": fix.get("id", ""), "reason": "Missing package or old version for confirmation."}) + continue + haystacks = [] + for manifest in fix.get("manifests", []): + manifest_path = root / manifest + if manifest_path.exists(): + haystacks.append((manifest, manifest_path.read_text(errors="ignore"))) + sibling_locks = [ + manifest_path.parent / name + for name in ("go.sum", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "uv.lock", "poetry.lock") + ] + for lock in sibling_locks: + if lock.exists(): + haystacks.append((str(lock.relative_to(root)), lock.read_text(errors="ignore"))) + + needle_patterns = [ + old_version, + f"{package}@{old_version}", + f"{package} {old_version}", + ] + offenders = [ + path + for path, content in haystacks + if any(pattern and pattern in content for pattern in needle_patterns) + ] + if offenders: + failures.append({"id": fix.get("id", ""), "reason": f"Old vulnerable version {old_version} still present in: {', '.join(sorted(set(offenders)))}"}) + else: + confirmed.append({"id": fix.get("id", ""), "package": package, "old_version": old_version, "target_version": fix.get("target_version") or ""}) + return {"ok": not failures, "confirmed": confirmed, "failures": failures} + + +def render_pr_body(triage: dict[str, Any], fix_result: dict[str, Any], confirmation: dict[str, Any]) -> str: + lines = [ + "## Vulnerability Remediation", + "", + "> This PR was generated by the Socket-centric vulnerability remediation workflow. Review the planned dependency changes and confirmation evidence before merging.", + "", + "### Fixed", + "| CVE/GHSA | Package | Ecosystem | Old Version | New Version | Manifest | Confirmation |", + "|---|---|---|---|---|---|---|", + ] + confirmed_ids = {item.get("id") for item in confirmation.get("confirmed", [])} + for item in fix_result.get("fixed", []): + vuln_id = item.get("ghsa") or item.get("cve") or item.get("id") or "Unavailable from detector" + status = "confirmed" if vuln_id in confirmed_ids else "unconfirmed" + lines.append( + f"| {vuln_id} | {item.get('package','')} | {item.get('ecosystem','')} | {item.get('old_version','')} | {item.get('new_version','')} | {item.get('manifest','')} | {status} |" + ) + if not fix_result.get("fixed"): + lines.append("| (none) | | | | | | |") + + lines.extend([ + "", + "### Deferred / Rejected", + "| CVE/GHSA | Package | Reason |", + "|---|---|---|", + ]) + deferred = triage.get("deferred") or fix_result.get("reverted") or [] + for item in deferred: + vuln_id = item.get("ghsa") or item.get("cve") or item.get("id") or "Unavailable from detector" + lines.append(f"| {vuln_id} | {item.get('package','')} | {item.get('reason','')} |") + if not deferred: + lines.append("| (none) | | |") + return "\n".join(lines) + "\n" + + +def summarize_fix_result(context: dict[str, Any], confirmation: dict[str, Any]) -> dict[str, Any]: + confirmed_ids = {item.get("id") for item in confirmation.get("confirmed", [])} + fixed = [] + reverted = [] + for item in context.get("fixes", []): + if item.get("id") in confirmed_ids: + fixed.append( + { + "id": item.get("id"), + "cve": item.get("cve"), + "ghsa": item.get("ghsa"), + "package": item.get("package"), + "ecosystem": item.get("ecosystem"), + "old_version": item.get("old_version"), + "new_version": item.get("target_version") or "see lockfile", + "manifest": ", ".join(item.get("manifests", [])), + } + ) + else: + reverted.append( + { + "id": item.get("id"), + "cve": item.get("cve"), + "ghsa": item.get("ghsa"), + "package": item.get("package"), + "ecosystem": item.get("ecosystem"), + "reason": "Fix was not confirmed after dependency updates.", + } + ) + return { + "fixed": fixed, + "reverted": reverted, + "summary": f"{len(fixed)} fixed, {len(reverted)} unconfirmed", + } + + +def read_changed_files(root: Path, path: Path | None) -> list[str]: + if path: + return [line.strip() for line in path.read_text().splitlines() if line.strip()] + output = run_git(["diff", "--name-only"], root) or "" + return [line.strip() for line in output.splitlines() if line.strip()] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + normalize = sub.add_parser("normalize") + normalize.add_argument("--input", required=True, type=Path) + normalize.add_argument("--output", required=True, type=Path) + + context = sub.add_parser("build-context") + context.add_argument("--input", required=True, type=Path) + context.add_argument("--fix-plan", type=Path) + context.add_argument("--output", required=True, type=Path) + + validate = sub.add_parser("validate-diff") + validate.add_argument("--repo-root", type=Path, default=Path(".")) + validate.add_argument("--context", type=Path) + validate.add_argument("--changed-files", type=Path) + validate.add_argument("--output", type=Path) + + confirm = sub.add_parser("confirm") + confirm.add_argument("--repo-root", type=Path, default=Path(".")) + confirm.add_argument("--context", required=True, type=Path) + confirm.add_argument("--output", required=True, type=Path) + + pr_body = sub.add_parser("render-pr-body") + pr_body.add_argument("--triage", required=True, type=Path) + pr_body.add_argument("--fix-result", required=True, type=Path) + pr_body.add_argument("--confirmation", required=True, type=Path) + pr_body.add_argument("--output", required=True, type=Path) + + summarize = sub.add_parser("summarize-fix") + summarize.add_argument("--context", required=True, type=Path) + summarize.add_argument("--confirmation", required=True, type=Path) + summarize.add_argument("--output", required=True, type=Path) + + args = parser.parse_args() + if args.command == "normalize": + write_json(args.output, normalize_input(load_json(args.input, {}))) + return 0 + if args.command == "build-context": + write_json(args.output, build_context(load_json(args.input, {}), load_json(args.fix_plan, {}) if args.fix_plan else None)) + return 0 + if args.command == "validate-diff": + result = validate_diff(args.repo_root, read_changed_files(args.repo_root, args.changed_files), load_json(args.context, {}) if args.context else {}) + if args.output: + write_json(args.output, {"ok": result.ok, "errors": result.errors}) + if not result.ok: + for error in result.errors: + print(error, file=sys.stderr) + return 1 + return 0 + if args.command == "confirm": + result = confirm_fix(args.repo_root, load_json(args.context, {})) + write_json(args.output, result) + return 0 if result.get("ok") else 1 + if args.command == "render-pr-body": + args.output.write_text(render_pr_body(load_json(args.triage, {}), load_json(args.fix_result, {}), load_json(args.confirmation, {}))) + return 0 + if args.command == "summarize-fix": + write_json(args.output, summarize_fix_result(load_json(args.context, {}), load_json(args.confirmation, {}))) + return 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main())