Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions src/http-client.mts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,41 @@ export interface ReshapeArtifactOptions {
policy?: Map<string, string> | 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<string, string> | 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<string, unknown>,
>(data: T, options: ReshapeArtifactOptions): T {
Expand All @@ -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,
Expand Down
145 changes: 145 additions & 0 deletions test/repo/unit/reshape-artifact-public-policy.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down