Skip to content

Commit 1962ea0

Browse files
committed
feat(sca): discovery-source provenance + ecosystem-locked matching
- Lock SCA findings to the ecosystem of their source: the manifest walker prunes ecosystem install dirs (single source of truth in internal/ecosystems) so a foreign manifest bundled in an install dir is not mis-attributed. - Capture per-package discovery provenance — declared in a manifest vs found only in an install directory (SourceType/InstalledPath) — and send it to /v2/cli.sca so the Introduced Via tab can show it. - Extend install-directory discovery (npm node_modules, python site-packages) and pip requirements detection.
1 parent 18a8f2a commit 1962ea0

17 files changed

Lines changed: 1378 additions & 139 deletions

cmd/cli_sca.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,8 @@ func buildCliPackages(pkgs []scan.ScopedPackage, manifestGroups []scan.ManifestG
530530
Scope: scope,
531531
License: licenseByKey[key],
532532
IntroducedVia: [][]string{chain},
533+
SourceType: p.SourceType,
534+
InstalledPath: p.InstalledPath,
533535
}
534536

535537
if len(p.Checksums) > 0 {

cmd/scan.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -898,9 +898,34 @@ func runLocalScan(
898898
pkgs = resolved
899899
}
900900

901-
// Replace absolute path with relative path in each package.
901+
// Python build-or-lock gate: an unpinned pip manifest (requirements
902+
// files, pyproject.toml, Pipfile) with no sibling lock must resolve
903+
// against the installed environment. A confident file that can't be
904+
// resolved is a fatal error (build the app or generate a lock file); a
905+
// tentatively-detected file (bare names, ambiguous) that can't be
906+
// confirmed against installed packages is silently disregarded.
907+
if scan.IsPythonGatedManifest(f.ManifestInfo.Type) && len(pkgs) > 0 &&
908+
!scan.RequirementsFullyLocked(pkgs) && !scan.PyLockfilePresent(filepath.Dir(f.Path)) {
909+
confident := f.ManifestInfo.Confidence != scan.ConfidenceTentative
910+
resolved, rerr := scan.ResolvePythonRequirementsFromSitePackages(f.Path, f.RelPath, pkgs, confident)
911+
if rerr != nil {
912+
if confident {
913+
return rerr
914+
}
915+
continue // tentative + unconfirmed → not a requirements file
916+
}
917+
pkgs = resolved
918+
}
919+
920+
// Replace absolute path with relative path in each package, and tag
921+
// manifest-declared packages. The npm node_modules resolver may have
922+
// already flagged install-only packages as "installed" — don't clobber
923+
// that; only default the unset (manifest-parsed) packages.
902924
for i := range pkgs {
903925
pkgs[i].SourceFile = f.RelPath
926+
if pkgs[i].SourceType == "" {
927+
pkgs[i].SourceType = scan.SourceTypeManifest
928+
}
904929
}
905930

906931
// Count by scope for the per-file summary line.

internal/cdx/local.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,16 +507,36 @@ func PopulateLicenses(bom *BOM, licenseMap map[string]string) {
507507
// BuildDependencies creates the CycloneDX dependencies array from ManifestGroup edges.
508508
// compRefs maps "name@version" → bom-ref for cross-referencing.
509509
func BuildDependencies(groups []scan.ManifestGroup, compRefs map[string]string) []CDXDependency {
510-
// Build name → bom-ref index (without version, since edges use bare names).
510+
// normName folds PyPI naming quirks (case, dash/underscore/dot) so edges keyed
511+
// in normalised form still resolve to components keyed by their literal name.
512+
normName := func(s string) string {
513+
return strings.ToLower(strings.NewReplacer("-", "_", ".", "_").Replace(s))
514+
}
515+
516+
// Build name → bom-ref indexes (without version, since edges use bare names):
517+
// one raw, one normalised. Raw wins; the normalised index is a fallback.
511518
nameToRef := map[string]string{}
519+
normToRef := map[string]string{}
512520
for key, ref := range compRefs {
513521
// key is "name@version"; extract name.
514522
if idx := strings.LastIndex(key, "@"); idx > 0 {
515523
name := key[:idx]
516524
if _, exists := nameToRef[name]; !exists {
517525
nameToRef[name] = ref
518526
}
527+
if nk := normName(name); nk != "" {
528+
if _, exists := normToRef[nk]; !exists {
529+
normToRef[nk] = ref
530+
}
531+
}
532+
}
533+
}
534+
resolveRef := func(name string) (string, bool) {
535+
if ref, ok := nameToRef[name]; ok {
536+
return ref, true
519537
}
538+
ref, ok := normToRef[normName(name)]
539+
return ref, ok
520540
}
521541

522542
seen := map[string]bool{}
@@ -527,7 +547,7 @@ func BuildDependencies(groups []scan.ManifestGroup, compRefs map[string]string)
527547
continue
528548
}
529549
for parent, children := range mg.Graph.Edges {
530-
parentRef, ok := nameToRef[parent]
550+
parentRef, ok := resolveRef(parent)
531551
if !ok {
532552
continue
533553
}
@@ -539,7 +559,7 @@ func BuildDependencies(groups []scan.ManifestGroup, compRefs map[string]string)
539559
seenChild := map[string]bool{}
540560
var childRefs []string
541561
for _, child := range children {
542-
if childRef, ok := nameToRef[child]; ok && !seenChild[childRef] {
562+
if childRef, ok := resolveRef(child); ok && !seenChild[childRef] {
543563
seenChild[childRef] = true
544564
childRefs = append(childRefs, childRef)
545565
}

internal/filetree/collector.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ var lockfileAssociations = map[string][]string{
3131
"package.json": {"package-lock.json", "yarn.lock", "pnpm-lock.yaml"},
3232
"go.mod": {"go.sum"},
3333
"Cargo.toml": {"Cargo.lock"},
34-
"requirements.txt": {"Pipfile.lock", "poetry.lock", "uv.lock"},
34+
"requirements.txt": {"Pipfile.lock", "poetry.lock", "uv.lock", "pylock.toml"},
35+
"requirements.in": {"requirements.txt", "poetry.lock", "uv.lock", "pylock.toml"},
3536
"Pipfile": {"Pipfile.lock"},
36-
"pyproject.toml": {"poetry.lock", "uv.lock"},
37+
"pyproject.toml": {"poetry.lock", "uv.lock", "pylock.toml"},
3738
"Gemfile": {"Gemfile.lock"},
3839
"composer.json": {"composer.lock"},
3940
"pubspec.yaml": {"pubspec.lock"},

internal/scan/depgraph.go

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"os/exec"
77
"path/filepath"
88
"strings"
9+
10+
"github.com/BurntSushi/toml"
911
)
1012

1113
// DepGraph tracks direct vs transitive dependency relationships for a manifest group.
@@ -283,6 +285,150 @@ func (g *DepGraph) PopulateNpmLockEdges(lockFilePath string) error {
283285
return nil
284286
}
285287

288+
// PopulatePypiLockEdges builds dependency-tree edges for a pypi manifest group
289+
// from the lock files in dir. Edge keys/values are normalised (normPypi) so the
290+
// lock, `# via`, and installed-METADATA sources all merge on one key form;
291+
// BuildDependencies resolves SBOM component names against the same normalisation.
292+
// uv.lock and pylock.toml carry an explicit dependency tree; a `pip/uv compile`
293+
// requirements.txt carries it as inverted `# via` comments. All present sources
294+
// are merged.
295+
func (g *DepGraph) PopulatePypiLockEdges(dir string) {
296+
if g == nil {
297+
return
298+
}
299+
if g.Edges == nil {
300+
g.Edges = make(map[string][]string)
301+
}
302+
303+
// uv.lock — richest tree (explicit dependencies + optional-dependencies).
304+
if data, err := os.ReadFile(filepath.Join(dir, "uv.lock")); err == nil {
305+
var lock uvLockFile
306+
if _, derr := toml.Decode(string(data), &lock); derr == nil {
307+
for _, p := range lock.Package {
308+
var children []string
309+
for _, d := range p.Dependencies {
310+
if d.Name != "" {
311+
children = append(children, d.Name)
312+
}
313+
}
314+
for _, deps := range p.OptionalDependencies {
315+
for _, d := range deps {
316+
if d.Name != "" {
317+
children = append(children, d.Name)
318+
}
319+
}
320+
}
321+
g.addPypiEdges(normPypi(p.Name), normPypiList(children))
322+
}
323+
}
324+
}
325+
326+
// pylock.toml — PEP 751 per-package dependencies, when the generator emits them.
327+
if data, err := os.ReadFile(filepath.Join(dir, "pylock.toml")); err == nil {
328+
var lock pylockFile
329+
if _, derr := toml.Decode(string(data), &lock); derr == nil {
330+
for _, p := range lock.Packages {
331+
var children []string
332+
for _, d := range p.Dependencies {
333+
if d.Name != "" {
334+
children = append(children, d.Name)
335+
}
336+
}
337+
g.addPypiEdges(normPypi(p.Name), normPypiList(children))
338+
}
339+
}
340+
}
341+
342+
// requirements.txt — invert the `# via` comments (only tree source when no
343+
// lock file exists alongside a compiled, hashed requirements.txt).
344+
if data, err := os.ReadFile(filepath.Join(dir, "requirements.txt")); err == nil {
345+
for parent, children := range parseRequirementsViaEdges(string(data)) {
346+
g.addPypiEdges(normPypi(parent), normPypiList(children))
347+
}
348+
}
349+
}
350+
351+
// normPypiList normalises a list of PyPI package names (dropping empties).
352+
func normPypiList(names []string) []string {
353+
out := make([]string, 0, len(names))
354+
for _, n := range names {
355+
if n != "" {
356+
out = append(out, normPypi(n))
357+
}
358+
}
359+
return out
360+
}
361+
362+
// addPypiEdges appends children under parent, de-duplicating existing edges.
363+
func (g *DepGraph) addPypiEdges(parent string, children []string) {
364+
if parent == "" || len(children) == 0 {
365+
return
366+
}
367+
existing := make(map[string]bool, len(g.Edges[parent]))
368+
for _, c := range g.Edges[parent] {
369+
existing[c] = true
370+
}
371+
for _, c := range children {
372+
if c != "" && !existing[c] {
373+
g.Edges[parent] = append(g.Edges[parent], c)
374+
existing[c] = true
375+
}
376+
}
377+
}
378+
379+
// parseRequirementsViaEdges inverts the `# via` annotations of a compiled
380+
// requirements.txt into forward edges (parent → child). A `# via -r file`
381+
// reference is an include, not a parent package, and is skipped.
382+
func parseRequirementsViaEdges(content string) map[string][]string {
383+
edges := map[string][]string{}
384+
curName := ""
385+
curVia := false
386+
for _, ll := range joinReqContinuations(content) {
387+
line := strings.TrimSpace(ll)
388+
if line == "" {
389+
continue
390+
}
391+
if strings.HasPrefix(line, "#") {
392+
if curName == "" {
393+
continue
394+
}
395+
body := strings.TrimSpace(strings.TrimPrefix(line, "#"))
396+
if rest, ok := strings.CutPrefix(body, "via"); ok {
397+
curVia = true
398+
body = strings.TrimSpace(rest)
399+
}
400+
if !curVia || body == "" || isRequirementsInclude(body) {
401+
continue
402+
}
403+
if parent, _, _, ok := parsePEP508(body); ok && parent != "" {
404+
edges[parent] = append(edges[parent], curName)
405+
}
406+
continue
407+
}
408+
curVia = false
409+
if strings.HasPrefix(line, "-") {
410+
curName = ""
411+
continue
412+
}
413+
if ci := strings.Index(line, " #"); ci >= 0 {
414+
line = strings.TrimSpace(line[:ci])
415+
}
416+
var specParts []string
417+
for _, tok := range strings.Fields(line) {
418+
if strings.HasPrefix(tok, "--hash=") {
419+
continue
420+
}
421+
specParts = append(specParts, tok)
422+
}
423+
if name, _, _, ok := parsePEP508(strings.Join(specParts, " ")); ok {
424+
curName = name
425+
} else {
426+
curName = ""
427+
}
428+
}
429+
return edges
430+
}
431+
286432
// BuildGoDepGraph correlates go.mod (direct) and go.sum (all) packages from the
287433
// same directory to determine which dependencies are direct vs transitive.
288434
func BuildGoDepGraph(goModPkgs, goSumPkgs []ScopedPackage) *DepGraph {
@@ -425,9 +571,9 @@ func BuildManifestGroups(filePackages map[string][]ScopedPackage, fileEcosystems
425571
for relPath, pkgs := range gd.files {
426572
base := strings.ToLower(filepath.Base(relPath))
427573
switch base {
428-
case "pyproject.toml":
429-
directPkgs = pkgs
430-
case "uv.lock", "pipfile.lock", "poetry.lock", "requirements.txt":
574+
case "pyproject.toml", "requirements.in":
575+
directPkgs = append(directPkgs, pkgs...)
576+
case "uv.lock", "pipfile.lock", "poetry.lock", "pylock.toml", "requirements.txt":
431577
lockPkgs = append(lockPkgs, pkgs...)
432578
}
433579
}

0 commit comments

Comments
 (0)