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
14 changes: 14 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ Global patterns used widely:
|------|---------|
| `--json` | Machine-readable JSON (same shape MCP tools use) |
| `--mode audit\|warn\|block` | Enforcement mode |
| `--fail-on none\|warn\|block` | Process exit threshold for scan commands (see below) |
| `--policy <path>` | Custom policy file |
| `--offline` | Use local cache / DB only (no registry fetch when required data is missing, fail closed) |
| `--behavior disabled\|heuristic\|isolated` | Optional lifecycle execution (default: disabled) |

**Scan exit codes:** package scan commands print a decision and, by default, exit
`0` in `warn`/`audit` mode so interactive review stays usable. With
`--mode block` (or explicit `--fail-on block`), a `block` or `review_required`
decision exits `1` so scripts fail closed:

```bash
pkgsafe scan-npm-package axois --mode block && npm install axois # blocked install never runs
pkgsafe scan-local-npm ./pkg --fail-on warn # exit 1 on warn or worse
```

`pkgsafe scan` (workspace) defaults to failing on block even in warn mode so it
stays a project gate. Use `--fail-on none` to override.

---

## Health and version
Expand Down
8 changes: 8 additions & 0 deletions internal/ci/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) {
fmt.Fprintf(w, "Exceptions Used: %s\n", strings.Join(result.ExceptionsUsed, ", "))
}
fmt.Fprintf(w, "Packages Scanned: %d\n", result.Summary.PackagesScanned)
if result.ChangedOnly && result.Summary.PackagesScanned == 0 {
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)
fmt.Fprintln(w, "Summary:")
fmt.Fprintf(w, "- Allow: %d\n", result.Summary.Allow)
Expand Down Expand Up @@ -133,6 +139,8 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) {
fmt.Fprintln(w, "Request authorized human review before merging.")
} else if result.Decision == "warn" {
fmt.Fprintln(w, "Review package warnings before merging.")
} else if result.ChangedOnly && result.Summary.PackagesScanned == 0 {
fmt.Fprintln(w, "No dependency changes to gate. Use a full scan if you need whole-lockfile coverage.")
} else {
fmt.Fprintln(w, "Package scan completed successfully. No action required.")
}
Expand Down
30 changes: 30 additions & 0 deletions internal/ci/summary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ci

import (
"bytes"
"strings"
"testing"
)

func TestWriteHumanSummaryChangedOnlyZeroPackages(t *testing.T) {
var buf bytes.Buffer
WriteHumanSummary(&buf, &ScanResult{
Decision: "allow",
Mode: "warn",
FailOn: "block",
Lockfile: "package-lock.json",
Ecosystem: "npm",
ChangedOnly: true,
Summary: Summary{PackagesScanned: 0},
})
out := buf.String()
if !strings.Contains(out, "changed-only scan found 0 packages") {
t.Fatalf("expected zero-package notice, got:\n%s", out)
}
if !strings.Contains(out, "not that the full project is clean") {
t.Fatalf("expected full-project disclaimer, got:\n%s", out)
}
if !strings.Contains(out, "No dependency changes to gate") {
t.Fatalf("expected recommended action for zero changes, got:\n%s", out)
}
}
115 changes: 75 additions & 40 deletions pkg/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ func cmdScanPyPIPackage(args []string) error {
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
offline := fs.Bool("offline", false, "run scan offline using cached database and metadata")
behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated")
sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic; PyPI execution remains disabled without isolated backend")
Expand Down Expand Up @@ -344,14 +345,18 @@ func cmdScanPyPIPackage(args []string) error {
}
res = stripEnterprise(res, false)
_ = saveResult(res)
return output.Write(os.Stdout, res, *asJSON)
if err := output.Write(os.Stdout, res, *asJSON); err != nil {
return err
}
return exitIfScanFails(res.Decision, pol.Mode, *failOn)
}

