Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ message CraftingSchema {
SYSINTERNALS_SIGCHECK = 34;
// Sysinternals AccessChk text output https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk
SYSINTERNALS_ACCESSCHK = 35;
// CERT/CC dranzer ActiveX/COM control test report (plain text) https://github.com/CERTCC/dranzer
// CERT/CC dranzer ActiveX/COM control test report (plain text): a single
// report or an archive (zip or tar.gz) holding the per-mode reports of one
// run (-b, -p, -s, -t) https://github.com/CERTCC/dranzer
CERTCC_DRANZER = 36;
// OpenSSF Scorecard result in JSON format
// https://github.com/ossf/scorecard
Expand Down
9 changes: 7 additions & 2 deletions pkg/attestation/crafter/api/attestation/v1/crafting_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,16 @@ func (m *Attestation_Material) ingestMaterialToJSON(rawMaterial []byte, value st
// dranzer emits plain text; project it to JSON so the policy engine,
// which only consumes JSON, can evaluate it. The raw text is preserved
// in the projection's "raw" field for string-matching fallbacks.
report, err := dranzer.Parse(rawMaterial)
//
// The material may also be an archive of the per-mode reports of one run
// (-b/-p/-s/-t), so the projection aggregates its entries. Parsing the
// archive bytes as text instead would yield an empty report, and the
// policy would then skip rather than evaluate — a false pass.
bundle, err := dranzer.ParseBundle(rawMaterial)
if err != nil {
return nil, fmt.Errorf("invalid dranzer material: %w", err)
}
return json.Marshal(report)
return json.Marshal(bundle)
}

return rawMaterial, nil
Expand Down
36 changes: 36 additions & 0 deletions pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,42 @@ func TestGetEvaluableContentWithMetadata(t *testing.T) {
}
}

// TestDranzerBundleIsEvaluable guards that a CERTCC_DRANZER material holding an
// archive of per-mode reports projects to an aggregate the existing
// activex-controls-fuzzed policy can evaluate. Recording the archive whole
// without aggregating would hand the policy engine zip bytes, which parse to an
// empty report and make the policy *skip* — a clean-looking false pass.
func TestDranzerBundleIsEvaluable(t *testing.T) {
m := &Attestation_Material{
MaterialType: schemaapi.CraftingSchema_Material_CERTCC_DRANZER,
M: &Attestation_Material_Artifact_{
Artifact: &Attestation_Material_Artifact{Name: "dranzer-report", Digest: "sha256:deadbeef"},
},
}

content, err := m.GetEvaluableContent("testdata/dranzer-bundle.zip")
require.NoError(t, err)

var decoded map[string]any
require.NoError(t, json.NewDecoder(bytes.NewReader(content)).Decode(&decoded))

// The policy reads tool.name and needs run evidence to avoid skipping.
assert.Equal(t, "dranzer", decoded["tool"].(map[string]any)["name"])
assert.Equal(t, "96", decoded["tool"].(map[string]any)["version"])

// failed_count sums across the bundle, so the -t mode's single failure is
// what makes the gate fire.
summary := decoded["summary"].(map[string]any)
assert.EqualValues(t, 1, summary["failed_count"])
assert.EqualValues(t, 0, summary["hung_count"])
assert.EqualValues(t, 16, summary["object_count"])

assert.Len(t, decoded["findings"], 1, "the -t report's crash finding must survive aggregation")

// The CSV companion is not a report, so only the four modes are listed.
assert.Len(t, decoded["reports"], 4)
}

// TestCoberturaEmptyReportIsEvaluable guards the requirement that a legitimate
// empty coverage report (line-rate="NaN", no packages) projects to valid JSON
// the policy engine can evaluate — instead of failing with a NaN marshal error,
Expand Down
Binary file not shown.
256 changes: 38 additions & 218 deletions pkg/attestation/crafter/materials/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,262 +16,82 @@
package materials

import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"strings"
"syscall"

schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/archiveio"
)

// The archive container primitives live in the archiveio leaf package so the
// policy-input projections can share them without importing this package (which
// would be an import cycle). The names below are kept as aliases so existing
// callers and their tests are unaffected.

// ArchiveFormat identifies a supported archive container.
type ArchiveFormat int
type ArchiveFormat = archiveio.Format

const (
ArchiveNone ArchiveFormat = iota
ArchiveZip
ArchiveTar
ArchiveTarGz
ArchiveNone = archiveio.None
ArchiveZip = archiveio.Zip
ArchiveTar = archiveio.Tar
ArchiveTarGz = archiveio.TarGz
)

