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:
Kjell Tore Guttormsen 2026-08-02 21:10:47 +02:00
commit ff4d8e8a31
5 changed files with 149 additions and 33 deletions

View file

@ -74,4 +74,37 @@ describe('content-extractor — stripInjection removes what it reports', () => {
assert.match(sanitized, /keep-after/);
assert.ok(!sanitized.includes(encoded));
});
it('flags a second, cross-line occurrence of an already-stripped label as unstripped', () => {
// Two occurrences of the SAME label: the first is single-line encoded
// (caught + redacted by the per-line pass), the second is split across
// two lines so no individual line's decoded form matches — only the
// whole-text normalization does (the documented residual gap). A global
// `attributed.has(label)` check wrongly treats the second occurrence as
// handled because the FIRST one was; it must be judged on its own line
// span instead.
const singleLine = htmlEntities('ignore all previous');
const crossLineA = htmlEntities('ignore all');
const crossLineB = htmlEntities('previous');
const text = [
'keep-before',
singleLine,
'keep-middle',
crossLineA,
crossLineB,
'keep-after',
'',
].join('\n');
const { sanitized, findings } = stripInjection(text, 'README.md');
assert.ok(!sanitized.includes(singleLine), 'the single-line occurrence must be stripped');
assert.ok(
sanitized.includes(crossLineA) && sanitized.includes(crossLineB),
'the cross-line occurrence is expected to survive stripping (documented residual gap)'
);
assert.ok(
findings.some(f => f.unstripped === true),
'a payload that survives into sanitized output must be flagged unstripped, not silently reported as handled'
);
});
});

View file

@ -117,6 +117,39 @@ describe('supply-chain-recheck: npm blocklist', () => {
}
});
it('detects compromised event-stream@3.3.6 nested under lockfileVersion 1 dependencies', async () => {
// lockfileVersion 1's `dependencies` is a nested tree — a transitive,
// non-hoisted copy sits under a parent's own `dependencies` object.
// parsePackageLock must recurse, mirroring the hook's walk()
// (pre-install-supply-chain.mjs #48).
if (existsSync(TEMP)) rmSync(TEMP, { recursive: true });
mkdirSync(TEMP, { recursive: true });
writeFileSync(join(TEMP, 'package-lock.json'), JSON.stringify({
name: 'sample',
lockfileVersion: 1,
dependencies: {
a: {
version: '1.0.0',
dependencies: {
'event-stream': { version: '3.3.6' },
},
},
},
}));
try {
const result = await scan(TEMP, { files: [] });
const compromised = result.findings.filter(
f => f.title.includes('Compromised') && f.title.includes('event-stream')
);
assert.ok(
compromised.length >= 1,
`Expected compromised finding for nested event-stream, got ${result.findings.map(f => f.title).join('; ')}`
);
} finally {
cleanupTemp();
}
});
it('does not flag clean packages in package-lock.json', async () => {
setupTemp({ 'package-lock.json': 'package-lock-clean.json' });
try {
@ -140,6 +173,26 @@ describe('supply-chain-recheck: npm blocklist', () => {
cleanupTemp();
}
});
it('detects compromised event-stream@3.3.6 in a Berry yarn.lock (version: x format)', async () => {
// Berry (yarn v2+) writes `version: x` with no quotes, unlike Classic's
// `version "x"`. parseYarnLock must recognize both, mirroring the hook's
// parser (pre-install-supply-chain.mjs #17).
setupTemp({ 'yarn.lock': 'yarn-berry-compromised.lock' });
try {
const result = await scan(TEMP, { files: [] });
const compromised = result.findings.filter(
f => f.title.includes('Compromised') && f.title.includes('event-stream')
);
assert.ok(
compromised.length >= 1,
`Expected compromised finding for event-stream, got ${result.findings.map(f => f.title).join('; ')}`
);
assert.equal(compromised[0].severity, 'critical');
} finally {
cleanupTemp();
}
});
});
// ============================================================================