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
3,052 changes: 1,539 additions & 1,513 deletions dist/action.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/action.cjs.map

Large diffs are not rendered by default.

44 changes: 30 additions & 14 deletions src/ecosystems/deno/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { join } from 'node:path';
import type { PackageChange } from 'lockdelta';
import type { PackageAnalysis } from '../../types.js';
import { extractTarball } from '../../utils/extract.js';
import type { FileMap } from '../../utils/extract.js';
import { extractTarball, extractTarballBinaries } from '../../utils/extract.js';
import type { AnalysisOptions, EcosystemAnalyzer } from '../base.js';
import {
computeMetadataDelta,
Expand All @@ -11,6 +12,7 @@ import {
fetchNpmVersion,
getArtifactInfo,
} from '../javascript/npm.js';
import { type BinaryScan, binaryFindingsDelta, scanBinary } from '../shared/binary-scan.js';
import { diffFiles } from '../shared/diff.js';
import { annotateHooks, detectNpmHooks } from '../shared/install-hooks.js';
import { checkRegistry } from '../shared/registry-check.js';
Expand All @@ -24,6 +26,20 @@ import {
} from './jsr.js';
import { DANGEROUS_PATTERNS, JSR_EXTENSIONS, NPM_EXTENSIONS } from './patterns.js';

interface NpmArtifactScan {
files: FileMap;
binaryScans: Map<string, BinaryScan>;
}

async function scanNpmArtifact(data: Buffer, destDir: string): Promise<NpmArtifactScan> {
const files = await extractTarball(data, destDir, NPM_EXTENSIONS);
const binaries = await extractTarballBinaries(destDir);
const binaryScans = new Map(
[...binaries.entries()].map(([name, buf]) => [name, scanBinary(name, buf)]),
);
return { files, binaryScans };
}

export class DenoAnalyzer implements EcosystemAnalyzer {
readonly ecosystem = 'deno';

Expand Down Expand Up @@ -85,11 +101,7 @@ export class DenoAnalyzer implements EcosystemAnalyzer {

const download = (version: string, slot: string) => (url: string) =>
downloadNpmTarball(url).then((data) =>
extractTarball(
data,
join(options.tmpDir, `deno_npm_${safeName}_${version}_${slot}`),
NPM_EXTENSIONS,
),
scanNpmArtifact(data, join(options.tmpDir, `deno_npm_${safeName}_${version}_${slot}`)),
);

if (changeType === 'removed') {
Expand All @@ -106,14 +118,15 @@ export class DenoAnalyzer implements EcosystemAnalyzer {
const newArtifact = getArtifactInfo(newMeta);
const repoUrl = extractRepoUrl(newMeta);

const [newFiles, registryInfo, repoCheck] = await Promise.all([
const [newScan, registryInfo, repoCheck] = await Promise.all([
download(newVersion!, 'new')(newArtifact.url),
extractRegistryInfo(newMeta),
checkRepoRelease({ repoUrl, packageName: name, oldVersion: null, newVersion }),
]);

const newFindings = scanPatterns(newFiles, DANGEROUS_PATTERNS);
const newHooks = detectNpmHooks(newFiles);
const newFindings = scanPatterns(newScan.files, DANGEROUS_PATTERNS);
const newHooks = detectNpmHooks(newScan.files);
const binaryDelta = binaryFindingsDelta(new Map(), newScan.binaryScans);

return {
...base,
Expand All @@ -131,6 +144,7 @@ export class DenoAnalyzer implements EcosystemAnalyzer {
delta: newFindings,
platformDivergence: false,
},
...(binaryDelta.length > 0 && { binaryFindings: { delta: binaryDelta } }),
...(newHooks.length > 0 && { installHooks: newHooks.map((h) => ({ ...h, isNew: true })) }),
...(repoCheck && { repoCheck }),
...(registryCheck && { registryCheck }),
Expand All @@ -146,15 +160,16 @@ export class DenoAnalyzer implements EcosystemAnalyzer {
const oldArtifact = getArtifactInfo(oldMeta);
const repoUrl = extractRepoUrl(newMeta);

const [newFiles, oldFiles, repoCheck] = await Promise.all([
const [newScan, oldScan, repoCheck] = await Promise.all([
download(newVersion!, 'new')(newArtifact.url),
download(oldVersion!, 'old')(oldArtifact.url),
checkRepoRelease({ repoUrl, packageName: name, oldVersion, newVersion }),
]);

const newFindings = scanPatterns(newFiles, DANGEROUS_PATTERNS);
const oldFindings = scanPatterns(oldFiles, DANGEROUS_PATTERNS);
const annotated = annotateHooks(detectNpmHooks(oldFiles), detectNpmHooks(newFiles));
const newFindings = scanPatterns(newScan.files, DANGEROUS_PATTERNS);
const oldFindings = scanPatterns(oldScan.files, DANGEROUS_PATTERNS);
const annotated = annotateHooks(detectNpmHooks(oldScan.files), detectNpmHooks(newScan.files));
const binaryDelta = binaryFindingsDelta(oldScan.binaryScans, newScan.binaryScans);
const metadataDelta = computeMetadataDelta(oldMeta, newMeta);

return {
Expand All @@ -167,13 +182,14 @@ export class DenoAnalyzer implements EcosystemAnalyzer {
newArtifacts: [newArtifact],
},
metadataDelta,
codeDelta: diffFiles(oldFiles, newFiles),
codeDelta: diffFiles(oldScan.files, newScan.files),
securityFindings: {
old: oldFindings,
new: newFindings,
delta: findingsDelta(oldFindings, newFindings),
platformDivergence: false,
},
...(binaryDelta.length > 0 && { binaryFindings: { delta: binaryDelta } }),
...(annotated.length > 0 && { installHooks: annotated }),
...(repoCheck && { repoCheck }),
...(registryCheck && { registryCheck }),
Expand Down
41 changes: 30 additions & 11 deletions src/ecosystems/javascript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { join } from 'node:path';
import type { PackageChange } from 'lockdelta';
import type { PackageAnalysis } from '../../types.js';
import type { FileMap } from '../../utils/extract.js';
import { extractTarball } from '../../utils/extract.js';
import { extractTarball, extractTarballBinaries } from '../../utils/extract.js';
import type { AnalysisOptions, EcosystemAnalyzer } from '../base.js';
import { type BinaryScan, binaryFindingsDelta, scanBinary } from '../shared/binary-scan.js';
import { diffFiles } from '../shared/diff.js';
import { findingsDelta, scanPatterns } from '../shared/scan.js';

Expand Down Expand Up @@ -33,6 +34,20 @@ import {
} from './npm.js';
import { DANGEROUS_PATTERNS, JS_EXTENSIONS } from './patterns.js';

interface NpmArtifactScan {
files: FileMap;
binaryScans: Map<string, BinaryScan>;
}

async function scanNpmArtifact(data: Buffer, destDir: string): Promise<NpmArtifactScan> {
const files = await extractTarball(data, destDir, JS_EXTENSIONS);
const binaries = await extractTarballBinaries(destDir);
const binaryScans = new Map(
[...binaries.entries()].map(([name, buf]) => [name, scanBinary(name, buf)]),
);
return { files, binaryScans };
}

export class JavaScriptAnalyzer implements EcosystemAnalyzer {
readonly ecosystem = 'javascript';

Expand Down Expand Up @@ -60,7 +75,7 @@ export class JavaScriptAnalyzer implements EcosystemAnalyzer {

const download = (version: string, slot: string) => (url: string) =>
downloadNpmTarball(url).then((data) =>
extractTarball(data, join(options.tmpDir, `${safeName}_${version}_${slot}`), JS_EXTENSIONS),
scanNpmArtifact(data, join(options.tmpDir, `${safeName}_${version}_${slot}`)),
);

if (change_type === 'added') {
Expand All @@ -69,14 +84,15 @@ export class JavaScriptAnalyzer implements EcosystemAnalyzer {
const repoUrl = extractRepoUrl(newMeta);

// tarball download+extract, registry info, and repo check are all independent network I/O
const [newFiles, registryInfo, repoCheck] = await Promise.all([
const [newScan, registryInfo, repoCheck] = await Promise.all([
download(new_version!, 'new')(newArtifact.url),
extractRegistryInfo(newMeta),
checkRepoRelease({ repoUrl, packageName: name, oldVersion: null, newVersion: new_version }),
]);

const newFindings = scanPatterns(newFiles, DANGEROUS_PATTERNS);
const newHooks = detectNpmHooks(newFiles);
const newFindings = scanPatterns(newScan.files, DANGEROUS_PATTERNS);
const newHooks = detectNpmHooks(newScan.files);
const binaryDelta = binaryFindingsDelta(new Map(), newScan.binaryScans);

return {
...base,
Expand All @@ -94,6 +110,7 @@ export class JavaScriptAnalyzer implements EcosystemAnalyzer {
delta: newFindings,
platformDivergence: false,
},
...(binaryDelta.length > 0 && { binaryFindings: { delta: binaryDelta } }),
...(newHooks.length > 0 && { installHooks: newHooks.map((h) => ({ ...h, isNew: true })) }),
...(repoCheck && { repoCheck }),
...(registryCheck && { registryCheck }),
Expand All @@ -110,7 +127,7 @@ export class JavaScriptAnalyzer implements EcosystemAnalyzer {
const oldArtifact = getArtifactInfo(oldMeta);
const repoUrl = extractRepoUrl(newMeta);

const [newFiles, oldFiles, repoCheck] = await Promise.all([
const [newScan, oldScan, repoCheck] = await Promise.all([
download(new_version!, 'new')(newArtifact.url),
download(old_version!, 'old')(oldArtifact.url),
checkRepoRelease({
Expand All @@ -121,11 +138,12 @@ export class JavaScriptAnalyzer implements EcosystemAnalyzer {
}),
]);

const newFindings = scanPatterns(newFiles, DANGEROUS_PATTERNS);
const oldFindings = scanPatterns(oldFiles, DANGEROUS_PATTERNS);
const annotated = annotateHooks(detectNpmHooks(oldFiles), detectNpmHooks(newFiles));
const newFindings = scanPatterns(newScan.files, DANGEROUS_PATTERNS);
const oldFindings = scanPatterns(oldScan.files, DANGEROUS_PATTERNS);
const annotated = annotateHooks(detectNpmHooks(oldScan.files), detectNpmHooks(newScan.files));
const binaryDelta = binaryFindingsDelta(oldScan.binaryScans, newScan.binaryScans);

const buildSystemChanged = checkNpmNativeBuild(oldFiles, newFiles);
const buildSystemChanged = checkNpmNativeBuild(oldScan.files, newScan.files);
const baseDelta = computeMetadataDelta(oldMeta, newMeta);
const metadataDelta = { ...baseDelta, ...(buildSystemChanged && { buildSystemChanged }) };

Expand All @@ -139,13 +157,14 @@ export class JavaScriptAnalyzer implements EcosystemAnalyzer {
newArtifacts: [newArtifact],
},
metadataDelta,
codeDelta: diffFiles(oldFiles, newFiles),
codeDelta: diffFiles(oldScan.files, newScan.files),
securityFindings: {
old: oldFindings,
new: newFindings,
delta: findingsDelta(oldFindings, newFindings),
platformDivergence: false,
},
...(binaryDelta.length > 0 && { binaryFindings: { delta: binaryDelta } }),
...(annotated.length > 0 && { installHooks: annotated }),
...(repoCheck && { repoCheck }),
...(registryCheck && { registryCheck }),
Expand Down
2 changes: 1 addition & 1 deletion src/ecosystems/python/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import {
type FileMap,
} from '../../utils/extract.js';
import type { AnalysisOptions, EcosystemAnalyzer } from '../base.js';
import { type BinaryScan, binaryFindingsDelta, scanBinary } from '../shared/binary-scan.js';
import { diffFiles } from '../shared/diff.js';
import { annotateHooks, detectPythonWheelHooks } from '../shared/install-hooks.js';
import { checkRegistry } from '../shared/registry-check.js';
import { checkRepoRelease } from '../shared/repo-check.js';
import { findingsDelta, scanPatterns } from '../shared/scan.js';
import { type BinaryScan, binaryFindingsDelta, scanBinary } from './binary-scan.js';
import { DANGEROUS_PATTERNS, PY_EXTENSIONS } from './patterns.js';
import {
computeMetadataDelta,
Expand Down
39 changes: 31 additions & 8 deletions src/utils/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const SKIP_DIRS = new Set([
'.ruff_cache',
]);

// 'build' and 'dist' are excluded above to skip duplicated/transpiled source when
// pattern-scanning text files, but 'build/Release' (node-gyp) and 'build/<platform>'
// (prebuild/napi-rs) are exactly where compiled .node addons live — never skip them
// when walking for binaries.
const BINARY_SKIP_DIRS = new Set(['.git', 'node_modules']);

/**
* Extracts a .tgz or .tar.gz archive, stripping the top-level directory
* (works for both npm tarballs `package/` and Python sdists `pkg-1.0/`).
Expand All @@ -36,11 +42,12 @@ export async function extractTarball(
return collectFiles(destDir, fileExtensions);
}

const BINARY_EXTENSIONS = new Set(['.so', '.pyd', '.dylib', '.dll']);
/** Compiled native extensions — structurally ELF/Mach-O/PE regardless of the ecosystem's own extension convention. */
const BINARY_EXTENSIONS = new Set(['.so', '.pyd', '.dylib', '.dll', '.node']);

/**
* Extracts compiled binary extensions from a .whl (zip) archive in-memory.
* Returns a map of entry name → raw bytes for .so/.pyd/.dylib/.dll files.
* Returns a map of entry name → raw bytes for .so/.pyd/.dylib/.dll/.node files.
*/
export function extractZipBinaries(data: Buffer): Map<string, Buffer> {
const files = new Map<string, Buffer>();
Expand All @@ -53,6 +60,20 @@ export function extractZipBinaries(data: Buffer): Map<string, Buffer> {
return files;
}

/**
* Collects compiled binary extensions from a directory previously extracted by
* `extractTarball` (e.g. npm native addons shipped as `.node` files).
* Returns a map of relative path → raw bytes.
*/
export async function extractTarballBinaries(
destDir: string,
extensions: Set<string> = BINARY_EXTENSIONS,
): Promise<Map<string, Buffer>> {
const files = new Map<string, Buffer>();
await walkDir(destDir, destDir, extensions, files, (path) => readFile(path), BINARY_SKIP_DIRS);
return files;
}

/**
* Extracts a .zip / .whl archive in-memory (synchronous via adm-zip).
* Wheel files have no top-level container directory so no stripping is needed.
Expand All @@ -74,15 +95,17 @@ export function extractZip(data: Buffer, fileExtensions: Set<string>): FileMap {

async function collectFiles(dir: string, extensions: Set<string>): Promise<FileMap> {
const files: FileMap = new Map();
await walkDir(dir, dir, extensions, files);
await walkDir(dir, dir, extensions, files, (path) => readFile(path, 'utf8'), SKIP_DIRS);
return files;
}

async function walkDir(
async function walkDir<T>(
baseDir: string,
currentDir: string,
extensions: Set<string>,
files: FileMap,
files: Map<string, T>,
readAs: (path: string) => Promise<T>,
skipDirs: Set<string>,
): Promise<void> {
let entries: string[];
try {
Expand All @@ -101,13 +124,13 @@ async function walkDir(
}

if (s.isDirectory()) {
if (!SKIP_DIRS.has(entry)) {
await walkDir(baseDir, fullPath, extensions, files);
if (!skipDirs.has(entry)) {
await walkDir(baseDir, fullPath, extensions, files, readAs, skipDirs);
}
} else if (extensions.has(extname(entry))) {
const rel = relative(baseDir, fullPath);
try {
files.set(rel, await readFile(fullPath, 'utf8'));
files.set(rel, await readAs(fullPath));
} catch {
// skip unreadable files
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractImportedSymbols } from '../../../src/ecosystems/python/binary-formats.js';
import { extractImportedSymbols } from '../../../src/ecosystems/shared/binary-formats.js';
import { buildElf, buildMachO, buildPe } from './binary-fixtures.js';

describe('extractImportedSymbols — ELF', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { binaryFindingsDelta, scanBinary } from '../../../src/ecosystems/python/binary-scan.js';
import { binaryFindingsDelta, scanBinary } from '../../../src/ecosystems/shared/binary-scan.js';
import { buildElf, buildMachO } from './binary-fixtures.js';

describe('scanBinary — native symbol detection (issue #4)', () => {
Expand Down
Loading