fix(llm-security): v8 Phase 1 — Berry lockfile, nested-v1 recursion, per-occurrence strip attribution
Three TDD-first fixes surviving the B8 roadmap bucket (v8.0.0-plan.local.md Phase 1, items 1-3; item 4 JAR hardening scoped out at review): - supply-chain-recheck.mjs parseYarnLock: ported the hook's per-entry parser (pre-install-supply-chain.mjs) so Berry's `version: x` format (unquoted) is recognized alongside Classic's `version "x"` — Berry lockfiles previously yielded zero deps, silently missing pinned compromised packages. - supply-chain-recheck.mjs parsePackageLock: lockfileVersion-1 fallback now recurses nested `dependencies`, mirroring the hook's walk() — a transitive, non-hoisted compromised copy below the top level was previously invisible. - content-extractor.mjs stripInjection: attribution moved from a global `Set<label>` to `Set<label::lineIndex>`. The old check silenced the unstripped flag for ANY occurrence of a label once ANY occurrence had been line-redacted, so a second, cross-line-only encoded occurrence of the same label survived into sanitized output without being flagged. Full suite 2019/2019 (one known-flaky timing test confirmed green in isolation).
This commit is contained in:
parent
b929ddc2bb
commit
ff4d8e8a31
5 changed files with 149 additions and 33 deletions
|
|
@ -84,21 +84,32 @@ async function parsePackageLock(filePath) {
|
|||
}
|
||||
}
|
||||
|
||||
// v1 fallback: dependencies object
|
||||
// v1 fallback: dependencies is a nested tree — recurse so a non-hoisted
|
||||
// nested copy of a package is still found, mirroring the hook's walk()
|
||||
// (pre-install-supply-chain.mjs scanNpmLockfile).
|
||||
if (deps.length === 0 && lock.dependencies) {
|
||||
for (const [name, info] of Object.entries(lock.dependencies)) {
|
||||
if (info.version) {
|
||||
deps.push({ name, version: info.version, ecosystem: 'npm' });
|
||||
const walk = (dependencies) => {
|
||||
for (const [name, info] of Object.entries(dependencies)) {
|
||||
if (info && info.version) {
|
||||
deps.push({ name, version: info.version, ecosystem: 'npm' });
|
||||
}
|
||||
if (info && info.dependencies) walk(info.dependencies);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(lock.dependencies);
|
||||
}
|
||||
} catch { /* parse error — skip */ }
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse yarn.lock (v1 format).
|
||||
* Extracts package name and resolved version from each entry.
|
||||
* Parse yarn.lock — both Classic (v1) and Berry (v2+) formats.
|
||||
* Parses per entry: an entry starts at column 0 with one or more specs
|
||||
* ending in ':' ("pkg@range" / Berry '"pkg@npm:range"'), followed by an
|
||||
* indented body carrying `version "x"` (Classic) or `version: x` (Berry).
|
||||
* The version is associated with ITS OWN entry (no unanchored substring
|
||||
* matching across entries), mirroring the hook's parser
|
||||
* (pre-install-supply-chain.mjs scanNpmLockfile).
|
||||
* @param {string} filePath - Absolute path to yarn.lock
|
||||
* @returns {Promise<{ name: string, version: string, ecosystem: string }[]>}
|
||||
*/
|
||||
|
|
@ -106,28 +117,21 @@ async function parseYarnLock(filePath) {
|
|||
const deps = [];
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
let currentPkg = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Package header: "pkg@^1.0.0", "pkg@1.0.0:" or "@scope/pkg@^1.0.0":
|
||||
if (!line.startsWith(' ') && !line.startsWith('#') && line.includes('@')) {
|
||||
const trimmed = line.replace(/[":]/g, '').trim();
|
||||
if (trimmed.startsWith('@')) {
|
||||
// Scoped: @scope/pkg@version
|
||||
const rest = trimmed.slice(1);
|
||||
const atIdx = rest.indexOf('@');
|
||||
if (atIdx > 0) currentPkg = '@' + rest.slice(0, atIdx);
|
||||
} else {
|
||||
const atIdx = trimmed.indexOf('@');
|
||||
if (atIdx > 0) currentPkg = trimmed.slice(0, atIdx);
|
||||
}
|
||||
}
|
||||
// Version line: " version "1.2.3""
|
||||
const versionMatch = line.match(/^\s+version\s+"([^"]+)"/);
|
||||
if (versionMatch && currentPkg) {
|
||||
deps.push({ name: currentPkg, version: versionMatch[1], ecosystem: 'npm' });
|
||||
currentPkg = null;
|
||||
for (const entry of raw.split(/\n(?=\S)/)) {
|
||||
const nl = entry.indexOf('\n');
|
||||
if (nl === -1) continue;
|
||||
const header = entry.slice(0, nl).trim();
|
||||
if (!header.endsWith(':') || header.startsWith('#')) continue;
|
||||
const vMatch = entry.match(/^\s+version:?\s+"?([^"\s]+)"?\s*$/m);
|
||||
const entryVersion = vMatch ? vMatch[1] : null;
|
||||
if (!entryVersion) continue;
|
||||
const names = new Set(header.slice(0, -1).split(',').map(part => {
|
||||
const spec = part.trim().replace(/^"|"$/g, '');
|
||||
const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
|
||||
return at > 0 ? spec.slice(0, at) : spec;
|
||||
}));
|
||||
for (const name of names) {
|
||||
deps.push({ name, version: entryVersion, ecosystem: 'npm' });
|
||||
}
|
||||
}
|
||||
} catch { /* parse error — skip */ }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue