From b8a3ffacce6cecbe9ca11115b02aff08edee9132 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 28 Jul 2026 10:15:13 +0200 Subject: [PATCH] feat(cli): accept an archive of dranzer reports as one material A single dranzer run emits one report per test mode (-b, -p, -s, -t), so the evidence arrives as a bundle rather than a single file. CERTCC_DRANZER now accepts either a single report or a zip/tar.gz of them, following the RADAMSA_CRASHES precedent: the archive is recorded whole under the contract's declared material name, with the report count as an annotation. Recording the archive alone is not enough, because the policy engine reads dranzer content: parsing archive bytes as text yields an empty report, which makes the ActiveX policy skip rather than evaluate and reads as a clean run. The projection therefore aggregates the archive's entries, summing every counter and unioning objects and findings, and keeps a per-report breakdown alongside. The aggregate is promoted to the top level so existing policies reading tool, summary and findings are unaffected. Craft time and evaluation time share both predicates -- what counts as an archive and what counts as a report -- because a disagreement would accept a material whose projection then silently skips. Container detection is by content on both sides for the same reason: a zip carrying a prepended stub still opens, since its central directory is at the end, but no longer starts with the zip magic, so detecting by filename at craft time would accept what the projection cannot read. Archive container detection and walking move to a new archiveio leaf package so the projection can share the entry-count, size and traversal guards with the crafters without an import cycle. Those guards are also corrected: the size cap reported its error alongside a full buffer, which io.ReadFull discards, so a stream read in exact block sizes -- every tar header -- passed the limit unreported; and entries skipped as directories or symlinks counted against neither limit, letting a directory-only tar.gz decompress unmeasured. LooksLikeDranzer now judges only what the parser extracted rather than the presence of a phrase in the raw text. The CSV companion that ships beside real reports quotes both the per-object banner and an error line inside its columns, so a substring match accepted a file that yields no version, objects, findings or counters -- which would then skip policy evaluation. Genuine reports put those lines on their own line where the anchored patterns match them, and a parsed counter counts even when zero, so a run that found no COM objects is still recognized. Container detection fills its peek buffer instead of trusting a single read, since the tar marker sits at offset 257 and a short read would misdetect a valid tar as a plain file, and it now surfaces read failures rather than reporting them as "not an archive". Refs PFM-6467 Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 2b0a76d9-abe5-44af-919a-419aca149653 --- .../workflowcontract/v1/crafting_schema.ts | 6 +- .../workflowcontract/v1/crafting_schema.pb.go | 4 +- .../workflowcontract/v1/crafting_schema.proto | 4 +- .../api/attestation/v1/crafting_state.go | 9 +- .../api/attestation/v1/crafting_state_test.go | 36 ++ .../v1/testdata/dranzer-bundle.zip | Bin 0 -> 2118 bytes pkg/attestation/crafter/materials/archive.go | 256 ++---------- .../crafter/materials/archive_test.go | 25 -- .../crafter/materials/archiveio/archiveio.go | 391 ++++++++++++++++++ .../materials/archiveio/archiveio_test.go | 309 ++++++++++++++ pkg/attestation/crafter/materials/dranzer.go | 57 ++- .../crafter/materials/dranzer/bundle.go | 283 +++++++++++++ .../crafter/materials/dranzer/bundle_test.go | 342 +++++++++++++++ .../crafter/materials/dranzer/dranzer.go | 82 ++-- .../crafter/materials/dranzer/dranzer_test.go | 130 ++++++ .../crafter/materials/dranzer_test.go | 113 +++++ .../dranzer-bundle/checkResult_Dranzer.csv | 2 + .../example-app_1.0.0_b_Result.txt | 11 + .../example-app_1.0.0_p_Result.txt | 12 + .../example-app_1.0.0_s_Result.txt | 12 + .../example-app_1.0.0_t_Result.txt | 44 ++ 21 files changed, 1836 insertions(+), 292 deletions(-) create mode 100644 pkg/attestation/crafter/api/attestation/v1/testdata/dranzer-bundle.zip create mode 100644 pkg/attestation/crafter/materials/archiveio/archiveio.go create mode 100644 pkg/attestation/crafter/materials/archiveio/archiveio_test.go create mode 100644 pkg/attestation/crafter/materials/dranzer/bundle.go create mode 100644 pkg/attestation/crafter/materials/dranzer/bundle_test.go create mode 100644 pkg/attestation/crafter/materials/testdata/dranzer-bundle/checkResult_Dranzer.csv create mode 100644 pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_b_Result.txt create mode 100644 pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_p_Result.txt create mode 100644 pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_s_Result.txt create mode 100644 pkg/attestation/crafter/materials/testdata/dranzer-bundle/example-app_1.0.0_t_Result.txt 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 0000000000000000000000000000000000000000..efbc86a3942a90ca199014900deb602c118b319c GIT binary patch literal 2118 zcmWIWW@Zs#U|`^2m=pLX2FPGw0ueyK0mLpviFsA2Mf%JP0VwMFkDqB@43z&4#PUe$ zk~311vx8ELOLI!%p?dX_i_3cZHgX*@5OMwP^#5TmSLYR#I>)G`&0<|g9c7Ygr)A9Y z5Za*dZ}$RO85x_~vhQ|=ONlb&g!HFeonjf8)jWU4Q31zQ4JyZ1Y-zkSZ|?ihE9aJZ z8m{=4Rx7YYFdnrNj#lXtkoh=@{#r6##gr<+Ahjv-xTFsx+g)p@z7)W zv%+=p8n1S(YCN=WWBBWLVqEX%+8gpA--Au3kw+$=VBs+=mPVSRd@Y@c%=b-&`4&4U3}$mxQ^L zk&tlqnn)7vP8~0Vmo8` z$z%7UHW{R!dCgT?z4RRCK4ZR%%DcI*-T6Laqx6dx;->fZzD#(%O>m)bTm2u&4LLjh zFFDAly?U$mGpI(*v`Rz+N-5WOFe$TJ6qGN6M`#&GkW^_Jw6lP-=>JHhuhQrUG6;^WKXt zD|{ca!D_DG@uJx4TW!vNUb9$AZcUxrbwl^xlUfB|%@c|8%#-@7FoPxG;8p*PLZAE` z;x$*UTj*+}GVk?H_qooOZ>)d*jh*A1!9^dJ3$uF?ii*37dBy(3n_Lh$d(ZaTp1Fp7 zP3r_}4rG;Ep7%+1$zR&U9l|-8$?M018OJ(0!^Ju>OM;I%ODuma9rED|tKu@j2W_HX zZe=N!txK74?rYy1{yyKFc7fe)63@ltU02Wfcfcf7%6GB)bwknH*CQ`X)-vl=berPr ze=PdwqNVFB_LoPP%_-nJTE)aA93sv0)~UYNNj;#M-zh8NL`ciBiOV^b&p0F6T^f2~ za^t4CA8wp)+B-X8Z&rqA;4^)`!kkYtCpcf0;OO-Vh|~WRdH$48dRN>Q5$%=2Qt!MHuvhIB-A!pqpFsb^?{Jrk8&)dD|?&v)J`R!rH25I?yHBHU@ zH#SOX6a~)SezJQ9!ydI)s+YZg9JFZH({5*~?A8j7O*UWEx3n$OkSD=Q{KvFcmcBwf z3p>`e+;*{D-19zYuG*{bAua{8R{i0b`p1pAypk!Ib8~KwfdKbSAK8s5araLDH&TGw$jISP%h$0K;EL5Dlwr&~>3#HXyYiu%%H0NWygCsCv*1LoeCECV>s( zU_=;(znn)m6TP%SnE4Q|naD*f@n)U?W_XyVG2Mw=o}rtGUR)sDxt= 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 +*******************************************************************************