added signed artifacts and SBOM generation capabilities
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
package sbom
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseResult holds components extracted from a single manifest file.
|
||||
type ParseResult struct {
|
||||
Ecosystem string
|
||||
Components []Component
|
||||
}
|
||||
|
||||
// ─── go.mod ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ParseGoMod parses a go.mod file and returns Go module components.
|
||||
// Handles both single-line `require x v1` and block `require ( ... )` forms.
|
||||
func ParseGoMod(content []byte) []Component {
|
||||
var components []Component
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
|
||||
inBlock := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Strip inline comments.
|
||||
if idx := strings.Index(line, "//"); idx >= 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if line == "require (" {
|
||||
inBlock = true
|
||||
continue
|
||||
}
|
||||
if inBlock && line == ")" {
|
||||
inBlock = false
|
||||
continue
|
||||
}
|
||||
|
||||
var modulePath, version string
|
||||
|
||||
if inBlock {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
modulePath, version = parts[0], parts[1]
|
||||
}
|
||||
} else if strings.HasPrefix(line, "require ") {
|
||||
parts := strings.Fields(strings.TrimPrefix(line, "require "))
|
||||
if len(parts) >= 2 {
|
||||
modulePath, version = parts[0], parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if modulePath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Indirect deps are still included — they are part of the supply chain.
|
||||
components = append(components, Component{
|
||||
Type: "library",
|
||||
Name: modulePath,
|
||||
Version: version,
|
||||
PURL: golangPURL(modulePath, version),
|
||||
})
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
// ─── package.json ─────────────────────────────────────────────────────────────
|
||||
|
||||
type packageJSON struct {
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
DevDependencies map[string]string `json:"devDependencies"`
|
||||
PeerDependencies map[string]string `json:"peerDependencies"`
|
||||
}
|
||||
|
||||
// ParsePackageJSON parses a package.json and returns npm components.
|
||||
func ParsePackageJSON(content []byte) []Component {
|
||||
var pkg packageJSON
|
||||
if err := json.Unmarshal(content, &pkg); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var components []Component
|
||||
|
||||
add := func(name, version, scope string) {
|
||||
if seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
// Strip semver range prefixes: ^, ~, >=, >, <=, <, =
|
||||
clean := strings.TrimLeft(version, "^~>=<")
|
||||
components = append(components, Component{
|
||||
Type: "library",
|
||||
Name: name,
|
||||
Version: clean,
|
||||
PURL: npmPURL(name, clean),
|
||||
Scope: scope,
|
||||
})
|
||||
}
|
||||
|
||||
for name, ver := range pkg.Dependencies {
|
||||
add(name, ver, "required")
|
||||
}
|
||||
for name, ver := range pkg.DevDependencies {
|
||||
add(name, ver, "optional")
|
||||
}
|
||||
for name, ver := range pkg.PeerDependencies {
|
||||
add(name, ver, "optional")
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
// ─── requirements.txt ────────────────────────────────────────────────────────
|
||||
|
||||
// ParseRequirementsTxt parses a pip requirements.txt.
|
||||
// Handles: pkg==1.0, pkg>=1.0, pkg~=1.0, pkg (no version), comments, extras.
|
||||
func ParseRequirementsTxt(content []byte) []Component {
|
||||
var components []Component
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
|
||||
continue
|
||||
}
|
||||
// Strip inline comments.
|
||||
if idx := strings.Index(line, " #"); idx >= 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
// Strip extras: package[extra]==1.0 → package, ==1.0
|
||||
name := line
|
||||
version := ""
|
||||
|
||||
for _, op := range []string{"==", ">=", "<=", "~=", "!=", ">", "<"} {
|
||||
if idx := strings.Index(line, op); idx >= 0 {
|
||||
name = strings.TrimSpace(line[:idx])
|
||||
version = strings.TrimSpace(line[idx+len(op):])
|
||||
// Take only the first version specifier.
|
||||
if commaIdx := strings.Index(version, ","); commaIdx >= 0 {
|
||||
version = version[:commaIdx]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// Strip extras [extra1,extra2] from name.
|
||||
if bIdx := strings.Index(name, "["); bIdx >= 0 {
|
||||
name = name[:bIdx]
|
||||
}
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
components = append(components, Component{
|
||||
Type: "library",
|
||||
Name: name,
|
||||
Version: version,
|
||||
PURL: pypiPURL(name, version),
|
||||
})
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ParseCargoToml parses a Cargo.toml [dependencies] section.
|
||||
// Handles: name = "version" and name = { version = "x", ... }.
|
||||
func ParseCargoToml(content []byte) []Component {
|
||||
var components []Component
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
|
||||
inDeps := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Section headers.
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inDeps = line == "[dependencies]" ||
|
||||
line == "[dev-dependencies]" ||
|
||||
line == "[build-dependencies]"
|
||||
continue
|
||||
}
|
||||
|
||||
if !inDeps || line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
eqIdx := strings.Index(line, "=")
|
||||
if eqIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(line[:eqIdx])
|
||||
rest := strings.TrimSpace(line[eqIdx+1:])
|
||||
|
||||
var version string
|
||||
if strings.HasPrefix(rest, `"`) {
|
||||
// name = "version"
|
||||
version = strings.Trim(rest, `"`)
|
||||
} else if strings.HasPrefix(rest, "{") {
|
||||
// name = { version = "x", features = [...] }
|
||||
if vIdx := strings.Index(rest, `version = "`); vIdx >= 0 {
|
||||
vIdx += len(`version = "`)
|
||||
endIdx := strings.Index(rest[vIdx:], `"`)
|
||||
if endIdx >= 0 {
|
||||
version = rest[vIdx : vIdx+endIdx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
components = append(components, Component{
|
||||
Type: "library",
|
||||
Name: name,
|
||||
Version: version,
|
||||
PURL: cargoPURL(name, version),
|
||||
})
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ParseGemfileLock parses a Gemfile.lock and extracts gem components.
|
||||
// The GEM section format is:
|
||||
//
|
||||
// GEM
|
||||
// remote: https://rubygems.org/
|
||||
// specs:
|
||||
// activesupport (7.1.0)
|
||||
// ...
|
||||
func ParseGemfileLock(content []byte) []Component {
|
||||
var components []Component
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
|
||||
inSpecs := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "GEM" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "specs:" {
|
||||
inSpecs = true
|
||||
continue
|
||||
}
|
||||
// Any non-indented non-empty line ends the specs block.
|
||||
if inSpecs && !strings.HasPrefix(line, " ") && trimmed != "" {
|
||||
inSpecs = false
|
||||
}
|
||||
if !inSpecs {
|
||||
continue
|
||||
}
|
||||
|
||||
// Specs entries are indented exactly 4 spaces: " name (version)"
|
||||
// Sub-dependencies are indented 6+ spaces — skip them.
|
||||
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse: " gemname (version)"
|
||||
entry := strings.TrimSpace(line)
|
||||
oIdx := strings.Index(entry, " (")
|
||||
if oIdx < 0 {
|
||||
continue
|
||||
}
|
||||
name := entry[:oIdx]
|
||||
versionFull := strings.TrimSuffix(entry[oIdx+2:], ")")
|
||||
version := strings.Fields(versionFull)[0]
|
||||
|
||||
components = append(components, Component{
|
||||
Type: "library",
|
||||
Name: name,
|
||||
Version: version,
|
||||
PURL: gemPURL(name, version),
|
||||
})
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
// ─── pom.xml (minimal) ───────────────────────────────────────────────────────
|
||||
|
||||
// ParsePomXML does a lightweight line-scan extraction of Maven dependencies.
|
||||
// It avoids pulling in an XML parser — it looks for <dependency> blocks and
|
||||
// extracts groupId, artifactId, version tags.
|
||||
func ParsePomXML(content []byte) []Component {
|
||||
var components []Component
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
|
||||
var groupID, artifactID, version string
|
||||
inDep := false
|
||||
|
||||
extract := func(line, tag string) string {
|
||||
open := "<" + tag + ">"
|
||||
close := "</" + tag + ">"
|
||||
sIdx := strings.Index(line, open)
|
||||
eIdx := strings.Index(line, close)
|
||||
if sIdx >= 0 && eIdx > sIdx {
|
||||
return line[sIdx+len(open) : eIdx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if strings.Contains(line, "<dependency>") {
|
||||
inDep = true
|
||||
groupID, artifactID, version = "", "", ""
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "</dependency>") {
|
||||
if inDep && groupID != "" && artifactID != "" {
|
||||
name := groupID + ":" + artifactID
|
||||
components = append(components, Component{
|
||||
Type: "library",
|
||||
Name: name,
|
||||
Version: version,
|
||||
PURL: mavenPURL(groupID, artifactID, version),
|
||||
})
|
||||
}
|
||||
inDep = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !inDep {
|
||||
continue
|
||||
}
|
||||
if v := extract(line, "groupId"); v != "" {
|
||||
groupID = v
|
||||
}
|
||||
if v := extract(line, "artifactId"); v != "" {
|
||||
artifactID = v
|
||||
}
|
||||
if v := extract(line, "version"); v != "" {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
return components
|
||||
}
|
||||
Reference in New Issue
Block a user