diff --git a/examples/deps/README.md b/examples/deps/README.md index 5799d27..5963016 100644 --- a/examples/deps/README.md +++ b/examples/deps/README.md @@ -19,6 +19,9 @@ These manifests pin **exact versions** (or lockfile-resolved versions) of packag | `python/pyproject-app/poetry.lock` | resolved | `requests` | `2.31.0` | (from lock; resolves `>=2.31.0` in pyproject.toml) | — | | `python/uv-app/pyproject.toml` / `uv.lock` | `[project.dependencies]` | `urllib3` | `1.26.4` | `CVE-2021-33503` | `>= 1.26.5` | | `python/uv-app/uv.lock` | resolved | `requests` | `2.31.0` | (from lock; resolves `>=2.31.0` in pyproject.toml) | — | +| `go/mod-app/go.mod` | `require` (direct) | `golang.org/x/crypto` | `0.16.0` | `CVE-2023-48795` | `>= 0.17.0` | +| `go/mod-app/go.mod` | `require` (direct) | `github.com/go-jose/go-jose/v3` | `3.0.0` | `CVE-2024-28176` | `>= 3.0.3` | +| `go/mod-app/go.mod` | `require` (indirect) | `github.com/google/uuid` | `1.6.0` | (no known advisory) | — | Run against the full fixture tree: diff --git a/examples/deps/go/mod-app/go.mod b/examples/deps/go/mod-app/go.mod new file mode 100644 index 0000000..6ddbca2 --- /dev/null +++ b/examples/deps/go/mod-app/go.mod @@ -0,0 +1,12 @@ +module example.com/vulnerable-go-app + +go 1.21 + +require ( + golang.org/x/crypto v0.16.0 + github.com/go-jose/go-jose/v3 v3.0.0 +) + +require ( + github.com/google/uuid v1.6.0 // indirect +) diff --git a/src/scan/deps/extract.ts b/src/scan/deps/extract.ts index d6c05c8..cbc9563 100644 --- a/src/scan/deps/extract.ts +++ b/src/scan/deps/extract.ts @@ -8,6 +8,7 @@ import { extractPnpmLockDependencies } from "./extract/pnpmLock"; import { extractPyprojectTomlDependencies } from "./extract/pyprojectToml"; import { extractRequirementsTxtDependencies } from "./extract/requirementsTxt"; import { extractUvLockDependencies } from "./extract/uvLock"; +import { extractGoModDependencies } from "./extract/goMod"; import { DependencyExtractionResult, NPM_ECOSYSTEM, @@ -101,6 +102,9 @@ export function extractDependenciesForManifestWithDiagnostics( if (baseName === "pyproject.toml") { return extractPyprojectTomlDependencies(manifestPath); } + if (baseName === "go.mod") { + return extractGoModDependencies(manifestPath); + } return { dependencies: [], warnings: [] }; } diff --git a/src/scan/deps/extract/goMod.ts b/src/scan/deps/extract/goMod.ts new file mode 100644 index 0000000..bc323ae --- /dev/null +++ b/src/scan/deps/extract/goMod.ts @@ -0,0 +1,119 @@ +import { DependencyCoordinate } from "../types"; +import { + DependencyExtractionResult, + dedupeCoordinates, + emptyExtractionResult, + manifestReadWarning, + readManifestSource +} from "./shared"; + +export const GO_ECOSYSTEM = "Go"; + +// Matches a module path + semver version tag on a require line. +// Module path: printable, no whitespace; version: v..[prerelease/build] +const GO_REQUIRE_LINE_RE = + /^([A-Za-z0-9][A-Za-z0-9.\-_/]*(?:\/v\d+)?)\s+(v\d+\.\d+\.\d+(?:[.\-][0-9A-Za-z.-]*)*)/; + +// Pseudo-version pattern — skip these as they carry no meaningful semver for OSV. +const GO_PSEUDO_VERSION_RE = /^v\d+\.\d+\.\d+-\d{14}-[0-9a-f]{12}$/; + +function parseGoVersion(rawVersion: string): string | null { + if (GO_PSEUDO_VERSION_RE.test(rawVersion)) { + return null; + } + // Strip the mandatory 'v' prefix; OSV Go ecosystem uses bare semver. + const match = rawVersion.match(/^v(\d+\.\d+\.\d+(?:[.\-][0-9A-Za-z.-]*)?)$/); + return match?.[1] ?? null; +} + +function parseRequireLine( + line: string +): { name: string; version: string } | null { + // Strip inline comment (e.g. "// indirect", "// indirect; go 1.17") + const withoutComment = line.split("//")[0]?.trim() ?? ""; + const match = withoutComment.match(GO_REQUIRE_LINE_RE); + if (!match) { + return null; + } + + const name = match[1]; + const rawVersion = match[2]; + if (!name || !rawVersion) { + return null; + } + + const version = parseGoVersion(rawVersion); + if (!version) { + return null; + } + + return { name, version }; +} + +export function extractGoModDependencies(manifestPath: string): DependencyExtractionResult { + const readResult = readManifestSource(manifestPath); + if (!readResult.source) { + if (readResult.warning) { + return emptyExtractionResult(manifestReadWarning(readResult.absolutePath, readResult.warning)); + } + return emptyExtractionResult(); + } + + const source = readResult.source; + const lines = source.split(/\r?\n/); + const dependencies: DependencyCoordinate[] = []; + let inRequireBlock = false; + + for (let index = 0; index < lines.length; index++) { + const raw = lines[index] ?? ""; + const line = raw.trim(); + + if (!inRequireBlock) { + // Opening block: require ( ... + if (/^require\s*\($/.test(line)) { + inRequireBlock = true; + continue; + } + + // Single-line: require module/path v1.2.3 + if (line.startsWith("require ")) { + const rest = line.slice("require ".length).trim(); + const parsed = parseRequireLine(rest); + if (parsed) { + dependencies.push({ + ecosystem: GO_ECOSYSTEM, + name: parsed.name, + version: parsed.version, + manifestPath: readResult.absolutePath, + manifestLine: index + 1 + }); + } + } + } else { + if (line === ")") { + inRequireBlock = false; + continue; + } + + if (!line || line.startsWith("//")) { + continue; + } + + const parsed = parseRequireLine(line); + if (parsed) { + dependencies.push({ + ecosystem: GO_ECOSYSTEM, + name: parsed.name, + version: parsed.version, + manifestPath: readResult.absolutePath, + manifestLine: index + 1 + }); + } + } + } + + return { + dependencies: dedupeCoordinates(dependencies), + warnings: [] + }; +} diff --git a/tests/depsExamples.test.ts b/tests/depsExamples.test.ts index 6b637e2..08f4e4a 100644 --- a/tests/depsExamples.test.ts +++ b/tests/depsExamples.test.ts @@ -11,6 +11,7 @@ import { ScanContext } from "../src/scan/types"; const FIXTURE_ROOT = path.join(process.cwd(), "examples", "deps", "npm"); const PYTHON_FIXTURE_ROOT = path.join(process.cwd(), "examples", "deps", "python"); +const GO_FIXTURE_ROOT = path.join(process.cwd(), "examples", "deps", "go"); const OSV_RUNTIME_APP_BATCH = path.join( process.cwd(), "tests", @@ -177,3 +178,17 @@ test("examples deps fixtures normalize stubbed OSV batch into findings with CVE globalThis.fetch = originalFetch; } }); + +test("examples go deps fixtures expose exact Go coordinates", () => { + const manifests = [path.join(GO_FIXTURE_ROOT, "mod-app", "go.mod")]; + + const coordinates = manifests.flatMap((manifestPath) => extractDependenciesForManifest(manifestPath)); + const labels = coordinates.map((dep) => `${dep.ecosystem}:${dep.name}@${dep.version}`).sort(); + + assert.deepEqual(labels, [ + "Go:github.com/go-jose/go-jose/v3@3.0.0", + "Go:github.com/google/uuid@1.6.0", + "Go:golang.org/x/crypto@0.16.0" + ]); + assert.ok(coordinates.every((dep) => dep.manifestLine > 0)); +}); diff --git a/tests/depsExtraction.test.ts b/tests/depsExtraction.test.ts index 18f9acf..ba7ffa6 100644 --- a/tests/depsExtraction.test.ts +++ b/tests/depsExtraction.test.ts @@ -649,3 +649,136 @@ test("collectDependencies does not warn about unscoped lockfiles when lockfile i fs.rmSync(tmpDir, { recursive: true, force: true }); }); + +test("extractDependenciesForManifest reads go.mod block require entries", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codefence-gomod-block-")); + const manifestPath = path.join(tmpDir, "go.mod"); + fs.writeFileSync( + manifestPath, + [ + "module example.com/myapp", + "", + "go 1.21", + "", + "require (", + "\tgolang.org/x/crypto v0.16.0", + "\tgithub.com/gin-gonic/gin v1.8.1", + "\tgithub.com/google/uuid v1.6.0 // indirect", + ")", + "" + ].join("\n"), + "utf8" + ); + + const result = extractDependenciesForManifestWithDiagnostics(manifestPath); + assert.deepEqual( + result.dependencies.map((dep) => `${dep.ecosystem}:${dep.name}@${dep.version}`).sort(), + [ + "Go:github.com/gin-gonic/gin@1.8.1", + "Go:github.com/google/uuid@1.6.0", + "Go:golang.org/x/crypto@0.16.0" + ] + ); + assert.equal(result.dependencies.find((dep) => dep.name === "golang.org/x/crypto")?.manifestLine, 6); + assert.equal(result.warnings.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("extractDependenciesForManifest reads go.mod single-line require entries", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codefence-gomod-single-")); + const manifestPath = path.join(tmpDir, "go.mod"); + fs.writeFileSync( + manifestPath, + [ + "module example.com/myapp", + "", + "go 1.21", + "", + "require golang.org/x/crypto v0.16.0", + "require github.com/go-jose/go-jose/v3 v3.0.0", + "" + ].join("\n"), + "utf8" + ); + + const result = extractDependenciesForManifestWithDiagnostics(manifestPath); + assert.deepEqual( + result.dependencies.map((dep) => `${dep.ecosystem}:${dep.name}@${dep.version}`).sort(), + [ + "Go:github.com/go-jose/go-jose/v3@3.0.0", + "Go:golang.org/x/crypto@0.16.0" + ] + ); + assert.equal(result.dependencies.find((dep) => dep.name === "golang.org/x/crypto")?.manifestLine, 5); + assert.equal(result.warnings.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("extractDependenciesForManifest skips pseudo-versions in go.mod", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codefence-gomod-pseudo-")); + const manifestPath = path.join(tmpDir, "go.mod"); + fs.writeFileSync( + manifestPath, + [ + "module example.com/myapp", + "", + "go 1.21", + "", + "require (", + "\tgolang.org/x/crypto v0.16.0", + "\tgithub.com/some/dev-dep v0.0.0-20231113122135-a4f7c8f4c9d3", + ")", + "" + ].join("\n"), + "utf8" + ); + + const result = extractDependenciesForManifestWithDiagnostics(manifestPath); + assert.deepEqual( + result.dependencies.map((dep) => `${dep.ecosystem}:${dep.name}@${dep.version}`), + ["Go:golang.org/x/crypto@0.16.0"] + ); + assert.equal(result.warnings.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("extractDependenciesForManifest returns empty result for malformed go.mod", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codefence-gomod-malformed-")); + const manifestPath = path.join(tmpDir, "go.mod"); + fs.writeFileSync(manifestPath, "not a valid go.mod\n!!!###", "utf8"); + + const result = extractDependenciesForManifestWithDiagnostics(manifestPath); + assert.deepEqual(result.dependencies, []); + assert.equal(result.warnings.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("extractDependenciesForManifest deduplicates go.mod entries", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codefence-gomod-dedup-")); + const manifestPath = path.join(tmpDir, "go.mod"); + fs.writeFileSync( + manifestPath, + [ + "module example.com/myapp", + "", + "go 1.21", + "", + "require (", + "\tgolang.org/x/crypto v0.16.0", + "\tgolang.org/x/crypto v0.16.0", + ")", + "" + ].join("\n"), + "utf8" + ); + + const result = extractDependenciesForManifestWithDiagnostics(manifestPath); + assert.equal(result.dependencies.length, 1); + assert.equal(result.dependencies[0]?.name, "golang.org/x/crypto"); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +});