1616 */
1717package io .github .guacsec .trustifyda .providers ;
1818
19+ import com .fasterxml .jackson .databind .JsonNode ;
1920import com .github .packageurl .PackageURL ;
21+ import io .github .guacsec .trustifyda .Api ;
2022import io .github .guacsec .trustifyda .license .LicenseUtils ;
2123import io .github .guacsec .trustifyda .logging .LoggersFactory ;
24+ import io .github .guacsec .trustifyda .sbom .Sbom ;
25+ import io .github .guacsec .trustifyda .sbom .SbomFactory ;
26+ import io .github .guacsec .trustifyda .tools .Operations ;
27+ import io .github .guacsec .trustifyda .utils .Environment ;
2228import io .github .guacsec .trustifyda .utils .PythonControllerBase ;
2329import java .io .IOException ;
30+ import java .nio .charset .StandardCharsets ;
2431import java .nio .file .Files ;
2532import java .nio .file .Path ;
2633import java .util .ArrayList ;
34+ import java .util .Base64 ;
35+ import java .util .HashMap ;
2736import java .util .HashSet ;
2837import java .util .List ;
38+ import java .util .Map ;
2939import java .util .Set ;
3040import java .util .logging .Logger ;
41+ import java .util .regex .Matcher ;
42+ import java .util .regex .Pattern ;
3143import java .util .stream .Collectors ;
3244import org .tomlj .Toml ;
3345import org .tomlj .TomlArray ;
@@ -39,6 +51,12 @@ public final class PythonPyprojectProvider extends PythonProvider {
3951 private static final Logger log =
4052 LoggersFactory .getLogger (PythonPyprojectProvider .class .getName ());
4153
54+ static final String PROP_TRUSTIFY_DA_PIP_REPORT = "TRUSTIFY_DA_PIP_REPORT" ;
55+
56+ private static final Pattern DEP_NAME_PATTERN =
57+ Pattern .compile ("^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)" );
58+ private static final Pattern EXTRA_MARKER_PATTERN = Pattern .compile (";\\ s*.*extra\\ s*==" );
59+
4260 private Set <String > collectedIgnoredDeps ;
4361 private TomlParseResult cachedToml ;
4462
@@ -47,18 +65,214 @@ public PythonPyprojectProvider(Path manifest) {
4765 }
4866
4967 @ Override
50- protected Path getRequirementsPath () throws IOException {
51- List <String > depStrings = parseDependencyStrings ();
52- Path tmpDir = Files .createTempDirectory ("trustify_da_pyproject_" );
53- Path tmpFile = Files .createFile (tmpDir .resolve ("requirements.txt" ));
54- Files .write (tmpFile , depStrings );
55- return tmpFile ;
68+ protected Path getRequirementsPath () {
69+ throw new UnsupportedOperationException ("pip report approach does not use requirements.txt" );
70+ }
71+
72+ @ Override
73+ protected void cleanupRequirementsPath (Path requirementsPath ) {
74+ // no-op: pip report approach does not create temporary files
75+ }
76+
77+ @ Override
78+ public Content provideStack () throws IOException {
79+ parseDependencyStrings ();
80+ String reportJson = getPipReportOutput (manifest .getParent ());
81+ PipReportData data = parsePipReport (reportJson );
82+
83+ Sbom sbom = SbomFactory .newInstance (Sbom .BelongingCondition .PURL , "sensitive" );
84+ sbom .addRoot (
85+ toPurl (getRootComponentName (), getRootComponentVersion ()), readLicenseFromManifest ());
86+
87+ for (String directKey : data .directDeps ) {
88+ PipPackage pkg = data .graph .get (directKey );
89+ if (pkg != null ) {
90+ addDependencyTree (sbom .getRoot (), pkg , data .graph , sbom , new HashSet <>());
91+ }
92+ }
93+
94+ String manifestContent = Files .readString (manifest );
95+ handleIgnoredDependencies (manifestContent , sbom );
96+ return new Content (
97+ sbom .getAsJsonString ().getBytes (StandardCharsets .UTF_8 ), Api .CYCLONEDX_MEDIA_TYPE );
5698 }
5799
58100 @ Override
59- protected void cleanupRequirementsPath (Path requirementsPath ) throws IOException {
60- Files .deleteIfExists (requirementsPath );
61- Files .deleteIfExists (requirementsPath .getParent ());
101+ public Content provideComponent () throws IOException {
102+ parseDependencyStrings ();
103+ String reportJson = getPipReportOutput (manifest .getParent ());
104+ PipReportData data = parsePipReport (reportJson );
105+
106+ Sbom sbom = SbomFactory .newInstance ();
107+ sbom .addRoot (
108+ toPurl (getRootComponentName (), getRootComponentVersion ()), readLicenseFromManifest ());
109+
110+ for (String directKey : data .directDeps ) {
111+ PipPackage pkg = data .graph .get (directKey );
112+ if (pkg != null ) {
113+ sbom .addDependency (sbom .getRoot (), toPurl (pkg .name , pkg .version ), null );
114+ }
115+ }
116+
117+ String manifestContent = Files .readString (manifest );
118+ handleIgnoredDependencies (manifestContent , sbom );
119+ return new Content (
120+ sbom .getAsJsonString ().getBytes (StandardCharsets .UTF_8 ), Api .CYCLONEDX_MEDIA_TYPE );
121+ }
122+
123+ private void addDependencyTree (
124+ PackageURL source ,
125+ PipPackage pkg ,
126+ Map <String , PipPackage > graph ,
127+ Sbom sbom ,
128+ Set <String > visited ) {
129+ PackageURL packageURL = toPurl (pkg .name , pkg .version );
130+ sbom .addDependency (source , packageURL , null );
131+
132+ String key = canonicalize (pkg .name );
133+ if (!visited .add (key )) {
134+ return ;
135+ }
136+
137+ for (String childKey : pkg .children ) {
138+ PipPackage child = graph .get (childKey );
139+ if (child != null ) {
140+ addDependencyTree (packageURL , child , graph , sbom , visited );
141+ }
142+ }
143+ }
144+
145+ String getPipReportOutput (Path manifestDir ) {
146+ String envValue = Environment .get (PROP_TRUSTIFY_DA_PIP_REPORT );
147+ if (envValue != null && !envValue .isBlank ()) {
148+ return new String (Base64 .getDecoder ().decode (envValue ), StandardCharsets .UTF_8 );
149+ }
150+
151+ String pip = findPipBinary ();
152+ return Operations .runProcessGetOutput (
153+ manifestDir ,
154+ new String [] {pip , "install" , "--dry-run" , "--ignore-installed" , "--report" , "-" , "." });
155+ }
156+
157+ private String findPipBinary () {
158+ String pip = Operations .getCustomPathOrElse ("pip3" );
159+ try {
160+ Operations .runProcess (pip , "--version" );
161+ return pip ;
162+ } catch (Exception e ) {
163+ pip = Operations .getCustomPathOrElse ("pip" );
164+ Operations .runProcess (pip , "--version" );
165+ return pip ;
166+ }
167+ }
168+
169+ PipReportData parsePipReport (String reportJson ) throws IOException {
170+ JsonNode report = objectMapper .readTree (reportJson );
171+ JsonNode installArray = report .get ("install" );
172+ if (installArray == null || !installArray .isArray ()) {
173+ return new PipReportData (List .of (), Map .of ());
174+ }
175+
176+ // Find root entry (has dir_info in download_info) and collect non-root packages
177+ JsonNode rootEntry = null ;
178+ List <JsonNode > nonRootPackages = new ArrayList <>();
179+ for (JsonNode entry : installArray ) {
180+ JsonNode downloadInfo = entry .get ("download_info" );
181+ if (downloadInfo != null && downloadInfo .has ("dir_info" )) {
182+ rootEntry = entry ;
183+ } else {
184+ nonRootPackages .add (entry );
185+ }
186+ }
187+
188+ // Extract direct dependency names from root's requires_dist
189+ Set <String > directDepNames = new HashSet <>();
190+ if (rootEntry != null ) {
191+ JsonNode metadata = rootEntry .get ("metadata" );
192+ if (metadata != null ) {
193+ JsonNode requiresDist = metadata .get ("requires_dist" );
194+ if (requiresDist != null && requiresDist .isArray ()) {
195+ for (JsonNode req : requiresDist ) {
196+ String reqStr = req .asText ();
197+ if (hasExtraMarker (reqStr )) {
198+ continue ;
199+ }
200+ String name = extractDepName (reqStr );
201+ if (name != null ) {
202+ directDepNames .add (canonicalize (name ));
203+ }
204+ }
205+ }
206+ }
207+ }
208+
209+ // Build graph from non-root packages
210+ Map <String , PipPackage > graph = new HashMap <>();
211+ for (JsonNode pkg : nonRootPackages ) {
212+ JsonNode metadata = pkg .get ("metadata" );
213+ if (metadata == null ) {
214+ continue ;
215+ }
216+ String name = metadata .has ("name" ) ? metadata .get ("name" ).asText () : null ;
217+ String version = metadata .has ("version" ) ? metadata .get ("version" ).asText () : null ;
218+ if (name == null ) {
219+ continue ;
220+ }
221+ String key = canonicalize (name );
222+ graph .put (key , new PipPackage (name , version , new ArrayList <>()));
223+ }
224+
225+ // Build children from each package's requires_dist
226+ for (JsonNode pkg : nonRootPackages ) {
227+ JsonNode metadata = pkg .get ("metadata" );
228+ if (metadata == null ) {
229+ continue ;
230+ }
231+ String name = metadata .has ("name" ) ? metadata .get ("name" ).asText () : null ;
232+ if (name == null ) {
233+ continue ;
234+ }
235+ String key = canonicalize (name );
236+ PipPackage pipPkg = graph .get (key );
237+ if (pipPkg == null ) {
238+ continue ;
239+ }
240+ JsonNode requiresDist = metadata .get ("requires_dist" );
241+ if (requiresDist == null || !requiresDist .isArray ()) {
242+ continue ;
243+ }
244+ for (JsonNode req : requiresDist ) {
245+ String reqStr = req .asText ();
246+ if (hasExtraMarker (reqStr )) {
247+ continue ;
248+ }
249+ String depName = extractDepName (reqStr );
250+ if (depName == null ) {
251+ continue ;
252+ }
253+ String depKey = canonicalize (depName );
254+ if (graph .containsKey (depKey )) {
255+ pipPkg .children .add (depKey );
256+ }
257+ }
258+ }
259+
260+ List <String > directDeps =
261+ directDepNames .stream ().filter (graph ::containsKey ).collect (Collectors .toList ());
262+ return new PipReportData (directDeps , graph );
263+ }
264+
265+ static boolean hasExtraMarker (String req ) {
266+ return EXTRA_MARKER_PATTERN .matcher (req ).find ();
267+ }
268+
269+ static String extractDepName (String req ) {
270+ Matcher m = DEP_NAME_PATTERN .matcher (req );
271+ return m .find () ? m .group (1 ) : null ;
272+ }
273+
274+ static String canonicalize (String name ) {
275+ return name .toLowerCase ().replaceAll ("[-_.]+" , "-" );
62276 }
63277
64278 private TomlParseResult getToml () throws IOException {
@@ -259,4 +473,26 @@ private static String convertTilde(String ver) {
259473 String fullVer = major + "." + minor + "." + patch ;
260474 return ">=" + fullVer + ",<" + major + "." + (minor + 1 ) + ".0" ;
261475 }
476+
477+ static final class PipPackage {
478+ final String name ;
479+ final String version ;
480+ final List <String > children ;
481+
482+ PipPackage (String name , String version , List <String > children ) {
483+ this .name = name ;
484+ this .version = version ;
485+ this .children = children ;
486+ }
487+ }
488+
489+ static final class PipReportData {
490+ final List <String > directDeps ;
491+ final Map <String , PipPackage > graph ;
492+
493+ PipReportData (List <String > directDeps , Map <String , PipPackage > graph ) {
494+ this .directDeps = directDeps ;
495+ this .graph = graph ;
496+ }
497+ }
262498}
0 commit comments