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
20 changes: 18 additions & 2 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ pkgsafe ci scan [flags]
| `--policy <path>` | Policy file (defaults to `.pkgsafe/policy.yaml` if present) |
| `--mode audit\|warn\|block` | Decision mode |
| `--fail-on none\|warn\|block` | Minimum decision that fails the job (default: `block`) |
| `--changed-only` | Only packages changed vs baseline |
| `--changed-only` | Only packages changed vs baseline (PR-style) |
| `--full` | Scan the **entire** lockfile (overrides policy `ci.changed_only` and `--changed-only`) |
| `--baseline <branch>` | Baseline branch (default: `main`) |
| `--ecosystem npm\|pypi\|…` | When the project is not npm-default |
| `--behavior disabled\|heuristic\|isolated` | Default `disabled` |
Expand All @@ -30,6 +31,15 @@ pkgsafe ci scan [flags]

`--sandbox` is deprecated; it means `--behavior heuristic` (host execution, not a sandbox).

### Full vs changed-only (important)

| Mode | When to use | Caveat |
|------|-------------|--------|
| **Changed-only** (`--changed-only` or policy `ci.changed_only: true`) | PR gates: only new/updated deps | If **0 packages** changed, decision is ALLOW — that does **not** mean the whole tree is clean. Output shows `scan_coverage: changed_only_empty` and a NOTICE. |
| **Full** (`--full` or `--changed-only=false`) | Main branch, release, nightly, audit | Scans every package in the lockfile |

The GitHub Action defaults to **changed-only** for PR speed. For merge-to-main or release jobs, use `--full`.

## Exit codes

