Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/odd-emus-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nodesecure/tree-walker": major
"@nodesecure/npm-types": minor
"@nodesecure/scanner": minor
"@nodesecure/mama": minor
---

Implement ManifestManager class deep into scanner and tree-walker. Implement documentDigest into ManifestManager class and fix issue with pacote.manifest type.
19 changes: 18 additions & 1 deletion workspaces/mama/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ if (ManifestManager.isLocated(locatedManifest)) {
}
```

### constructor(document: ManifestManagerDocument, options?: ManifestManagerOptions)
### constructor(document: ManifestManagerDocument | AbbreviatedManifestDocument, options?: ManifestManagerOptions)

document is described by the following type:
```ts
Expand Down Expand Up @@ -157,6 +157,23 @@ If `dependencies` and `scripts` are missing, they are defaulted to an empty obje
> [!CAUTION]
> This is not available for Workspaces

### documentDigest

Return an [SSRI](https://w3c.github.io/webappsec-subresource-integrity/) `sha512` hash of the **full serialized document** as a string, or `null` if the instance is a workspace.

```ts
const mama = new ManifestManager({
name: "foo",
version: "1.0.0"
});
console.log(mama.documentDigest); // "sha512-<base64>"
```

Unlike [`integrity`](#integrity), which hashes a small subset of fields for change-detection purposes, `documentDigest` covers every field present in the document. This makes it suitable for content-addressable lookups and cache invalidation where an exact byte-level match is required.

> [!IMPORTANT]
> Returns `null` for workspaces (whereas `integrity` throws).

### author
Return the author parsed as a **Contact** (or `null` if the property is missing).

Expand Down
3 changes: 2 additions & 1 deletion workspaces/mama/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"dependencies": {
"@nodesecure/npm-types": "^1.3.0",
"@nodesecure/utils": "^2.3.0",
"object-hash": "^3.0.0"
"object-hash": "^3.0.0",
"ssri": "13.0.1"
},
"devDependencies": {
"@types/object-hash": "^3.0.6"
Expand Down
17 changes: 14 additions & 3 deletions workspaces/mama/src/ManifestManager.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import type {
PackumentVersion,
PackageJSON,
WorkspacesPackageJSON,
Contact
Contact,
AbbreviatedManifestDocument
} from "@nodesecure/npm-types";
import { fromData } from "ssri";

// Import Internal Dependencies
import {
Expand Down Expand Up @@ -96,15 +98,15 @@ export class ManifestManager<
});

constructor(
document: ManifestManagerDocument,
document: ManifestManagerDocument | AbbreviatedManifestDocument,
options: ManifestManagerOptions = {}
) {
const { location } = options;

this.document = Object.assign(
{ ...ManifestManager.Default },
structuredClone(document)
);
) as WithRequired<ManifestManagerDocument, NonOptionalPackageJSONProperties>;
if (location) {
this.location = location.endsWith("package.json") ?
path.dirname(location) :
Expand All @@ -120,6 +122,15 @@ export class ManifestManager<
.some((script) => kUnsafeNPMScripts.has(script.toLowerCase()));
}

get documentDigest() {
const isWorkspace = "workspaces" in this.document;
const data = JSON.stringify(this.document);

return isWorkspace ?
null :
fromData(data, { algorithms: ["sha512"] }).toString();
}

get name() {
return this.document.name ?? "workspace";
}
Expand Down
39 changes: 39 additions & 0 deletions workspaces/mama/test/ManifestManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,45 @@ describe("ManifestManager", () => {
});
});

describe("get documentDigest", () => {
test("Given a minimal PackageJSON, it must return a string starting with 'sha512-'", () => {
const mama = new ManifestManager(kMinimalPackageJSON);

const digest = mama.documentDigest;
assert.ok(typeof digest === "string");
assert.ok(digest!.startsWith("sha512-"), `Expected '${digest}' to start with 'sha512-'`);
});

test("Given a WorkspacesPackageJSON, it must return null", () => {
const mama = new ManifestManager({
...kMinimalPackageJSON,
workspaces: ["src/a"]
});

assert.strictEqual(mama.documentDigest, null);
});

test("Given two identical PackageJSON objects, the values must be strictly equal", () => {
const mamaA = new ManifestManager({ ...kMinimalPackageJSON });
const mamaB = new ManifestManager({ ...kMinimalPackageJSON });

assert.strictEqual(mamaA.documentDigest, mamaB.documentDigest);
});

test("Given two different PackageJSON objects, the values must differ", () => {
const mamaA = new ManifestManager({ ...kMinimalPackageJSON });
const mamaB = new ManifestManager({ ...kMinimalPackageJSON, description: "different" });

assert.notStrictEqual(mamaA.documentDigest, mamaB.documentDigest);
});

test("The value must be deterministic (two accesses on the same instance return the same string)", () => {
const mama = new ManifestManager(kMinimalPackageJSON);

assert.strictEqual(mama.documentDigest, mama.documentDigest);
});
});

