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 docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `supply-chain init`: PowerShell profile injection is now supported. On machines where PowerShell (`pwsh`) is detected, `init` writes wrapper functions into the `CurrentUserAllHosts` profile (`~/.config/powershell/profile.ps1` on Unix/macOS, `Documents\PowerShell\profile.ps1` for pwsh on Windows, or `Documents\WindowsPowerShell\profile.ps1` for Windows PowerShell 5.1). Package managers with dotted names (e.g. `pip3.12`) are skipped in the PowerShell profile — PowerShell function names may not contain dots — and a muted note explains the skip, listing any non-dotted alternatives that are still wrapped. (PPSC-1065)
- `supply-chain`: a wrapped install that fails right after the age filter now names the likely culprit instead of a generic note. The proxy is graph-blind — when it withholds a brand-new release and repoints `latest` to an older version, that older version may no longer satisfy a dependent's range, and npm/pnpm/bun/yarn then reject the install. On the npm family a deterministic post-install, one-hop constraint check reports exactly which dependency became unsatisfiable and which package required it (e.g. `scheduler has no version older than the 3-day policy that satisfies ^0.24.0 (required by react-dom)`). The check reads dependency ranges npm already embeds in the metadata the proxy fetched — it is one hop, advisory, and recover-guarded so it can never affect the finished install; multi-hop chains and full resolution are out of scope. pip/uv get the blocked-package name plus a pointer to `uv tree`/`pipdeptree` (PyPI's Simple API carries no dependency ranges). The failure note leads with the protection rationale and lays out remediation surgical-first (allow one package → team exception → relax window), deliberately omitting the global kill switch so a frustrated developer isn't nudged to the blunt instrument. (PPSC-984)
- `supply-chain`: a machine-readable compliance report for audit trails ("prove no young package entered this build"). Set `ARMIS_SUPPLY_CHAIN_REPORT=<path>` for a wrapped install (`-` writes to stderr), or pass `--report <path>` to `supply-chain check`. The JSON carries the effective policy, the enforcement mode (`proxy`/`pre-install`/`check`), and the `checked`/`blocked`/`resolved`/`warned_through`/`conflicts` sets plus `install_status`, so CI can gate with `jq`. A wrap report uses an env var, not a flag, because the wrapped command forwards every flag verbatim to the underlying package manager. (PPSC-984)
- `supply-chain`: an opt-in `transitive-policy: warn` config key (and `ARMIS_SUPPLY_CHAIN_TRANSITIVE=warn` env override for the wrap path) that lets a young **transitive** dependency through with a warning instead of failing the build, while still hard-blocking young **direct** dependencies. The default stays `block` — no posture change without opt-in. Direct vs. transitive is determined by reading the root `package.json` (npm family only); if the direct set can't be determined the proxy fails safe and treats every package as direct (blocks). Each warned-through package is printed and marked in the compliance report so security teams can audit exactly which freshly-published packages entered the build. Residual risk and scope are documented in `docs/FEATURES.md`. (PPSC-984)
Expand Down
57 changes: 54 additions & 3 deletions internal/cmd/supply_chain_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ registry responses. poetry, pipenv, pdm, mvn, and gradle use a pre-install check
that blocks the build if violations are found.

Four modes are available:
rc — Inject shell functions into ~/.bashrc / ~/.zshrc (default, interactive)
rc — Inject shell functions into ~/.bashrc / ~/.zshrc / fish config /
PowerShell profile (default, interactive)
env — Print an eval command for CI or manual sourcing
npmrc — Add a marker comment to .npmrc (the registry override itself is set
dynamically by 'supply-chain wrap'; use with the rc or env modes)
Expand Down Expand Up @@ -297,6 +298,35 @@ func summarizeDetectedPMs(s *output.Styles, pms []string) string {
return strings.Join(parts, ", ")
}

// powerShellSkippedDottedPMs returns the package-manager names in pms that a
// PowerShell wrapper cannot define, because PowerShell function names may not
// contain a dot (e.g. pip3.12). It returns nil unless at least one detected
// shell is PowerShell — the limitation only matters when a PowerShell wrapper is
// actually being written, and on a bash/zsh/fish-only machine the dotted variant
// is wrapped normally. The init flow uses this to print a one-line note so a
// PowerShell user understands pip3.12 was skipped while pip and pip3 still cover
// the common case (the runtime canonicalizes every pip variant to PyPI anyway).
func powerShellSkippedDottedPMs(pms []string, shells []supplychain.Shell) []string {
hasPowerShell := false
for _, sh := range shells {
if supplychain.IsPowerShell(sh.Name) {
hasPowerShell = true
break
}
}
if !hasPowerShell {
return nil
}

var dotted []string
for _, pm := range pms {
if strings.Contains(pm, ".") {
dotted = append(dotted, pm)
}
}
return dotted
}

