fix(llm-security): F-2/F-3 — contain path traversal + kill npm-view shell injection
Session B of the security-fix track. Both sinks trusted untrusted strings at a
filesystem/subprocess boundary; same fix family as F-1.
F-2 (HIGH, arbitrary file write) — scanners/auto-cleaner.mjs: applyFixes() did
resolve(targetPath, f.file) with no containment, then wrote the cleaned content
back. f.file is untrusted (scanned-repo filenames, or a fully attacker-chosen
--findings file), so file: "../../.claude/settings.json" let the cleaner modify
files OUTSIDE the scanned tree. Add a prefix-containment check before grouping:
absPath must equal targetPath or start with targetPath + sep, else the finding
is refused and reported as skipped. (Documented residual gap: prefix containment
does not stop a symlink inside the tree pointing out — noted inline.)
F-3 (HIGH, command injection, pre-confirmation) — hooks/scripts/
pre-install-supply-chain.mjs: inspectNpmPackage ran execSafe(`npm view ${spec}
--json`), a shell string. spec derives from package tokens parsed out of the
scanned Bash command, so a metachar-bearing token reached the shell on
PreToolUse(Bash) — BEFORE the install, so it ran even if the user then denied
the command. Switch to spawnSync('npm', ['view', spec, '--json']) (no shell);
spec is passed as one argv element. The static `npm audit --json` execSafe call
is left as-is (no interpolation).
TDD (repro -> red -> green):
- tests/scanners/auto-cleaner-traversal.test.mjs: a "../secret.txt" finding must
not rewrite the outside file; contained files still get cleaned.
- tests/hooks/supply-chain-injection.test.mjs: `npm install $(>/abs/PWNED)`
(redirect-only $(...) survives normalizeBashExpansion + the whitespace split)
must not create the sentinel.
Closing gates: full node --test suite 1863/0 (was 1860; +3); gitleaks clean;
F-1 regression re-run GREEN (sink still closed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
This commit is contained in:
parent
a546b6a1aa
commit
3f64aa5bab
4 changed files with 180 additions and 2 deletions
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
import { readFile, writeFile, rename, unlink, stat } from 'node:fs/promises';
|
||||
import { writeFileSync, unlinkSync } from 'node:fs';
|
||||
import { resolve, extname, join, dirname } from 'node:path';
|
||||
import { resolve, extname, join, dirname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fixResult, cleanEnvelope } from './lib/output.mjs';
|
||||
|
|
@ -774,6 +774,25 @@ async function applyFixes(targetPath, findings, dryRun) {
|
|||
}
|
||||
|
||||
const absPath = resolve(targetPath, f.file);
|
||||
|
||||
// F-2: path-traversal containment. `f.file` is untrusted (scanned-repo
|
||||
// filenames, or a fully attacker-chosen --findings file). A value like
|
||||
// "../../.claude/settings.json" resolves OUTSIDE the scanned tree. Refuse
|
||||
// anything that is not the target itself or contained within it, so the
|
||||
// cleaner never writes outside the directory it was pointed at.
|
||||
// NOTE: prefix containment does NOT defend against a symlink inside the
|
||||
// tree pointing out of it — a known residual gap (see security-fix brief).
|
||||
if (absPath !== targetPath && !absPath.startsWith(targetPath + sep)) {
|
||||
fixes.push(fixResult({
|
||||
finding_id: f.id,
|
||||
file: f.file,
|
||||
operation: 'skip',
|
||||
status: 'skipped',
|
||||
description: 'Path escapes target directory — refused (path traversal)',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileGroups.has(f.file)) {
|
||||
fileGroups.set(f.file, { findings: [], absPath });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue