Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
724b45b
fix(security): raise transitive dependency floors
seonghobae Aug 20, 2026
868b88f
test(security): define pnpm supply-chain policy
seonghobae Aug 20, 2026
1e1fd96
test(security): run pnpm supply-chain policy contract
seonghobae Aug 20, 2026
b70fb18
fix(security): enforce pnpm supply-chain policy
seonghobae Aug 20, 2026
114315b
test(security): bound legacy trust-policy compatibility
seonghobae Aug 20, 2026
735d618
fix(security): bound legacy trust-policy compatibility
seonghobae Aug 20, 2026
235535d
test(security): move pnpm policy contract into canonical suite
seonghobae Aug 20, 2026
637a1c5
fix(ownership): release package manifest writer
seonghobae Aug 20, 2026
244e205
fix(ownership): keep security contract in canonical suite
seonghobae Aug 20, 2026
93fab95
test(security): avoid dynamic regexp in policy parser
seonghobae Aug 20, 2026
905b291
test(security): reject duplicate workspace policy scalars
seonghobae Aug 20, 2026
6954201
fix(security): fail closed on duplicate workspace policy keys
seonghobae Aug 20, 2026
b666460
test(package): reject symlinked consumer resolution escapes
seonghobae Aug 20, 2026
4a39d8a
fix(package): canonicalize consumer resolution paths
seonghobae Aug 20, 2026
522200a
test(package): execute symlink escape validators
seonghobae Aug 20, 2026
41f9788
fix(package): validate paths before loading consumers
seonghobae Aug 20, 2026
3b5446a
test(package): pin validated runtime entry loading
seonghobae Aug 21, 2026
c2db417
fix(package): load validated canonical runtime entries
seonghobae Aug 21, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim

## [Unreleased]

### Security
- Raised workspace-wide transitive development-tool overrides for `fast-uri`, `nanoid`, and `postcss` to patched minimums, keeping the lockfile audit clean without changing runtime package authority.
- Normalized isolated package-verifier temporary roots before containment checks on macOS.

## [0.6.0] — 2026-08-10

### Release
Expand Down
21 changes: 11 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 17 additions & 4 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,35 @@ onlyBuiltDependencies:
allowBuilds:
esbuild: true

# Keep install-time supply-chain policy explicit instead of relying on pnpm's
# mutable defaults. Transitive exotic sources stay blocked, newly published
# versions wait seven days, and trust downgrades fail for packages released in
# the last 30 days. The bounded legacy window keeps the frozen TipTap v2 line
# (published months before trusted-publishing adoption) installable without
# exempting current package names or disabling lockfile verification.
blockExoticSubdeps: true
minimumReleaseAge: 10080
trustPolicy: no-downgrade
trustPolicyIgnoreAfter: 43200

# Force patched transitive dependencies across the whole tree. pnpm 10+ reads
# `overrides` from pnpm-workspace.yaml, NOT the deprecated package.json `pnpm`
# field, so every security override must live here to actually take effect.
# - vite<6.4.3: GHSA-fx2h-pf6j-xcff / GHSA-4w7w-66w2-5vf9 / GHSA-v6wh-96g9-6wx3
# (vitest's @vitest/mocker still resolves a transitive vite@5)
# - esbuild<0.25: GHSA-67mh-4wv8-2f99 (dragged in by the transitive vite@5)
# - fast-uri<3.1.4: GHSA-v2hh-gcrm-f6hx / CVE-2026-16221 (via ajv in vite-plugin-dts)
# - postcss<=8.5.17: GHSA-r28c-9q8g-f849 (source-map path traversal via vite)
# - fast-uri<3.1.5: GHSA-v2hh-gcrm-f6hx / GHSA-7p8r-x3mc-p8w7 (via ajv in vite-plugin-dts)
# - nanoid<3.3.18: GHSA-2v37-7h3g-55p8 (via postcss)
# - postcss<=8.5.22: GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp (source-map path traversal via vite)
# - brace-expansion<=5.0.7: GHSA-mh99-v99m-4gvg (unbounded-expansion DoS). The
# only patched line is >=5.0.8; minimatch@9 consumes brace-expansion@2.x and
# cannot take 5.x, so lift minimatch to ^10 (glob@10 / test-exclude@7 resolve
# cleanly) and pin brace-expansion to the patched 5.x line.
overrides:
vite: ^6.4.3
esbuild: ^0.25.0
fast-uri: ^3.1.4
postcss: ^8.5.18
fast-uri: ^3.1.5
nanoid: ^3.3.18
postcss: ^8.5.23
brace-expansion: ^5.0.8
minimatch: ^10.0.0
191 changes: 190 additions & 1 deletion scripts/revision-evidence-consumer-config.test.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
Expand Down Expand Up @@ -88,7 +90,10 @@ test('copies the locked pnpm dependency tree into the independent consumer', ()
true,
);
const stagedPackage = realpathSync(join(targetNodeModules, 'example'));
assert.equal(relative(targetNodeModules, stagedPackage).startsWith('..'), false);
assert.equal(
relative(realpathSync(targetNodeModules), stagedPackage).startsWith('..'),
false,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down Expand Up @@ -198,3 +203,187 @@ test('exposes only declared direct dependencies from the staged lockfile tree',
rmSync(root, { recursive: true, force: true });
}
});