func cmdScanPythonDeps(args []string) error {
fs := flag.NewFlagSet("scan-python-deps", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
offline := fs.Bool("offline", false, "run scan offline using cached database and metadata")
registryConfig := fs.String("registry-config", "", "path to registries.yaml")
if err := fs.Parse(reorderFlags(args)); err != nil {
Expand Down Expand Up @@ -387,24 +392,28 @@ func cmdScanPythonDeps(args []string) error {
if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(results)
}
for i, res := range results {
if i > 0 {
fmt.Fprintln(os.Stdout)
}
if err := output.Write(os.Stdout, res, false); err != nil {
if err := enc.Encode(results); err != nil {
return err
}
} else {
for i, res := range results {
if i > 0 {
fmt.Fprintln(os.Stdout)
}
if err := output.Write(os.Stdout, res, false); err != nil {
return err
}
}
}
return nil
return exitIfScanResultsFail(results, pol.Mode, *failOn)
}

func cmdScanGoDeps(args []string) error {
fs := flag.NewFlagSet("scan-go-deps", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
offline := fs.Bool("offline", false, "run scan offline using cached database and metadata")
registryConfig := fs.String("registry-config", "", "path to registries.yaml")
if err := fs.Parse(reorderFlags(args)); err != nil {
Expand Down Expand Up @@ -453,24 +462,28 @@ func cmdScanGoDeps(args []string) error {
if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(results)
}
for i, res := range results {
if i > 0 {
fmt.Fprintln(os.Stdout)
}
if err := output.Write(os.Stdout, res, false); err != nil {
if err := enc.Encode(results); err != nil {
return err
}
} else {
for i, res := range results {
if i > 0 {
fmt.Fprintln(os.Stdout)
}
if err := output.Write(os.Stdout, res, false); err != nil {
return err
}
}
}
return nil
return exitIfScanResultsFail(results, pol.Mode, *failOn)
}

func cmdScanCargoDeps(args []string) error {
fs := flag.NewFlagSet("scan-cargo-deps", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
offline := fs.Bool("offline", false, "run scan offline using cached database and metadata")
registryConfig := fs.String("registry-config", "", "path to registries.yaml")
if err := fs.Parse(reorderFlags(args)); err != nil {
Expand Down Expand Up @@ -519,24 +532,28 @@ func cmdScanCargoDeps(args []string) error {
if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(results)
}
for i, res := range results {
if i > 0 {
fmt.Fprintln(os.Stdout)
}
if err := output.Write(os.Stdout, res, false); err != nil {
if err := enc.Encode(results); err != nil {
return err
}
} else {
for i, res := range results {
if i > 0 {
fmt.Fprintln(os.Stdout)
}
if err := output.Write(os.Stdout, res, false); err != nil {
return err
}
}
}
return nil
return exitIfScanResultsFail(results, pol.Mode, *failOn)
}

func cmdScanLocalNPM(args []string) error {
fs := flag.NewFlagSet("scan-local-npm", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated")
sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic")
timeout := fs.Duration("timeout", 10*time.Second, "behavior-analysis execution timeout")
Expand Down Expand Up @@ -599,7 +616,10 @@ func cmdScanLocalNPM(args []string) error {
}
res = stripEnterprise(res, false)
_ = saveResult(res)
return output.Write(os.Stdout, res, *asJSON)
if err := output.Write(os.Stdout, res, *asJSON); err != nil {
return err
}
return exitIfScanFails(res.Decision, pol.Mode, *failOn)
}

func cmdScanNPMPackage(args []string) error {
Expand All @@ -608,6 +628,7 @@ func cmdScanNPMPackage(args []string) error {
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
offline := fs.Bool("offline", false, "run scan offline using cached database and metadata")
behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated")
sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic")
Expand Down Expand Up @@ -676,14 +697,18 @@ func cmdScanNPMPackage(args []string) error {
}
res = stripEnterprise(res, false)
_ = saveResult(res)
return output.Write(os.Stdout, res, *asJSON)
if err := output.Write(os.Stdout, res, *asJSON); err != nil {
return err
}
return exitIfScanFails(res.Decision, pol.Mode, *failOn)
}

func cmdScanLockfile(args []string) error {
fs := flag.NewFlagSet("scan-lockfile", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
_ = fs.Bool("offline", false, "run scan offline using cached database")
registryConfig := fs.String("registry-config", "", "path to registries.yaml")
if err := fs.Parse(reorderFlags(args)); err != nil {
Expand Down Expand Up @@ -713,7 +738,10 @@ func cmdScanLockfile(args []string) error {
}
res = stripEnterprise(res, false)
logLockfileToAudit(pol, lockPath, res)
return output.Write(os.Stdout, res, *asJSON)
if err := output.Write(os.Stdout, res, *asJSON); err != nil {
return err
}
return exitIfScanFails(res.Decision, pol.Mode, *failOn)
}

func detectEcosystem(pkgName string, pol policy.Policy, offline bool) (string, string) {
Expand Down Expand Up @@ -2038,6 +2066,7 @@ func cmdScan(args []string) error {
asJSON := fs.Bool("json", false, "write JSON output")
policyPath := fs.String("policy", "", "policy YAML path")
mode := fs.String("mode", "", "audit, warn, or block")
failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)")
offline := fs.Bool("offline", false, "run scan offline using cached database")
registryConfig := fs.String("registry-config", "", "path to registries.yaml")
if err := fs.Parse(reorderFlags(args)); err != nil {
Expand Down Expand Up @@ -2342,10 +2371,26 @@ func cmdScan(args []string) error {
}
}

// Aggregate worst decision across workspace files (decisions may be mixed case).
aggDecision := types.DecisionAllow
for _, r := range results {
aggDecision = worseDecision(aggDecision, types.Decision(strings.ToLower(r.decision)))
}

// Workspace scan historically failed on block even in warn mode; keep that
// default so `pkgsafe scan` remains a fail-closed project gate.
workspaceFailOn := *failOn
if strings.TrimSpace(workspaceFailOn) == "" {
workspaceFailOn = "block"
}

if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(jsonResults)
if err := enc.Encode(jsonResults); err != nil {
return err
}
return exitIfScanFails(aggDecision, pol.Mode, workspaceFailOn)
}

var bold, reset string
Expand Down Expand Up @@ -2389,17 +2434,7 @@ func cmdScan(args []string) error {
}
fmt.Println()

hasBlock := false
for _, r := range results {
if summaryDecisionIsBlocking(r.decision) {
hasBlock = true
}
}
if hasBlock {
return fmt.Errorf("scan failed: one or more project files violate policy")
}

return nil
return exitIfScanFails(aggDecision, pol.Mode, workspaceFailOn)
}

func unique(in []string) []string {
Expand Down
Loading
Loading