describe("get hasZeroSemver", () => {
test("Given a PackageJSON with a semver higher than 1.x.x then it must return false", () => {
const packageJSON: PackageJSON = {
Expand Down
16 changes: 16 additions & 0 deletions workspaces/npm-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ Abbreviated package metadata format (corgi format). Lighter alternative to `Pack
import type { Manifest } from "@nodesecure/npm-types";
```

#### `AbbreviatedManifestDocument`
Minimal manifest shape compatible with abbreviated registry manifests (e.g. `pacote.manifest()`).

- No `[field: string]: unknown` index signature (unlike `BasePackageJSON`).
- Avoids the need for explicit casts when assigning `Pick`-based types like `pacote.AbbreviatedManifest & pacote.ManifestResult`.

```ts
import type { AbbreviatedManifestDocument } from "@nodesecure/npm-types";

const doc: AbbreviatedManifestDocument = {
name: "my-package",
version: "1.0.0",
dependencies: { lodash: "^4.17.21" }
};
```

### Utility Types

#### `Contact`
Expand Down
22 changes: 22 additions & 0 deletions workspaces/npm-types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,25 @@ export type PackTarball = {
}[];
bundled: string[];
}

/**
* Minimal manifest shape compatible with abbreviated registry manifests
* (e.g. `pacote.manifest()`).
*
* - No `[field: string]: unknown` index signature (unlike `BasePackageJSON`).
* - Avoids the need for explicit casts when assigning `Pick`-based types
* like `pacote.AbbreviatedManifest & pacote.ManifestResult`.
*/
export interface AbbreviatedManifestDocument {
name?: string;
version?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
bundledDependencies?: string[] | boolean;
scripts?: Record<string, string>;
gypfile?: boolean;
bin?: Record<string, string>;
engines?: Record<string, string>;
}
1 change: 0 additions & 1 deletion workspaces/scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
"frequency-set": "^2.1.0",
"pacote": "^21.0.0",
"semver": "^7.5.4",
"ssri": "13.0.1",
"type-fest": "^5.0.1"
},
"devDependencies": {
Expand Down
71 changes: 36 additions & 35 deletions workspaces/scanner/src/depWalker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import {
import { DefaultCollectableSet } from "@nodesecure/js-x-ray";
import * as Vulnera from "@nodesecure/vulnera";
import { npm } from "@nodesecure/tree-walker";
import { parseAuthor } from "@nodesecure/utils";
import { parseNpmSpec } from "@nodesecure/mama";
import type { ManifestVersion, PackageJSON, WorkspacesPackageJSON } from "@nodesecure/npm-types";
import {
ManifestManager,
parseNpmSpec
} from "@nodesecure/mama";
import { getNpmRegistryURL } from "@nodesecure/npm-registry-sdk";
import type Config from "@npmcli/config";
import { fromData } from "ssri";
import semver from "semver";

// Import Internal Dependencies
Expand All @@ -28,11 +28,17 @@ import {
NPM_TOKEN
} from "./utils/index.ts";
import { getRegistryForPackage } from "./utils/npmrc.ts";
import { NpmRegistryProvider, type NpmApiClient } from "./registry/NpmRegistryProvider.ts";
import {
NpmRegistryProvider,
type NpmApiClient
} from "./registry/NpmRegistryProvider.ts";
import { StatsCollector } from "./class/StatsCollector.class.ts";
import { RegistryTokenStore } from "./registry/RegistryTokenStore.ts";
import { TempDirectory } from "./class/TempDirectory.class.ts";
import { Logger, ScannerLoggerEvents } from "./class/logger.class.ts";
import {
Logger,
ScannerLoggerEvents
} from "./class/logger.class.ts";
import { TarballScanner } from "./class/TarballScanner.class.ts";
import type {
Dependency,
Expand Down Expand Up @@ -109,7 +115,7 @@ type Metadata = {
};

export async function depWalker(
manifest: PackageJSON | WorkspacesPackageJSON | ManifestVersion,
mama: ManifestManager,
options: WalkerOptions,
logger = new Logger()
): Promise<Payload> {
Expand All @@ -130,10 +136,15 @@ export async function depWalker(
} = options;

const statsCollector = new StatsCollector({ logger }, { isVerbose });
const collectables = kCollectableTypes.map(
(type) => new DefaultCollectableSet<Metadata>(type)
);

const collectables = kCollectableTypes.map((type) => new DefaultCollectableSet<Metadata>(type));

const tokenStore = new RegistryTokenStore(npmRcConfig, NPM_TOKEN.token, npmRcEntries);
const tokenStore = new RegistryTokenStore(
npmRcConfig,
NPM_TOKEN.token,
npmRcEntries
);

const npmProjectConfig = tokenStore.getConfig(registry);
const pacoteScopedConfig = {
Expand Down Expand Up @@ -164,8 +175,8 @@ export async function depWalker(
const payload: InitialPayload = {
id: tempDir.id,
rootDependency: {
name: manifest.name ?? "workspace",
version: manifest.version ?? "0.0.0",
name: mama.name,
version: mama.version,
integrity: null
},
scannerVersion: packageVersion,
Expand Down Expand Up @@ -231,7 +242,7 @@ export async function depWalker(
packageLock
};
try {
for await (const current of npmTreeWalker.walk(manifest, rootDepsOptions)) {
for await (const current of npmTreeWalker.walk(mama, rootDepsOptions)) {
const { name, version, integrity, ...currentVersion } = current;
const dependency: Dependency = {
versions: {
Expand Down Expand Up @@ -276,7 +287,7 @@ export async function depWalker(
payload.rootDependency.integrity = integrity;
}
else if (isRoot) {
payload.rootDependency.integrity = manifestIntegrity ?? getManifestIntegrity(manifest);
payload.rootDependency.integrity = manifestIntegrity ?? mama.documentDigest;
}

// If the dependency is a DevDependencies we ignore it.
Expand Down Expand Up @@ -315,7 +326,7 @@ export async function depWalker(
version,
ref: dependency.versions[version] as any,
location,
isRootNode: scanRootNode && name === manifest.name,
isRootNode: scanRootNode && name === mama.name,
registry
})
);
Expand Down Expand Up @@ -383,16 +394,17 @@ export async function depWalker(
...addMissingVersionFlags(new Set(verDescriptor.flags), dependency)
);

if (isLocalManifest(verDescriptor, manifest, packageName)) {
if (isLocalManifest(verDescriptor, mama, packageName)) {
const author = mama.author;
Object.assign(dependency.metadata, {
author: parseAuthor(manifest.author),
homepage: manifest.homepage
author,
homepage: mama.document.homepage
});

Object.assign(verDescriptor, {
author: parseAuthor(manifest.author),
links: getManifestLinks(manifest),
repository: manifest.repository
author,
links: getManifestLinks(mama.document),
repository: mama.document.repository
});
}

Expand Down Expand Up @@ -431,17 +443,6 @@ export async function depWalker(
}
}

export function getManifestIntegrity(
manifest: PackageJSON | WorkspacesPackageJSON
): string | null {
const isWorkspace = "workspaces" in manifest;
const integrity = isWorkspace ?
null :
fromData(JSON.stringify(manifest), { algorithms: ["sha512"] }).toString();

return integrity;
}

function extractHighlightedIdentifiers(
collectables: DefaultCollectableSet<Metadata>[],
identifiersToHighlight: Set<string>
Expand All @@ -466,10 +467,10 @@ function extractHighlightedIdentifiers(

function isLocalManifest(
verDescriptor: DependencyVersion,
manifest: PackageJSON | WorkspacesPackageJSON | ManifestVersion,
mama: ManifestManager,
packageName: string
): manifest is PackageJSON | WorkspacesPackageJSON {
): boolean {
return verDescriptor.existOnRemoteRegistry === false && (
packageName === manifest.name || manifest.name === undefined
packageName === mama.document.name || mama.document.name === undefined
);
}
Loading
Loading