Skip to content

Commit 081d2c8

Browse files
chore: add parsing utilities
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent c935edf commit 081d2c8

5 files changed

Lines changed: 500 additions & 16 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ module github.com/step-security/dev-machine-guard
33
go 1.24
44

55
require golang.org/x/sys v0.33.0
6+
7+
require gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
22
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
3+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
4+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
5+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/detector/lockfile.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"fmt"
66
"strings"
7+
8+
"gopkg.in/yaml.v3"
79
)
810

911
// LockfilePackage represents a single resolved package from a lockfile.
@@ -200,3 +202,208 @@ func GlobalNodeModulesDir(prefix, goos string) string {
200202
return prefix + "/lib/node_modules"
201203
}
202204
}
205+
206+
// ---------- yarn.lock v1 parsing ----------
207+
//
208+
// Yarn v1 lockfile uses a custom format (not JSON or YAML):
209+
//
210+
// "react@^19.0.0", "react@^19.2.5":
211+
// version "19.2.5"
212+
// resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#..."
213+
// integrity sha512-...
214+
// dependencies:
215+
// scheduler "^0.27.0"
216+
//
217+
// Each block starts with a non-indented header of name@range(s), followed by
218+
// indented key-value pairs. We extract version from each block.
219+
220+
// ParseYarnLockV1 parses a yarn.lock v1 file and returns resolved packages.
221+
func ParseYarnLockV1(data []byte) (*LockfileResult, error) {
222+
content := string(data)
223+
lines := strings.Split(content, "\n")
224+
225+
var pkgs []LockfilePackage
226+
var currentName string
227+
228+
for _, line := range lines {
229+
// Skip comments and empty lines
230+
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
231+
continue
232+
}
233+
234+
// Non-indented line = new block header
235+
if len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
236+
currentName = extractYarnV1PackageName(line)
237+
continue
238+
}
239+
240+
// Indented line inside a block — look for "version"
241+
trimmed := strings.TrimSpace(line)
242+
if currentName != "" && strings.HasPrefix(trimmed, "version ") {
243+
version := strings.TrimPrefix(trimmed, "version ")
244+
version = strings.Trim(version, `"`)
245+
pkgs = append(pkgs, LockfilePackage{
246+
Name: currentName,
247+
Version: version,
248+
})
249+
currentName = "" // only take first version per block
250+
}
251+
}
252+
253+
return &LockfileResult{
254+
Source: "lockfile",
255+
LockfileFormat: "yarn-v1",
256+
LockfileVersion: 1,
257+
Packages: pkgs,
258+
}, nil
259+
}
260+
261+
// extractYarnV1PackageName extracts the package name from a yarn.lock v1 header line.
262+
// Header formats:
263+
//
264+
// "react@^19.0.0":
265+
// react@^19.0.0:
266+
// "react-dom@^19.2.5", "react-dom@^19.0.0":
267+
// "@babel/core@^7.0.0":
268+
func extractYarnV1PackageName(header string) string {
269+
// Take the first entry (before any comma)
270+
if idx := strings.Index(header, ","); idx >= 0 {
271+
header = header[:idx]
272+
}
273+
header = strings.TrimSpace(header)
274+
header = strings.Trim(header, `":`)
275+
276+
// Find the @ that separates name from version range
277+
// For scoped packages (@scope/name@range), skip the leading @
278+
searchFrom := 0
279+
if strings.HasPrefix(header, "@") {
280+
searchFrom = 1
281+
}
282+
atIdx := strings.Index(header[searchFrom:], "@")
283+
if atIdx < 0 {
284+
return ""
285+
}
286+
return header[:searchFrom+atIdx]
287+
}
288+
289+
// ---------- pnpm-lock.yaml parsing ----------
290+
//
291+
// pnpm-lock.yaml (v5/v6/v9) structure:
292+
//
293+
// lockfileVersion: '9.0'
294+
// importers:
295+
// .:
296+
// dependencies:
297+
// fastify:
298+
// specifier: ^5.8.4
299+
// version: 5.8.4
300+
// packages:
301+
// '@fastify/ajv-compiler@4.0.5':
302+
// resolution: {integrity: sha512-...}
303+
// fastify@5.8.4:
304+
// resolution: {integrity: sha512-...}
305+
// snapshots:
306+
// fastify@5.8.4:
307+
// dependencies:
308+
// ...
309+
310+
// pnpmLockfile is the top-level structure of pnpm-lock.yaml.
311+
type pnpmLockfile struct {
312+
LockfileVersion string `yaml:"lockfileVersion"`
313+
Packages map[string]pnpmPackageEntry `yaml:"packages"`
314+
Importers map[string]pnpmImporterEntry `yaml:"importers"`
315+
}
316+
317+
type pnpmPackageEntry struct {
318+
Resolution map[string]string `yaml:"resolution"`
319+
Dev bool `yaml:"dev"`
320+
}
321+
322+
type pnpmImporterEntry struct {
323+
Dependencies map[string]pnpmDepRef `yaml:"dependencies"`
324+
DevDependencies map[string]pnpmDepRef `yaml:"devDependencies"`
325+
}
326+
327+
type pnpmDepRef struct {
328+
Specifier string `yaml:"specifier"`
329+
Version string `yaml:"version"`
330+
}
331+
332+
// ParsePnpmLock parses a pnpm-lock.yaml file and returns resolved packages.
333+
func ParsePnpmLock(data []byte) (*LockfileResult, error) {
334+
var lock pnpmLockfile
335+
if err := yaml.Unmarshal(data, &lock); err != nil {
336+
return nil, fmt.Errorf("parse pnpm-lock.yaml: %w", err)
337+
}
338+
339+
var pkgs []LockfilePackage
340+
341+
// Build a set of dev dependency names from importers for tagging
342+
devDeps := make(map[string]bool)
343+
for _, imp := range lock.Importers {
344+
for name := range imp.DevDependencies {
345+
devDeps[name] = true
346+
}
347+
}
348+
349+
// Parse packages map — keys are "name@version" or "@scope/name@version"
350+
for key := range lock.Packages {
351+
name, version := parsePnpmPackageKey(key)
352+
if name == "" || version == "" {
353+
continue
354+
}
355+
pkgs = append(pkgs, LockfilePackage{
356+
Name: name,
357+
Version: version,
358+
Dev: devDeps[name],
359+
})
360+
}
361+
362+
// Determine lockfile version number
363+
lockVer := 0
364+
if strings.HasPrefix(lock.LockfileVersion, "9") {
365+
lockVer = 9
366+
} else if strings.HasPrefix(lock.LockfileVersion, "6") {
367+
lockVer = 6
368+
} else if strings.HasPrefix(lock.LockfileVersion, "5") {
369+
lockVer = 5
370+
}
371+
372+
return &LockfileResult{
373+
Source: "lockfile",
374+
LockfileFormat: fmt.Sprintf("pnpm-v%s", lock.LockfileVersion),
375+
LockfileVersion: lockVer,
376+
Packages: pkgs,
377+
}, nil
378+
}
379+
380+
// parsePnpmPackageKey extracts name and version from a pnpm packages key.
381+
// Keys look like "express@4.18.2" or "@fastify/ajv-compiler@4.0.5"
382+
// or "/express@4.18.2" (older lockfile versions use leading slash).
383+
func parsePnpmPackageKey(key string) (name, version string) {
384+
// Strip leading slash (pnpm v5/v6 format)
385+
key = strings.TrimPrefix(key, "/")
386+
387+
// For scoped packages: @scope/name@version
388+
// Find the last @ that separates name from version
389+
if strings.HasPrefix(key, "@") {
390+
// Scoped package: find @ after the first /
391+
slashIdx := strings.Index(key, "/")
392+
if slashIdx < 0 {
393+
return "", ""
394+
}
395+
atIdx := strings.LastIndex(key[slashIdx:], "@")
396+
if atIdx < 0 {
397+
return "", ""
398+
}
399+
atIdx += slashIdx
400+
return key[:atIdx], key[atIdx+1:]
401+
}
402+
403+
// Unscoped package: name@version
404+
atIdx := strings.LastIndex(key, "@")
405+
if atIdx <= 0 {
406+
return "", ""
407+
}
408+
return key[:atIdx], key[atIdx+1:]
409+
}

0 commit comments

Comments
 (0)