Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/sbom-dependency-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ When the same name and version is declared in more than one section, one mark is
| `pom.xml` dependencies inside `<profiles>` | 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.

Expand Down
43 changes: 43 additions & 0 deletions src/engine/lockfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<compat/resolve>":
version: 1.22.8
resolution: "resolve@patch:resolve@npm%3A^1.20.0#~builtin<compat/resolve>"

"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(
Expand Down
31 changes: 22 additions & 9 deletions src/engine/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
});
}