diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 67690cb..c4f8682 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -276,9 +276,10 @@ impl GoCrawler { _dir_name: &str, seen: &mut HashSet, ) -> Option { - // Get the relative path from the cache root + // Get the relative path from the cache root. + // Normalize to forward slashes so PURLs are correct on Windows. let rel_path = dir_path.strip_prefix(base_path).ok()?; - let rel_str = rel_path.to_string_lossy(); + let rel_str = rel_path.to_string_lossy().replace('\\', "/"); // Find the last `@` to split module path and version let at_idx = rel_str.rfind('@')?; diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index 1bd30f4..7cd06d6 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -1,13 +1,24 @@ { "name": "@socketsecurity/socket-patch", "version": "1.6.3", - "description": "CLI tool for applying security patches to dependencies", + "description": "CLI tool and schema library for applying security patches to dependencies", "bin": { "socket-patch": "bin/socket-patch" }, + "exports": { + "./schema": { + "types": "./dist/schema/manifest-schema.d.ts", + "import": "./dist/schema/manifest-schema.js", + "require": "./dist/schema/manifest-schema.js" + } + }, "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsc", + "test": "pnpm run build && node --test dist/**/*.test.js" + }, "keywords": [ "security", "patch", @@ -23,6 +34,13 @@ "engines": { "node": ">=18.0.0" }, + "dependencies": { + "zod": "^3.24.4" + }, + "devDependencies": { + "typescript": "^5.3.0", + "@types/node": "^20.0.0" + }, "optionalDependencies": { "@socketsecurity/socket-patch-android-arm64": "1.6.3", "@socketsecurity/socket-patch-darwin-arm64": "1.6.3", diff --git a/npm/socket-patch/src/schema/manifest-schema.test.ts b/npm/socket-patch/src/schema/manifest-schema.test.ts new file mode 100644 index 0000000..d87a0f5 --- /dev/null +++ b/npm/socket-patch/src/schema/manifest-schema.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from 'node:test' +import * as assert from 'node:assert/strict' +import { PatchManifestSchema, PatchRecordSchema } from './manifest-schema.js' + +describe('PatchManifestSchema', () => { + it('should validate a well-formed manifest', () => { + const manifest = { + patches: { + 'npm:simplehttpserver@0.0.6': { + uuid: '550e8400-e29b-41d4-a716-446655440000', + exportedAt: '2024-01-01T00:00:00Z', + files: { + 'node_modules/simplehttpserver/index.js': { + beforeHash: 'abc123', + afterHash: 'def456', + }, + }, + vulnerabilities: { + 'GHSA-jrhj-2j3q-xf3v': { + cves: ['CVE-2024-0001'], + summary: 'Path traversal vulnerability', + severity: 'high', + description: 'Allows reading arbitrary files', + }, + }, + description: 'Fix path traversal', + license: 'MIT', + tier: 'free', + }, + }, + } + + const result = PatchManifestSchema.safeParse(manifest) + assert.ok(result.success, 'Valid manifest should parse successfully') + assert.equal( + Object.keys(result.data.patches).length, + 1, + 'Should have one patch entry', + ) + }) + + it('should validate a manifest with multiple patches', () => { + const manifest = { + patches: { + 'npm:pkg-a@1.0.0': { + uuid: '550e8400-e29b-41d4-a716-446655440001', + exportedAt: '2024-01-01T00:00:00Z', + files: { + 'node_modules/pkg-a/lib/index.js': { + beforeHash: 'aaa', + afterHash: 'bbb', + }, + }, + vulnerabilities: {}, + description: 'Patch A', + license: 'MIT', + tier: 'free', + }, + 'npm:pkg-b@2.0.0': { + uuid: '550e8400-e29b-41d4-a716-446655440002', + exportedAt: '2024-02-01T00:00:00Z', + files: { + 'node_modules/pkg-b/src/main.js': { + beforeHash: 'ccc', + afterHash: 'ddd', + }, + }, + vulnerabilities: { + 'GHSA-xxxx-yyyy-zzzz': { + cves: [], + summary: 'Some vuln', + severity: 'medium', + description: 'A medium severity vulnerability', + }, + }, + description: 'Patch B', + license: 'Apache-2.0', + tier: 'paid', + }, + }, + } + + const result = PatchManifestSchema.safeParse(manifest) + assert.ok(result.success, 'Multi-patch manifest should parse successfully') + assert.equal(Object.keys(result.data.patches).length, 2) + }) + + it('should validate an empty manifest', () => { + const manifest = { patches: {} } + const result = PatchManifestSchema.safeParse(manifest) + assert.ok(result.success, 'Empty patches should be valid') + }) + + it('should reject a manifest missing the patches field', () => { + const result = PatchManifestSchema.safeParse({}) + assert.ok(!result.success, 'Missing patches should fail') + }) + + it('should reject a manifest with invalid patch record', () => { + const manifest = { + patches: { + 'npm:bad@1.0.0': { + // missing uuid, exportedAt, files, vulnerabilities, description, license, tier + }, + }, + } + const result = PatchManifestSchema.safeParse(manifest) + assert.ok(!result.success, 'Invalid patch record should fail') + }) + + it('should reject a patch with invalid uuid', () => { + const record = { + uuid: 'not-a-valid-uuid', + exportedAt: '2024-01-01T00:00:00Z', + files: {}, + vulnerabilities: {}, + description: 'Test', + license: 'MIT', + tier: 'free', + } + const result = PatchRecordSchema.safeParse(record) + assert.ok(!result.success, 'Invalid UUID should fail') + }) + + it('should reject non-object input', () => { + assert.ok(!PatchManifestSchema.safeParse(null).success) + assert.ok(!PatchManifestSchema.safeParse('string').success) + assert.ok(!PatchManifestSchema.safeParse(42).success) + assert.ok(!PatchManifestSchema.safeParse([]).success) + }) +}) diff --git a/npm/socket-patch/src/schema/manifest-schema.ts b/npm/socket-patch/src/schema/manifest-schema.ts new file mode 100644 index 0000000..96f201b --- /dev/null +++ b/npm/socket-patch/src/schema/manifest-schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +export const DEFAULT_PATCH_MANIFEST_PATH = '.socket/manifest.json' + +export const PatchRecordSchema = z.object({ + uuid: z.string().uuid(), + exportedAt: z.string(), + files: z.record( + z.string(), // File path + z.object({ + beforeHash: z.string(), + afterHash: z.string(), + }), + ), + vulnerabilities: z.record( + z.string(), // Vulnerability ID like "GHSA-jrhj-2j3q-xf3v" + z.object({ + cves: z.array(z.string()), + summary: z.string(), + severity: z.string(), + description: z.string(), + }), + ), + description: z.string(), + license: z.string(), + tier: z.string(), +}) + +export type PatchRecord = z.infer + +export const PatchManifestSchema = z.object({ + patches: z.record( + z.string(), // Package identifier like "npm:simplehttpserver@0.0.6" + PatchRecordSchema, + ), +}) + +export type PatchManifest = z.infer diff --git a/npm/socket-patch/tsconfig.json b/npm/socket-patch/tsconfig.json new file mode 100644 index 0000000..1b950f9 --- /dev/null +++ b/npm/socket-patch/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "composite": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src"] +}