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
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
go-version: '1.20.1'
- name: Install pnpm
run: npm install -g pnpm
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: get Python location
id: python-location
run: |
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ public class TrustifyExample {
<li><a href="https://www.java.com/">Java</a> - <a href="https://maven.apache.org/">Maven</a></li>
<li><a href="https://www.javascript.com//">JavaScript</a> - <a href="https://www.npmjs.com//">Npm</a></li>
<li><a href="https://go.dev//">Golang</a> - <a href="https://go.dev/blog/using-go-modules//">Go Modules</a></li>
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> (<code>requirements.txt</code>, <code>pyproject.toml</code> with PEP 621 format). <strong>Note:</strong> Poetry-style dependencies (<code>[tool.poetry.dependencies]</code>) are not supported.</li>
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> / <a href="https://docs.astral.sh/uv/">uv</a> (<code>requirements.txt</code>, <code>pyproject.toml</code> with PEP 621 format). <strong>Note:</strong> Poetry-style dependencies (<code>[tool.poetry.dependencies]</code>) are not supported.</li>
<li><a href="https://gradle.org//">Gradle</a> - <a href="https://gradle.org/install//">Gradle Installation</a></li>
<li><a href="https://www.rust-lang.org/">Rust</a> - <a href="https://doc.rust-lang.org/cargo/">Cargo</a></li>

Expand Down Expand Up @@ -393,6 +393,7 @@ System.setProperty("TRUSTIFY_DA_PYTHON3_PATH", "/path/to/python3");
System.setProperty("TRUSTIFY_DA_PIP3_PATH", "/path/to/pip3");
System.setProperty("TRUSTIFY_DA_PYTHON_PATH", "/path/to/python");
System.setProperty("TRUSTIFY_DA_PIP_PATH", "/path/to/pip");
System.setProperty("TRUSTIFY_DA_UV_PATH", "/path/to/custom/uv");
// Configure proxy for all requests
System.setProperty("TRUSTIFY_DA_PROXY_URL", "http://proxy.example.com:8080");
// Configure Maven settings and repository
Expand Down Expand Up @@ -504,6 +505,11 @@ following keys for setting custom paths for the said executables.
<td>TRUSTIFY_DA_PIP_PATH</td>
</tr>
<tr>
<td><a href="https://docs.astral.sh/uv/">uv Package Manager</a></td>
<td><em>uv</em></td>
<td>TRUSTIFY_DA_UV_PATH</td>
</tr>
<tr>
<td><a href="https://doc.rust-lang.org/cargo/">Cargo Package Manager</a></td>
<td><em>cargo</em></td>
<td>TRUSTIFY_DA_CARGO_PATH</td>
Expand Down Expand Up @@ -600,6 +606,14 @@ TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED=false

Python support works with both `requirements.txt` and `pyproject.toml` manifest files. The `pyproject.toml` provider scans production dependencies from PEP 621 `[project.dependencies]` and Poetry `[tool.poetry.dependencies]` sections. Optional dependencies (`[project.optional-dependencies]`) and Poetry group dependencies (`[tool.poetry.group.*.dependencies]`) are intentionally excluded to focus on runtime dependencies.

##### uv Support

When a `uv.lock` file is present alongside `pyproject.toml`, the client automatically uses the [uv](https://docs.astral.sh/uv/) package manager for dependency resolution instead of pip. Dependency data is collected via `uv export --format requirements.txt --frozen --no-hashes --no-dev`. No additional configuration is required — the provider is selected automatically based on lock file detection.

To use a custom uv binary, set `TRUSTIFY_DA_UV_PATH` to the path of your uv executable.

##### pip Support

By default, Python support assumes that the package is installed using the pip/pip3 binary on the system PATH, or of the customized
Binaries passed to environment variables. If the package is not installed , then an error will be thrown.

Expand Down Expand Up @@ -683,6 +697,12 @@ Options:
- `--output <path>` - Write SBOM JSON to the specified file
- (default) - Print SBOM JSON to stdout

**SBOM for uv-managed Python project**
```shell
# When uv.lock is present alongside pyproject.toml, the uv provider is used automatically
java -jar trustify-da-java-client-cli.jar sbom /path/to/uv-project/pyproject.toml
```

**Image Analysis**
```shell
java -jar trustify-da-java-client-cli.jar image <image_ref> [<image_ref>...] [--summary|--html]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright 2023-2025 Trustify Dependency Analytics Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.guacsec.trustifyda.providers;

import io.github.guacsec.trustifyda.tools.Operations;
import io.github.guacsec.trustifyda.utils.Environment;
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.function.Function;

/**
* Factory for creating the appropriate {@link PythonProvider} based on lock file presence. Follows
* the same pattern as {@link JavaScriptProviderFactory}.
*/
public final class PythonProviderFactory {

private static final Map<String, Function<Path, PythonProvider>> PYTHON_PROVIDERS =
Map.of(PythonUvProvider.LOCK_FILE, PythonUvProvider::new);

/**
* Creates a Python provider for {@code pyproject.toml} manifests by checking for known lock files
* in the manifest directory and parent directories (for workspace members). When {@code uv.lock}
* is present, returns a {@link PythonUvProvider}; otherwise falls back to {@link
* PythonPyprojectProvider} (pip-based).
*
* @param manifestPath the path to the pyproject.toml manifest
* @return the matching Python provider
*/
public static PythonProvider create(final Path manifestPath) {
Comment thread
a-oren marked this conversation as resolved.
var manifestDir = manifestPath.getParent();

// Check manifest directory first (fast path)
for (var entry : PYTHON_PROVIDERS.entrySet()) {
if (Files.isRegularFile(manifestDir.resolve(entry.getKey()))) {
return entry.getValue().apply(manifestPath);
}
}

// Walk up parent directories to find lock file at workspace root
Path lockFileDir = findLockFileDirInParents(manifestDir);
if (lockFileDir != null) {
for (var entry : PYTHON_PROVIDERS.entrySet()) {
if (Files.isRegularFile(lockFileDir.resolve(entry.getKey()))) {
return entry.getValue().apply(manifestPath);
}
}
}

// Unlike JavaScript, pip fallback is valid — no lock file required
return new PythonPyprojectProvider(manifestPath);
}

/**
* Walks up from the given directory to find a parent containing a known Python lock file.
* Respects {@code TRUSTIFY_DA_WORKSPACE_DIR} override, stops at uv workspace root boundaries and
* the git root.
*
* @param startDir the directory to start searching from (typically the manifest directory)
* @return the directory containing the lock file, or {@code null} if not found
*/
static Path findLockFileDirInParents(Path startDir) {
Comment thread
a-oren marked this conversation as resolved.
// Environment override takes precedence
String workspaceDirOverride = Environment.get("TRUSTIFY_DA_WORKSPACE_DIR");
if (workspaceDirOverride != null && !workspaceDirOverride.isBlank()) {
Path overrideDir = Path.of(workspaceDirOverride);
for (String lockFile : PYTHON_PROVIDERS.keySet()) {
if (Files.isRegularFile(overrideDir.resolve(lockFile))) {
return overrideDir;
}
}
return null;
}

// If startDir itself is a workspace root, don't walk up to avoid escaping into a parent
// workspace
if (PyprojectTomlUtils.isUvWorkspaceRoot(startDir)) {
return null;
}

String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null);
Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot();

Path current = startDir.toAbsolutePath().normalize().getParent();
while (current != null) {
for (String lockFile : PYTHON_PROVIDERS.keySet()) {
if (Files.isRegularFile(current.resolve(lockFile))) {
return current;
}
}

// Stop at uv workspace root boundary
if (PyprojectTomlUtils.isUvWorkspaceRoot(current)) {
return null;
}

if (current.equals(boundary.toAbsolutePath().normalize())) {
break;
}

current = current.getParent();
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import io.github.guacsec.trustifyda.sbom.SbomFactory;
import io.github.guacsec.trustifyda.tools.Operations;
import io.github.guacsec.trustifyda.utils.Environment;
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
Expand All @@ -42,10 +43,8 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.tomlj.Toml;
import org.tomlj.TomlArray;
import org.tomlj.TomlParseResult;
import org.tomlj.TomlTable;

/**
* Provider for Python projects using {@code pyproject.toml} with <a
Expand Down Expand Up @@ -133,7 +132,7 @@ private void addDependencyTree(
PackageURL packageURL = toPurl(pkg.name, pkg.version);
sbom.addDependency(source, packageURL, null);

String key = canonicalize(pkg.name);
String key = PyprojectTomlUtils.canonicalize(pkg.name);
if (!visited.add(key)) {
return;
}
Expand Down Expand Up @@ -266,7 +265,7 @@ PipReportData parsePipReport(String reportJson) throws IOException {
}
String name = extractDepName(reqStr);
if (name != null) {
directDepNames.add(canonicalize(name));
directDepNames.add(PyprojectTomlUtils.canonicalize(name));
}
}
}
Expand All @@ -285,7 +284,7 @@ PipReportData parsePipReport(String reportJson) throws IOException {
if (name == null) {
continue;
}
String key = canonicalize(name);
String key = PyprojectTomlUtils.canonicalize(name);
graph.put(key, new PipPackage(name, version, new ArrayList<>()));
}

Expand All @@ -299,7 +298,7 @@ PipReportData parsePipReport(String reportJson) throws IOException {
if (name == null) {
continue;
}
String key = canonicalize(name);
String key = PyprojectTomlUtils.canonicalize(name);
PipPackage pipPkg = graph.get(key);
if (pipPkg == null) {
continue;
Expand All @@ -317,7 +316,7 @@ PipReportData parsePipReport(String reportJson) throws IOException {
if (depName == null) {
continue;
}
String depKey = canonicalize(depName);
String depKey = PyprojectTomlUtils.canonicalize(depName);
if (graph.containsKey(depKey)) {
pipPkg.children.add(depKey);
}
Expand All @@ -338,28 +337,18 @@ static String extractDepName(String req) {
return m.find() ? m.group(1) : null;
}

static String canonicalize(String name) {
return name.toLowerCase().replaceAll("[-_.]+", "-");
}

private TomlParseResult getToml() throws IOException {
if (cachedToml == null) {
TomlParseResult parsed = Toml.parse(manifestPath);
if (parsed.hasErrors()) {
throw new IOException(
"Invalid pyproject.toml format: " + parsed.errors().get(0).getMessage());
}
cachedToml = parsed;
cachedToml = PyprojectTomlUtils.parseToml(manifestPath);
}
return cachedToml;
}

@Override
protected String getRootComponentName() {
try {
TomlParseResult toml = getToml();
String name = toml.getString("project.name");
if (name != null && !name.isBlank()) {
String name = PyprojectTomlUtils.getProjectName(getToml());
if (name != null) {
return name;
}
} catch (IOException e) {
Expand All @@ -371,9 +360,8 @@ protected String getRootComponentName() {
@Override
protected String getRootComponentVersion() {
try {
TomlParseResult toml = getToml();
String version = toml.getString("project.version");
if (version != null && !version.isBlank()) {
String version = PyprojectTomlUtils.getProjectVersion(getToml());
if (version != null) {
return version;
}
} catch (IOException e) {
Expand All @@ -385,16 +373,10 @@ protected String getRootComponentVersion() {
@Override
public String readLicenseFromManifest() {
try {
TomlParseResult toml = getToml();
String license = toml.getString("project.license");
if (license != null && !license.isBlank()) {
String license = PyprojectTomlUtils.getLicense(getToml());
if (license != null) {
return license;
}
// PEP 639: license may be in project.license.text
String licenseText = toml.getString("project.license.text");
if (licenseText != null && !licenseText.isBlank()) {
return licenseText;
}
} catch (IOException e) {
log.fine("Failed to parse pyproject.toml for license: " + e.getMessage());
}
Expand All @@ -416,28 +398,15 @@ protected Set<PackageURL> getIgnoredDependencies(String manifestContent) {
}

private void rejectPoetryDependencies() throws IOException {
TomlParseResult toml = getToml();
TomlTable poetryDeps = toml.getTable("tool.poetry.dependencies");
if (poetryDeps != null) {
if (PyprojectTomlUtils.hasPoetryDependencies(getToml())) {
throw new IllegalStateException(
"Poetry dependencies in pyproject.toml are not supported."
+ " Please use PEP 621 [project.dependencies] format instead.");
}
}

private void collectIgnoredDeps() throws IOException {
TomlParseResult toml = getToml();
List<String> rawLines = Files.readAllLines(manifestPath);
collectedIgnoredDeps = new HashSet<>();

// [project.dependencies] - PEP 621
TomlArray projectDeps = toml.getArray("project.dependencies");
if (projectDeps != null) {
for (int i = 0; i < projectDeps.size(); i++) {
String dep = projectDeps.getString(i);
checkIgnored(rawLines, dep, dep);
}
}
collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml());
}

List<String> parseDependencyStrings() throws IOException {
Expand All @@ -455,15 +424,6 @@ List<String> parseDependencyStrings() throws IOException {
return deps;
}

private void checkIgnored(List<String> rawLines, String searchToken, String depIdentifier) {
for (String line : rawLines) {
if (line.contains(searchToken) && containsIgnorePattern(line)) {
collectedIgnoredDeps.add(depIdentifier);
break;
}
}
}

static final class PipPackage {
final String name;
final String version;
Expand Down
Loading
Loading