// promptYesNo asks the user a yes/no question and reports their answer.
//
// On an interactive terminal it renders a themed huh.Confirm, matching the
Expand Down Expand Up @@ -443,7 +473,7 @@ func runInitRC(pms []string) error {

shells := supplychain.DetectShells()
if len(shells) == 0 {
return fmt.Errorf("no supported shells detected (bash, zsh, or fish)")
return fmt.Errorf("no supported shells detected (bash, zsh, fish, or PowerShell)")
}

// Short-circuit: if every detected shell already has the exact wrapper we
Expand Down Expand Up @@ -480,6 +510,27 @@ func runInitRC(pms []string) error {
fmt.Fprintf(os.Stderr, "%s %s\n\n",
s.MutedText.Render("Package manager(s) to wrap:"), summarizeDetectedPMs(s, pms))

// PowerShell cannot define functions whose name contains a dot, so a versioned
// pip variant (pip3.12) is skipped in the PowerShell profile. Surface that as a
// single muted line — only when a PowerShell shell is among those detected and a
// dotted variant is actually present — so the user knows the skip is intentional
// and that pip/pip3 still cover the common case.
if dotted := powerShellSkippedDottedPMs(pms, shells); len(dotted) > 0 {
var nonDottedPip []string
for _, pm := range pms {
if (pm == "pip" || pm == "pip3") && !strings.Contains(pm, ".") {
nonDottedPip = append(nonDottedPip, pm)
}
}
var suffix string
if len(nonDottedPip) > 0 {
suffix = fmt.Sprintf("; %s are wrapped instead", strings.Join(nonDottedPip, " and "))
}
fmt.Fprintf(os.Stderr, "%s\n\n", s.MutedText.Render(fmt.Sprintf(
"Note: %s can't be wrapped in PowerShell (dotted function names are unsupported)%s.",
strings.Join(dotted, ", "), suffix)))
}

// Preview each distinct wrapper. bash/zsh share the posix wrapper while fish
// uses different syntax, so group shells by the wrapper they produce to keep
// the preview accurate when multiple shells are detected.
Expand Down Expand Up @@ -524,7 +575,7 @@ func runInitRC(pms []string) error {

fmt.Fprintf(os.Stderr, "\n%s Restart your shell or run:\n", s.SuccessText.Render("Done!"))
for _, sh := range shells {
fmt.Fprintf(os.Stderr, " %s\n", s.Bold.Render("source "+sh.RCFile))
fmt.Fprintf(os.Stderr, " %s\n", s.Bold.Render(supplychain.ShellReloadCommand(sh.Name, sh.RCFile)))
}
policy := resolveWrapPolicy()
fmt.Fprintf(os.Stderr, "\n%s block packages published less than %s ago\n", s.MutedText.Render("Policy:"), policy.MinReleaseAge)
Expand Down
66 changes: 66 additions & 0 deletions internal/cmd/supply_chain_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,72 @@ func TestSummarizeDetectedPMs(t *testing.T) {
}
}

// TestPowerShellSkippedDottedPMs verifies the helper that drives the init-time
// note about pip variants PowerShell can't wrap: it reports dotted PM names only
// when a PowerShell shell is among those detected, and stays silent on a
// POSIX/fish-only machine (where dotted variants wrap fine).
func TestPowerShellSkippedDottedPMs(t *testing.T) {
pwsh := []supplychain.Shell{{Name: "pwsh", RCFile: "/p/profile.ps1"}}
posix := []supplychain.Shell{{Name: "bash", RCFile: "/h/.bashrc"}}
mixed := []supplychain.Shell{
{Name: "zsh", RCFile: "/h/.zshrc"},
{Name: "powershell", RCFile: "/p/profile.ps1"},
}

tests := []struct {
name string
pms []string
shells []supplychain.Shell
want []string
}{
{
name: "powershell detected with a dotted variant",
pms: []string{"npm", "pip", "pip3", "pip3.12"},
shells: pwsh,
want: []string{"pip3.12"},
},
{
name: "multiple dotted variants are all reported",
pms: []string{"pip3.11", "pip3.12"},
shells: pwsh,
want: []string{"pip3.11", "pip3.12"},
},
{
name: "powershell among mixed shells still reports",
pms: []string{"pip", "pip3.10"},
shells: mixed,
want: []string{"pip3.10"},
},
{
name: "no powershell means no note even with a dotted variant",
pms: []string{"pip", "pip3.12"},
shells: posix,
want: nil,
},
{
name: "powershell but no dotted variant is silent",
pms: []string{"npm", "pip", "pip3"},
shells: pwsh,
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := powerShellSkippedDottedPMs(tt.pms, tt.shells)
if len(got) != len(tt.want) {
t.Fatalf("powerShellSkippedDottedPMs = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("powerShellSkippedDottedPMs = %v, want %v", got, tt.want)
break
}
}
})
}
}

func TestExtractScope(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/supply_chain_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ func runSupplyChainStatusJSON(dir string) error {
result.Ecosystems = []statusEcosystemJSON{}
}

// armis:ignore cwe:770 reason:DetectShells returns at most one entry per known shell (bash/zsh/fish); the result set is bounded by a fixed allowlist, not by attacker input
// armis:ignore cwe:770 reason:DetectShells returns at most one entry per known shell (bash/zsh/fish/powershell); the result set is bounded by a fixed allowlist, not by attacker input
shells := supplychain.DetectShells()
for _, sh := range shells {
active := supplychain.HasInjection(sh.RCFile)
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/supply_chain_uninit.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ var scUninitCmd = &cobra.Command{
Short: "Remove shell wrapper functions injected by supply-chain init",
Long: `Remove the changes made by 'armis-cli supply-chain init'.

This scans your shell RC files (bashrc, zshrc, fish config) for armis-cli supply-chain
This scans your shell RC files (bashrc, zshrc, fish config, PowerShell profile) for armis-cli supply-chain
blocks and removes them, and strips the marker comment from a project .npmrc in the
current directory if present. Your package manager will return to its normal behavior.

Expand Down
Loading
Loading