diff --git a/attestation/system-packages/backends.go b/attestation/system-packages/backends.go new file mode 100644 index 00000000..0a937f44 --- /dev/null +++ b/attestation/system-packages/backends.go @@ -0,0 +1,53 @@ +// Copyright 2025 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package systempackages + +import ( + "os/exec" + + "github.com/in-toto/go-witness/attestation" +) + +type UbuntuBackend struct { + DebianBackend +} + +func NewUbuntuBackend(osReleaseFile string) Backend { + return &UbuntuBackend{ + DebianBackend: *NewDebianBackend(osReleaseFile).(*DebianBackend), + } +} + +func (b *UbuntuBackend) RunType() attestation.RunType { + return RunType +} + +type RedHatBackend struct { + RPMBackend +} + +func NewRedHatBackend(osReleaseFile string) Backend { + return &RedHatBackend{ + RPMBackend: *NewRPMBackend(osReleaseFile).(*RPMBackend), + } +} + +func (b *RedHatBackend) RunType() attestation.RunType { + return RunType +} + +func (b *RedHatBackend) SetExecCommand(cmd func(name string, arg ...string) *exec.Cmd) { + b.RPMBackend.SetExecCommand(cmd) +} diff --git a/attestation/system-packages/debian.go b/attestation/system-packages/debian.go new file mode 100644 index 00000000..82624920 --- /dev/null +++ b/attestation/system-packages/debian.go @@ -0,0 +1,64 @@ +// Copyright 2025 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package systempackages + +import ( + "bufio" + "os/exec" + "strings" +) + +type DebianBackend struct { + osReleaseFile string + execCommand func(name string, arg ...string) *exec.Cmd +} + +func NewDebianBackend(osReleaseFile string) Backend { + return &DebianBackend{ + osReleaseFile: osReleaseFile, + execCommand: exec.Command, + } +} + +func (b *DebianBackend) SetExecCommand(cmd func(name string, arg ...string) *exec.Cmd) { + b.execCommand = cmd +} + +func (b *DebianBackend) DetermineOSInfo() (string, string, string, error) { + return determineDistribution(b.osReleaseFile) +} + +func (b *DebianBackend) GatherPackages() ([]Package, error) { + cmd := b.execCommand("dpkg-query", "-W", "-f", "${Package}\t${Version}\n") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var packages []Package + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, "\t") + if len(parts) == 2 { + packages = append(packages, Package{ + Name: parts[0], + Version: parts[1], + }) + } + } + + return packages, nil +} diff --git a/attestation/system-packages/rpm.go b/attestation/system-packages/rpm.go new file mode 100644 index 00000000..8362ff9e --- /dev/null +++ b/attestation/system-packages/rpm.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package systempackages + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/in-toto/go-witness/attestation" +) + +type RPMBackend struct { + osReleaseFile string + execCommand func(name string, arg ...string) *exec.Cmd +} + +func NewRPMBackend(osReleaseFile string) Backend { + return &RPMBackend{ + osReleaseFile: osReleaseFile, + execCommand: exec.Command, + } +} + +func (r *RPMBackend) DetermineOSInfo() (string, string, string, error) { + file, err := os.Open(r.osReleaseFile) + if err != nil { + return "", "", "", err + } + defer file.Close() + + var distribution, version string + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.Trim(strings.TrimSpace(parts[1]), "\"") + + switch key { + case "ID": + distribution = value + case "VERSION_ID": + version = value + } + } + + if err := scanner.Err(); err != nil { + return "", "", "", err + } + + return "linux", distribution, version, nil +} + +func (r *RPMBackend) GatherPackages() ([]Package, error) { + cmd := r.execCommand("rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}\n") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + fmt.Println("gather RPM packages:", string(output)) + + var packages []Package + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, "\t") + if len(parts) == 2 { + packages = append(packages, Package{ + Name: parts[0], + Version: parts[1], + }) + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return packages, nil +} + +// SetExecCommand allows setting a custom exec.Command function for testing +func (r *RPMBackend) SetExecCommand(cmd func(name string, arg ...string) *exec.Cmd) { + r.execCommand = cmd +} + +// RunType returns the run type for the RPM backend +func (r *RPMBackend) RunType() attestation.RunType { + return RunType +} diff --git a/attestation/system-packages/system-packages.go b/attestation/system-packages/system-packages.go new file mode 100644 index 00000000..8ba8d1e4 --- /dev/null +++ b/attestation/system-packages/system-packages.go @@ -0,0 +1,161 @@ +// Copyright 2025 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package systempackages + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/in-toto/go-witness/attestation" + "github.com/invopop/jsonschema" +) + +const ( + Name = "system-packages" + Type = "https://witness.dev/attestations/system-packages/v0.1" + RunType = attestation.PreMaterialRunType +) + +func init() { + attestation.RegisterAttestation(Name, Type, RunType, func() attestation.Attestor { + return NewSystemPackagesAttestor() + }) +} + +type Attestor struct { + OS string `json:"os"` + Distribution string `json:"distribution"` + Version string `json:"version"` + Packages []Package `json:"packages"` + backend Backend +} + +type Package struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type Backend interface { + DetermineOSInfo() (string, string, string, error) + GatherPackages() ([]Package, error) + SetExecCommand(cmd func(name string, arg ...string) *exec.Cmd) +} + +func NewSystemPackagesAttestor() *Attestor { + osReleaseFile := "/etc/os-release" + _, distribution, _, err := determineDistribution(osReleaseFile) + fmt.Println("discovered distribution:", distribution) + if err != nil { + // Default to Debian-based system if we can't determine the distribution + return &Attestor{ + backend: NewDebianBackend(osReleaseFile), + } + } + + switch distribution { + case "fedora", "rhel", "centos", "rocky", "alma", "oracle", "suse", "opensuse", "amazon": + return &Attestor{ + backend: NewRPMBackend(osReleaseFile), + } + case "debian", "ubuntu": + return &Attestor{ + backend: NewDebianBackend(osReleaseFile), + } + default: + // Use Debian backend for any other unrecognized distributions + return &Attestor{ + backend: NewDebianBackend(osReleaseFile), + } + } +} + +func determineDistribution(osReleaseFile string) (string, string, string, error) { + file, err := os.Open(osReleaseFile) + if err != nil { + return "", "", "", err + } + defer file.Close() + + var distribution, version string + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.Trim(strings.TrimSpace(parts[1]), "\"") + + switch key { + case "ID": + distribution = value + case "VERSION_ID": + version = value + } + } + + if err := scanner.Err(); err != nil { + return "", "", "", err + } + + return "linux", distribution, version, nil +} + +// Attest implements attestation.Attestor. +func (a *Attestor) Attest(ctx *attestation.AttestationContext) error { + os, dist, version, err := a.backend.DetermineOSInfo() + fmt.Println(os) + fmt.Println(dist) + fmt.Println(version) + if err != nil { + return err + } + a.OS = os + a.Distribution = dist + a.Version = version + + packages, err := a.backend.GatherPackages() + if err != nil { + return err + } + a.Packages = packages + + return nil +} + +// Name implements attestation.Attestor. +func (a *Attestor) Name() string { + return Name +} + +// RunType implements attestation.Attestor. +func (a *Attestor) RunType() attestation.RunType { + return RunType +} + +// Schema implements attestation.Attestor. +func (a *Attestor) Schema() *jsonschema.Schema { + return jsonschema.Reflect(a) +} + +// Type implements attestation.Attestor. +func (a *Attestor) Type() string { + return Type +} diff --git a/imports.go b/imports.go index 4db6e32c..2d177e47 100644 --- a/imports.go +++ b/imports.go @@ -35,6 +35,7 @@ import ( _ "github.com/in-toto/go-witness/attestation/sarif" _ "github.com/in-toto/go-witness/attestation/sbom" _ "github.com/in-toto/go-witness/attestation/slsa" + _ "github.com/in-toto/go-witness/attestation/system-packages" _ "github.com/in-toto/go-witness/attestation/vex" // signer providers diff --git a/schemagen/system-packages.json b/schemagen/system-packages.json new file mode 100644 index 00000000..53e08314 --- /dev/null +++ b/schemagen/system-packages.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/in-toto/go-witness/attestation/system-packages/attestor", + "$ref": "#/$defs/Attestor", + "$defs": { + "Attestor": { + "properties": { + "os": { + "type": "string" + }, + "distribution": { + "type": "string" + }, + "version": { + "type": "string" + }, + "packages": { + "items": { + "$ref": "#/$defs/Package" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "os", + "distribution", + "version", + "packages" + ] + }, + "Package": { + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "version" + ] + } + } +} \ No newline at end of file