Skip to content

Commit fc216b2

Browse files
authored
Merge pull request #121 from advanced-security/juxtin/direct-vs-transitive
Use explicitlyReferencedComponentIds to determine which packages are direct
2 parents b242ddf + 5b2736e commit fc216b2

File tree

5 files changed

+228
-73
lines changed

5 files changed

+228
-73
lines changed

componentDetection.test.ts

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ComponentDetection from "./componentDetection";
1+
import ComponentDetection, { DependencyGraphs } from "./componentDetection";
22
import fs from "fs";
33

44
test("Downloads CLI", async () => {
@@ -70,7 +70,7 @@ describe("ComponentDetection.makePackageUrl", () => {
7070
});
7171

7272
describe("ComponentDetection.processComponentsToManifests", () => {
73-
test("adds package as direct dependency when no top level referrers", () => {
73+
test("adds package as direct dependency when it is listed as an explicitlyReferencedComponentIds", () => {
7474
const componentsFound = [
7575
{
7676
component: {
@@ -86,20 +86,29 @@ describe("ComponentDetection.processComponentsToManifests", () => {
8686
},
8787
isDevelopmentDependency: false,
8888
topLevelReferrers: [], // Empty = direct dependency
89-
locationsFoundAt: ["package.json"]
89+
locationsFoundAt: ["/package.json"]
9090
}
9191
];
9292

93-
const manifests = ComponentDetection.processComponentsToManifests(componentsFound);
93+
const dependencyGraphs: DependencyGraphs = {
94+
"/package.json": {
95+
graph: { "test-package": null },
96+
explicitlyReferencedComponentIds: ["test-package 1.0.0 - npm"],
97+
developmentDependencies: [],
98+
dependencies: []
99+
}
100+
};
101+
102+
const manifests = ComponentDetection.processComponentsToManifests(componentsFound, dependencyGraphs);
94103

95104
expect(manifests).toHaveLength(1);
96-
expect(manifests[0].name).toBe("package.json");
105+
expect(manifests[0].name).toBe("/package.json");
97106
expect(manifests[0].directDependencies()).toHaveLength(1);
98107
expect(manifests[0].indirectDependencies()).toHaveLength(0);
99108
expect(manifests[0].countDependencies()).toBe(1);
100109
});
101110

102-
test("adds package as indirect dependency when has top level referrers", () => {
111+
test("adds package as indirect dependency when it is not in explicitlyReferencedComponentIds", () => {
103112
const componentsFound = [
104113
{
105114
component: {
@@ -126,56 +135,75 @@ describe("ComponentDetection.processComponentsToManifests", () => {
126135
}
127136
}
128137
],
129-
locationsFoundAt: ["package.json"]
138+
locationsFoundAt: ["/package.json"]
130139
}
131140
];
132141

133-
const manifests = ComponentDetection.processComponentsToManifests(componentsFound);
142+
const dependencyGraphs: DependencyGraphs = {
143+
"/package.json": {
144+
graph: { "parent-package": null },
145+
explicitlyReferencedComponentIds: [],
146+
developmentDependencies: [],
147+
dependencies: []
148+
}
149+
};
150+
151+
const manifests = ComponentDetection.processComponentsToManifests(componentsFound, dependencyGraphs);
134152

135153
expect(manifests).toHaveLength(1);
136-
expect(manifests[0].name).toBe("package.json");
154+
expect(manifests[0].name).toBe("/package.json");
137155
expect(manifests[0].directDependencies()).toHaveLength(0);
138156
expect(manifests[0].indirectDependencies()).toHaveLength(1);
139157
expect(manifests[0].countDependencies()).toBe(1);
140158
});
159+
});
141160

142-
test("adds package as direct dependency when top level referrer is itself", () => {
143-
const componentsFound = [
144-
{
145-
component: {
146-
name: "test-package",
147-
version: "1.0.0",
148-
packageUrl: {
149-
Scheme: "pkg",
150-
Type: "npm",
151-
Name: "test-package",
152-
Version: "1.0.0"
153-
},
154-
id: "test-package 1.0.0 - npm"
155-
},
156-
isDevelopmentDependency: false,
157-
topLevelReferrers: [
158-
{
159-
name: "test-package",
160-
version: "1.0.0",
161-
packageUrl: {
162-
Scheme: "pkg",
163-
Type: "npm",
164-
Name: "test-package",
165-
Version: "1.0.0"
166-
}
167-
}
168-
],
169-
locationsFoundAt: ["package.json"]
161+
describe('normalizeDependencyGraphPaths', () => {
162+
test('converts absolute paths to relative paths based on filePath input', () => {
163+
// Simulate a repo at /repo and a scan root at /repo/packages
164+
const fakeCwd = '/workspaces';
165+
const filePathInput = 'my-super-cool-repo';
166+
const absBase = '/workspaces/my-super-cool-repo';
167+
const dependencyGraphs: DependencyGraphs = {
168+
'/workspaces/my-super-cool-repo/a/package.json': {
169+
graph: { 'foo': null },
170+
explicitlyReferencedComponentIds: [],
171+
developmentDependencies: [],
172+
dependencies: []
173+
},
174+
'/workspaces/my-super-cool-repo/b/package.json': {
175+
graph: { 'bar': null },
176+
explicitlyReferencedComponentIds: [],
177+
developmentDependencies: [],
178+
dependencies: []
170179
}
171-
];
172-
173-
const manifests = ComponentDetection.processComponentsToManifests(componentsFound);
180+
};
181+
// Patch process.cwd for this test
182+
const originalCwd = process.cwd;
183+
(process as any).cwd = () => fakeCwd;
184+
const normalized = ComponentDetection.normalizeDependencyGraphPaths(dependencyGraphs, filePathInput);
185+
// Restore process.cwd
186+
(process as any).cwd = originalCwd;
187+
expect(Object.keys(normalized)).toContain('/a/package.json');
188+
expect(Object.keys(normalized)).toContain('/b/package.json');
189+
expect(normalized['/a/package.json'].graph).toEqual({ 'foo': null });
190+
expect(normalized['/b/package.json'].graph).toEqual({ 'bar': null });
191+
});
192+
});
174193

175-
expect(manifests).toHaveLength(1);
176-
expect(manifests[0].name).toBe("package.json");
177-
expect(manifests[0].directDependencies()).toHaveLength(1);
178-
expect(manifests[0].indirectDependencies()).toHaveLength(0);
179-
expect(manifests[0].countDependencies()).toBe(1);
194+
describe('normalizeDependencyGraphPaths with real output.json', () => {
195+
test('converts absolute paths in output.json to relative paths using current cwd and filePath', () => {
196+
const output = JSON.parse(fs.readFileSync('./output.json', 'utf8'));
197+
const dependencyGraphs = output.dependencyGraphs;
198+
// Use the same filePath as the action default (".")
199+
const normalized = ComponentDetection.normalizeDependencyGraphPaths(dependencyGraphs, 'test');
200+
// Should contain /package.json and /package-lock.json as keys
201+
expect(Object.keys(normalized)).toContain('/package.json');
202+
expect(Object.keys(normalized)).toContain('/package-lock.json');
203+
// All keys should now be relative to the repo root (cwd) and start with '/'
204+
for (const key of Object.keys(normalized)) {
205+
expect(key.startsWith('/')).toBe(true);
206+
expect(key).not.toMatch(/^\w:\\|^\/\/|^\.{1,2}\//); // Not windows absolute, not network, not relative
207+
}
180208
});
181209
});

componentDetection.ts

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Package,
88
Snapshot,
99
Manifest,
10-
submitSnapshot
10+
submitSnapshot,
1111
} from '@github/dependency-submission-toolkit'
1212
import fetch from 'cross-fetch'
1313
import tar from 'tar'
@@ -16,6 +16,7 @@ import * as exec from '@actions/exec';
1616
import dotenv from 'dotenv'
1717
import { Context } from '@actions/github/lib/context'
1818
import { unmockedModulePathPatterns } from './jest.config'
19+
import path from 'path';
1920
dotenv.config();
2021

2122
export default class ComponentDetection {
@@ -70,10 +71,11 @@ export default class ComponentDetection {
7071
core.info("Getting manifests from results");
7172
const results = await fs.readFileSync(this.outputPath, 'utf8');
7273
var json: any = JSON.parse(results);
73-
return this.processComponentsToManifests(json.componentsFound);
74+
let dependencyGraphs: DependencyGraphs = this.normalizeDependencyGraphPaths(json.dependencyGraphs, core.getInput('filePath'));
75+
return this.processComponentsToManifests(json.componentsFound, dependencyGraphs);
7476
}
7577

76-
public static processComponentsToManifests(componentsFound: any[]): Manifest[] {
78+
public static processComponentsToManifests(componentsFound: any[], dependencyGraphs: DependencyGraphs): Manifest[] {
7779
// Parse the result file and add the packages to the package cache
7880
const packageCache = new PackageCache();
7981
const packages: Array<ComponentDetectionPackage> = [];
@@ -126,6 +128,10 @@ export default class ComponentDetection {
126128

127129
try {
128130
const referrerPackage = packageCache.lookupPackage(referrerUrl);
131+
if (referrerPackage === pkg) {
132+
core.debug(`Skipping self-reference for package: ${pkg.id}`);
133+
return; // Skip self-references
134+
}
129135
if (referrerPackage) {
130136
referrerPackage.dependsOn(pkg);
131137
}
@@ -139,29 +145,40 @@ export default class ComponentDetection {
139145
const manifests: Array<Manifest> = [];
140146

141147
// Check the locationsFoundAt for every package and add each as a manifest
142-
this.addPackagesToManifests(packages, manifests);
148+
this.addPackagesToManifests(packages, manifests, dependencyGraphs);
143149

144150
return manifests;
145151
}
146152

147-
private static addPackagesToManifests(packages: Array<ComponentDetectionPackage>, manifests: Array<Manifest>): void {
153+
private static addPackagesToManifests(packages: Array<ComponentDetectionPackage>, manifests: Array<Manifest>, dependencyGraphs: DependencyGraphs): void {
148154
packages.forEach((pkg: ComponentDetectionPackage) => {
149155
pkg.locationsFoundAt.forEach((location: any) => {
150156
if (!manifests.find((manifest: Manifest) => manifest.name == location)) {
151157
const manifest = new Manifest(location, location);
152158
manifests.push(manifest);
153159
}
154160

155-
// Filter out self-references from topLevelReferrers
156-
const nonSelfReferrers = pkg.topLevelReferrers.filter((referrer: any) => {
157-
if (!referrer.packageUrlString) return false;
158-
return referrer.packageUrlString !== pkg.packageUrlString;
159-
});
161+
const depGraphEntry = dependencyGraphs[location];
162+
if (!depGraphEntry) {
163+
core.warning(`No dependency graph entry found for manifest location: ${location}`);
164+
return; // Skip this location if not found in dependencyGraphs
165+
}
160166

161-
if (nonSelfReferrers.length == 0) {
162-
manifests.find((manifest: Manifest) => manifest.name == location)?.addDirectDependency(pkg, ComponentDetection.getDependencyScope(pkg));
167+
const directDependencies = depGraphEntry.explicitlyReferencedComponentIds;
168+
if (directDependencies.includes(pkg.id)) {
169+
manifests
170+
.find((manifest: Manifest) => manifest.name == location)
171+
?.addDirectDependency(
172+
pkg,
173+
ComponentDetection.getDependencyScope(pkg)
174+
);
163175
} else {
164-
manifests.find((manifest: Manifest) => manifest.name == location)?.addIndirectDependency(pkg, ComponentDetection.getDependencyScope(pkg));
176+
manifests
177+
.find((manifest: Manifest) => manifest.name == location)
178+
?.addIndirectDependency(
179+
pkg,
180+
ComponentDetection.getDependencyScope(pkg)
181+
);
165182
}
166183
});
167184
});
@@ -249,6 +266,29 @@ export default class ComponentDetection {
249266
throw new Error("Failed to download latest release");
250267
}
251268
}
269+
270+
/**
271+
* Normalizes the keys of a DependencyGraphs object to be relative paths from the resolved filePath input.
272+
* @param dependencyGraphs The DependencyGraphs object to normalize.
273+
* @param filePathInput The filePath input (relative or absolute) from the action configuration.
274+
* @returns A new DependencyGraphs object with relative path keys.
275+
*/
276+
public static normalizeDependencyGraphPaths(
277+
dependencyGraphs: DependencyGraphs,
278+
filePathInput: string
279+
): DependencyGraphs {
280+
// Resolve the base directory from filePathInput (relative to cwd if not absolute)
281+
const baseDir = path.resolve(process.cwd(), filePathInput);
282+
const normalized: DependencyGraphs = {};
283+
for (const absPath in dependencyGraphs) {
284+
// Make the path relative to the baseDir
285+
let relPath = path.relative(baseDir, absPath).replace(/\\/g, '/');
286+
// Ensure leading slash to represent repo root
287+
if (!relPath.startsWith('/')) relPath = '/' + relPath;
288+
normalized[relPath] = dependencyGraphs[absPath];
289+
}
290+
return normalized;
291+
}
252292
}
253293

254294
class ComponentDetectionPackage extends Package {
@@ -261,6 +301,33 @@ class ComponentDetectionPackage extends Package {
261301
}
262302
}
263303

304+
/**
305+
* Types for the dependencyGraphs section of output.json
306+
*/
307+
export type DependencyGraph = {
308+
/**
309+
* The dependency graph: keys are component IDs, values are either null (no dependencies) or an array of component IDs (dependencies)
310+
*/
311+
graph: Record<string, string[] | null>;
312+
/**
313+
* Explicitly referenced component IDs
314+
*/
315+
explicitlyReferencedComponentIds: string[];
316+
/**
317+
* Development dependencies
318+
*/
319+
developmentDependencies: string[];
320+
/**
321+
* Regular dependencies
322+
*/
323+
dependencies: string[];
324+
};
325+
326+
/**
327+
* The top-level dependencyGraphs object: keys are manifest file paths, values are DependencyGraph objects
328+
*/
329+
export type DependencyGraphs = Record<string, DependencyGraph>;
330+
264331

265332

266333

dist/componentDetection.d.ts

Lines changed: 33 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)