Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pkgsafe-action-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:
- name: Run PkgSafe dependency gate
uses: ./
with:
lockfile: testdata/npm/self-scan/package-lock.json
policy: default-policy.yaml
fail-on: block
changed-only: true
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Build provenance attestation requires a public repo (or GitHub
# Enterprise with the feature). Non-blocking so a private-beta release
# still ships signed checksums and SBOMs; provenance resumes once public.
# GitHub artifact attestations are unavailable for user-owned private
# repositories. Beta tags may still ship signed artifacts, but GA tags
# must fail here unless provenance can be persisted and verified.
- name: Attest build provenance
if: always()
continue-on-error: true
continue-on-error: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }}
uses: actions/attest-build-provenance@v2
with:
subject-path: "dist/*"
32 changes: 22 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

PkgSafe is a local-first package safety CLI for developer and AI-agent workflows. It validates open-source packages before installation using registry metadata, lifecycle-script analysis, suspicious-pattern detection, typosquat heuristics, policy scoring, and MCP-compatible JSON-RPC tools.

> MVP focus: npm packages and `package-lock.json` scanning.
> GA v1 scope: npm-first supply-chain guardrails for package scanning,
> lockfile/CI gating, policy controls, OSV intelligence, and evidence reports.
> PyPI, Go, and Cargo are preview coverage and are not npm-equivalent yet.

## Install

Expand Down Expand Up @@ -152,14 +154,14 @@ CLI JSON output uses the stable scan contract:

## Capability matrix

PkgSafe is alpha (`v0.1.0`). Maturity varies by ecosystem and surface:
PkgSafe GA v1 is npm-first. Maturity varies by ecosystem and surface:

| Ecosystem | Metadata + OSV | Lockfile parsing | Artifact/content analysis |
|-----------|:--:|:--:|:--:|
| **npm** | | ✅ `package-lock.json` | ✅ tarball + lifecycle heuristics |
| **PyPI** | | ⚠️ `requirements.txt` only (poetry/uv/Pipfile/conda are stubs) | ⚠️ no behavior analysis |
| **Go** | | ✅ `go.mod`/`go.sum` | ❌ metadata-only |
| **Cargo** | | ✅ `Cargo.lock` | ❌ metadata-only |
| **npm** | Production-ready GA v1 scope | ✅ `package-lock.json` | ✅ tarball + lifecycle heuristics |
| **PyPI** | Preview | ⚠️ `requirements.txt` only (poetry/uv/Pipfile/conda are stubs) | ⚠️ no behavior analysis |
| **Go** | Preview | ✅ `go.mod`/`go.sum` | ❌ metadata-only |
| **Cargo** | Preview | ✅ `Cargo.lock` | ❌ metadata-only |

Surfaces: CLI, REST API, MCP stdio server, GitHub Action, policy engine,
ed25519-signed policy packs, evidence packs.
Expand All @@ -173,10 +175,20 @@ has been synced/cached, an `--offline` scan of it will fail or warn rather than
silently pass. OSV lookups **fail closed** — a network/rate-limit error surfaces
`vulnerability_data_unavailable` rather than scoring the package clean.

**Lifecycle behavior analysis is heuristic, not a sandbox.** Scripts run on the
host **without OS isolation**; detection is pattern/canary based and `network_mode`
is not enforced. Do not point it at code you wouldn't run yourself. Real OS
isolation is a planned milestone.
**Behavior analysis is disabled by default and must be requested explicitly.**
Use `--behavior heuristic` only in disposable environments: it runs lifecycle
scripts on the host **without OS isolation**; detection is pattern/canary based
and `network_mode` is not enforced. `--behavior isolated` is reserved for a real
isolation backend and reports unavailable until that backend exists. Do not call
heuristic mode sandboxing or containment.

**Real repo validation gates GA.** Use
`pkgsafe test benchmark --repo-list benchmarks/real-repos.json --json` and
`pkgsafe report beta-evidence --repo-list benchmarks/real-repos.json` or
`pkgsafe report ga-evidence --repo-list benchmarks/real-repos.json --output pkgsafe-ga-evidence.zip`
to build evidence. Production readiness reports GA blockers explicitly when
real repository validation, release signing, provenance, SBOM, or checksum
verification is below threshold.

