diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index e90eb31de..ea16ec84b 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -293,7 +293,11 @@ export enum CraftingSchema_Material_MaterialType { SYSINTERNALS_SIGCHECK = 34, /** SYSINTERNALS_ACCESSCHK - Sysinternals AccessChk text output https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk */ SYSINTERNALS_ACCESSCHK = 35, - /** CERTCC_DRANZER - CERT/CC dranzer ActiveX/COM control test report (plain text) https://github.com/CERTCC/dranzer */ + /** + * 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, /** * OSSF_SCORECARD_JSON - OpenSSF Scorecard result in JSON format diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 9e17497ef..1d81067b7 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -229,7 +229,9 @@ const ( CraftingSchema_Material_SYSINTERNALS_SIGCHECK CraftingSchema_Material_MaterialType = 34 // Sysinternals AccessChk text output https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk CraftingSchema_Material_SYSINTERNALS_ACCESSCHK CraftingSchema_Material_MaterialType = 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 CraftingSchema_Material_CERTCC_DRANZER CraftingSchema_Material_MaterialType = 36 // OpenSSF Scorecard result in JSON format // https://github.com/ossf/scorecard diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 476132dcd..d43c19409 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -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 diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go index 94e400179..717b9195c 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go @@ -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 diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go index dc8e567bf..efa29d189 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go @@ -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, diff --git a/pkg/attestation/crafter/api/attestation/v1/testdata/dranzer-bundle.zip b/pkg/attestation/crafter/api/attestation/v1/testdata/dranzer-bundle.zip new file mode 100644 index 000000000..efbc86a39 Binary files /dev/null and b/pkg/attestation/crafter/api/attestation/v1/testdata/dranzer-bundle.zip differ diff --git a/pkg/attestation/crafter/materials/archive.go b/pkg/attestation/crafter/materials/archive.go index 9e3a80f70..7518ec80f 100644 --- a/pkg/attestation/crafter/materials/archive.go +++ b/pkg/attestation/crafter/materials/archive.go @@ -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(): {}, diff --git a/pkg/attestation/crafter/materials/archive_test.go b/pkg/attestation/crafter/materials/archive_test.go index 127cb3266..32b3c277c 100644 --- a/pkg/attestation/crafter/materials/archive_test.go +++ b/pkg/attestation/crafter/materials/archive_test.go @@ -194,31 +194,6 @@ func TestWalkArchiveEntries(t *testing.T) { }) } -func TestSafeArchivePath(t *testing.T) { - tests := []struct { - name string - path string - want bool - }{ - {"absolute path", "/etc/passwd", false}, - {"windows drive-letter backslash", "C:\\Windows\\system32", false}, - {"windows drive-letter forward slash", "c:/windows/system32", false}, - {"path traversal", "../escape.txt", false}, - {"nested path traversal", "foo/../../../etc/passwd", false}, - {"double dot in filename is ok", "foo..bar.json", true}, - {"escape via nested double dot", "a/../../etc/passwd", false}, - {"valid nested path", "a/b.txt", true}, - {"valid simple path", "file.txt", true}, - {"valid with subdirs", "nested/dir/file.txt", true}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := safeArchivePath(tc.path) - assert.Equal(t, tc.want, got) - }) - } -} - func TestArchiveEntryBaseName(t *testing.T) { tests := []struct{ name, in, want string }{ {"simple", "scan.json", "scan.json"}, diff --git a/pkg/attestation/crafter/materials/archiveio/archiveio.go b/pkg/attestation/crafter/materials/archiveio/archiveio.go new file mode 100644 index 000000000..b285dcc10 --- /dev/null +++ b/pkg/attestation/crafter/materials/archiveio/archiveio.go @@ -0,0 +1,391 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 archiveio detects and walks the archive containers Chainloop accepts +// as material values (zip, tar, tar.gz), enforcing the guards that keep a +// hostile archive from exhausting the host: entry-count and uncompressed-size +// limits, and rejection of paths that escape the extraction root. +// +// It is a leaf package so that both the material crafters (which validate an +// archive from a path on disk) and the policy-input projections (which +// aggregate an archive already held in memory as material bytes) can share one +// implementation of those guards. Duplicating them would risk the two paths +// disagreeing about what is safe. +package archiveio + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "strings" + "syscall" +) + +// Format identifies a supported archive container. +type Format int + +const ( + None Format = iota + Zip + Tar + TarGz +) + +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") + // ErrArchiveTooLarge is returned when the running uncompressed size of an + // archive exceeds the configured maximum. + ErrArchiveTooLarge = errors.New("archive exceeds the maximum uncompressed size") + // ErrUnsafeEntry is returned when an archive entry's path is absolute or escapes the extraction root. + ErrUnsafeEntry = errors.New("unsafe entry path in archive") +) + +// Limits bounds archive expansion to guard against zip bombs. +type Limits struct { + MaxEntries int + MaxTotalSize int64 +} + +// DefaultLimits returns the safe defaults: 10000 entries and 1 GiB total +// uncompressed size. +func DefaultLimits() Limits { + return Limits{MaxEntries: 10000, MaxTotalSize: 1 << 30} +} + +// magicPeek is the number of leading bytes needed to recognize every supported +// container: enough for the gzip/zip magic and the tar "ustar" marker at +// offset 257. +const magicPeek = 512 + +// DetectPath 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 (None, nil). +func DetectPath(p string) (Format, error) { + if f := detectByExtension(p); f != None { + return f, nil + } + + return detectPathByMagic(p) +} + +// detectByExtension recognizes a container from a filename suffix, returning +// None when the name carries no archive extension. +func detectByExtension(p string) Format { + lower := strings.ToLower(p) + switch { + case strings.HasSuffix(lower, ".zip"): + return Zip + case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): + return TarGz + case strings.HasSuffix(lower, ".tar"): + return Tar + } + return None +} + +// DetectFile reports the container format of the file at p from its content +// alone, ignoring its name. A non-archive returns (None, nil). +// +// Prefer this over DetectPath when the same content will later be detected from +// bytes with DetectBytes and the two decisions must agree — for example a crafter +// validating a value that a policy-input projection will re-read from the +// recorded material. DetectPath trusts the filename first, so a name can +// disagree with the content: a zip carrying a prepended stub still opens (its +// central directory is at the end) but no longer starts with the zip magic, so +// DetectPath calls it an archive and DetectBytes does not. Detecting from content +// on both sides makes such a value fail loudly at craft time instead of being +// accepted and then silently failing to project. +func DetectFile(p string) (Format, error) { + return detectPathByMagic(p) +} + +func detectPathByMagic(p string) (Format, error) { + f, err := os.Open(p) + 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 None, nil + } + return None, fmt.Errorf("opening %q: %w", p, err) + } + defer f.Close() + + // Fill the buffer rather than trusting a single Read to return it all: the tar + // marker sits at offset 257, so a short read would misdetect a valid tar as a + // plain file. A file smaller than the buffer is an expected short read; any + // other read failure is real and must surface instead of being reported as + // "not an archive". + header := make([]byte, magicPeek) + n, err := io.ReadFull(f, header) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return None, fmt.Errorf("reading %q: %w", p, err) + } + + return DetectBytes(header[:n]), nil +} + +// DetectBytes reports the container format of an in-memory blob from its magic +// bytes alone, returning None when the bytes are not a supported archive. It is +// the counterpart to DetectPath for callers that already hold the content, such +// as a policy-input projection working from recorded material bytes. +func DetectBytes(data []byte) Format { + switch { + case bytes.HasPrefix(data, []byte("PK\x03\x04")), bytes.HasPrefix(data, []byte("PK\x05\x06")): + return Zip + case bytes.HasPrefix(data, []byte{0x1f, 0x8b}): + return TarGz + case len(data) >= 262 && bytes.Equal(data[257:262], []byte("ustar")): + return Tar + } + return None +} + +// 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 +} + +// Read reports ErrArchiveTooLarge with n == 0 rather than alongside the bytes +// that broke the budget. Returning the error together with a full buffer would +// let it be swallowed: io.ReadFull — which archive/tar uses for every 512-byte +// header — discards an error that accompanies a complete read, so a stream read in +// exact block sizes would blow past the limit unreported. +// +// One byte beyond the budget is read deliberately, so a stream sized exactly at +// the limit is allowed while one that exceeds it is caught. +func (c *capReader) Read(p []byte) (int, error) { + if *c.total > c.max { + return 0, ErrArchiveTooLarge + } + if probe := c.max - *c.total + 1; int64(len(p)) > probe { + p = p[:probe] + } + + n, err := c.r.Read(p) + *c.total += int64(n) + if *c.total > c.max { + return 0, ErrArchiveTooLarge + } + + return n, err +} + +// walkGuard applies the entry-count, size and path-safety limits shared by every +// walk entry point. +// +// The limits are checked for every entry the walker reads a header for, not only +// the ones handed to the caller: skipping an entry still costs the work of +// decompressing enough of the archive to reach the next one, so an archive of +// nothing but directory entries must not walk unmeasured. +type walkGuard struct { + limits Limits + yield func(name string, r io.Reader) error + total int64 + count int + // capEntries bounds each entry body individually, for containers whose entries + // decompress independently (zip). Stream containers (tar) bound the whole + // stream instead — see capStream — which already covers their entry bodies. + capEntries bool +} + +// consider records an entry the walker has read a header for, whether or not it +// will be handed to the caller. +func (g *walkGuard) consider(name string) error { + if !safePath(name) { + return fmt.Errorf("%w: %q", ErrUnsafeEntry, name) + } + g.count++ + if g.count > g.limits.MaxEntries { + return ErrTooManyEntries + } + return nil +} + +// visit hands a qualifying entry's body to the caller. +func (g *walkGuard) visit(name string, r io.Reader) error { + if g.capEntries { + r = &capReader{r: r, total: &g.total, max: g.limits.MaxTotalSize} + } + if err := g.yield(name, r); err != nil { + return fmt.Errorf("processing entry %q: %w", name, err) + } + return nil +} + +// capStream bounds every byte read from a stream container, so headers and the +// padding skipped past unqualifying entries count towards the size limit too. +func (g *walkGuard) capStream(r io.Reader) io.Reader { + return &capReader{r: r, total: &g.total, max: g.limits.MaxTotalSize} +} + +// WalkPath calls yield for every regular file in the archive at p, enforcing +// limits and skipping directories, symlinks, hardlinks, empty entries, and +// path-traversal entries. +func WalkPath(p string, format Format, limits Limits, yield func(name string, r io.Reader) error) error { + switch format { + case Zip: + zr, err := zip.OpenReader(p) + if err != nil { + return fmt.Errorf("opening zip: %w", err) + } + defer zr.Close() + return walkZipEntries(zr.File, &walkGuard{limits: limits, yield: yield, capEntries: true}) + case Tar, TarGz: + f, err := os.Open(p) + if err != nil { + return fmt.Errorf("opening tar: %w", err) + } + defer f.Close() + return walkTarStream(f, format == TarGz, &walkGuard{limits: limits, yield: yield}) + default: + return fmt.Errorf("unsupported archive format") + } +} + +// WalkBytes is WalkPath for an archive already held in memory. It applies the +// same guards, so a projection reading recorded material bytes cannot be +// tricked by an archive a crafter would have rejected. +func WalkBytes(data []byte, format Format, limits Limits, yield func(name string, r io.Reader) error) error { + switch format { + case Zip: + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return fmt.Errorf("opening zip: %w", err) + } + return walkZipEntries(zr.File, &walkGuard{limits: limits, yield: yield, capEntries: true}) + case Tar, TarGz: + return walkTarStream(bytes.NewReader(data), format == TarGz, &walkGuard{limits: limits, yield: yield}) + default: + return fmt.Errorf("unsupported archive format") + } +} + +// safePath 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 safePath(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 walkZipEntries(files []*zip.File, g *walkGuard) error { + for _, f := range files { + if err := g.consider(f.Name); err != nil { + return err + } + // 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 = g.visit(f.Name, rc) + rc.Close() + if err != nil { + return err + } + } + return nil +} + +func walkTarStream(src io.Reader, gzipped bool, g *walkGuard) error { + if gzipped { + gz, err := gzip.NewReader(src) + if err != nil { + return fmt.Errorf("opening gzip: %w", err) + } + defer gz.Close() + src = gz + } + + // A tar is one stream, so capping it bounds every byte the walk decompresses: + // entry bodies, headers, and the padding skipped past entries that never reach + // the caller. + tr := tar.NewReader(g.capStream(src)) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + if errors.Is(err, ErrArchiveTooLarge) { + return err + } + return fmt.Errorf("reading tar: %w", err) + } + if err := g.consider(hdr.Name); err != nil { + return 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 := g.visit(hdr.Name, tr); err != nil { + return err + } + } +} diff --git a/pkg/attestation/crafter/materials/archiveio/archiveio_test.go b/pkg/attestation/crafter/materials/archiveio/archiveio_test.go new file mode 100644 index 000000000..4f3032885 --- /dev/null +++ b/pkg/attestation/crafter/materials/archiveio/archiveio_test.go @@ -0,0 +1,309 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 archiveio + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// zipBytes builds an in-memory zip containing the given entries. +func zipBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range files { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +// tarGzBytes builds an in-memory tar.gz containing the given regular files. +func tarGzBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for name, content := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Mode: 0o600, + Size: int64(len(content)), + })) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return buf.Bytes() +} + +// collect walks data and returns entry name → content. +func collect(t *testing.T, data []byte, format Format, limits Limits) (map[string]string, error) { + t.Helper() + got := map[string]string{} + err := WalkBytes(data, format, limits, func(name string, r io.Reader) error { + content, err := io.ReadAll(r) + if err != nil { + return err + } + got[name] = string(content) + return nil + }) + return got, err +} + +func TestDetectBytes(t *testing.T) { + tests := []struct { + name string + data []byte + want Format + }{ + {"zip local file header", []byte("PK\x03\x04rest"), Zip}, + {"empty zip end-of-central-directory", []byte("PK\x05\x06rest"), Zip}, + {"gzip", []byte{0x1f, 0x8b, 0x08, 0x00}, TarGz}, + {"plain text", []byte("Test Engine Version: $Rev: 96 $"), None}, + {"empty", nil, None}, + {"too short for ustar", make([]byte, 100), None}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, DetectBytes(tc.data)) + }) + } +} + +func TestDetectBytesTar(t *testing.T) { + // A tar is recognized by the "ustar" marker at offset 257 rather than a + // leading magic, so build a real one instead of hand-rolling the header. + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "a.txt", Typeflag: tar.TypeReg, Mode: 0o600, Size: 1, + })) + _, err := tw.Write([]byte("x")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + + assert.Equal(t, Tar, DetectBytes(buf.Bytes())) +} + +func TestWalkBytes(t *testing.T) { + t.Run("zip yields every regular entry", func(t *testing.T) { + data := zipBytes(t, map[string]string{"a.txt": "alpha", "nested/b.txt": "beta"}) + + got, err := collect(t, data, Zip, DefaultLimits()) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"a.txt": "alpha", "nested/b.txt": "beta"}, got) + }) + + t.Run("tar.gz yields every regular entry", func(t *testing.T) { + data := tarGzBytes(t, map[string]string{"a.txt": "alpha", "b.txt": "beta"}) + + got, err := collect(t, data, TarGz, DefaultLimits()) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"a.txt": "alpha", "b.txt": "beta"}, got) + }) + + t.Run("skips directory entries", func(t *testing.T) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + _, err := zw.Create("adir/") + require.NoError(t, err) + w, err := zw.Create("adir/a.txt") + require.NoError(t, err) + _, err = w.Write([]byte("alpha")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + got, err := collect(t, buf.Bytes(), Zip, DefaultLimits()) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"adir/a.txt": "alpha"}, got) + }) + + t.Run("enforces the entry-count limit", func(t *testing.T) { + data := zipBytes(t, map[string]string{"a.txt": "alpha", "b.txt": "beta"}) + + _, err := collect(t, data, Zip, Limits{MaxEntries: 1, MaxTotalSize: 1 << 30}) + + assert.ErrorIs(t, err, ErrTooManyEntries) + }) + + t.Run("enforces the uncompressed-size limit", func(t *testing.T) { + data := zipBytes(t, map[string]string{"a.txt": "aaaaaaaaaa"}) + + _, err := collect(t, data, Zip, Limits{MaxEntries: 10, MaxTotalSize: 4}) + + assert.ErrorIs(t, err, ErrArchiveTooLarge) + }) + + // Entries that carry no content are skipped rather than yielded, but reaching + // them still costs decompression, so they must count against the limits. A + // tar.gz of nothing but directory entries compresses to almost nothing and + // would otherwise be walked without either guard ever measuring it. + t.Run("counts skipped entries against the entry limit", func(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for i := 0; i < 50; i++ { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: fmt.Sprintf("dir%d/", i), Typeflag: tar.TypeDir, Mode: 0o700, + })) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + _, err := collect(t, buf.Bytes(), TarGz, Limits{MaxEntries: 10, MaxTotalSize: 1 << 30}) + + assert.ErrorIs(t, err, ErrTooManyEntries) + }) + + // Tar headers and padding are decompressed to reach the next entry, so the size + // cap has to bound the whole stream and not merely the bodies handed to the + // caller: a dir-only archive yields nothing yet still costs 512 bytes of output + // per entry. + t.Run("counts stream bytes read past skipped entries against the size limit", func(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for i := 0; i < 200; i++ { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: fmt.Sprintf("dir%d/", i), Typeflag: tar.TypeDir, Mode: 0o700, + })) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + // Generous entry allowance, tight size allowance: only a stream-level cap + // can catch this. + _, err := collect(t, buf.Bytes(), TarGz, Limits{MaxEntries: 1000, MaxTotalSize: 4096}) + + assert.ErrorIs(t, err, ErrArchiveTooLarge) + }) + + t.Run("rejects a path-traversal entry", func(t *testing.T) { + data := tarGzBytes(t, map[string]string{"../escape.txt": "evil"}) + + _, err := collect(t, data, TarGz, DefaultLimits()) + + assert.ErrorIs(t, err, ErrUnsafeEntry) + }) + + t.Run("rejects an unsupported format", func(t *testing.T) { + _, err := collect(t, []byte("nope"), None, DefaultLimits()) + + require.Error(t, err) + }) +} + +func TestDetectFileSurfacesReadErrors(t *testing.T) { + // A path that opens but cannot be read must not be reported as "not an + // archive": swallowing the read error would turn an I/O failure into a + // silent misclassification, and the caller would go on to treat the value as + // a plain file. + _, err := DetectFile(t.TempDir()) + + require.Error(t, err) +} + +func TestDetectFileReadsEnoughForTheTarMarker(t *testing.T) { + // tar is recognized by "ustar" at offset 257 rather than a leading magic, so + // detection has to fill the peek buffer instead of trusting a single Read to + // return it all. + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "a.txt", Typeflag: tar.TypeReg, Mode: 0o600, Size: 1, + })) + _, err := tw.Write([]byte("x")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + + p := filepath.Join(t.TempDir(), "noextension") + require.NoError(t, os.WriteFile(p, buf.Bytes(), 0o600)) + + got, err := DetectFile(p) + + require.NoError(t, err) + assert.Equal(t, Tar, got) +} + +func TestDetectFileOnShortAndMissingFiles(t *testing.T) { + dir := t.TempDir() + + short := filepath.Join(dir, "short.bin") + require.NoError(t, os.WriteFile(short, []byte("tiny"), 0o600)) + empty := filepath.Join(dir, "empty.bin") + require.NoError(t, os.WriteFile(empty, nil, 0o600)) + + // A file smaller than the peek buffer is an expected short read, not an error. + got, err := DetectFile(short) + require.NoError(t, err) + assert.Equal(t, None, got) + + got, err = DetectFile(empty) + require.NoError(t, err) + assert.Equal(t, None, got) + + // A value that is not a file at all stays a non-archive without erroring, so + // callers passing non-path values (a STRING material) are not surprised. + got, err = DetectFile(filepath.Join(dir, "nope")) + require.NoError(t, err) + assert.Equal(t, None, got) +} + +func TestSafePath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {"absolute path", "/etc/passwd", false}, + {"windows drive-letter backslash", "C:\\Windows\\system32", false}, + {"windows drive-letter forward slash", "c:/windows/system32", false}, + {"path traversal", "../escape.txt", false}, + {"nested path traversal", "foo/../../../etc/passwd", false}, + {"double dot in filename is ok", "foo..bar.json", true}, + {"escape via nested double dot", "a/../../etc/passwd", false}, + {"valid nested path", "a/b.txt", true}, + {"valid simple path", "file.txt", true}, + {"valid with subdirs", "nested/dir/file.txt", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := safePath(tc.path) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/attestation/crafter/materials/dranzer.go b/pkg/attestation/crafter/materials/dranzer.go index ad4c48999..f42ff16df 100644 --- a/pkg/attestation/crafter/materials/dranzer.go +++ b/pkg/attestation/crafter/materials/dranzer.go @@ -17,8 +17,9 @@ package materials import ( "context" + "errors" "fmt" - "os" + "strconv" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" @@ -27,10 +28,20 @@ import ( "github.com/rs/zerolog" ) +// AnnotationDranzerReportsCount is the annotation holding the number of dranzer +// reports recorded in a CERTCC_DRANZER material: 1 for a single report, or the +// number of report entries found in a bundle. +const AnnotationDranzerReportsCount = "chainloop.material.dranzer.reports.count" + // DranzerCrafter stores the text report of the CERT/CC dranzer ActiveX/COM // control tester as supply-chain evidence. The raw text is stored as-is; the // text-to-JSON projection used by the policy engine happens later, at // evaluation time. +// +// A single dranzer run produces one report per test mode (-b, -p, -s, -t), so the +// value may also be an archive holding several of them. The archive is recorded +// whole — it is the artifact the customer produced — and the projection +// aggregates its entries at evaluation time. type DranzerCrafter struct { *crafterCommon backend *casclient.CASBackend @@ -45,40 +56,44 @@ func NewDranzerCrafter(schema *schemaapi.CraftingSchema_Material, backend *cascl } func (i *DranzerCrafter) Craft(ctx context.Context, filePath string) (*api.Attestation_Material, error) { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("can't open the file: %w", err) - } - - // Soft fingerprint: dranzer emits free-form text, so we only require that - // the input is valid text that resembles dranzer output (a test-object - // banner or the test-engine version line). The raw text is stored unchanged; - // it is projected to JSON later for policy evaluation. - report, err := dranzer.Parse(data) + // dranzer emits free-form text, so the fingerprint is soft: the input only has + // to resemble dranzer output (a test-engine version banner, a parsed object or + // finding, or the run-summary line). Inspect accepts a single report or an + // archive of them, applying the same recognition predicate the policy-input + // projection uses later. + inspection, err := dranzer.Inspect(filePath) if err != nil { - return nil, fmt.Errorf("invalid dranzer output: %w", ErrInvalidMaterialType) - } - - if !report.LooksLikeDranzer() { - return nil, fmt.Errorf("input does not look like dranzer output: %w", ErrInvalidMaterialType) + switch { + case errors.Is(err, dranzer.ErrNoReports): + return nil, fmt.Errorf("input does not look like dranzer output: %w", ErrInvalidMaterialType) + case errors.Is(err, ErrTooManyEntries), errors.Is(err, ErrArchiveTooLarge): + // The bundle limits are sized for one run's per-mode reports, so hitting + // them usually means a whole output directory was archived. Say what to + // provide instead rather than only reporting the limit. + return nil, fmt.Errorf("%w: provide an archive holding just the dranzer reports of a single run", err) + } + return nil, err } + // The value is stored unchanged — for a bundle that means the archive as the + // customer produced it — and projected to JSON later for policy evaluation. m, err := uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger) if err != nil { return nil, err } - i.injectAnnotations(m, report) + i.injectAnnotations(m, inspection) return m, nil } -func (i *DranzerCrafter) injectAnnotations(m *api.Attestation_Material, report *dranzer.Report) { +func (i *DranzerCrafter) injectAnnotations(m *api.Attestation_Material, inspection dranzer.Inspection) { if m.Annotations == nil { m.Annotations = make(map[string]string) } - m.Annotations[AnnotationToolNameKey] = report.Tool.Name - if report.Tool.Version != "" { - m.Annotations[AnnotationToolVersionKey] = report.Tool.Version + m.Annotations[AnnotationToolNameKey] = dranzer.ToolName + if inspection.Version != "" { + m.Annotations[AnnotationToolVersionKey] = inspection.Version } + m.Annotations[AnnotationDranzerReportsCount] = strconv.Itoa(inspection.Reports) } diff --git a/pkg/attestation/crafter/materials/dranzer/bundle.go b/pkg/attestation/crafter/materials/dranzer/bundle.go new file mode 100644 index 000000000..56a4ca518 --- /dev/null +++ b/pkg/attestation/crafter/materials/dranzer/bundle.go @@ -0,0 +1,283 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 dranzer + +import ( + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/archiveio" +) + +// ErrNoReports is returned when a dranzer material holds no recognizable report: +// a single file that is not dranzer output, or an archive with no report entry. +var ErrNoReports = errors.New("no dranzer report found") + +// bundleLimits bounds bundle expansion. One dranzer run emits a handful of text +// reports (one per test mode) totalling tens of kilobytes, so this is far tighter +// than the generic archive defaults. It matters because the aggregate is held in +// memory, serialized to JSON, and then parsed into policy-engine values, each a +// multiple of the uncompressed size. +func bundleLimits() archiveio.Limits { + return archiveio.Limits{MaxEntries: 256, MaxTotalSize: 16 << 20} +} + +// Entry is one report's contribution to a Bundle: which archive entry it came +// from and the counters it reported. Its objects and findings are not repeated +// here — they are in the Bundle's aggregate, each stamped with the same Source — +// so the policy input carries them once. +type Entry struct { + Source string `json:"source,omitempty"` + Tool Tool `json:"tool"` + Summary Summary `json:"summary"` +} + +// Bundle is the policy projection of a CERTCC_DRANZER material, which may hold a +// single report or an archive of the per-mode reports produced by one dranzer +// run (its -b, -p, -s and -t modes). +// +// Report is embedded so the aggregate is promoted to the top level of the JSON: +// a policy reading input.summary / input.findings / input.tool behaves the same +// whether the material was one report or a bundle of them. Reports carries the +// per-mode breakdown for policies that need to be precise about which mode +// reported what. +type Bundle struct { + Report + Reports []Entry `json:"reports"` +} + +// Inspection is what reading a dranzer material tells us about it, without +// retaining its content: how many reports it holds and which tool version +// produced them. +type Inspection struct { + Reports int + Version string +} + +// Inspect validates a dranzer material on disk and summarizes it, accepting +// either a single report or an archive of them. Archives are streamed rather +// than held in memory. +// +// It shares both of its predicates with ParseBundle — what counts as an archive +// (archiveio.DetectFile, which reads content just as the projection's +// DetectBytes does, rather than trusting the filename) and what counts as a +// report (parseReportEntry) — so a material this accepts is necessarily one the +// projection can aggregate. Were the two to disagree, a material would be +// attested at craft time and then silently skip policy evaluation, which on a +// compliance gate reads as a clean run. +// +// ErrNoReports means the material is not dranzer evidence at all. +func Inspect(p string) (Inspection, error) { + format, err := archiveio.DetectFile(p) + if err != nil { + return Inspection{}, fmt.Errorf("inspecting %q: %w", p, err) + } + + if format == archiveio.None { + data, err := os.ReadFile(p) + if err != nil { + return Inspection{}, fmt.Errorf("can't open the file: %w", err) + } + report, err := Parse(data) + if err != nil { + return Inspection{}, err + } + if !report.LooksLikeDranzer() { + return Inspection{}, ErrNoReports + } + return Inspection{Reports: 1, Version: report.Tool.Version}, nil + } + + var out Inspection + var versionFrom string + err = archiveio.WalkPath(p, format, bundleLimits(), func(name string, r io.Reader) error { + report, err := parseReportEntry(r) + if err != nil || report == nil { + return err + } + out.Reports++ + // ParseBundle sorts entries before folding them, so take the version from + // the lexicographically first entry that declares one rather than the first + // the walk happens to reach. Otherwise the recorded annotation and the + // projected tool.version could disagree for a bundle whose reports were + // produced by different tool versions. + if report.Tool.Version != "" && (versionFrom == "" || name < versionFrom) { + versionFrom, out.Version = name, report.Tool.Version + } + return nil + }) + if err != nil { + return Inspection{}, fmt.Errorf("reading dranzer archive: %w", err) + } + if out.Reports == 0 { + return Inspection{}, ErrNoReports + } + + return out, nil +} + +// ParseBundle projects one dranzer material to JSON-ready form. A value that is +// not an archive is treated as a bundle of one; an archive is expanded and every +// entry that is a recognizable report contributes to the aggregate. +// +// A single non-archive value is never rejected, so projecting an +// already-recorded material cannot start failing — validating the input is +// Inspect's job, at craft time. An archive, by contrast, must yield at least one +// report: selecting entries is only meaningful if something was selected. +func ParseBundle(data []byte) (*Bundle, error) { + format := archiveio.DetectBytes(data) + if format == archiveio.None { + report, err := Parse(data) + if err != nil { + return nil, err + } + return newBundle([]Report{*report}), nil + } + + reports, err := parseArchiveReports(data, format) + if err != nil { + return nil, err + } + if len(reports) == 0 { + return nil, ErrNoReports + } + + // Deterministic order so the projection — and therefore the policy input and + // any decision derived from it — does not depend on archive iteration order. + sort.Slice(reports, func(i, j int) bool { return reports[i].Source < reports[j].Source }) + + return newBundle(reports), nil +} + +// parseReportEntry reads one archive entry and returns the report it holds, or +// nil when the entry is not a dranzer report. Bundles ship non-report companion +// files (a CSV summary) beside the reports, so those are skipped rather than +// treated as an error. +// +// This is the single definition of "is this entry a report", shared by Inspect +// and ParseBundle. +func parseReportEntry(r io.Reader) (*Report, error) { + content, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("reading entry: %w", err) + } + report, err := Parse(content) + if err != nil { + return nil, fmt.Errorf("parsing entry: %w", err) + } + if !report.LooksLikeDranzer() { + return nil, nil + } + return report, nil +} + +// parseArchiveReports walks an in-memory archive and returns the entries that are +// dranzer reports, each stamped with its entry name. +func parseArchiveReports(data []byte, format archiveio.Format) ([]Report, error) { + var reports []Report + + err := archiveio.WalkBytes(data, format, bundleLimits(), func(name string, r io.Reader) error { + report, err := parseReportEntry(r) + if err != nil || report == nil { + return err + } + report.Source = name + reports = append(reports, *report) + return nil + }) + if err != nil { + return nil, fmt.Errorf("reading dranzer archive: %w", err) + } + + return reports, nil +} + +// newBundle folds reports into a Bundle: the aggregate at the top level, and the +// per-report breakdown in Reports. +func newBundle(reports []Report) *Bundle { + nObjects, nFindings, nRaw := 0, 0, 0 + for _, r := range reports { + nObjects += len(r.Objects) + nFindings += len(r.Findings) + nRaw += len(r.Raw) + len(r.Source) + len("===== =====\n\n") + } + + b := &Bundle{ + Report: Report{ + Tool: Tool{Name: ToolName}, + Objects: make([]Object, 0, nObjects), + Findings: make([]Finding, 0, nFindings), + Summary: Summary{Counters: map[string]int{}}, + }, + Reports: make([]Entry, 0, len(reports)), + } + + var raw strings.Builder + raw.Grow(nRaw) + + for _, r := range reports { + if b.Tool.Version == "" { + b.Tool.Version = r.Tool.Version + } + + // Stamp each object and finding with its source so the aggregate stays + // attributable without repeating them in the per-report breakdown. + for _, o := range r.Objects { + o.Source = r.Source + b.Objects = append(b.Objects, o) + } + for _, f := range r.Findings { + f.Source = r.Source + b.Findings = append(b.Findings, f) + } + b.Summary.add(r.Summary) + + // A single report projects to its own text verbatim; entries in a bundle + // are attributed and newline-terminated so they cannot run together. + if r.Source == "" { + raw.WriteString(r.Raw) + } else { + fmt.Fprintf(&raw, "===== %s =====\n%s\n", r.Source, strings.TrimSuffix(r.Raw, "\n")) + } + + b.Reports = append(b.Reports, Entry{Source: r.Source, Tool: r.Tool, Summary: r.Summary}) + } + b.Raw = raw.String() + + return b +} + +// add accumulates another report's counters into s. Every counter is summed, so +// the aggregate answers "did any report in this bundle see a failure" — which is +// what the compliance gate asks. Note this makes object_count a total across +// reports rather than a count of distinct controls: one run's four modes each +// test the same controls. +func (s *Summary) add(other Summary) { + if s.Counters == nil { + s.Counters = map[string]int{} + } + for k, v := range other.Counters { + s.Counters[k] += v + // Keep the explicit fields in step with the map they mirror. + if field := s.wellKnownCounter(k); field != nil { + *field = s.Counters[k] + } + } +} diff --git a/pkg/attestation/crafter/materials/dranzer/bundle_test.go b/pkg/attestation/crafter/materials/dranzer/bundle_test.go new file mode 100644 index 000000000..6125d906e --- /dev/null +++ b/pkg/attestation/crafter/materials/dranzer/bundle_test.go @@ -0,0 +1,342 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 dranzer + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/archiveio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const bundleDir = "../testdata/dranzer-bundle/" + +// bundleFiles are the entries of the reference bundle: one report per +// dranzer test mode plus the CSV companion that is not a report. +var bundleFiles = []string{ + "example-app_1.0.0_b_Result.txt", + "example-app_1.0.0_p_Result.txt", + "example-app_1.0.0_s_Result.txt", + "example-app_1.0.0_t_Result.txt", + "checkResult_Dranzer.csv", +} + +func readBundleFiles(t *testing.T, names []string) map[string][]byte { + t.Helper() + out := make(map[string][]byte, len(names)) + for _, n := range names { + data, err := os.ReadFile(bundleDir + n) + require.NoError(t, err) + out["Dranzer/"+n] = data + } + return out +} + +func zipOf(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + // A real bundle carries a directory entry; include it so it is exercised. + _, err := zw.Create("Dranzer/") + require.NoError(t, err) + for name, content := range files { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write(content) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +func tarGzOf(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for name, content := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, Typeflag: tar.TypeReg, Mode: 0o600, Size: int64(len(content)), + })) + _, err := tw.Write(content) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return buf.Bytes() +} + +// assertBundleAggregate pins the aggregate of the four mode reports. +// The same 4 COM controls are tested in each mode, so object_count sums to 16 +// rather than 4: the aggregate reports totals across the bundle's reports, and +// failed_count summing to 1 is what makes the gate fire. +func assertBundleAggregate(t *testing.T, b *Bundle) { + t.Helper() + assert.Equal(t, ToolName, b.Tool.Name) + assert.Equal(t, "96", b.Tool.Version) + assert.Equal(t, 16, b.Summary.ObjectCount) + assert.Equal(t, 15, b.Summary.Passed) + assert.Equal(t, 1, b.Summary.Failed) + assert.Equal(t, 0, b.Summary.Hung) + assert.Equal(t, 0, b.Summary.KillBit) + assert.Equal(t, 16, b.Summary.Counters["com_objects_without_kill_bit"]) + // Only the -t mode emits per-object blocks and the failure finding. + assert.Len(t, b.Objects, 1) + assert.Len(t, b.Findings, 1) +} + +func TestParseBundleZip(t *testing.T) { + data := zipOf(t, readBundleFiles(t, bundleFiles)) + + b, err := ParseBundle(data) + require.NoError(t, err) + + assertBundleAggregate(t, b) + + // The CSV companion is excluded, leaving exactly the four mode reports, + // ordered deterministically by entry name. + require.Len(t, b.Reports, 4) + assert.Equal(t, []string{ + "Dranzer/example-app_1.0.0_b_Result.txt", + "Dranzer/example-app_1.0.0_p_Result.txt", + "Dranzer/example-app_1.0.0_s_Result.txt", + "Dranzer/example-app_1.0.0_t_Result.txt", + }, []string{b.Reports[0].Source, b.Reports[1].Source, b.Reports[2].Source, b.Reports[3].Source}) +} + +func TestParseBundleTarGz(t *testing.T) { + data := tarGzOf(t, readBundleFiles(t, bundleFiles)) + + b, err := ParseBundle(data) + require.NoError(t, err) + + assertBundleAggregate(t, b) + assert.Len(t, b.Reports, 4) +} + +func TestParseBundleStampsFindingSource(t *testing.T) { + data := zipOf(t, readBundleFiles(t, bundleFiles)) + + b, err := ParseBundle(data) + require.NoError(t, err) + + // The single finding must be traceable to the report it came from. + require.Len(t, b.Findings, 1) + assert.Equal(t, "Dranzer/example-app_1.0.0_t_Result.txt", b.Findings[0].Source) + assert.Equal(t, "0xe0434352", b.Findings[0].ErrorCode) +} + +func TestParseBundleRawHoldsEveryReportOnce(t *testing.T) { + data := zipOf(t, readBundleFiles(t, bundleFiles)) + + b, err := ParseBundle(data) + require.NoError(t, err) + + // The aggregate Raw keeps every report's text for string-matching policies, + // each attributed to its source. + for _, r := range b.Reports { + assert.Contains(t, b.Raw, r.Source) + } + assert.Contains(t, b.Raw, "Example.WidgetControl") + // The CSV is not a report, so its content must not leak into the aggregate. + assert.NotContains(t, b.Raw, "Detail Information") + + // The aggregate is the only place the text lives: reports[] carries counters, + // not content, so the policy input never holds a report twice. + out, err := json.Marshal(b) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(out), `"raw"`), + "report text must be serialized exactly once") +} + +func TestParseBundleBreakdownKeepsPerModeCounters(t *testing.T) { + data := zipOf(t, readBundleFiles(t, bundleFiles)) + + b, err := ParseBundle(data) + require.NoError(t, err) + + // The per-mode counters are what the breakdown exists for: three clean modes + // and the -t mode that failed one control. + require.Len(t, b.Reports, 4) + failed := make([]int, 0, len(b.Reports)) + for _, e := range b.Reports { + failed = append(failed, e.Summary.Failed) + assert.Equal(t, ToolName, e.Tool.Name) + } + assert.Equal(t, []int{0, 0, 0, 1}, failed) +} + +func TestParseBundleSingleReport(t *testing.T) { + data, err := os.ReadFile(bundleDir + "example-app_1.0.0_t_Result.txt") + require.NoError(t, err) + + b, err := ParseBundle(data) + require.NoError(t, err) + + // A non-archive value behaves as a bundle of one, so policies can iterate + // reports uniformly. The aggregate equals the single report. + assert.Equal(t, "96", b.Tool.Version) + assert.Equal(t, 4, b.Summary.ObjectCount) + assert.Equal(t, 1, b.Summary.Failed) + assert.Len(t, b.Findings, 1) + require.Len(t, b.Reports, 1) + assert.Empty(t, b.Reports[0].Source, "a single report has no archive entry name") + assert.NotEmpty(t, b.Raw) +} + +func TestParseBundleSingleNonReportIsNotRejected(t *testing.T) { + // Backward compatibility: the projection of an already-recorded single-file + // material must not start failing. Validation is the crafter's job. + b, err := ParseBundle([]byte("not a dranzer report at all")) + + require.NoError(t, err) + assert.False(t, b.LooksLikeDranzer()) + assert.Len(t, b.Reports, 1) +} + +// TestInspectAgreesWithParseBundle pins the invariant the two entry points exist +// to uphold: whatever craft time accepts, evaluation time must be able to +// aggregate. A material accepted by Inspect whose ParseBundle projection is +// unrecognizable would be attested and then silently *skip* policy evaluation, +// which on a compliance gate reads as a clean run. +// +// The prepended-stub case is the one that matters: a zip's central directory sits +// at the end, so a reader still opens it, but its leading bytes are no longer the +// zip magic. Detecting the container by filename at craft time and by content at +// evaluation time would disagree on exactly that input. +func TestInspectAgreesWithParseBundle(t *testing.T) { + reports := readBundleFiles(t, bundleFiles) + + write := func(t *testing.T, name string, data []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(p, data, 0o600)) + return p + } + + singleReport, err := os.ReadFile(bundleDir + "example-app_1.0.0_t_Result.txt") + require.NoError(t, err) + + testCases := []struct { + name string + file string + data []byte + }{ + {"bundle zip", "Dranzer.zip", zipOf(t, reports)}, + {"bundle tar.gz", "Dranzer.tar.gz", tarGzOf(t, reports)}, + {"single report", "report.txt", singleReport}, + {"zip with a prepended stub", "Dranzer.zip", append([]byte("MZ-self-extracting-stub-"), zipOf(t, reports)...)}, + {"zip of only the CSV companion", "Dranzer.zip", zipOf(t, readBundleFiles(t, []string{"checkResult_Dranzer.csv"}))}, + {"not an archive and not a report", "notes.zip", []byte("just some text")}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + inspection, inspectErr := Inspect(write(t, tc.file, tc.data)) + if inspectErr != nil { + // Rejected at craft time; nothing is recorded, so there is no + // projection to disagree with. + return + } + + bundle, err := ParseBundle(tc.data) + require.NoError(t, err, "Inspect accepted this material, so ParseBundle must project it") + assert.True(t, bundle.LooksLikeDranzer(), + "Inspect accepted this material, so its projection must be recognizable or the policy silently skips") + assert.Equal(t, inspection.Reports, len(bundle.Reports), + "craft time and evaluation time must find the same number of reports") + }) + } +} + +// TestBundleLimitsAreEnforced pins that both entry points bound expansion with +// the dranzer-specific limits rather than the far looser generic defaults. A +// dranzer bundle is a handful of small text reports, and the aggregate is held in +// memory, serialized to JSON, then parsed into policy-engine values — so the +// generic 10000-entry allowance would let a hostile archive cost orders of +// magnitude more than any real input. +func TestBundleLimitsAreEnforced(t *testing.T) { + limits := bundleLimits() + + // One entry beyond the cap. Under the generic defaults this would walk fine. + entries := make(map[string][]byte, limits.MaxEntries+1) + report, err := os.ReadFile(bundleDir + "example-app_1.0.0_b_Result.txt") + require.NoError(t, err) + for i := 0; i <= limits.MaxEntries; i++ { + entries[fmt.Sprintf("Dranzer/report%d.txt", i)] = report + } + data := zipOf(t, entries) + + t.Run("ParseBundle", func(t *testing.T) { + _, err := ParseBundle(data) + assert.ErrorIs(t, err, archiveio.ErrTooManyEntries) + }) + + t.Run("Inspect", func(t *testing.T) { + p := filepath.Join(t.TempDir(), "Dranzer.zip") + require.NoError(t, os.WriteFile(p, data, 0o600)) + + _, err := Inspect(p) + assert.ErrorIs(t, err, archiveio.ErrTooManyEntries) + }) +} + +func TestParseBundleArchiveWithNoReports(t *testing.T) { + data := zipOf(t, readBundleFiles(t, []string{"checkResult_Dranzer.csv"})) + + _, err := ParseBundle(data) + + assert.ErrorIs(t, err, ErrNoReports) +} + +func TestParseBundleJSONShapeIsBackwardCompatible(t *testing.T) { + data, err := os.ReadFile(bundleDir + "example-app_1.0.0_t_Result.txt") + require.NoError(t, err) + + b, err := ParseBundle(data) + require.NoError(t, err) + + out, err := json.Marshal(b) + require.NoError(t, err) + + // The aggregate is promoted to the top level so existing policies that read + // input.summary / input.findings / input.tool keep working unchanged, with + // reports[] added alongside. + var decoded struct { + Tool Tool `json:"tool"` + Summary Summary `json:"summary"` + Findings []Finding `json:"findings"` + Raw string `json:"raw"` + Reports []Entry `json:"reports"` + } + require.NoError(t, json.Unmarshal(out, &decoded)) + + assert.Equal(t, "dranzer", decoded.Tool.Name) + assert.Equal(t, 1, decoded.Summary.Failed) + assert.Len(t, decoded.Findings, 1) + assert.NotEmpty(t, decoded.Raw) + assert.Len(t, decoded.Reports, 1) +} diff --git a/pkg/attestation/crafter/materials/dranzer/dranzer.go b/pkg/attestation/crafter/materials/dranzer/dranzer.go index cca4dca79..cd00b9622 100644 --- a/pkg/attestation/crafter/materials/dranzer/dranzer.go +++ b/pkg/attestation/crafter/materials/dranzer/dranzer.go @@ -75,8 +75,10 @@ type Tool struct { // Finding is a single error reported against a COM object during the run. The // header failure blocks populate CLSID/ClassName/ErrorCode/ErrorMessage; the // inline access-violation and exception blocks additionally populate Method, -// Address and AccessType. +// Address and AccessType. Source names the archive entry the finding came from +// when the material is a bundle of reports, and is empty otherwise. type Finding struct { + Source string `json:"source,omitempty"` CLSID string `json:"clsid,omitempty"` ClassName string `json:"class_name,omitempty"` Method string `json:"method,omitempty"` @@ -88,8 +90,11 @@ type Finding struct { // Object is a single COM/ActiveX control described in the report, with its // version/identity metadata. Only the per-object test modes (e.g. -t) emit -// these blocks; summary-only modes (-b/-p/-s) leave Objects empty. +// these blocks; summary-only modes (-b/-p/-s) leave Objects empty. Source names +// the archive entry it came from when the material is a bundle of reports, and is +// empty otherwise. type Object struct { + Source string `json:"source,omitempty"` CLSID string `json:"clsid,omitempty"` Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` @@ -108,8 +113,14 @@ type Summary struct { Counters map[string]int `json:"counters,omitempty"` } -// Report is the structured projection of a dranzer run. +// Report is the structured projection of a dranzer run. Source names the +// archive entry it was read from when the material is a bundle of reports, and +// is empty for a single-file material. +// +// Raw is always emitted, even when empty, so a policy reading input.raw for a +// string-matching fallback finds a string rather than an undefined key. type Report struct { + Source string `json:"source,omitempty"` Tool Tool `json:"tool"` Objects []Object `json:"objects"` Findings []Finding `json:"findings"` @@ -255,21 +266,32 @@ func inlineFinding(current *Object, f Finding) Finding { // applyCounter records a summary counter both in the explicit field that maps to // its well-known label and, always, in the Counters map under a normalized key. func (r *Report) applyCounter(label string, value int) { - label = strings.TrimSpace(label) - r.Summary.Counters[normalizeKey(label)] = value - - switch strings.ToLower(label) { - case "com objects": - r.Summary.ObjectCount = value - case "com objects with kill bit": - r.Summary.KillBit = value - case "com objects passed test": - r.Summary.Passed = value - case "com objects failed test": - r.Summary.Failed = value - case "com objects hung during test": - r.Summary.Hung = value + key := normalizeKey(label) + r.Summary.Counters[key] = value + + if field := r.Summary.wellKnownCounter(key); field != nil { + *field = value + } +} + +// wellKnownCounter returns the explicit Summary field mirroring the normalized +// counter key, or nil for a counter that only lives in the Counters map. It is +// the single definition of that mapping, so recording a counter and aggregating +// counters across a bundle cannot disagree about which field a label feeds. +func (s *Summary) wellKnownCounter(key string) *int { + switch key { + case "com_objects": + return &s.ObjectCount + case "com_objects_with_kill_bit": + return &s.KillBit + case "com_objects_passed_test": + return &s.Passed + case "com_objects_failed_test": + return &s.Failed + case "com_objects_hung_during_test": + return &s.Hung } + return nil } // normalizeKey turns a human label such as "COM Object Filename" into a stable @@ -301,13 +323,27 @@ func isSeparatorLine(trimmed string) bool { } // LooksLikeDranzer reports whether the parsed report resembles genuine dranzer -// output. It is deliberately lenient: the test-engine version banner, a parsed -// object or finding, or the recognizable run-summary line is enough. +// output. It is lenient about structure — a test-engine version banner, a parsed +// object, a parsed finding, or any parsed run counter is enough, and every mode +// emits the banner — but it judges only what the parser actually extracted, never +// the presence of a phrase in the raw text. +// +// That distinction matters because dranzer bundles ship a CSV companion beside the +// reports which quotes both the per-object banner and an error line inside its +// columns. A raw substring match accepts such a file even though it yields no +// version, objects, findings or counters; it would then be recorded as dranzer +// evidence and *skip* policy evaluation, which on a compliance gate is +// indistinguishable from a clean run. Genuine reports put the banner and the +// counters on their own lines, where the line-anchored patterns match them, so +// nothing real is lost. +// +// A parsed counter counts even when its value is zero, so a legitimate run that +// found no COM objects is still recognized. func (r *Report) LooksLikeDranzer() bool { - if r.Tool.Version != "" || len(r.Objects) > 0 || len(r.Findings) > 0 { - return true - } - return strings.Contains(r.Raw, "Testing COM Object -") || strings.Contains(r.Raw, "Number of COM Objects") + return r.Tool.Version != "" || + len(r.Objects) > 0 || + len(r.Findings) > 0 || + len(r.Summary.Counters) > 0 } // JSON returns the report serialized as JSON for the policy engine. diff --git a/pkg/attestation/crafter/materials/dranzer/dranzer_test.go b/pkg/attestation/crafter/materials/dranzer/dranzer_test.go index ecb4d58bb..1f7c2db38 100644 --- a/pkg/attestation/crafter/materials/dranzer/dranzer_test.go +++ b/pkg/attestation/crafter/materials/dranzer/dranzer_test.go @@ -84,6 +84,136 @@ func TestParse(t *testing.T) { } } +// TestParseModeReports pins the parser against the output shape of a single dranzer +// run invoked in each of its four test modes, plus the CSV companion +// file that ships alongside them in the same bundle. The CSV quotes a +// "Testing COM Object - {GUID}" banner inside one of its columns, so it must be +// rejected on parsed content rather than on a raw substring match: accepting it +// would record a non-report as dranzer evidence whose policy evaluation then +// silently skips. +func TestParseModeReports(t *testing.T) { + const bundle = "../testdata/dranzer-bundle/" + + testCases := []struct { + name string + file string + wantLooksLike bool + wantVersion string + wantObjectCount int + wantPassed int + wantFailed int + wantObjects int + wantFindingCount int + }{ + { + name: "-b mode is summary only", + file: "example-app_1.0.0_b_Result.txt", + wantLooksLike: true, + wantVersion: "96", + wantObjectCount: 4, + wantPassed: 4, + }, + { + name: "-p mode is summary only", + file: "example-app_1.0.0_p_Result.txt", + wantLooksLike: true, + wantVersion: "96", + wantObjectCount: 4, + wantPassed: 4, + }, + { + name: "-s mode is summary only", + file: "example-app_1.0.0_s_Result.txt", + wantLooksLike: true, + wantVersion: "96", + wantObjectCount: 4, + wantPassed: 4, + }, + { + name: "-t mode reports a failed object", + file: "example-app_1.0.0_t_Result.txt", + wantLooksLike: true, + wantVersion: "96", + wantObjectCount: 4, + wantPassed: 3, + wantFailed: 1, + wantObjects: 1, + wantFindingCount: 1, + }, + { + name: "CSV companion is not a report", + file: "checkResult_Dranzer.csv", + wantLooksLike: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + data, err := os.ReadFile(bundle + tc.file) + require.NoError(t, err) + + report, err := Parse(data) + require.NoError(t, err) + + assert.Equal(t, tc.wantLooksLike, report.LooksLikeDranzer()) + assert.Equal(t, tc.wantVersion, report.Tool.Version) + assert.Equal(t, tc.wantObjectCount, report.Summary.ObjectCount) + assert.Equal(t, tc.wantPassed, report.Summary.Passed) + assert.Equal(t, tc.wantFailed, report.Summary.Failed) + assert.Len(t, report.Objects, tc.wantObjects) + assert.Len(t, report.Findings, tc.wantFindingCount) + + // Real reports are ANSI-encoded, so the projection must always be + // valid UTF-8 regardless of the input bytes. + assert.True(t, utf8.ValidString(report.Raw)) + }) + } +} + +// TestLooksLikeDranzerJudgesParsedContent pins that recognition rests on what the +// parser extracted, never on the raw text containing a phrase a report happens to +// use. Prose or a spreadsheet column quoting a dranzer label yields no version, +// objects, findings or counters, so accepting it would record a non-report as +// dranzer evidence whose policy evaluation then skips — indistinguishable from a +// clean run on a compliance gate. +func TestLooksLikeDranzerJudgesParsedContent(t *testing.T) { + testCases := []struct { + name string + input string + want bool + }{ + { + name: "prose quoting a counter label is not a report", + input: "The report shows Number of COM Objects and other fields.\n", + want: false, + }, + { + name: "prose quoting the per-object banner is not a report", + input: "Look for the Testing COM Object - line in the output.\n", + want: false, + }, + { + name: "a parsed counter is enough, even when every count is zero", + input: "Number of COM Objects 0\nNumber of COM Objects Failed Test 0\n", + want: true, + }, + { + name: "the version banner alone is enough", + input: "Test Engine Version: $Rev: 96 $\n", + want: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + report, err := Parse([]byte(tc.input)) + require.NoError(t, err) + + assert.Equal(t, tc.want, report.LooksLikeDranzer()) + }) + } +} + func TestParseExtractsFindingAndMetadata(t *testing.T) { data, err := os.ReadFile("testdata/dranzer-report.txt") require.NoError(t, err) diff --git a/pkg/attestation/crafter/materials/dranzer_test.go b/pkg/attestation/crafter/materials/dranzer_test.go index 20d3ebd8d..af7c71bef 100644 --- a/pkg/attestation/crafter/materials/dranzer_test.go +++ b/pkg/attestation/crafter/materials/dranzer_test.go @@ -17,9 +17,12 @@ package materials_test import ( "context" + "os" + "path/filepath" "testing" contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" "github.com/chainloop-dev/chainloop/pkg/casclient" mUploader "github.com/chainloop-dev/chainloop/pkg/casclient/mocks" @@ -29,6 +32,47 @@ import ( "github.com/stretchr/testify/require" ) +// dranzerBundleFiles are the report entries of the reference bundle: one per +// dranzer test mode. checkResult_Dranzer.csv is the companion that is not a report. +var dranzerBundleFiles = []string{ + "example-app_1.0.0_b_Result.txt", + "example-app_1.0.0_p_Result.txt", + "example-app_1.0.0_s_Result.txt", + "example-app_1.0.0_t_Result.txt", +} + +// dranzerBundleEntries reads the named files from the reference bundle fixture, +// keyed by the archive entry name they should be stored under. +func dranzerBundleEntries(t *testing.T, names []string) map[string][]byte { + t.Helper() + entries := make(map[string][]byte, len(names)) + for _, n := range names { + content, err := os.ReadFile(filepath.Join("./testdata/dranzer-bundle", n)) + require.NoError(t, err) + entries["Dranzer/"+n] = content + } + return entries +} + +// writeDranzerZip builds a zip of the named fixture files, including a directory +// entry as the reference bundle carries one. +func writeDranzerZip(t *testing.T, names []string) string { + t.Helper() + entries := dranzerBundleEntries(t, names) + entries["Dranzer/"] = nil + p := filepath.Join(t.TempDir(), "Dranzer.zip") + writeZip(t, p, entries) + return p +} + +// writeDranzerTarGz builds a tar.gz of the named fixture files. +func writeDranzerTarGz(t *testing.T, names []string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "Dranzer.tar.gz") + writeTarGz(t, p, dranzerBundleEntries(t, names)) + return p +} + func TestNewDranzerCrafter(t *testing.T) { testCases := []struct { name string @@ -118,3 +162,72 @@ func TestDranzerCrafter_Craft(t *testing.T) { }) } } + +// TestDranzerCrafter_CraftArchive covers the bundle form: one dranzer run emits a +// report per test mode (-b/-p/-s/-t), delivered as a single archive. The archive +// is recorded whole as one material — it is what the customer produced — so it +// fills the contract's declared slot, with the report count as an annotation. +func TestDranzerCrafter_CraftArchive(t *testing.T) { + schema := &contractAPI.CraftingSchema_Material{ + Name: "dranzer-report", + Type: contractAPI.CraftingSchema_Material_CERTCC_DRANZER, + } + l := zerolog.Nop() + + craft := func(t *testing.T, path string, wantUpload bool) (*api.Attestation_Material, error) { + t.Helper() + uploader := mUploader.NewUploader(t) + if wantUpload { + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{}, nil) + } + crafter, err := materials.NewDranzerCrafter(schema, &casclient.CASBackend{Uploader: uploader}, &l) + require.NoError(t, err) + return crafter.Craft(context.TODO(), path) + } + + t.Run("zip of the four mode reports is accepted", func(t *testing.T) { + got, err := craft(t, writeDranzerZip(t, dranzerBundleFiles), true) + + require.NoError(t, err) + assert.Equal(t, "dranzer", got.Annotations["chainloop.material.tool.name"]) + assert.Equal(t, "96", got.Annotations["chainloop.material.tool.version"]) + assert.Equal(t, "4", got.Annotations["chainloop.material.dranzer.reports.count"]) + }) + + t.Run("tar.gz of the four mode reports is accepted", func(t *testing.T) { + got, err := craft(t, writeDranzerTarGz(t, dranzerBundleFiles), true) + + require.NoError(t, err) + assert.Equal(t, "4", got.Annotations["chainloop.material.dranzer.reports.count"]) + }) + + t.Run("the CSV companion alongside the reports does not count as a report", func(t *testing.T) { + withCSV := append(append([]string{}, dranzerBundleFiles...), "checkResult_Dranzer.csv") + + got, err := craft(t, writeDranzerZip(t, withCSV), true) + + require.NoError(t, err) + assert.Equal(t, "4", got.Annotations["chainloop.material.dranzer.reports.count"], + "the CSV must not be counted as a dranzer report") + }) + + t.Run("archive holding only the CSV companion is rejected", func(t *testing.T) { + _, err := craft(t, writeDranzerZip(t, []string{"checkResult_Dranzer.csv"}), false) + + assert.ErrorIs(t, err, materials.ErrInvalidMaterialType) + }) + + t.Run("archive with no entries at all is rejected", func(t *testing.T) { + _, err := craft(t, writeDranzerZip(t, nil), false) + + assert.ErrorIs(t, err, materials.ErrInvalidMaterialType) + }) + + t.Run("single report records a count of one", func(t *testing.T) { + got, err := craft(t, "./testdata/dranzer-bundle/example-app_1.0.0_t_Result.txt", true) + + require.NoError(t, err) + assert.Equal(t, "1", got.Annotations["chainloop.material.dranzer.reports.count"]) + }) +} diff --git a/pkg/attestation/crafter/materials/testdata/dranzer-bundle/checkResult_Dranzer.csv b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/checkResult_Dranzer.csv new file mode 100644 index 000000000..cd3b362ad --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/checkResult_Dranzer.csv @@ -0,0 +1,2 @@ +Class Name,GUID,File Name,Test Case,Detail Information +Example.WidgetControl,11111111-2222-3333-4444-555555555555,example.ocx,Testing COM Object - {11111111-2222-3333-4444-555555555555} Example.WidgetControl,ERROR - [Unknown Error] (0xe0434352) diff --git a/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_b_Result.txt b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_b_Result.txt new file mode 100644 index 000000000..9b815be5f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_b_Result.txt @@ -0,0 +1,11 @@ +******************************************************************************* +Test Engine Version: $Rev: 96 $ +******************************************************************************* +Number of COM Objects 4 +Number of COM Objects With Kill Bit 0 +Number of COM Objects Without Kill Bit 4 +Number of COM Objects Passed Test 4 +Number of COM Objects Failed Test 0 +Number of COM Objects Hung During Test 0 +Number of COM Objects Misc Errors 0 +******************************************************************************* diff --git a/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_p_Result.txt b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_p_Result.txt new file mode 100644 index 000000000..f4181692b --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_p_Result.txt @@ -0,0 +1,12 @@ +******************************************************************************* +Test Engine Version: $Rev: 96 $ +******************************************************************************* +Number of COM Objects 4 +Number of COM Objects With Kill Bit 0 +Number of COM Objects Without Kill Bit 4 +Number of COM Objects Not Safe for Init 0 +Number of COM Objects Passed Test 4 +Number of COM Objects Failed Test 0 +Number of COM Objects Hung During Test 0 +Number of COM Objects Misc Errors 0 +******************************************************************************* diff --git a/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_s_Result.txt b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_s_Result.txt new file mode 100644 index 000000000..f4181692b --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_s_Result.txt @@ -0,0 +1,12 @@ +******************************************************************************* +Test Engine Version: $Rev: 96 $ +******************************************************************************* +Number of COM Objects 4 +Number of COM Objects With Kill Bit 0 +Number of COM Objects Without Kill Bit 4 +Number of COM Objects Not Safe for Init 0 +Number of COM Objects Passed Test 4 +Number of COM Objects Failed Test 0 +Number of COM Objects Hung During Test 0 +Number of COM Objects Misc Errors 0 +******************************************************************************* diff --git a/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_t_Result.txt b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_t_Result.txt new file mode 100644 index 000000000..2638701e9 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_t_Result.txt @@ -0,0 +1,44 @@ +******************************************************************************* +{11111111-2222-3333-4444-555555555555}-Example.WidgetControl +ERROR - [Unknown Error] (0xe0434352) +******************************************************************************* +******************************************************************************* +Testing COM Object - {11111111-2222-3333-4444-555555555555} Example.WidgetControl +******************************************************************************* +COM Object Filename : example.ocx +Major Version : 1 +Minor Version : 0 +Build Number : 100 +Revision Number : 1 +Product Version : 1.0.100.1 +Product Name : Example® Suite +Company Name : Example Corp +Legal Copyright : © Example Corp. All rights reserved. +Comments : not found +File Description : Example Widget Control +File Version : 1.0.100.1 (Build.100101.0800) +Internal Name : example.ocx +Legal Trademarks : not found +Private Build : not found +Special Build : not found +Language : not found +******************************************************************************* +******************************************************************************* +Invoking Method - IExampleWidget::void DoThing([in] BSTR path) +Invoking Method - IExampleWidget::BSTR GetName() +Invoking Method - IExampleWidget::void SetMode([in] VARIANT_BOOL enabled<10000>) + + +******************************************************************************* +Test Engine Version: $Rev: 96 $ +******************************************************************************* +Number of COM Objects 4 +Number of COM Objects With Kill Bit 0 +Number of COM Objects Without Kill Bit 4 +Number of COM Objects Not Script Safe 0 +Number of COM Objects Passed Test 3 +Number of COM Objects Failed Test 1 +Number of COM Objects Hung During Test 0 +Number of COM Objects with No Type Info 0 +Number of COM Objects Misc Errors 0 +*******************************************************************************