Skip to content

Commit 0ce2f96

Browse files
authored
feat: support for stripped/cgo go binaries (#728)
* feat: support stripped Go/CGO binaries without .gopclntab Make .gopclntab optional when scanning Go binaries. When the section is missing (stripped/CGo builds), report module-level dependencies instead of failing. This enables scanning of previously undetectable binaries. * test: update SLE15.3 snapshot
1 parent 8f5d746 commit 0ce2f96

File tree

4 files changed

+352
-15
lines changed

4 files changed

+352
-15
lines changed

lib/go-parser/go-binary.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as depGraph from "@snyk/dep-graph";
2+
import * as Debug from "debug";
23
import { eventLoopSpinner } from "event-loop-spinner";
34
// NOTE: Paths will always be normalized to POSIX even on Windows.
45
// This makes it easier to ignore differences between Linux and Windows.
@@ -10,9 +11,40 @@ import { GoModule } from "./go-module";
1011
import { LineTable } from "./pclntab";
1112
import { Elf, ElfProgram } from "./types";
1213

14+
const debug = Debug("snyk");
15+
16+
/**
17+
* GoBinary: Parser for Go compiled binaries
18+
*
19+
* This class extracts dependency information from Go binaries by reading ELF sections.
20+
* It implements two scanning strategies depending on binary characteristics:
21+
* - If .gopclntab exists: Extract source files → Map to packages → Report packages
22+
* - If .gopclntab missing: Extract modules from .go.buildinfo → Report all modules
23+
*
24+
* Binary Types:
25+
*
26+
* 1. Normal Go Binaries (with .gopclntab):
27+
* - Built with standard flags
28+
* - Contains .gopclntab (Go Program Counter Line Table) section
29+
* - .gopclntab maps program counter addresses to source files
30+
*
31+
* 2. Stripped Go Binaries (without .gopclntab):
32+
* - Built with -ldflags='-s -w' flag
33+
* - Removes debug symbols, symbol tables (.symtab, .strtab), and .gopclntab
34+
*
35+
* 3. CGo Go Binaries:
36+
* - Built with CGO_ENABLED=1 (calls C code)
37+
* - May or may not contain .gopclntab depending on build configuration
38+
*
39+
* ELF Sections Used:
40+
* - .go.buildinfo: Module names, versions, and build information (always present)
41+
* - .gopclntab: Source file to package mapping (missing in stripped/some CGo binaries)
42+
*
43+
*/
1344
export class GoBinary {
1445
public name: string;
1546
public modules: GoModule[];
47+
private hasPclnTab: boolean;
1648

1749
constructor(goElfBinary: Elf) {
1850
[this.name, this.modules] = extractModuleInformation(goElfBinary);
@@ -21,16 +53,20 @@ export class GoBinary {
2153
(section) => section.name === ".gopclntab",
2254
);
2355

24-
// some CGo built binaries might not contain a pclnTab, which means we
25-
// cannot scan the files.
26-
// TODO: from a technical perspective, it would be enough to only report the
27-
// modules, as the only remediation path is to upgrade a full module
28-
// anyways. From a product perspective, it's not clear (yet).
29-
if (pclnTab === undefined) {
30-
throw Error("no pcln table present in Go binary");
56+
// Track whether pclnTab exists to determine reporting strategy
57+
this.hasPclnTab = pclnTab !== undefined;
58+
59+
// Stripped binaries (built with -ldflags='-s -w') and some CGo binaries
60+
// do not contain .gopclntab, which means we cannot detect package-level
61+
// dependencies. In this case, we fall back to module-level reporting from
62+
// .go.buildinfo, as remediation is performed at the module level anyway.
63+
if (pclnTab !== undefined) {
64+
try {
65+
this.matchFilesToModules(new LineTable(pclnTab.data).go12MapFiles());
66+
} catch (err) {
67+
debug(`Failed to parse .gopclntab in ${this.name}`, err.stack || err);
68+
}
3169
}
32-
33-
this.matchFilesToModules(new LineTable(pclnTab.data).go12MapFiles());
3470
}
3571

3672
public async depGraph(): Promise<depGraph.DepGraph> {
@@ -40,18 +76,36 @@ export class GoBinary {
4076
);
4177

4278
for (const module of this.modules) {
43-
for (const pkg of module.packages) {
44-
if (eventLoopSpinner.isStarving()) {
45-
await eventLoopSpinner.spin();
46-
}
79+
if (eventLoopSpinner.isStarving()) {
80+
await eventLoopSpinner.spin();
81+
}
4782

48-
const nodeId = `${pkg}@${module.version}`;
83+
// If we have package-level information (from pclntab), use it
84+
if (module.packages.length > 0) {
85+
for (const pkg of module.packages) {
86+
const nodeId = `${pkg}@${module.version}`;
87+
goModulesDepGraph.addPkgNode(
88+
{ name: pkg, version: module.version },
89+
nodeId,
90+
);
91+
goModulesDepGraph.connectDep(goModulesDepGraph.rootNodeId, nodeId);
92+
}
93+
} else if (!this.hasPclnTab) {
94+
// ONLY if .gopclntab is missing (stripped/CGo binaries), report module-level
95+
// dependencies from .go.buildinfo.
96+
//
97+
// Note: .go.buildinfo contains ALL modules from the build graph, including
98+
// modules required only for version resolution (transitive dependencies with
99+
// no code actually compiled into the binary). Without .gopclntab, we cannot
100+
// distinguish these from modules with actual code present.
101+
const nodeId = `${module.name}@${module.version}`;
49102
goModulesDepGraph.addPkgNode(
50-
{ name: pkg, version: module.version },
103+
{ name: module.name, version: module.version },
51104
nodeId,
52105
);
53106
goModulesDepGraph.connectDep(goModulesDepGraph.rootNodeId, nodeId);
54107
}
108+
// else: pclnTab exists but module has no packages - don't report anything
55109
}
56110

57111
return goModulesDepGraph.build();
Binary file not shown.

test/system/application-scans/gomodules.spec.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as elf from "elfy";
2+
import * as fs from "fs";
3+
import * as path from "path";
24

35
import { extractContent, scan } from "../../../lib";
46
import { getGoModulesContentAction } from "../../../lib/go-parser";
7+
import { GoBinary } from "../../../lib/go-parser/go-binary";
58
import { getFixture } from "../../util";
69

710
describe("gomodules binaries scanning", () => {
@@ -94,3 +97,180 @@ describe("parse go modules from various versions of compiled binaries", () => {
9497
expect(pluginResult).toMatchSnapshot();
9598
});
9699
});
100+
101+
/**
102+
* Unit Tests: Stripped/CGo Binary Support
103+
*
104+
* Tests GoBinary class directly with a stripped binary fixture (no .gopclntab section).
105+
* Validates module-level dependency extraction from .go.buildinfo.
106+
*
107+
* Fixture: test/fixtures/go-binaries/no-pcln-tab
108+
* - Source: github.com/rootless-containers/rootlesskit/cmd/rootlesskit-docker-proxy
109+
* - Go Version: 1.17.11
110+
* - Dependencies: 3 modules
111+
* - Expected output verified with: go version -m test/fixtures/go-binaries/no-pcln-tab
112+
*/
113+
describe("Stripped Go binary without .gopclntab: no-pcln-tab fixture", () => {
114+
const fixturesPath = path.join(__dirname, "../../fixtures/go-binaries");
115+
const noPclnTabPath = path.join(fixturesPath, "no-pcln-tab");
116+
117+
// Expected dependencies for no-pcln-tab fixture based on `go version -m`
118+
const expectedDepsNoPcln = [
119+
{ name: "github.com/pkg/errors", version: "v0.9.1" },
120+
{ name: "github.com/sirupsen/logrus", version: "v1.8.1" },
121+
{ name: "golang.org/x/sys", version: "v0.0.0-20210119212857-b64e53b001e4" },
122+
];
123+
124+
it("should have .go.buildinfo but no .gopclntab", () => {
125+
const fileContent = fs.readFileSync(noPclnTabPath);
126+
const binary = elf.parse(fileContent);
127+
128+
const goBuildInfo = binary.body.sections.find(
129+
(section) => section.name === ".go.buildinfo",
130+
);
131+
const goPclnTab = binary.body.sections.find(
132+
(section) => section.name === ".gopclntab",
133+
);
134+
135+
expect(goBuildInfo).toBeDefined();
136+
expect(goPclnTab).toBeUndefined();
137+
});
138+
139+
it("should extract 3 module-level dependencies from .go.buildinfo", async () => {
140+
const fileContent = fs.readFileSync(noPclnTabPath);
141+
const binary = elf.parse(fileContent);
142+
143+
const goBinary = new GoBinary(binary);
144+
const depGraph = await goBinary.depGraph();
145+
146+
const deps = depGraph
147+
.getPkgs()
148+
.filter((pkg) => pkg.name !== depGraph.rootPkg.name);
149+
150+
expectedDepsNoPcln.forEach((expectedDep) => {
151+
const found = deps.find(
152+
(dep) =>
153+
dep.name === expectedDep.name && dep.version === expectedDep.version,
154+
);
155+
expect(found).toBeDefined();
156+
});
157+
158+
expect(deps.length).toBe(expectedDepsNoPcln.length);
159+
expect(depGraph.rootPkg.name).toBe(
160+
"github.com/rootless-containers/rootlesskit",
161+
);
162+
});
163+
164+
it("should report module-level dependencies (not package-level)", async () => {
165+
const fileContent = fs.readFileSync(noPclnTabPath);
166+
const binary = elf.parse(fileContent);
167+
168+
const goBinary = new GoBinary(binary);
169+
170+
const hasPackageLevelInfo = goBinary.modules.some(
171+
(mod) => mod.packages.length > 0,
172+
);
173+
174+
expect(hasPackageLevelInfo).toBe(false);
175+
expect(goBinary.modules.length).toBe(3);
176+
});
177+
});
178+
179+
/**
180+
* Test Image: test/fixtures/docker-archives/stripped-go-binaries-minimal.tar.gz
181+
* - Size: 18 MB compressed, 62 MB uncompressed
182+
* - Source: elastic-agent-complete:8.18.8
183+
* - Binaries: 2 stripped Go binaries
184+
* 1. fleet-server (76 modules)
185+
* 2. osquery-extension.ext (10 modules) - we currently filter out binaries with extensions TODO-fix this
186+
*/
187+
describe("Stripped and CGo Go binaries detection scan handler test", () => {
188+
const testImagePath = getFixture(
189+
"docker-archives/stripped-go-binaries-minimal.tar.gz",
190+
);
191+
jest.setTimeout(180000);
192+
const getScanOptions = () => {
193+
return {
194+
path: `docker-archive:${testImagePath}`,
195+
"app-vulns": true,
196+
};
197+
};
198+
199+
it("should detect stripped/CGo Go binaries missing .gopclntab section", async () => {
200+
const pluginResult = await scan(getScanOptions());
201+
202+
const goModules = pluginResult.scanResults.filter(
203+
(r) => r.identity.type === "gomodules",
204+
);
205+
206+
expect(goModules.length).toBeGreaterThanOrEqual(1);
207+
208+
const detectedBinaries: {
209+
fleetServer: { targetFile: string; moduleCount: number } | null;
210+
osqueryExt: { targetFile: string; moduleCount: number } | null;
211+
} = {
212+
fleetServer: null,
213+
osqueryExt: null,
214+
};
215+
216+
goModules.forEach((result) => {
217+
const targetFile = result.identity.targetFile || "";
218+
const depGraphFact = result.facts.find((f) => f.type === "depGraph");
219+
const depGraph = depGraphFact?.data;
220+
221+
if (!depGraph) {
222+
return;
223+
}
224+
225+
const packages = depGraph.getPkgs();
226+
const moduleCount = packages.length;
227+
228+
if (targetFile.includes("fleet-server")) {
229+
detectedBinaries.fleetServer = { targetFile, moduleCount };
230+
}
231+
});
232+
233+
if (detectedBinaries.fleetServer) {
234+
expect(detectedBinaries.fleetServer.moduleCount).toEqual(76);
235+
} else {
236+
fail("fleet-server not detected");
237+
}
238+
239+
const detectedCount =
240+
Object.values(detectedBinaries).filter(Boolean).length;
241+
expect(detectedCount).toBe(1);
242+
});
243+
244+
it("should report module-level dependencies (not package-level) for stripped/CGo binaries", async () => {
245+
const pluginResult = await scan(getScanOptions());
246+
247+
const goModules = pluginResult.scanResults.filter(
248+
(r) => r.identity.type === "gomodules",
249+
);
250+
251+
expect(goModules.length).toEqual(1);
252+
253+
const fleetServer = goModules.find((r) =>
254+
r.identity.targetFile?.includes("fleet-server"),
255+
);
256+
257+
if (!fleetServer) {
258+
return;
259+
}
260+
261+
const depGraphFact = fleetServer.facts.find((f) => f.type === "depGraph");
262+
const depGraph = depGraphFact?.data;
263+
264+
expect(depGraph).toBeDefined();
265+
266+
const packages = depGraph.getPkgs();
267+
const sampleDeps = packages.slice(0, 10);
268+
269+
sampleDeps.forEach((pkg: any) => {
270+
expect(pkg.name).toBeDefined();
271+
if (pkg.version !== undefined) {
272+
expect(typeof pkg.version).toBe("string");
273+
}
274+
});
275+
});
276+
});

0 commit comments

Comments
 (0)