diff --git a/docs/sbom-dependency-scope.md b/docs/sbom-dependency-scope.md index 78a916d..103b7a7 100644 --- a/docs/sbom-dependency-scope.md +++ b/docs/sbom-dependency-scope.md @@ -67,6 +67,12 @@ When the same name and version is declared in more than one section, one mark is | `pom.xml` dependencies inside `` | Not parsed. | Absent. | Absent. | | Gradle `implementation`, `api`, `compileOnly`, `runtimeOnly`, `annotationProcessor`, `kapt`, and `platform` | Section `dependencies`. The configuration name is not a scan field. Exported. | `vibgrate:scope` = `direct`. Component `scope` omitted. `dependencies` omitted. | Annotation `scope=direct`. No document relationship. | | Gradle `testImplementation`, `testRuntimeOnly`, and `testCompileOnly` | Section `devDependencies`. A coordinate also declared on a production configuration in the same file stays section `dependencies`, and that production declaration supplies the version. The configuration name is not a scan field. Exported. | `vibgrate:scope` = `direct`. Component `scope` = `excluded`. `dependencies` omitted. | Annotation `scope=direct`. `DEV_DEPENDENCY_OF` from that package to `SPDXRef-DOCUMENT`. | +| Ruby `Gemfile` `group :development` / `group :test` blocks and inline `group:` | Parsed as section `devDependencies`. Exported as direct rows. | `vibgrate:scope` = `direct`. Component `scope` = `excluded`. | Annotation `scope=direct`. `DEV_DEPENDENCY_OF` from the package to `SPDXRef-DOCUMENT`. | +| Ruby gemspec `add_development_dependency` | Parsed as section `devDependencies`. Exported as direct rows. | `vibgrate:scope` = `direct`. Component `scope` = `excluded`. | Annotation `scope=direct`. `DEV_DEPENDENCY_OF` from the package to `SPDXRef-DOCUMENT`. | +| Go `go.mod` requirement marked `// indirect` | Not kept as a direct row. If the dependency appears in `go.sum`, it can be included as a lockfile-only component. | `vibgrate:scope` = `transitive` when supplied only by `go.sum`. Component `scope` omitted. | Annotation `scope=transitive` when supplied only by `go.sum`. No development relationship. | +| Go `go.sum` entry | Supplies lockfile-only components. A dependency already declared as a direct row remains `direct`; a lockfile-only component is `transitive`. | `vibgrate:scope` = `direct` or `transitive` according to the scan and lockfile match. Component `scope` omitted for lockfile-only rows. | Annotation `scope=direct` or `scope=transitive`. No development relationship for lockfile-only rows. | +| Dart `pubspec.yaml` `dev_dependencies` | Parsed, then removed by `allDeps.filter(d => !d.isDev)`. | Not a direct row. Absent unless supplied through another supported component source. | Absent unless supplied through another supported component source. | +| PHP `composer.json` `require-dev` | Parsed, then removed by `allDeps.filter(d => !d.isDev)`. `composer.lock` is not expanded into SBOM components. | Absent. | Absent. | `--no-transitive` keeps the manifest rows (development, optional, and peer included) and omits lockfile-only rows. It omits CycloneDX `dependencies`. SPDX still writes `DEV_DEPENDENCY_OF` and `OPTIONAL_DEPENDENCY_OF` for those manifest rows, and it omits `DEPENDS_ON` because the lockfile graph is not read. diff --git a/src/engine/lockfile.test.ts b/src/engine/lockfile.test.ts index 5816d42..285c925 100644 --- a/src/engine/lockfile.test.ts +++ b/src/engine/lockfile.test.ts @@ -158,6 +158,49 @@ describe('lockfileVersion — npm precedence', () => { }); }); +describe('Yarn Berry lockfile', () => { + it('skips workspace entries and resolves patch and alias descriptors', () => { + write( + 'yarn.lock', + ` +__metadata: + version: 8 + cacheKey: 10c0 + +"lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + languageName: node + linkType: hard + +"my-app@workspace:.": + version: 0.0.0-use.local + resolution: "my-app@workspace:." + languageName: unknown + linkType: soft + +"resolve@patch:resolve@npm%3A^1.20.0#~builtin": + version: 1.22.8 + resolution: "resolve@patch:resolve@npm%3A^1.20.0#~builtin" + +"string-width-cjs@npm:string-width@^4.2.3": + version: 4.2.3 + resolution: "string-width-cjs@npm:string-width@^4.2.3" +`.trimStart(), + ); + + const tree = fullDependencyTree(root); + const names = Object.keys(tree || {}); + + expect(names).toContain('lodash'); + expect(names).not.toContain('my-app'); + expect(names).not.toContain('__metadata'); + + expect(lockfileVersion(root, 'npm', 'resolve')).toBe('1.22.8'); + expect(lockfileVersion(root, 'npm', 'string-width-cjs')).toBe('4.2.3'); + }); +}); + describe('fullDependencyTree', () => { it('reads the full transitive graph from an npm v2/v3 package-lock.json, not just direct deps', () => { write( diff --git a/src/engine/lockfile.ts b/src/engine/lockfile.ts index c5bc211..0260830 100644 --- a/src/engine/lockfile.ts +++ b/src/engine/lockfile.ts @@ -506,13 +506,29 @@ function yarnLockTree(root: string): LockfileComponent[] | undefined { }; for (const line of text.split('\n')) { if (line && !/^\s/.test(line) && !line.startsWith('#')) { - pendingNames = line + const specs = line .replace(/:\s*$/, '') .split(',') - .map((spec) => yarnHeaderRealName(spec.trim().replace(/^"|"$/g, ''))) - .filter((n): n is string => Boolean(n)); - pendingDigests = []; - open = []; + .map((spec) => spec.trim().replace(/^"|"$/g, '')); + + // Yarn Berry workspace root (e.g. "my-app@workspace:.") is the project, + // not a registry package — never emit it as an SBOM component. + const allWorkspace = specs.length > 0 && specs.every((spec) => { + const parsed = splitYarnDescriptor(spec); + return parsed?.range.startsWith('workspace:'); + }); + + if (allWorkspace) { + pendingNames = []; + pendingDigests = []; + open = []; + } else { + pendingNames = specs + .map((spec) => yarnHeaderRealName(spec)) + .filter((n): n is string => Boolean(n)); + pendingDigests = []; + open = []; + } } else if (pendingNames.length) { const m = /^\s+version:?\s+"?([^"\s]+)"?/.exec(line); if (m) { @@ -730,9 +746,6 @@ function headerNamesPackage(header: string, name: string): boolean { .split(',') .some((spec) => { const s = spec.trim().replace(/^"|"$/g, ''); - const at = s.lastIndexOf('@'); - if (at <= 0) return false; // <=0 keeps scoped leading '@' from matching empty name - const specName = s.slice(0, at); - return specName === name; + return splitYarnDescriptor(s)?.name === name; }); }