Skip to content

Commit 05f0eac

Browse files
committed
feat: add rubygems
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 637c408 commit 05f0eac

17 files changed

Lines changed: 1029 additions & 5 deletions

backend/src/api/public/v1/packages/blastRadius.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { z } from 'zod'
22

3-
export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm', 'go', 'maven', 'cargo', 'nuget'] as const
3+
export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = [
4+
'npm',
5+
'go',
6+
'maven',
7+
'cargo',
8+
'nuget',
9+
'rubygems',
10+
] as const
411

512
// Always exactly one job per request — advisory-wide (package omitted) or narrowed
613
// to a single package. package accepts either a full purl or a bare package name,

services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest'
44
import { SUPPORTED_ECOSYSTEMS, buildEcosystemNotSupportedFailure } from '../ecosystemSupport'
55

66
describe('SUPPORTED_ECOSYSTEMS', () => {
7-
it('includes cargo and nuget alongside npm, go, and maven', () => {
8-
expect(SUPPORTED_ECOSYSTEMS).toEqual(['npm', 'go', 'maven', 'cargo', 'nuget'])
7+
it('includes cargo, nuget, and rubygems alongside npm, go, and maven', () => {
8+
expect(SUPPORTED_ECOSYSTEMS).toEqual(['npm', 'go', 'maven', 'cargo', 'nuget', 'rubygems'])
99
})
1010
})
1111

services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22

3-
import { toBareNpmName, toBareNuGetId, toDbCargoName } from '../packageIdentifier'
3+
import { toBareGemName, toBareNpmName, toBareNuGetId, toDbCargoName } from '../packageIdentifier'
44

55
describe('toBareNpmName', () => {
66
it('returns a bare name unchanged', () => {
@@ -69,3 +69,25 @@ describe('toBareNuGetId', () => {
6969
)
7070
})
7171
})
72+
73+
describe('toBareGemName', () => {
74+
it('returns a bare name unchanged', () => {
75+
expect(toBareGemName('rack')).toBe('rack')
76+
})
77+
78+
it('strips the pkg:gem/ prefix', () => {
79+
expect(toBareGemName('pkg:gem/rack')).toBe('rack')
80+
})
81+
82+
it('strips a trailing version', () => {
83+
expect(toBareGemName('pkg:gem/rack@3.0.8')).toBe('rack')
84+
})
85+
86+
it('strips qualifiers and subpath', () => {
87+
expect(toBareGemName('pkg:gem/rack@3.0.8?foo=bar#sub')).toBe('rack')
88+
})
89+
90+
it('does not lowercase the name — preserves non-lowercase published spellings', () => {
91+
expect(toBareGemName('pkg:gem/RedCloth@4.3.2')).toBe('RedCloth')
92+
})
93+
})
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Parallels nugetPrompts.ts/goPrompts.ts — shared shape lives in promptKit.ts;
2+
// only Ruby-specific keys/enum and system-prompt prose live here.
3+
import {
4+
buildIntelPrompt,
5+
buildIntelSchema,
6+
buildReachabilitySymbolsBlock,
7+
buildVerdictSchema,
8+
} from './promptKit'
9+
import { SymbolSpec } from './prompts'
10+
11+
// ---------- STAGE 1: INTEL ----------
12+
13+
const IMPORT_SIGNATURE_KEYS = ['require', 'require_relative', 'autoload', 'gem_dependency']
14+
15+
export const RUBYGEMS_INTEL_SCHEMA = buildIntelSchema(IMPORT_SIGNATURE_KEYS)
16+
17+
export const RUBYGEMS_INTEL_SYSTEM_PROMPT = `You are a vulnerability analyst. Your working directory contains the source (downloaded from
18+
rubygems.org) of the vulnerable version of a RubyGems gem. You are given the security advisory
19+
and the patch (diff) that fixed the vulnerability.
20+
21+
Your job is to determine, precisely, WHAT is vulnerable — so that downstream analysts can
22+
check whether other gems actually reach the vulnerable code.
23+
24+
Rules:
25+
- Identify the exact vulnerable method(s)/class(es)/module(s) from the patch and the source.
26+
Be minimal and precise: do NOT include similar-but-unaffected symbols. If the patch only
27+
touches a \`private\`/\`protected\` method, trace which public instance/module methods route
28+
through it and list those as the reachable surface (note the helper in \`notes\`).
29+
- Ruby has no compile-time visibility enforcement — treat \`private\`/\`protected\` markers in
30+
the source as the declared intent, and note the exact module/class-qualified name (e.g.
31+
\`Rack::Utils.something\`) each symbol lives in.
32+
- Build \`import_signatures\`: concrete code patterns a Ruby dependent would contain if it uses
33+
the vulnerable symbol. Cover: a plain \`require 'gem/path'\` followed by usage, a
34+
\`require_relative\`, an \`autoload\` declaration, and the gem showing up as a
35+
\`gem_dependency\` (a \`.gemspec\` \`add_dependency\`/\`add_runtime_dependency\` line, or a
36+
\`Gemfile\` \`gem\` line). These are the patterns analysts will grep for — make them literal
37+
and greppable, not prose.
38+
- \`reachability_notes\` must state what does NOT count (e.g. sibling methods that look similar
39+
but are not affected, usage confined to \`spec/\`, \`test/\`, \`features/\`, or \`vendor/\`) and
40+
any conditions required for exploitability.
41+
- Set \`confidence\` for your identification: 0.9+ only if the patch unambiguously
42+
identifies the symbol(s); lower if you had to infer from indirect evidence.`
43+
44+
export const buildRubyGemsIntelPrompt = buildIntelPrompt
45+
46+
// ---------- STAGE 3: REACHABILITY ----------
47+
48+
const IMPORT_STYLE_ENUM = [
49+
'require',
50+
'require_relative',
51+
'autoload',
52+
'gem_dependency',
53+
'reexport',
54+
'none',
55+
]
56+
57+
export const RUBYGEMS_VERDICT_SCHEMA = buildVerdictSchema(IMPORT_STYLE_ENUM)
58+
59+
export function buildRubyGemsReachabilitySystemPrompt(spec: SymbolSpec): string {
60+
const { symbolsText, signatures } = buildReachabilitySymbolsBlock(spec)
61+
62+
return `You are a security reachability analyst. Your working directory contains the source
63+
(downloaded from rubygems.org) of ONE gem (the "dependent") that declares a dependency on
64+
\`${spec.package}\`, which has a known vulnerability (${spec.vuln_id}).
65+
66+
## The vulnerability
67+
${spec.summary}
68+
69+
Vulnerable symbol(s) in \`${spec.package}\`:
70+
${symbolsText}
71+
72+
Exploit preconditions: ${spec.exploit_preconditions}
73+
74+
Analyst notes: ${spec.reachability_notes}
75+
76+
## Import signatures to look for
77+
${signatures}
78+
79+
## Your task
80+
Decide whether THIS dependent's own code actually reaches the vulnerable symbol(s).
81+
82+
Scope rules — follow strictly:
83+
1. Only the dependent's OWN shipped code counts. Ignore anything under \`spec/\`, \`test/\`,
84+
\`features/\`, or \`vendor/\`. Usage of the vulnerable symbol inside the dependent's other
85+
dependencies is OUT OF SCOPE (that is second-level analysis, done separately).
86+
2. Merely declaring \`${spec.package}\` as a dependency (a \`Gemfile\` \`gem\` line, or a
87+
\`.gemspec\` \`add_dependency\`/\`add_runtime_dependency\`) is NOT enough — the vulnerable
88+
symbol itself must be reached. Uses of other methods/classes from the gem are irrelevant.
89+
3. Usage only in \`spec/\`, \`test/\`, or \`features/\` that is not part of the shipped runtime
90+
code → \`not_affected\` (explain in reasoning).
91+
4. If the dependent RE-EXPORTS the vulnerable symbol to its own consumers (a thin wrapper
92+
method that passes arguments through, or a subclass that doesn't override the vulnerable
93+
method), that DOES count as \`affected\` with \`import_style: "reexport"\` — it propagates
94+
the vulnerable surface.
95+
5. Watch for indirect reachability inside the dependent's own code: \`method_missing\`
96+
delegation, \`send\`/\`public_send\` dispatch, module mixins (\`include\`/\`extend\`/\`prepend\`),
97+
and metaprogramming that defines methods dynamically.
98+
6. \`import_style\` describes how the VULNERABLE SYMBOL is reached, not how the gem is
99+
required: report \`none\` whenever the vulnerable symbol itself is not reached, even if
100+
the gem is required for other functionality.
101+
102+
Method: grep for the import signatures (and the bare symbol names) across the source, open
103+
every hit, and trace whether the symbol is actually invoked. Check the \`.gemspec\` and
104+
\`Gemfile\`/\`Gemfile.lock\` to confirm the declared dependency and its version. Exclude
105+
\`spec/\`, \`test/\`, \`features/\`, and \`vendor/\` from consideration.
106+
107+
## Confidence calibration
108+
- 0.8–1.0: direct evidence — you found (or ruled out) the require AND the call site
109+
explicitly; source was readable.
110+
- 0.4–0.8: symbol is required but the call path is ambiguous (mixin dispatch, conditional
111+
use, metaprogramming).
112+
- <0.4 and/or \`unclear\`: source is generated/absent, or indirection you could not resolve.
113+
114+
Report evidence as exact file paths, line numbers, and short verbatim snippets.`
115+
}
116+
117+
export const RUBYGEMS_REACHABILITY_PROMPT =
118+
'Analyze this package per your instructions and produce the structured verdict. ' +
119+
'Start by listing the project structure and grepping for the import signatures.'
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as fs from 'fs'
2+
import * as os from 'os'
3+
import * as path from 'path'
4+
import { Readable } from 'stream'
5+
import * as tar from 'tar'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
import { RubyGemsSourceNotFoundError, downloadAndExtractRubyGemsSource } from '../rubygemsSource'
9+
10+
// Builds a real .gem-shaped fixture: an uncompressed outer tar containing metadata.gz,
11+
// data.tar.gz and checksums.yaml.gz, where data.tar.gz is itself a gzip-tar with its
12+
// entries at the archive root — matching the real format verified against rubygems.org.
13+
async function buildFixtureGem(files: Record<string, string>): Promise<Buffer> {
14+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemfixture-'))
15+
try {
16+
for (const [name, content] of Object.entries(files)) {
17+
const full = path.join(workDir, name)
18+
fs.mkdirSync(path.dirname(full), { recursive: true })
19+
fs.writeFileSync(full, content)
20+
}
21+
22+
const dataTarGzPath = path.join(workDir, 'data.tar.gz')
23+
await tar.create({ gzip: true, cwd: workDir, file: dataTarGzPath }, Object.keys(files))
24+
25+
fs.writeFileSync(path.join(workDir, 'metadata.gz'), 'fake-metadata')
26+
fs.writeFileSync(path.join(workDir, 'checksums.yaml.gz'), 'fake-checksums')
27+
28+
const outerTarPath = path.join(workDir, 'fixture.gem')
29+
await tar.create({ cwd: workDir, file: outerTarPath }, [
30+
'metadata.gz',
31+
'data.tar.gz',
32+
'checksums.yaml.gz',
33+
])
34+
35+
return fs.readFileSync(outerTarPath)
36+
} finally {
37+
fs.rmSync(workDir, { recursive: true, force: true })
38+
}
39+
}
40+
41+
function mockFetchOnce(response: { status?: number; ok?: boolean; body?: Buffer | null }): void {
42+
const body = response.body
43+
vi.stubGlobal(
44+
'fetch',
45+
vi.fn().mockResolvedValue({
46+
status: response.status ?? 200,
47+
ok: response.ok ?? true,
48+
statusText: 'OK',
49+
body: body ? Readable.toWeb(Readable.from(body)) : null,
50+
}),
51+
)
52+
}
53+
54+
describe('downloadAndExtractRubyGemsSource', () => {
55+
let destDir: string
56+
57+
beforeEach(() => {
58+
destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemdest-'))
59+
})
60+
61+
afterEach(() => {
62+
fs.rmSync(destDir, { recursive: true, force: true })
63+
vi.unstubAllGlobals()
64+
})
65+
66+
it('extracts data.tar.gz entries to destDir root, with no wrapper directory', async () => {
67+
const gemBuffer = await buildFixtureGem({
68+
'lib/rack.rb': 'module Rack; end',
69+
'README.md': '# rack',
70+
})
71+
mockFetchOnce({ body: gemBuffer })
72+
73+
await downloadAndExtractRubyGemsSource('rack', '3.0.8', destDir)
74+
75+
expect(fs.readFileSync(path.join(destDir, 'lib/rack.rb'), 'utf8')).toBe('module Rack; end')
76+
expect(fs.readFileSync(path.join(destDir, 'README.md'), 'utf8')).toBe('# rack')
77+
})
78+
79+
it('throws RubyGemsSourceNotFoundError on a 404', async () => {
80+
mockFetchOnce({ status: 404, ok: false, body: null })
81+
82+
await expect(
83+
downloadAndExtractRubyGemsSource('nonexistent-gem', '1.0.0', destDir),
84+
).rejects.toThrow(RubyGemsSourceNotFoundError)
85+
})
86+
87+
it('throws RubyGemsSourceNotFoundError when the fetch itself rejects', async () => {
88+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
89+
90+
await expect(downloadAndExtractRubyGemsSource('rack', '3.0.8', destDir)).rejects.toThrow(
91+
RubyGemsSourceNotFoundError,
92+
)
93+
})
94+
95+
it('throws RubyGemsSourceNotFoundError when the .gem has no data.tar.gz entry', async () => {
96+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gembroken-'))
97+
try {
98+
fs.writeFileSync(path.join(workDir, 'metadata.gz'), 'fake-metadata')
99+
const outerTarPath = path.join(workDir, 'broken.gem')
100+
await tar.create({ cwd: workDir, file: outerTarPath }, ['metadata.gz'])
101+
const gemBuffer = fs.readFileSync(outerTarPath)
102+
103+
mockFetchOnce({ body: gemBuffer })
104+
105+
await expect(downloadAndExtractRubyGemsSource('rack', '3.0.8', destDir)).rejects.toThrow(
106+
RubyGemsSourceNotFoundError,
107+
)
108+
} finally {
109+
fs.rmSync(workDir, { recursive: true, force: true })
110+
}
111+
})
112+
})

0 commit comments

Comments
 (0)