test('canonical containment detects a symlinked dependency that escapes the consumer tree', () => {
const root = realpathSync(
mkdtempSync(join(tmpdir(), 'inkspan-symlink-containment-')),
);
try {
const consumer = join(root, 'consumer');
const outside = join(root, 'outside');
const linkedPackage = join(consumer, 'node_modules', 'example');
mkdirSync(join(consumer, 'node_modules'), { recursive: true });
mkdirSync(outside, { recursive: true });
writeFileSync(join(outside, 'package.json'), '{}\n', 'utf8');
symlinkSync(outside, linkedPackage, 'dir');

assert.equal(relative(consumer, linkedPackage).startsWith('..'), false);
assert.equal(
relative(realpathSync(consumer), realpathSync(linkedPackage)).startsWith(
'..',
),
true,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('generated ESM and CommonJS package checks canonicalize runtime resolution paths', () => {
const verifierSource = readFileSync(
new URL('./verify-revision-evidence-package.mjs', import.meta.url),
'utf8',
);

assert.equal(
verifierSource.includes("import { realpathSync } from 'node:fs';"),
true,
);
assert.equal(
verifierSource.includes('const consumerDirectory = realpathSync('),
true,
);
assert.equal(
verifierSource.includes(
"const resolvedEntry = realpathSync(fileURLToPath(import.meta.resolve('",
),
true,
);
assert.equal(
verifierSource.includes(
"const { realpathSync } = require('node:fs');",
),
true,
);
assert.equal(
verifierSource.includes(
"const resolvedEntry = realpathSync(require.resolve('",
),
true,
);
});

function extractGeneratedRuntimeSource(verifierSource, functionName) {
const functionStart = verifierSource.indexOf(`function ${functionName}`);
assert.notEqual(functionStart, -1, `missing verifier function ${functionName}`);
const writeStart = verifierSource.indexOf('writeFileSync(', functionStart);
assert.notEqual(writeStart, -1, `missing writeFileSync in ${functionName}`);
const templateStart = verifierSource.indexOf('`', writeStart);
const templateEnd = verifierSource.indexOf("`,\n 'utf8',", templateStart);
assert.notEqual(templateStart, -1, `missing generated source in ${functionName}`);
assert.notEqual(templateEnd, -1, `missing generated source end in ${functionName}`);
return verifierSource.slice(templateStart + 1, templateEnd);
}

function instantiateGeneratedRuntimeSource(
generatedSource,
{ packageName, verificationDirectory, packageDirectory },
) {
return generatedSource
.replaceAll('${packageJson.name}', packageName)
.replaceAll(
'${JSON.stringify(verificationDirectory)}',
JSON.stringify(verificationDirectory),
)
.replaceAll(
'${JSON.stringify(packageDirectory)}',
JSON.stringify(packageDirectory),
);
}

test('generated ESM and CommonJS validators reject symlink escapes before package execution', () => {
const root = realpathSync(
mkdtempSync(join(tmpdir(), 'inkspan-generated-containment-')),
);
try {
const consumer = join(root, 'consumer');
const outside = join(root, 'outside-package');
const linkedPackage = join(consumer, 'node_modules', 'example');
const executionMarker = join(root, 'outside-package-executed');
mkdirSync(join(consumer, 'node_modules'), { recursive: true });
mkdirSync(outside, { recursive: true });
writeFileSync(
join(outside, 'package.json'),
`${JSON.stringify({
name: 'example',
version: '1.0.0',
type: 'module',
exports: {
'.': {
import: './index.mjs',
require: './index.cjs',
},
},
})}\n`,
'utf8',
);
writeFileSync(
join(outside, 'index.mjs'),
"import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.INKSPAN_ESCAPE_MARKER, 'esm');\nexport const sentinel = true;\n",
'utf8',
);
writeFileSync(
join(outside, 'index.cjs'),
"const { writeFileSync } = require('node:fs');\nwriteFileSync(process.env.INKSPAN_ESCAPE_MARKER, 'cjs');\nmodule.exports = { sentinel: true };\n",
'utf8',
);
symlinkSync(outside, linkedPackage, 'dir');

const verifierSource = readFileSync(
new URL('./verify-revision-evidence-package.mjs', import.meta.url),
'utf8',
);
const cases = [
{
functionName: 'verifyRevisionEvidenceEsmRuntime',
extension: 'mjs',
expectedError: 'packed ESM entry escaped consumer tree',
},
{
functionName: 'verifyRevisionEvidenceCommonJsRuntime',
extension: 'cjs',
expectedError: 'packed CommonJS entry escaped consumer tree',
},
];

for (const { functionName, extension, expectedError } of cases) {
rmSync(executionMarker, { force: true });
const generatedSource = instantiateGeneratedRuntimeSource(
extractGeneratedRuntimeSource(verifierSource, functionName),
{
packageName: 'example',
verificationDirectory: consumer,
packageDirectory: linkedPackage,
},
);
const scriptPath = join(consumer, `generated-containment.${extension}`);
writeFileSync(scriptPath, generatedSource, 'utf8');
const result = spawnSync(
process.execPath,
['--preserve-symlinks', scriptPath],
{
cwd: consumer,
encoding: 'utf8',
env: {
...process.env,
INKSPAN_ESCAPE_MARKER: executionMarker,
},
},
);

assert.notEqual(result.status, 0, `${extension} validator accepted escape`);
assert.equal(
result.stderr.includes(expectedError),
true,
`${extension} validator failed for the wrong reason: ${result.stderr}`,
);
assert.equal(
existsSync(executionMarker),
false,
`${extension} validator executed the escaped package before rejection`,
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
4 changes: 2 additions & 2 deletions scripts/verify-framework-free-revision-evidence-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = JSON.parse(
readFileSync(join(repositoryRoot, 'package.json'), 'utf8'),
);
const verificationRoot = mkdtempSync(
join(tmpdir(), 'inkspan-framework-free-evidence-'),
const verificationRoot = realpathSync(
mkdtempSync(join(tmpdir(), 'inkspan-framework-free-evidence-')),
);
const extractionDirectory = join(verificationRoot, 'extracted');
const consumerDirectory = join(verificationRoot, 'consumer');
Expand Down
Loading
Loading