Skip to content

Commit 022dd83

Browse files
authored
feat(scanner): add highlighted packages and contacts extractors (#718)
1 parent 283fa34 commit 022dd83

11 files changed

Lines changed: 526 additions & 134 deletions

File tree

.changeset/curvy-clouds-drive.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@nodesecure/contact": minor
3+
"@nodesecure/scanner": minor
4+
---
5+
6+
feat(scanner): add highlighted packages and contacts extractors

workspaces/contact/src/ContactExtractor.class.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type {
1515
};
1616

1717
export interface ContactExtractorPackageMetadata {
18-
author?: Contact;
18+
author?: Contact | null;
1919
maintainers: Contact[];
2020
}
2121

@@ -125,7 +125,7 @@ export class ContactExtractor {
125125
}
126126
}
127127

128-
function extractMetadataContacts(
128+
export function extractMetadataContacts(
129129
metadata: ContactPackageMetaData
130130
): Contact[] {
131131
return [

workspaces/contact/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export {
33
compareContact
44
} from "./utils/index.ts";
55
export { NsResolver } from "./NsResolver.class.ts";
6+
export { UnlitContact } from "./UnlitContact.class.ts";

workspaces/scanner/docs/extractors.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ Available probes include:
3939
| Warnings | manifest |
4040
| Extentions | manifest |
4141
| NodeDependencies | manifest |
42+
| HighlightedPackages | manifest |
43+
| HighlightedContacts | packument |
44+
45+
## ProbeExtractor
4246

4347
All probes follow the same `ProbeExtractor` interface, which acts as an iterator-like contract:
4448

workspaces/scanner/src/depWalker.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,11 @@ import {
1717
} from "@nodesecure/mama";
1818
import { getNpmRegistryURL } from "@nodesecure/npm-registry-sdk";
1919
import type Config from "@npmcli/config";
20-
import semver from "semver";
2120

2221
// Import Internal Dependencies
2322
import {
24-
getDependenciesWarnings,
2523
addMissingVersionFlags,
24+
getDependenciesWarnings,
2625
getUsedDeps,
2726
getManifestLinks,
2827
NPM_TOKEN
@@ -48,7 +47,7 @@ import type {
4847
Options,
4948
Payload
5049
} from "./types.ts";
51-
import { parseSemverRange } from "./utils/parseSemverRange.ts";
50+
import { HighlightedPackages } from "./extractors/probes/HighlightedPackagesExtractor.class.ts";
5251

5352
// CONSTANTS
5453
const kDefaultDependencyVersionFields = {
@@ -185,7 +184,6 @@ export async function depWalker(
185184
};
186185

187186
const dependencies: Map<string, Dependency> = new Map();
188-
const highlightedPackages: Set<string> = new Set();
189187
const identifiersToHighlight = new Set<string>(options.highlight?.identifiers ?? []);
190188
const npmTreeWalker = new npm.TreeWalker({
191189
registry,
@@ -363,6 +361,7 @@ export async function depWalker(
363361
// We do this because it "seem" impossible to link all dependencies in the first walk.
364362
// Because we are dealing with package only one time it may happen sometimes.
365363
const globalWarnings: GlobalWarning[] = [];
364+
const highlightedPackagesExtractor = new HighlightedPackages(options.highlight?.packages ?? {});
366365
for (const [packageName, dependency] of dependencies) {
367366
const metadataIntegrities = dependency.metadata?.integrity ?? {};
368367

@@ -388,22 +387,12 @@ export async function depWalker(
388387
});
389388
}
390389
}
391-
const semverRanges = parseSemverRange(options.highlight?.packages ?? {});
392390
for (const version of Object.entries(dependency.versions)) {
393391
const [verStr, verDescriptor] = version as [string, DependencyVersion];
394-
const packageRange = semverRanges?.[packageName];
395-
const org = parseNpmSpec(packageName)?.org;
396-
const isScopeHighlighted = org !== null && `@${org}` in semverRanges;
397-
398-
if (
399-
(packageRange && semver.satisfies(verStr, packageRange)) ||
400-
isScopeHighlighted
401-
) {
402-
highlightedPackages.add(`${packageName}@${verStr}`);
403-
}
404392
verDescriptor.flags.push(
405393
...addMissingVersionFlags(new Set(verDescriptor.flags), dependency)
406394
);
395+
highlightedPackagesExtractor.next(verStr, verDescriptor, { name: packageName, dependency });
407396

408397
if (isLocalManifest(verDescriptor, mama, packageName)) {
409398
const author = mama.author;
@@ -439,9 +428,10 @@ export async function depWalker(
439428
isRemoteScanning
440429
);
441430
payload.warnings = globalWarnings.concat(dependencyConfusionWarnings as GlobalWarning[]).concat(warnings);
431+
const { highlightedPackages } = highlightedPackagesExtractor.done();
442432
payload.highlighted = {
443433
contacts: illuminated,
444-
packages: [...highlightedPackages],
434+
packages: highlightedPackages,
445435
identifiers: extractHighlightedIdentifiers(collectables, identifiersToHighlight)
446436
};
447437
payload.dependencies = Object.fromEntries(dependencies);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Import Third-party Dependencies
2+
import { type EnforcedContact, type IlluminatedContact, UnlitContact, extractMetadataContacts } from "@nodesecure/contact";
3+
import type { Contact } from "@nodesecure/npm-types";
4+
5+
// Import Internal Dependencies
6+
import type {
7+
PackumentProbeExtractor
8+
} from "../payload.ts";
9+
import type { Dependency } from "../../types.ts";
10+
11+
export type HighlightedContactsResult = {
12+
illuminated: IlluminatedContact[];
13+
};
14+
15+
export class HighlightedContacts implements PackumentProbeExtractor<HighlightedContactsResult> {
16+
level = "packument" as const;
17+
18+
#unlitContacts: UnlitContact[];
19+
20+
constructor(contacts: EnforcedContact[]) {
21+
this.#unlitContacts = contacts.map((contact) => new UnlitContact(contact));
22+
}
23+
24+
next(packageName: string, dependency: Dependency) {
25+
const extractedContacts = extractMetadataContacts(dependency.metadata);
26+
this.addDependencyToUnlitContacts(extractedContacts, packageName);
27+
}
28+
29+
private addDependencyToUnlitContacts(
30+
contacts: Contact[],
31+
packageName: string
32+
) {
33+
for (const unlit of this.#unlitContacts) {
34+
const isMaintainer = contacts.some((contact) => unlit.compareTo(contact));
35+
if (isMaintainer) {
36+
unlit.dependencies.add(packageName);
37+
}
38+
}
39+
}
40+
41+
done() {
42+
const illuminated = this.#unlitContacts.flatMap(
43+
(unlit) => (unlit.dependencies.size > 0 ? [unlit.illuminate()] : [])
44+
);
45+
46+
return {
47+
illuminated
48+
};
49+
}
50+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Import Third-party Dependencies
2+
import { parseNpmSpec } from "@nodesecure/mama";
3+
import semver from "semver";
4+
5+
// Import Internal Dependencies
6+
import type {
7+
ManifestProbeExtractor,
8+
ProbeExtractorManifestParent
9+
} from "../payload.ts";
10+
import type { DependencyVersion, HighlightPackages } from "../../types.ts";
11+
12+
export type HighlightedPackagesResult = {
13+
highlightedPackages: string[];
14+
};
15+
16+
export class HighlightedPackages implements ManifestProbeExtractor<HighlightedPackagesResult> {
17+
level = "manifest" as const;
18+
#semverRanges: Record<string, string>;
19+
#highlightedPackages = new Set<string>();
20+
21+
constructor(packages: HighlightPackages) {
22+
this.#semverRanges = this.#parseSemverRange(packages);
23+
}
24+
25+
#parseSemverRange(packages: HighlightPackages) {
26+
const pkgs = Array.isArray(packages) ? this.#parseSpecs(packages) : packages;
27+
28+
return Object.entries(pkgs).reduce((acc, [name, semverRange]) => {
29+
if (Array.isArray(semverRange)) {
30+
acc[name] = semverRange.join(" || ");
31+
}
32+
else {
33+
acc[name] = semverRange;
34+
}
35+
36+
return acc;
37+
}, {});
38+
}
39+
40+
#parseSpecs(specs: string[]) {
41+
return specs.reduce((acc, spec) => {
42+
// Handle scope-only entries like "@fastify", matching all packages under that scope
43+
if (/^@[^/@]+$/.test(spec)) {
44+
acc[spec] = ["*"];
45+
46+
return acc;
47+
}
48+
49+
const parsedSpec = parseNpmSpec(spec);
50+
if (!parsedSpec) {
51+
return acc;
52+
}
53+
const { name, semver } = parsedSpec;
54+
const version = semver || "*";
55+
if (name in acc) {
56+
acc[name].push(version);
57+
}
58+
else {
59+
acc[name] = [version];
60+
}
61+
62+
return acc;
63+
}, {});
64+
}
65+
66+
next(
67+
version: string,
68+
_: DependencyVersion,
69+
parent: ProbeExtractorManifestParent
70+
) {
71+
const packageRange = this.#semverRanges?.[parent.name];
72+
const org = parseNpmSpec(parent.name)?.org;
73+
const isScopeHighlighted = org !== null && `@${org}` in this.#semverRanges;
74+
75+
if (
76+
(packageRange && semver.satisfies(version, packageRange)) ||
77+
isScopeHighlighted
78+
) {
79+
this.#highlightedPackages.add(`${parent.name}@${version}`);
80+
}
81+
}
82+
83+
done() {
84+
return {
85+
highlightedPackages: [...this.#highlightedPackages]
86+
};
87+
}
88+
}
89+

workspaces/scanner/src/extractors/probes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ export * from "./VulnerabilitiesExtractor.class.ts";
66
export * from "./FlagsExtractor.class.ts";
77
export * from "./ExtensionsExtractor.class.ts";
88
export * from "./NodeDependenciesExtractor.class.ts";
9+
export * from "./HighlightedPackagesExtractor.class.ts";
10+
export * from "./HighlightedContactsExtractor.class.ts";

workspaces/scanner/src/utils/parseSemverRange.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)