| Code | Meaning |
Expand All @@ -49,10 +59,16 @@ pkgsafe ci scan [flags]
pkgsafe ci scan --changed-only --baseline main --fail-on block
```

**Full lockfile gate (main / release):**

```bash
pkgsafe ci scan --full --fail-on block
```

**Offline + SARIF for code scanning upload:**

```bash
pkgsafe ci scan --offline --sarif-output pkgsafe-results.sarif
pkgsafe ci scan --full --offline --sarif-output pkgsafe-results.sarif
```

**PyPI project:**
Expand Down
4 changes: 4 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ pkgsafe verify package-lock.json
```bash
pkgsafe npm-install <packages...>
pkgsafe npm <args...> # intercept; non-install commands pass through
pkgsafe pnpm <args...> # install/add/i/ci
pkgsafe yarn <args...> # install/add (bare yarn = project install)
pkgsafe uv <args...> # uv pip install / uv add / uv sync
pkgsafe pip <args...>
pkgsafe python -m pip <args...>
```

Install paths scan first, then call the real package manager only if allowed.
Shell shims (`pkgsafe init shell`) alias npm, pnpm, yarn, pip, and uv.

---

Expand Down
16 changes: 16 additions & 0 deletions docs/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,28 @@ Still limited or out of scope:
- Direct URL / VCS deps may surface as unknown rather than scanned as a registry
package of the same name.

## Install interception coverage

| Manager | Intercept commands | Notes |
|---------|-------------------|--------|
| npm | `install` / `i` / `add` / `ci` | Primary |
| pnpm | `install` / `i` / `add` / `ci` | npm ecosystem scanners |
| yarn | `install` / `add` (bare `yarn`) | npm ecosystem scanners |
| pip | `install` | Primary for PyPI |
| uv | `pip install`, `add`, `sync` | PyPI scanners |
| python -m pip | `install` | Same as pip |

Git URLs, local paths, and tarballs still pass through without full scan.
Poetry/pdm/conda native installers are not first-class intercept targets.

## Other surfaces

- Local REST API is for **loopback** developer use, not a public service.
- Windows is supported for the binary; some isolation features remain Linux-only.
- Generated release artifacts and signing proofs must come from the official
release pipeline when you verify production installs.
- `test production-readiness` defaults to `benchmarks/real-repos.json` when
present so real-repo validation is counted without extra flags.

## Related

Expand Down
23 changes: 13 additions & 10 deletions internal/ci/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,19 @@ type Summary struct {
}

type ScanResult struct {
SchemaVersion string `json:"schema_version"`
Tool string `json:"tool"`
Command string `json:"command"`
Mode string `json:"mode"`
FailOn string `json:"fail_on"`
Decision string `json:"decision"`
Lockfile string `json:"lockfile"`
DependencyFiles []string `json:"dependency_files,omitempty"`
Ecosystem string `json:"ecosystem,omitempty"`
ChangedOnly bool `json:"changed_only"`
SchemaVersion string `json:"schema_version"`
Tool string `json:"tool"`
Command string `json:"command"`
Mode string `json:"mode"`
FailOn string `json:"fail_on"`
Decision string `json:"decision"`
Lockfile string `json:"lockfile"`
DependencyFiles []string `json:"dependency_files,omitempty"`
Ecosystem string `json:"ecosystem,omitempty"`
ChangedOnly bool `json:"changed_only"`
// ScanCoverage describes how much of the lockfile was actually gated:
// "full", "changed_only", or "changed_only_empty" (zero packages scanned).
ScanCoverage string `json:"scan_coverage,omitempty"`
Baseline string `json:"baseline"`
BaselineType string `json:"baseline_type,omitempty"`
Summary Summary `json:"summary"`
Expand Down
11 changes: 11 additions & 0 deletions internal/ci/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,15 @@ func RunScan(opts ScanOptions) (*ScanResult, error) {
exceptionsUsed = uniqueStrings(exceptionsUsed)
enrichVulnerabilitySummary(&summary, findings)

scanCoverage := "full"
if isChangedOnlyScan {
if summary.PackagesScanned == 0 {
scanCoverage = "changed_only_empty"
} else {
scanCoverage = "changed_only"
}
}

return &ScanResult{
SchemaVersion: "1.0",
Tool: "pkgsafe",
Expand All @@ -315,6 +324,7 @@ func RunScan(opts ScanOptions) (*ScanResult, error) {
Lockfile: lockfile,
Ecosystem: "npm",
ChangedOnly: isChangedOnlyScan,
ScanCoverage: scanCoverage,
Baseline: opts.Baseline,
BaselineType: baselineType,
Summary: summary,
Expand Down Expand Up @@ -474,6 +484,7 @@ func runPyPIScan(opts ScanOptions, pol policy.Policy, failOn string) (*ScanResul
DependencyFiles: files,
Ecosystem: "pypi",
ChangedOnly: false,
ScanCoverage: "full",
Baseline: opts.Baseline,
Summary: summary,
Findings: findings,
Expand Down
5 changes: 4 additions & 1 deletion internal/ci/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) {
fmt.Fprintf(w, "Lockfile: %s\n", result.Lockfile)
}
fmt.Fprintf(w, "Changed Only: %v\n", result.ChangedOnly)
if result.ScanCoverage != "" {
fmt.Fprintf(w, "Scan Coverage: %s\n", result.ScanCoverage)
}
if result.PolicyPack != "" {
fmt.Fprintf(w, "Policy Pack: %s@%s\n", result.PolicyPack, result.PolicyPackVersion)
}
Expand All @@ -35,7 +38,7 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) {
fmt.Fprintln(w)
fmt.Fprintln(w, "NOTICE: changed-only scan found 0 packages.")
fmt.Fprintln(w, " Decision ALLOW means no dependency changes were gated — not that the full project is clean.")
fmt.Fprintln(w, " Run with --changed-only=false for a full lockfile scan.")
fmt.Fprintln(w, " Run with --full (or --changed-only=false) for a complete lockfile scan.")
}
fmt.Fprintln(w)
fmt.Fprintln(w, "Summary:")
Expand Down
14 changes: 12 additions & 2 deletions internal/cli/init_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,19 @@ func autoInstallAliases() error {
// Append shims
var shims string
if shellName == "fish" {
shims = "\n# PkgSafe package install guard\nfunction npm\n pkgsafe npm $argv\nend\nfunction pip\n pkgsafe pip $argv\nend\n"
shims = "\n# PkgSafe package install guard\n" +
"function npm\n pkgsafe npm $argv\nend\n" +
"function pnpm\n pkgsafe pnpm $argv\nend\n" +
"function yarn\n pkgsafe yarn $argv\nend\n" +
"function pip\n pkgsafe pip $argv\nend\n" +
"function uv\n pkgsafe uv $argv\nend\n"
} else {
shims = "\n# PkgSafe package install guard\nalias npm=\"pkgsafe npm\"\nalias pip=\"pkgsafe pip\"\n"
shims = "\n# PkgSafe package install guard\n" +
"alias npm=\"pkgsafe npm\"\n" +
"alias pnpm=\"pkgsafe pnpm\"\n" +
"alias yarn=\"pkgsafe yarn\"\n" +
"alias pip=\"pkgsafe pip\"\n" +
"alias uv=\"pkgsafe uv\"\n"
}

f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
Expand Down
22 changes: 22 additions & 0 deletions internal/cli/node_intercept.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package cli

import (
"context"

"github.com/sairintechnologycom/pkgsafe/internal/intercept"
)

func PnpmIntercept(args []string) error {
ctx := context.Background()
return intercept.RunIntercept(ctx, "pnpm", args, intercept.DefaultExecutor{})
}

func YarnIntercept(args []string) error {
ctx := context.Background()
return intercept.RunIntercept(ctx, "yarn", args, intercept.DefaultExecutor{})
}

func UVIntercept(args []string) error {
ctx := context.Background()
return intercept.RunIntercept(ctx, "uv", args, intercept.DefaultExecutor{})
}
13 changes: 9 additions & 4 deletions internal/intercept/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ func (e DefaultExecutor) Resolve(pm string, pol policy.Policy) (string, error) {
// 1. Check if policy specifies a real_binary path
var configPath string
switch pm {
case "npm":
configPath = pol.PackageManagers.NPM.RealBinary
case "pip", "python-pip":
configPath = pol.PackageManagers.Pip.RealBinary
case "npm", "pnpm", "yarn":
// Node-family managers share NPM real_binary only for npm; others use PATH.
if pm == "npm" {
configPath = pol.PackageManagers.NPM.RealBinary
}
case "pip", "python-pip", "uv":
if pm == "pip" || pm == "python-pip" {
configPath = pol.PackageManagers.Pip.RealBinary
}
}

if configPath != "" {
Expand Down
6 changes: 6 additions & 0 deletions internal/intercept/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ func ParseCommand(pm string, args []string) (*InstallCommand, error) {
switch pm {
case "npm":
return ParseNPM(args)
case "pnpm":
return ParsePnpm(args)
case "yarn":
return ParseYarn(args)
case "uv":
return ParseUV(args)
case "pip":
return ParsePip(args)
case "python":
Expand Down
133 changes: 133 additions & 0 deletions internal/intercept/pm_parsers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package intercept

import (
"fmt"
"strings"
)

// ParsePnpm parses pnpm install/add/i/ci commands into the shared install model.
// Ecosystem remains npm; package manager is pnpm for binary resolution.
func ParsePnpm(args []string) (*InstallCommand, error) {
return parseNodeFamily("pnpm", args, []string{"install", "i", "add", "ci"})
}

// ParseYarn parses yarn install/add commands (classic and modern CLI).
func ParseYarn(args []string) (*InstallCommand, error) {
if len(args) == 0 {
// bare `yarn` is a project install
return &InstallCommand{
Ecosystem: "npm",
PackageManager: "yarn",
RawCommand: args,
Operation: "install",
IsProjectInstall: true,
DependencyFiles: []string{"package.json"},
}, nil
}
return parseNodeFamily("yarn", args, []string{"install", "add"})
}

// ParseUV parses uv package installs: `uv pip install …` and `uv add …`.
func ParseUV(args []string) (*InstallCommand, error) {
if len(args) == 0 {
return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("no command provided")}
}

// uv pip install …
if args[0] == "pip" {
if len(args) < 2 {
return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported uv pip command")}
}
cmd, err := ParsePip(args[1:])
if err != nil {
return nil, err
}
cmd.PackageManager = "uv"
cmd.RawCommand = args
return cmd, nil
}

// uv add pkg [pkg…]
if args[0] == "add" {
cmd := &InstallCommand{
Ecosystem: "pypi",
PackageManager: "uv",
RawCommand: args,
Operation: "add",
}
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "-") {
cmd.Flags = append(cmd.Flags, arg)
continue
}
if isUnsupportedPipInput(arg) {
return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported advanced uv input: %s", arg)}
}
name, specifier, exact := parsePipSpec(arg)
cmd.Packages = append(cmd.Packages, PackageRequest{
Name: name,
VersionSpecifier: specifier,
ExactVersion: exact,
IsDirect: true,
Source: "pypi",
})
}
if len(cmd.Packages) == 0 {
return nil, InterceptError{Code: ExitUsageError, Err: fmt.Errorf("uv add requires at least one package")}
}
return cmd, nil
}

// uv sync installs from lockfile — treat as project install for scanning.
if args[0] == "sync" {
return &InstallCommand{
Ecosystem: "pypi",
PackageManager: "uv",
RawCommand: args,
Operation: "sync",
IsProjectInstall: true,
DependencyFiles: []string{"pyproject.toml", "uv.lock", "requirements.txt"},
}, nil
}

return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported uv command: %s", args[0])}
}

func parseNodeFamily(pm string, args []string, ops []string) (*InstallCommand, error) {
if len(args) == 0 {
return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("no command provided")}
}

op := args[0]
allowed := false
for _, candidate := range ops {
if op == candidate {
allowed = true
break
}
}
if !allowed {
return nil, InterceptError{Code: ExitUnsupportedCommand, Err: fmt.Errorf("unsupported %s command: %s", pm, op)}
}

// Reuse npm install parsing for package extraction, then rebrand manager.
npmArgs := args
// yarn/pnpm use `add` which npm parser already accepts.
cmd, err := ParseNPM(npmArgs)
if err != nil {
return nil, err
}
cmd.PackageManager = pm
cmd.RawCommand = args

// Prefer ecosystem lockfiles when project install.
if cmd.IsProjectInstall || cmd.IsCIInstall {
switch pm {
case "pnpm":
cmd.DependencyFiles = []string{"package.json", "pnpm-lock.yaml"}
case "yarn":
cmd.DependencyFiles = []string{"package.json", "yarn.lock"}
}
}
return cmd, nil
}
Loading
Loading