From 12dc1f6b97a1a69c5586adb5e90f30e2e3b39c8c Mon Sep 17 00:00:00 2001 From: jdalton Date: Sat, 25 Jul 2026 23:08:20 -0400 Subject: [PATCH 1/2] fix: preserve inputPurl through public policy reshape reshapeArtifactForPublicPolicy stripped inputPurl from every artifact when a public API token was used, breaking request->response correlation for batchPackageFetch and batchPackageStream consumers. inputPurl is the caller's own echoed-back request PURL, not sensitive data, so carry it through the reshaped artifact. Scoped to inputPurl only; alert reshaping/low-severity filtering behavior is unchanged. --- src/http-client.mts | 5 ++ .../reshape-artifact-public-policy.test.mts | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/http-client.mts b/src/http-client.mts index 65f49c03..096a8e7b 100644 --- a/src/http-client.mts +++ b/src/http-client.mts @@ -351,6 +351,11 @@ export function reshapeArtifactForPublicPolicy< const resolvedPolicy = policy ?? defaultPublicPolicy const reshapeArtifact = (artifact: SocketArtifactWithExtras) => ({ + // Preserve the caller's echoed-back request PURL. It is the + // request->response correlation key batch consumers use to map replies + // back to the purl they asked for, and it is not sensitive (the caller + // supplied it), so it must survive the public reshape. + inputPurl: artifact.inputPurl, name: artifact.name, version: artifact.version, size: artifact.size, diff --git a/test/repo/unit/reshape-artifact-public-policy.test.mts b/test/repo/unit/reshape-artifact-public-policy.test.mts index dc186891..936d1868 100644 --- a/test/repo/unit/reshape-artifact-public-policy.test.mts +++ b/test/repo/unit/reshape-artifact-public-policy.test.mts @@ -273,6 +273,63 @@ describe('reshapeArtifactForPublicPolicy - Complete Coverage', () => { }) }) + describe('inputPurl correlation key preservation', () => { + it('should preserve inputPurl on each artifact in an artifacts array', () => { + const data = { + artifacts: [ + { + inputPurl: 'pkg:npm/test-package@1.0.0', + name: 'test-package', + version: '1.0.0', + alerts: [ + { type: 'criticalCVE', severity: 'high', key: 'alert1' }, + ], + }, + ], + } + + const result = reshapeArtifactForPublicPolicy(data, { + isAuthenticated: false, + }) + + expect(result.artifacts?.[0]?.inputPurl).toBe( + 'pkg:npm/test-package@1.0.0', + ) + }) + + it('should preserve inputPurl on a single reshaped artifact', () => { + const data = { + inputPurl: 'pkg:npm/single-package@2.0.0', + name: 'single-package', + version: '2.0.0', + alerts: [{ type: 'criticalCVE', severity: 'high', key: 'alert1' }], + } + + const result = reshapeArtifactForPublicPolicy(data, { + isAuthenticated: false, + }) + + expect(result.inputPurl).toBe('pkg:npm/single-package@2.0.0') + }) + + it('should preserve inputPurl on error rows (no alerts) for symmetry', () => { + // Error rows have no `alerts`/`artifacts`, so they fall through + // unchanged. This confirms success and error rows both carry the + // correlation key. + const data = { + inputPurl: 'pkg:npm/missing-package@9.9.9', + error: 'Package not found', + } + + const result = reshapeArtifactForPublicPolicy(data, { + isAuthenticated: false, + }) + + expect(result).toBe(data) + expect(result.inputPurl).toBe('pkg:npm/missing-package@9.9.9') + }) + }) + describe('data with neither artifacts nor alerts', () => { it('should return data unchanged when no artifacts or alerts present', () => { const data = { From e1dff4146bf558231e2a3555f52dde8ff9f0ff50 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 26 Jul 2026 00:10:46 -0400 Subject: [PATCH 2/2] fix: preserve namespace, score, and deep-link qualifiers in public reshape The public policy reshape rebuilt each artifact from a fixed key allowlist that omitted several fields the public batch consumer (the web extension popover) needs to render: - namespace: completes the coordinate for scoped/grouped packages (npm @scope, Maven groupId) used in the display title and socket.dev link; without it namespaced packages render wrong. - score: the SocketScore subscore object the popover reduces to drive the score bar; distinct from scorecards (already kept). Without it the bar always shows the default. - artifactId/classifier/ext/params/path/platform/section: optional deep-link routing qualifiers packageLink() reads to build correct socket.dev URLs for non-npm ecosystems (Maven classifier/ext, PyPI artifactId, RubyGems platform, Go path). Copied only when present. Alert reshaping is unchanged: the {action,key,severity,type} shape and the mandated public policy (low-severity + action filtering) stay exactly as-is. This only widens the per-artifact field allowlist. --- src/http-client.mts | 48 ++++++++++ .../reshape-artifact-public-policy.test.mts | 88 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/http-client.mts b/src/http-client.mts index 096a8e7b..5f85f502 100644 --- a/src/http-client.mts +++ b/src/http-client.mts @@ -336,6 +336,41 @@ export interface ReshapeArtifactOptions { policy?: Map | undefined } +// Non-sensitive deep-link routing hints public consumers (e.g. the web +// extension popover's packageLink builder) read off a batch artifact to +// construct correct socket.dev URLs for non-npm ecosystems (Maven +// classifier/ext, PyPI artifactId, RubyGems platform, Go path). They are not +// part of the generated OpenAPI SocketArtifact schema, so they are typed +// locally (all optional) and the public reshape copies each one explicitly, +// only when present, instead of dropping them. +export interface ArtifactDeepLinkQualifiers { + artifactId?: string | undefined + classifier?: string | undefined + ext?: string | undefined + params?: Record | undefined + path?: string | undefined + platform?: string | undefined + section?: string | undefined +} + +export function pickPresentDeepLinkFields( + artifact: SocketArtifactWithExtras & ArtifactDeepLinkQualifiers, +): ArtifactDeepLinkQualifiers { + return { + ...(artifact.artifactId !== undefined + ? { artifactId: artifact.artifactId } + : {}), + ...(artifact.classifier !== undefined + ? { classifier: artifact.classifier } + : {}), + ...(artifact.ext !== undefined ? { ext: artifact.ext } : {}), + ...(artifact.params !== undefined ? { params: artifact.params } : {}), + ...(artifact.path !== undefined ? { path: artifact.path } : {}), + ...(artifact.platform !== undefined ? { platform: artifact.platform } : {}), + ...(artifact.section !== undefined ? { section: artifact.section } : {}), + } +} + export function reshapeArtifactForPublicPolicy< T extends Record, >(data: T, options: ReshapeArtifactOptions): T { @@ -351,16 +386,29 @@ export function reshapeArtifactForPublicPolicy< const resolvedPolicy = policy ?? defaultPublicPolicy const reshapeArtifact = (artifact: SocketArtifactWithExtras) => ({ + // Deep-link qualifier fields (artifactId, classifier, ext, params, path, + // platform, section) are non-sensitive routing hints public consumers use + // to build correct socket.dev links for non-npm ecosystems. Copy only the + // ones actually present so the reshaped shape stays minimal. + ...pickPresentDeepLinkFields(artifact), // Preserve the caller's echoed-back request PURL. It is the // request->response correlation key batch consumers use to map replies // back to the purl they asked for, and it is not sensitive (the caller // supplied it), so it must survive the public reshape. inputPurl: artifact.inputPurl, + // namespace + score are non-sensitive package identity/health fields + // the public consumer (webext popover) needs to render: `namespace` + // completes the coordinate for scoped/grouped packages (npm @scope, + // Maven groupId) and `score` drives the score bar. The public policy + // (alert low-severity + action filtering below) is unaffected — these + // are per-object fields, not the alert set. + namespace: artifact.namespace, name: artifact.name, version: artifact.version, size: artifact.size, author: artifact.author, type: artifact.type, + score: artifact.score, supplyChainRisk: artifact.supplyChainRisk, scorecards: artifact.scorecards, topLevelAncestors: artifact.topLevelAncestors, diff --git a/test/repo/unit/reshape-artifact-public-policy.test.mts b/test/repo/unit/reshape-artifact-public-policy.test.mts index 936d1868..6c181a1d 100644 --- a/test/repo/unit/reshape-artifact-public-policy.test.mts +++ b/test/repo/unit/reshape-artifact-public-policy.test.mts @@ -312,6 +312,94 @@ describe('reshapeArtifactForPublicPolicy - Complete Coverage', () => { expect(result.inputPurl).toBe('pkg:npm/single-package@2.0.0') }) + it('should preserve namespace and score on reshaped artifacts', () => { + // namespace completes the coordinate for scoped/grouped packages and + // score drives the public score bar; both must survive the reshape. + const data = { + artifacts: [ + { + inputPurl: 'pkg:maven/org.example/lib@1.0.0', + namespace: 'org.example', + name: 'lib', + version: '1.0.0', + score: { overall: 0.8, maintenance: 0.9 }, + alerts: [{ type: 'criticalCVE', severity: 'high', key: 'a1' }], + }, + ], + } + + const result = reshapeArtifactForPublicPolicy(data, { + isAuthenticated: false, + }) + + expect(result.artifacts?.[0]?.namespace).toBe('org.example') + expect(result.artifacts?.[0]?.score).toEqual({ + overall: 0.8, + maintenance: 0.9, + }) + }) + + it('should preserve deep-link qualifier fields when present', () => { + const data = { + artifacts: [ + { + inputPurl: 'pkg:maven/org.example/lib@1.0.0', + name: 'lib', + version: '1.0.0', + classifier: 'sources', + ext: 'jar', + platform: 'ruby', + artifactId: 'lib-1.0.0.tar.gz', + path: 'go.mod', + section: 'files', + params: { foo: 'bar' }, + alerts: [{ type: 'criticalCVE', severity: 'high', key: 'a1' }], + }, + ], + } + + const result = reshapeArtifactForPublicPolicy(data, { + isAuthenticated: false, + }) + + const reshaped = result.artifacts?.[0] + expect(reshaped?.classifier).toBe('sources') + expect(reshaped?.ext).toBe('jar') + expect(reshaped?.platform).toBe('ruby') + expect(reshaped?.artifactId).toBe('lib-1.0.0.tar.gz') + expect(reshaped?.path).toBe('go.mod') + expect(reshaped?.section).toBe('files') + expect(reshaped?.params).toEqual({ foo: 'bar' }) + }) + + it('should omit deep-link qualifier fields when absent', () => { + // Only copy qualifier fields that are actually present so the reshaped + // shape stays minimal (npm artifacts carry none of them). + const data = { + artifacts: [ + { + inputPurl: 'pkg:npm/test@1.0.0', + name: 'test', + version: '1.0.0', + alerts: [{ type: 'criticalCVE', severity: 'high', key: 'a1' }], + }, + ], + } + + const result = reshapeArtifactForPublicPolicy(data, { + isAuthenticated: false, + }) + + const reshaped = result.artifacts?.[0] + expect(reshaped && 'classifier' in reshaped).toBe(false) + expect(reshaped && 'ext' in reshaped).toBe(false) + expect(reshaped && 'platform' in reshaped).toBe(false) + expect(reshaped && 'artifactId' in reshaped).toBe(false) + expect(reshaped && 'path' in reshaped).toBe(false) + expect(reshaped && 'section' in reshaped).toBe(false) + expect(reshaped && 'params' in reshaped).toBe(false) + }) + it('should preserve inputPurl on error rows (no alerts) for symmetry', () => { // Error rows have no `alerts`/`artifacts`, so they fall through // unchanged. This confirms success and error rows both carry the