diff --git a/src/http-client.mts b/src/http-client.mts index 65f49c03..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,11 +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 dc186891..6c181a1d 100644 --- a/test/repo/unit/reshape-artifact-public-policy.test.mts +++ b/test/repo/unit/reshape-artifact-public-policy.test.mts @@ -273,6 +273,151 @@ 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 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 + // 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 = {