// DetectArchive reports whether path is a supported archive and, if so, its
// format. Detection is by extension first; for files whose extension does not
// match, magic bytes are used as a backstop so renamed archives are still
// caught. A non-archive returns (ArchiveNone, nil).
func DetectArchive(path string) (ArchiveFormat, error) {
lower := strings.ToLower(path)
switch {
case strings.HasSuffix(lower, ".zip"):
return ArchiveZip, nil
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return ArchiveTarGz, nil
case strings.HasSuffix(lower, ".tar"):
return ArchiveTar, nil
}

return detectByMagic(path)
}

func detectByMagic(path string) (ArchiveFormat, error) {
f, err := os.Open(path)
if err != nil {
// These errors mean the value is not a file path at all (e.g. "hello
// world" for STRING, or "registry/app:v1" for CONTAINER_IMAGE where
// "registry" happens to be a regular file in the working directory, which
// yields ENOTDIR); treat them as a non-archive so callers passing non-file
// values are not surprised. Any other error (permissions, I/O) is real and
// must surface.
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
return ArchiveNone, nil
}
return ArchiveNone, fmt.Errorf("opening %q: %w", path, err)
}
defer f.Close()

// 512 bytes is enough for the gzip/zip magic and the tar "ustar" marker at
// offset 257.
header := make([]byte, 512)
n, _ := f.Read(header)
header = header[:n]

switch {
case bytes.HasPrefix(header, []byte("PK\x03\x04")), bytes.HasPrefix(header, []byte("PK\x05\x06")):
return ArchiveZip, nil
case bytes.HasPrefix(header, []byte{0x1f, 0x8b}):
return ArchiveTarGz, nil
case len(header) >= 262 && bytes.Equal(header[257:262], []byte("ustar")):
return ArchiveTar, nil
}

return ArchiveNone, nil
}
// ArchiveLimits bounds archive expansion to guard against zip bombs.
type ArchiveLimits = archiveio.Limits

var (
// ErrTooManyEntries is returned when an archive has more qualifying entries
// than the configured maximum.
ErrTooManyEntries = errors.New("archive exceeds the maximum number of entries")
ErrTooManyEntries = archiveio.ErrTooManyEntries
// ErrArchiveTooLarge is returned when the running uncompressed size of an
// archive exceeds the configured maximum.
ErrArchiveTooLarge = errors.New("archive exceeds the maximum uncompressed size")
ErrArchiveTooLarge = archiveio.ErrArchiveTooLarge
// ErrUnsafeEntry is returned when an archive entry's path is absolute or escapes the extraction root.
ErrUnsafeEntry = errors.New("unsafe entry path in archive")
ErrUnsafeEntry = archiveio.ErrUnsafeEntry
)

