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
3 changes: 2 additions & 1 deletion internal/cmd/supply_chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ compromised maintainer accounts, or dependency confusion.
Supported ecosystems:
Node: npm, pnpm, bun, yarn (transparent proxy enforcement)
Python: pip, uv (transparent proxy); poetry, pipenv, pdm (pre-install block)
Java: maven (pom.xml), gradle (gradle.lockfile) (pre-install block)

No Armis Cloud authentication is required — supply-chain queries public registries
(npm registry, PyPI).`,
(npm registry, PyPI, Maven Central).`,
Example: ` # Audit lockfile for recently-published packages (CI mode)
armis-cli supply-chain check

Expand Down
7 changes: 6 additions & 1 deletion internal/cmd/supply_chain_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ var scInitCmd = &cobra.Command{
This wraps your package manager (auto-detected from lockfiles) so that armis-cli
can enforce age policies on package installations. Node PMs (npm, pnpm, bun, yarn)
and pip/uv use a transparent proxy that filters registry responses. poetry, pipenv,
and pdm use a pre-install check that blocks the build if violations are found.
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)
Expand Down Expand Up @@ -144,6 +145,10 @@ func ecosystemToPM(eco supplychain.Ecosystem) string {
return pmPDM
case supplychain.EcosystemUV:
return pmUV
case supplychain.EcosystemMaven:
return pmMaven
case supplychain.EcosystemGradle:
return pmGradle
default:
return ""
}
Expand Down
17 changes: 14 additions & 3 deletions internal/cmd/supply_chain_wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const (
pmPoetry = "poetry"
pmPipenv = "pipenv"
pmPDM = "pdm"
pmMaven = "mvn"
pmGradle = "gradle"
)

var scWrapCmd = &cobra.Command{
Expand All @@ -57,6 +59,7 @@ func init() {
var allowedPMs = map[string]bool{
pmNPM: true, pmPNPM: true, pmBun: true, pmYarn: true,
pmPip: true, pmUV: true, pmPoetry: true, pmPipenv: true, pmPDM: true,
pmMaven: true, pmGradle: true,
}

func runSupplyChainWrap(cmd *cobra.Command, args []string) error {
Expand All @@ -70,7 +73,7 @@ func runSupplyChainWrap(cmd *cobra.Command, args []string) error {
canonical := canonicalPM(pmName)

if !allowedPMs[canonical] {
return fmt.Errorf("unsupported package manager: %s (allowed: npm, pnpm, bun, yarn, pip, uv, poetry, pipenv, pdm)", pmName)
return fmt.Errorf("unsupported package manager: %s (allowed: npm, pnpm, bun, yarn, pip, uv, poetry, pipenv, pdm, mvn, gradle)", pmName)
}

if os.Getenv(envSCActive) == "1" {
Expand Down Expand Up @@ -176,6 +179,10 @@ func execPM(pm string, args []string, extraEnv []string) (int, error) {
pmName = pmPipenv
case pmPDM:
pmName = pmPDM
case pmMaven:
pmName = pmMaven
case pmGradle:
pmName = pmGradle
default:
// Versioned pip variants (pip3, pip3.11, pip3.12) must execute the exact
// binary the user invoked so the install lands in that interpreter's
Expand All @@ -187,7 +194,7 @@ func execPM(pm string, args []string, extraEnv []string) (int, error) {
pmName = pm
break
}
return 1, fmt.Errorf("unsupported package manager: %s (allowed: npm, pnpm, bun, yarn, pip, uv, poetry, pipenv, pdm)", pm)
return 1, fmt.Errorf("unsupported package manager: %s (allowed: npm, pnpm, bun, yarn, pip, uv, poetry, pipenv, pdm, mvn, gradle)", pm)
}

// armis:ignore cwe:426 cwe:427 reason:pmName is one of the hardcoded string literals selected by the switch above, never the user argument; resolving the user's own PM from PATH is the point of a transparent wrapper
Expand Down Expand Up @@ -421,7 +428,7 @@ func resolveWrapPolicy() supplychain.Policy {
// too-young package.
func requiresPreInstallBlock(pm string) bool {
switch pm {
case pmPoetry, pmPipenv, pmPDM:
case pmPoetry, pmPipenv, pmPDM, pmMaven, pmGradle:
return true
}
return false
Expand Down Expand Up @@ -561,6 +568,10 @@ func pmToEcosystem(pm string) supplychain.Ecosystem {
return supplychain.EcosystemPipfile
case pmPDM:
return supplychain.EcosystemPDM
case pmMaven:
return supplychain.EcosystemMaven
case pmGradle:
return supplychain.EcosystemGradle
default:
return ""
}
Expand Down
11 changes: 11 additions & 0 deletions internal/supplychain/check/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ func parseLockfile(ecosystem supplychain.Ecosystem, path string) ([]PackageEntry
return ParsePDMLockfile(path)
case supplychain.EcosystemUV:
return ParseUVLockfile(path)
case supplychain.EcosystemMaven:
return ParseMavenDeps(path)
case supplychain.EcosystemGradle:
return ParseGradleLockfile(path)
default:
return ParseNPMLockfile(path)
}
Expand All @@ -114,6 +118,9 @@ func queryRegistry(ctx context.Context, ecosystem supplychain.Ecosystem, package
case supplychain.EcosystemPip, supplychain.EcosystemPoetry, supplychain.EcosystemPipfile, supplychain.EcosystemPDM, supplychain.EcosystemUV:
client := registry.NewPyPIClient()
return client.GetPublishDates(ctx, packages)
case supplychain.EcosystemMaven, supplychain.EcosystemGradle:
client := registry.NewMavenClient()
return client.GetPublishDates(ctx, packages)
default:
client := registry.NewClient()
return client.GetPublishDates(ctx, packages)
Expand All @@ -137,6 +144,10 @@ func detectEcosystemFromPath(path string) supplychain.Ecosystem {
return supplychain.EcosystemPDM
case strings.HasSuffix(lower, "uv.lock"):
return supplychain.EcosystemUV
case strings.HasSuffix(lower, "pom.xml"):
return supplychain.EcosystemMaven
case strings.HasSuffix(lower, "gradle.lockfile"):
return supplychain.EcosystemGradle
case isRequirementsFile(lower):
return supplychain.EcosystemPip
default:
Expand Down
79 changes: 79 additions & 0 deletions internal/supplychain/check/gradle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package check

import (
"bufio"
"bytes"
"fmt"
"strings"
)

// ParseGradleLockfile parses a Gradle lockfile (gradle.lockfile).
// Format: one dependency per line as "group:artifact:version=configurations"
// after a header, where the suffix after "=" is a comma-separated list of the
// configurations that resolved the dependency (e.g. "compileClasspath,runtimeClasspath").
// The parser treats everything after "=" as metadata and ignores it.
// armis:ignore cwe:22 cwe:23 cwe:73 reason:local CLI reading the user's own lockfile; path is from local detection or an explicit --lockfile flag, not untrusted input crossing a trust boundary
func ParseGradleLockfile(path string) ([]PackageEntry, error) {
// armis:ignore cwe:22 cwe:23 cwe:73 reason:local CLI reading the user's own lockfile; path is from local detection or an explicit --lockfile flag, not untrusted input crossing a trust boundary
data, err := readLockfile(path)
if err != nil {
return nil, err
}

scanner := bufio.NewScanner(bytes.NewReader(data))
// Gradle lockfile lines can carry a long, comma-separated configuration list
// after "=", so raise the scanner's per-line cap. data is already size-bounded
// by readLockfile.
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), maxLockfileSize)
Comment thread
yiftach-armis marked this conversation as resolved.

var entries []PackageEntry
headerPassed := false

for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())

if line == "" || strings.HasPrefix(line, "#") {
continue
}

// The header line "empty=" signals end of preamble in some formats
if !headerPassed {
if strings.Contains(line, "=") && !strings.Contains(line, ":") {
// Metadata line like "empty="
continue
}
headerPassed = true
}

// Expected: group:artifact:version=configurations
eqIdx := strings.Index(line, "=")
gav := line
if eqIdx > 0 {
gav = line[:eqIdx]
}

parts := strings.Split(gav, ":")
if len(parts) < 3 {
continue
}

group := parts[0]
artifact := parts[1]
version := parts[2]

if group == "" || artifact == "" || version == "" {
continue
}

entries = append(entries, PackageEntry{
Name: group + ":" + artifact,
Version: version,
})
}

if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning gradle lockfile: %w", err)
}

return entries, nil
}
44 changes: 44 additions & 0 deletions internal/supplychain/check/gradle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package check

import (
"path/filepath"
"sort"
"testing"
)

func TestParseGradleLockfile(t *testing.T) {
t.Run("valid gradle lockfile", func(t *testing.T) {
entries, err := ParseGradleLockfile(filepath.Join("testdata", "gradle.lockfile"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

sort.Slice(entries, func(i, j int) bool {
return entries[i].Name < entries[j].Name
})

expected := []PackageEntry{
{Name: "com.fasterxml.jackson.core:jackson-core", Version: "2.16.0"},
{Name: "com.google.guava:guava", Version: "32.1.3-jre"},
{Name: "org.slf4j:slf4j-api", Version: "2.0.9"},
{Name: "org.springframework:spring-core", Version: "6.1.2"},
}

if len(entries) != len(expected) {
t.Fatalf("expected %d entries, got %d: %+v", len(expected), len(entries), entries)
}

for i, e := range entries {
if e.Name != expected[i].Name || e.Version != expected[i].Version {
t.Errorf("entry %d: expected %s@%s, got %s@%s", i, expected[i].Name, expected[i].Version, e.Name, e.Version)
}
}
})

t.Run("file not found", func(t *testing.T) {
_, err := ParseGradleLockfile("testdata/nonexistent.lockfile")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
})
}
99 changes: 99 additions & 0 deletions internal/supplychain/check/maven.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package check

import (
"encoding/xml"
"fmt"
"strings"
)

type pomProject struct {
XMLName xml.Name `xml:"project"`
Dependencies pomDeps `xml:"dependencies"`
DepMgmt pomDepMgmt `xml:"dependencyManagement"`
}

type pomDepMgmt struct {
Dependencies pomDeps `xml:"dependencies"`
}

type pomDeps struct {
Dependency []pomDependency `xml:"dependency"`
}

type pomDependency struct {
GroupID string `xml:"groupId"`
ArtifactID string `xml:"artifactId"`
Version string `xml:"version"`
Scope string `xml:"scope"`
}

// ParseMavenDeps parses a pom.xml file for direct dependencies with explicit versions.
// Only direct dependencies are covered; transitive dependencies resolved by Maven
// at build time are not present in pom.xml. Entries under <dependencyManagement>
// are used only as a fallback version source for dependencies declared in
// <dependencies> that omit their own <version>; managed entries are not treated
// as dependencies themselves, since declaring a managed version does not pull a
// package into the build.
// armis:ignore cwe:22 cwe:23 cwe:73 reason:local CLI reading the user's own lockfile; path is from local detection or an explicit --lockfile flag, not untrusted input crossing a trust boundary
func ParseMavenDeps(path string) ([]PackageEntry, error) {
// armis:ignore cwe:22 cwe:23 cwe:73 reason:local CLI reading the user's own lockfile; path is from local detection or an explicit --lockfile flag, not untrusted input crossing a trust boundary
data, err := readLockfile(path)
if err != nil {
return nil, err
}

var project pomProject
// armis:ignore cwe:502 cwe:770 reason:xml.Unmarshal into a typed struct does not execute code; data is size-bounded by readLockfile and is the user's own lockfile, not untrusted data
if err := xml.Unmarshal(data, &project); err != nil {
return nil, fmt.Errorf("parsing pom.xml: %w", err)
}

// Build a groupId:artifactId -> version index from <dependencyManagement> so
// dependencies that omit their <version> can inherit the managed value.
managedVersions := make(map[string]string)
for _, dep := range project.DepMgmt.Dependencies.Dependency {
if dep.GroupID == "" || dep.ArtifactID == "" || dep.Version == "" {
continue
}
managedVersions[dep.GroupID+":"+dep.ArtifactID] = dep.Version
}

var entries []PackageEntry
seen := make(map[string]bool)

for _, dep := range project.Dependencies.Dependency {
// Backfill a missing version from <dependencyManagement> before converting.
if dep.Version == "" {
dep.Version = managedVersions[dep.GroupID+":"+dep.ArtifactID]
}
entry := mavenDepToEntry(dep)
if entry != nil && !seen[entry.Name+"@"+entry.Version] {
seen[entry.Name+"@"+entry.Version] = true
entries = append(entries, *entry)
}
}

return entries, nil
}

func mavenDepToEntry(dep pomDependency) *PackageEntry {
if dep.GroupID == "" || dep.ArtifactID == "" || dep.Version == "" {
return nil
}

// Skip property references that can't be resolved
if strings.Contains(dep.Version, "${") {
return nil
}

// Skip test and provided scope
scope := strings.ToLower(dep.Scope)
if scope == "test" || scope == "provided" {
return nil
}

return &PackageEntry{
Name: dep.GroupID + ":" + dep.ArtifactID,
Version: dep.Version,
}
}
Loading
Loading