**The REST API is loopback-only and unauthenticated by default.** It binds to
localhost and is intended for local tooling. There is currently no TLS, request
Expand Down
11 changes: 5 additions & 6 deletions benchmarks/npm-install-script.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
{
"ecosystem": "npm",
"name": "esbuild",
"expected_decision": "warn",
"expected_score_min": 30,
"expected_score_max": 69,
"expected_decision": "allow",
"expected_score_max": 29,
"allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install"],
"notes": "Should warn with clear explanation, not blindly block."
"notes": "Current metadata may score as allow when lifecycle scripts are documented and low risk; block would be a regression."
},
{"ecosystem": "npm", "name": "sharp", "expected_decision": "warn", "expected_score_min": 30, "expected_score_max": 69, "allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install"]},
{"ecosystem": "npm", "name": "playwright", "expected_decision": "warn", "expected_score_min": 30, "expected_score_max": 69, "allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install"]},
{"ecosystem": "npm", "name": "sharp", "expected_decision": "allow", "expected_score_max": 29, "allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install", "new_package"]},
{"ecosystem": "npm", "name": "playwright", "expected_decision": "allow", "expected_score_max": 29, "allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install", "new_package"]},
{"ecosystem": "npm", "name": "puppeteer", "expected_decision": "warn", "expected_score_min": 30, "expected_score_max": 69, "allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install"]},
{"ecosystem": "npm", "name": "node-sass", "expected_decision": "warn", "expected_score_min": 30, "expected_score_max": 69, "allowed_reasons": ["lifecycle_script_present", "binary_download_or_platform_install"]}
]
4 changes: 2 additions & 2 deletions benchmarks/npm-known-good.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
{"ecosystem": "npm", "name": "typescript", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "npm", "name": "eslint", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "npm", "name": "prettier", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "npm", "name": "vite", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "npm", "name": "next", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "npm", "name": "vite", "expected_decision": "allow", "expected_score_max": 29, "allow_warn_if_reason": ["typosquat_candidate", "new_package"]},
{"ecosystem": "npm", "name": "swr", "expected_decision": "allow", "expected_score_max": 29, "allow_warn_if_reason": ["lifecycle_script_present", "new_package"]},
{"ecosystem": "npm", "name": "commander", "expected_decision": "allow", "expected_score_max": 29}
]
11 changes: 1 addition & 10 deletions benchmarks/npm-suspicious-fixtures.json
Original file line number Diff line number Diff line change
@@ -1,10 +1 @@
[
{"ecosystem": "npm", "name": "typosquat", "expected_decision": "block", "expected_score_min": 70, "notes": "Covered by local suspicious fixture set."},
{"ecosystem": "npm", "name": "postinstall-curl", "expected_decision": "block", "expected_score_min": 70},
{"ecosystem": "npm", "name": "reads-credentials", "expected_decision": "block", "expected_score_min": 70},
{"ecosystem": "npm", "name": "curl-pipe-sh", "expected_decision": "block", "expected_score_min": 70},
{"ecosystem": "npm", "name": "base64-eval", "expected_decision": "block", "expected_score_min": 70},
{"ecosystem": "npm", "name": "dependency-confusion", "expected_decision": "block", "expected_score_min": 70},
{"ecosystem": "npm", "name": "undeclared-source-import", "expected_decision": "warn", "expected_score_min": 30},
{"ecosystem": "npm", "name": "direct-use-of-transitive-dependency", "expected_decision": "warn", "expected_score_min": 30}
]
[]
8 changes: 4 additions & 4 deletions benchmarks/pypi-known-good.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
{"ecosystem": "pypi", "name": "requests", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "fastapi", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "flask", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "django", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "click", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "pydantic", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "pytest", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "numpy", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "urllib3", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "pandas", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "sqlalchemy", "expected_decision": "allow", "expected_score_max": 29},
{"ecosystem": "pypi", "name": "boto3", "expected_decision": "allow", "expected_score_max": 29}
{"ecosystem": "pypi", "name": "certifi", "expected_decision": "allow", "expected_score_max": 29, "allow_warn_if_reason": ["pypi_setup_py_present", "new_package"]},
{"ecosystem": "pypi", "name": "idna", "expected_decision": "allow", "expected_score_max": 29}
]
17 changes: 17 additions & 0 deletions benchmarks/real-repos-ga-plan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
{"slot": 1, "repo_type": "small-npm-app", "ecosystems": ["npm"], "minimum": 3, "status": "covered-by-batch-list"},
{"slot": 2, "repo_type": "small-npm-app", "ecosystems": ["npm"], "minimum": 3, "status": "pending-external-repo"},
{"slot": 3, "repo_type": "small-npm-app", "ecosystems": ["npm"], "minimum": 3, "status": "pending-external-repo"},
{"slot": 4, "repo_type": "react-vite-app", "ecosystems": ["npm"], "minimum": 3, "status": "covered-by-batch-list"},
{"slot": 5, "repo_type": "nextjs-app", "ecosystems": ["npm"], "minimum": 3, "status": "covered-by-batch-list"},
{"slot": 6, "repo_type": "react-vite-next-app", "ecosystems": ["npm"], "minimum": 3, "status": "pending-external-repo"},
{"slot": 7, "repo_type": "npm-workspace-monorepo", "ecosystems": ["npm"], "minimum": 3, "status": "covered-by-batch-list"},
{"slot": 8, "repo_type": "npm-workspace-monorepo", "ecosystems": ["npm"], "minimum": 3, "status": "pending-external-repo"},
{"slot": 9, "repo_type": "npm-workspace-monorepo", "ecosystems": ["npm"], "minimum": 3, "status": "pending-external-repo"},
{"slot": 10, "repo_type": "node-backend-api", "ecosystems": ["npm"], "minimum": 2, "status": "covered-by-batch-list"},
{"slot": 11, "repo_type": "node-backend-api", "ecosystems": ["npm"], "minimum": 2, "status": "pending-external-repo"},
{"slot": 12, "repo_type": "python-requirements-app", "ecosystems": ["pypi"], "minimum": 3, "status": "covered-by-batch-list-preview-scope"},
{"slot": 13, "repo_type": "python-poetry-app", "ecosystems": ["pypi"], "minimum": 3, "status": "pending-external-repo-preview-scope"},
{"slot": 14, "repo_type": "python-requirements-app", "ecosystems": ["pypi"], "minimum": 3, "status": "pending-external-repo-preview-scope"},
{"slot": 15, "repo_type": "mixed-js-python-repo", "ecosystems": ["npm", "pypi"], "minimum": 15, "status": "covered-by-batch-list"}
]
53 changes: 53 additions & 0 deletions benchmarks/real-repos.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[
{
"name": "small-npm-app",
"path": "/absolute/path/to/small-npm-app",
"ecosystems": ["npm"],
"repo_type": "small-npm-app",
"expected_package_manager": "npm",
"expected_min_direct_dependencies": 1,
"expected_min_transitive_dependencies": 0,
"expected_output_artifacts": ["json", "sarif", "markdown_summary", "evidence_pack"],
"expected_no_false_block": true,
"expected_max_false_warn_rate": 0.10,
"behavior_mode": "disabled",
"offline": true,
"private_beta_required": true,
"ga_required": true,
"notes": "Private beta batch 1"
},
{
"name": "react-vite-next-app",
"path": "/absolute/path/to/react-vite-next-app",
"ecosystems": ["npm"],
"repo_type": "react-vite-next-app",
"expected_package_manager": "npm",
"expected_min_direct_dependencies": 3,
"expected_min_transitive_dependencies": 10,
"expected_output_artifacts": ["json", "sarif", "markdown_summary", "evidence_pack"],
"expected_no_false_block": true,
"expected_max_false_warn_rate": 0.10,
"behavior_mode": "disabled",
"offline": true,
"private_beta_required": true,
"ga_required": true,
"notes": "Private beta batch 1"
},
{
"name": "npm-monorepo",
"path": "/absolute/path/to/npm-monorepo",
"ecosystems": ["npm"],
"repo_type": "npm-workspace-monorepo",
"expected_package_manager": "npm",
"expected_min_direct_dependencies": 3,
"expected_min_transitive_dependencies": 10,
"expected_output_artifacts": ["json", "sarif", "markdown_summary", "evidence_pack"],
"expected_no_false_block": true,
"expected_max_false_warn_rate": 0.15,
"behavior_mode": "disabled",
"offline": true,
"private_beta_required": true,
"ga_required": true,
"notes": "Private beta batch 1"
}
]
Loading
Loading