// ArchiveLimits bounds archive expansion to guard against zip bombs.
type ArchiveLimits struct {
MaxEntries int
MaxTotalSize int64
// DetectArchive reports whether path is a supported archive and, if so, its
// format. A non-archive returns (ArchiveNone, nil).
func DetectArchive(path string) (ArchiveFormat, error) {
return archiveio.DetectPath(path)
}

// DefaultArchiveLimits returns the safe defaults: 10000 entries and 1 GiB
// total uncompressed size.
func DefaultArchiveLimits() ArchiveLimits {
return ArchiveLimits{MaxEntries: 10000, MaxTotalSize: 1 << 30}
}

// capReader wraps a reader and fails once the shared running total exceeds max,
// so we never trust an archive's declared sizes.
type capReader struct {
r io.Reader
total *int64
max int64
}

func (c *capReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
*c.total += int64(n)
if *c.total > c.max {
return n, ErrArchiveTooLarge
}
return n, err
return archiveio.DefaultLimits()
}

// WalkArchiveEntries calls yield for every regular file in the archive,
// enforcing the limits and skipping directories, symlinks, hardlinks, empty
// entries, and path-traversal entries.
func WalkArchiveEntries(path string, format ArchiveFormat, limits ArchiveLimits, yield func(name string, r io.Reader) error) error {
var total int64
count := 0
visit := func(name string, r io.Reader) error {
if !safeArchivePath(name) {
return fmt.Errorf("%w: %q", ErrUnsafeEntry, name)
}
count++
if count > limits.MaxEntries {
return ErrTooManyEntries
}
if err := yield(name, &capReader{r: r, total: &total, max: limits.MaxTotalSize}); err != nil {
return fmt.Errorf("processing entry %q: %w", name, err)
}
return nil
}

switch format {
case ArchiveZip:
return walkZip(path, visit)
case ArchiveTar:
return walkTar(path, false, visit)
case ArchiveTarGz:
return walkTar(path, true, visit)
default:
return fmt.Errorf("unsupported archive format")
}
}

// safeArchivePath rejects absolute paths and any path that escapes the
// extraction root via ".." path components. A filename that merely contains
// ".." as a substring (e.g. "foo..bar.json") is accepted; only actual path
// components equal to ".." are rejected.
func safeArchivePath(name string) bool {
normalized := strings.ReplaceAll(name, "\\", "/")
// Reject absolute paths, including Windows drive-letter (e.g. "C:/x") and
// UNC paths (which normalize to a leading "/").
if strings.HasPrefix(normalized, "/") || hasWindowsDriveLetter(normalized) {
return false
}
// Canonicalise against a virtual root and check that the result stays
// within it. path.Clean will resolve ".." components so a path like
// "a/../../etc/passwd" becomes "/etc/passwd" which does not start with
// the virtual prefix "/root/"; a safe path like "a/b.txt" becomes
// "/root/a/b.txt" which does.
const root = "/root"
clean := path.Clean(root + "/" + normalized)
return strings.HasPrefix(clean, root+"/") || clean == root
}

// hasWindowsDriveLetter reports whether name begins with a Windows drive-letter
// prefix such as "C:" or "c:/", which denotes an absolute path on Windows.
func hasWindowsDriveLetter(name string) bool {
if len(name) < 2 || name[1] != ':' {
return false
}
c := name[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

func walkZip(p string, visit func(name string, r io.Reader) error) error {
zr, err := zip.OpenReader(p)
if err != nil {
return fmt.Errorf("opening zip: %w", err)
}
defer zr.Close()

for _, f := range zr.File {
// Skip directories, symlinks, and empty entries: they carry no file
// content worth recording as a material. Empty-entry skipping is
// intentional per the explode design (an empty evidence file produces
// no material). Note: symlink detection relies on Unix mode bits stored
// in the zip; archives written without Unix metadata won't carry the
// symlink bit, so such a symlink would be treated as a regular file
// (its content being the stored target path). Tar symlinks are detected
// reliably via the typeflag below.
if f.FileInfo().IsDir() || f.Mode()&os.ModeSymlink != 0 || f.UncompressedSize64 == 0 {
continue
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("opening entry %q: %w", f.Name, err)
}
err = visit(f.Name, rc)
rc.Close()
if err != nil {
return err
}
}
return nil
}

func walkTar(p string, gzipped bool, visit func(name string, r io.Reader) error) error {
f, err := os.Open(p)
if err != nil {
return fmt.Errorf("opening tar: %w", err)
}
defer f.Close()

var src io.Reader = f
if gzipped {
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("opening gzip: %w", err)
}
defer gz.Close()
src = gz
}

tr := tar.NewReader(src)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("reading tar: %w", err)
}
// Only regular files become materials; directories, symlinks, hardlinks
// and other special entries are skipped via the typeflag. Empty entries
// are skipped intentionally (an empty evidence file produces no material).
if hdr.Typeflag != tar.TypeReg || hdr.Size == 0 {
continue
}
if err := visit(hdr.Name, tr); err != nil {
return err
}
}
return archiveio.WalkPath(path, format, limits, yield)
}

// explodableKinds is the allowlist of material kinds whose archive value is
// expanded into one material per entry. Every other kind (ARTIFACT, EVIDENCE,
// ZAP_DAST_ZIP, …) records the archive whole, so a customer can still provide a
// regular zip as a single material. Extend this set as more kinds gain a
// meaningful "bundle of the same kind" archive form.
// regular zip as a single material.
//
// A kind that accepts an archive has two strategies available, and this set
// selects the first:
//
// - Explode (this set): each entry is independent evidence that stands on its
// own, so each becomes its own material bound to policies by type.
// - Record whole plus an aggregating projection: the entries are one artifact
// whose parts only mean something together, so the archive fills a single
// contract slot and the projection folds the entries for the policy engine.
// CERTCC_DRANZER works this way — see dranzer.ParseBundle and the projection
// in Attestation_Material.ingestMaterialToJSON — because one run's per-mode
// reports are facets of a single result, not interchangeable evidence.
//
// Adding a kind here changes its contract-slot semantics from one material to N,
// so choose the strategy before extending the set.
var explodableKinds = map[string]struct{}{
schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON.String(): {},
schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON.String(): {},
Expand Down
Loading
Loading