Skip to content

Commit b8a3ffa

Browse files
committed
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 <javier@chainloop.dev> Chainloop-Trace-Sessions: 2b0a76d9-abe5-44af-919a-419aca149653
1 parent c3e36e4 commit b8a3ffa

21 files changed

Lines changed: 1836 additions & 292 deletions

app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/workflowcontract/v1/crafting_schema.proto

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,9 @@ message CraftingSchema {
178178
SYSINTERNALS_SIGCHECK = 34;
179179
// Sysinternals AccessChk text output https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk
180180
SYSINTERNALS_ACCESSCHK = 35;
181-
// CERT/CC dranzer ActiveX/COM control test report (plain text) https://github.com/CERTCC/dranzer
181+
// CERT/CC dranzer ActiveX/COM control test report (plain text): a single
182+
// report or an archive (zip or tar.gz) holding the per-mode reports of one
183+
// run (-b, -p, -s, -t) https://github.com/CERTCC/dranzer
182184
CERTCC_DRANZER = 36;
183185
// OpenSSF Scorecard result in JSON format
184186
// https://github.com/ossf/scorecard

pkg/attestation/crafter/api/attestation/v1/crafting_state.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,16 @@ func (m *Attestation_Material) ingestMaterialToJSON(rawMaterial []byte, value st
240240
// dranzer emits plain text; project it to JSON so the policy engine,
241241
// which only consumes JSON, can evaluate it. The raw text is preserved
242242
// in the projection's "raw" field for string-matching fallbacks.
243-
report, err := dranzer.Parse(rawMaterial)
243+
//
244+
// The material may also be an archive of the per-mode reports of one run
245+
// (-b/-p/-s/-t), so the projection aggregates its entries. Parsing the
246+
// archive bytes as text instead would yield an empty report, and the
247+
// policy would then skip rather than evaluate — a false pass.
248+
bundle, err := dranzer.ParseBundle(rawMaterial)
244249
if err != nil {
245250
return nil, fmt.Errorf("invalid dranzer material: %w", err)
246251
}
247-
return json.Marshal(report)
252+
return json.Marshal(bundle)
248253
}
249254

250255
return rawMaterial, nil

pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,42 @@ func TestGetEvaluableContentWithMetadata(t *testing.T) {
331331
}
332332
}
333333

334+
// TestDranzerBundleIsEvaluable guards that a CERTCC_DRANZER material holding an
335+
// archive of per-mode reports projects to an aggregate the existing
336+
// activex-controls-fuzzed policy can evaluate. Recording the archive whole
337+
// without aggregating would hand the policy engine zip bytes, which parse to an
338+
// empty report and make the policy *skip* — a clean-looking false pass.
339+
func TestDranzerBundleIsEvaluable(t *testing.T) {
340+
m := &Attestation_Material{
341+
MaterialType: schemaapi.CraftingSchema_Material_CERTCC_DRANZER,
342+
M: &Attestation_Material_Artifact_{
343+
Artifact: &Attestation_Material_Artifact{Name: "dranzer-report", Digest: "sha256:deadbeef"},
344+
},
345+
}
346+
347+
content, err := m.GetEvaluableContent("testdata/dranzer-bundle.zip")
348+
require.NoError(t, err)
349+
350+
var decoded map[string]any
351+
require.NoError(t, json.NewDecoder(bytes.NewReader(content)).Decode(&decoded))
352+
353+
// The policy reads tool.name and needs run evidence to avoid skipping.
354+
assert.Equal(t, "dranzer", decoded["tool"].(map[string]any)["name"])
355+
assert.Equal(t, "96", decoded["tool"].(map[string]any)["version"])
356+
357+
// failed_count sums across the bundle, so the -t mode's single failure is
358+
// what makes the gate fire.
359+
summary := decoded["summary"].(map[string]any)
360+
assert.EqualValues(t, 1, summary["failed_count"])
361+
assert.EqualValues(t, 0, summary["hung_count"])
362+
assert.EqualValues(t, 16, summary["object_count"])
363+
364+
assert.Len(t, decoded["findings"], 1, "the -t report's crash finding must survive aggregation")
365+
366+
// The CSV companion is not a report, so only the four modes are listed.
367+
assert.Len(t, decoded["reports"], 4)
368+
}
369+
334370
// TestCoberturaEmptyReportIsEvaluable guards the requirement that a legitimate
335371
// empty coverage report (line-rate="NaN", no packages) projects to valid JSON
336372
// the policy engine can evaluate — instead of failing with a NaN marshal error,
Binary file not shown.

pkg/attestation/crafter/materials/archive.go

Lines changed: 38 additions & 218 deletions
Original file line numberDiff line numberDiff line change
@@ -16,262 +16,82 @@
1616
package materials
1717

1818
import (
19-
"archive/tar"
20-
"archive/zip"
21-
"bytes"
22-
"compress/gzip"
23-
"errors"
2419
"fmt"
2520
"io"
26-
"io/fs"
27-
"os"
2821
"path"
2922
"strings"
30-
"syscall"
3123

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

28+
// The archive container primitives live in the archiveio leaf package so the
29+
// policy-input projections can share them without importing this package (which
30+
// would be an import cycle). The names below are kept as aliases so existing
31+
// callers and their tests are unaffected.
32+
3533
// ArchiveFormat identifies a supported archive container.
36-
type ArchiveFormat int
34+
type ArchiveFormat = archiveio.Format
3735

3836
const (
39-
ArchiveNone ArchiveFormat = iota
40-
ArchiveZip
41-
ArchiveTar
42-
ArchiveTarGz
37+
ArchiveNone = archiveio.None
38+
ArchiveZip = archiveio.Zip
39+
ArchiveTar = archiveio.Tar
40+
ArchiveTarGz = archiveio.TarGz
4341
)
4442

45-
// DetectArchive reports whether path is a supported archive and, if so, its
46-
// format. Detection is by extension first; for files whose extension does not
47-
// match, magic bytes are used as a backstop so renamed archives are still
48-
// caught. A non-archive returns (ArchiveNone, nil).
49-
func DetectArchive(path string) (ArchiveFormat, error) {
50-
lower := strings.ToLower(path)
51-
switch {
52-
case strings.HasSuffix(lower, ".zip"):
53-
return ArchiveZip, nil
54-
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
55-
return ArchiveTarGz, nil
56-
case strings.HasSuffix(lower, ".tar"):
57-
return ArchiveTar, nil
58-
}
59-
60-
return detectByMagic(path)
61-
}
62-
63-
func detectByMagic(path string) (ArchiveFormat, error) {
64-
f, err := os.Open(path)
65-
if err != nil {
66-
// These errors mean the value is not a file path at all (e.g. "hello
67-
// world" for STRING, or "registry/app:v1" for CONTAINER_IMAGE where
68-
// "registry" happens to be a regular file in the working directory, which
69-
// yields ENOTDIR); treat them as a non-archive so callers passing non-file
70-
// values are not surprised. Any other error (permissions, I/O) is real and
71-
// must surface.
72-
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
73-
return ArchiveNone, nil
74-
}
75-
return ArchiveNone, fmt.Errorf("opening %q: %w", path, err)
76-
}
77-
defer f.Close()
78-
79-
// 512 bytes is enough for the gzip/zip magic and the tar "ustar" marker at
80-
// offset 257.
81-
header := make([]byte, 512)
82-
n, _ := f.Read(header)
83-
header = header[:n]
84-
85-
switch {
86-
case bytes.HasPrefix(header, []byte("PK\x03\x04")), bytes.HasPrefix(header, []byte("PK\x05\x06")):
87-
return ArchiveZip, nil
88-
case bytes.HasPrefix(header, []byte{0x1f, 0x8b}):
89-
return ArchiveTarGz, nil
90-
case len(header) >= 262 && bytes.Equal(header[257:262], []byte("ustar")):
91-
return ArchiveTar, nil
92-
}
93-
94-
return ArchiveNone, nil
95-
}
43+
// ArchiveLimits bounds archive expansion to guard against zip bombs.
44+
type ArchiveLimits = archiveio.Limits
9645

9746
var (
9847
// ErrTooManyEntries is returned when an archive has more qualifying entries
9948
// than the configured maximum.
100-
ErrTooManyEntries = errors.New("archive exceeds the maximum number of entries")
49+
ErrTooManyEntries = archiveio.ErrTooManyEntries
10150
// ErrArchiveTooLarge is returned when the running uncompressed size of an
10251
// archive exceeds the configured maximum.
103-
ErrArchiveTooLarge = errors.New("archive exceeds the maximum uncompressed size")
52+
ErrArchiveTooLarge = archiveio.ErrArchiveTooLarge
10453
// ErrUnsafeEntry is returned when an archive entry's path is absolute or escapes the extraction root.
105-
ErrUnsafeEntry = errors.New("unsafe entry path in archive")
54+
ErrUnsafeEntry = archiveio.ErrUnsafeEntry
10655
)
10756

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

11463
// DefaultArchiveLimits returns the safe defaults: 10000 entries and 1 GiB
11564
// total uncompressed size.
11665
func DefaultArchiveLimits() ArchiveLimits {
117-
return ArchiveLimits{MaxEntries: 10000, MaxTotalSize: 1 << 30}
118-
}
119-
120-
// capReader wraps a reader and fails once the shared running total exceeds max,
121-
// so we never trust an archive's declared sizes.
122-
type capReader struct {
123-
r io.Reader
124-
total *int64
125-
max int64
126-
}
127-
128-
func (c *capReader) Read(p []byte) (int, error) {
129-
n, err := c.r.Read(p)
130-
*c.total += int64(n)
131-
if *c.total > c.max {
132-
return n, ErrArchiveTooLarge
133-
}
134-
return n, err
66+
return archiveio.DefaultLimits()
13567
}
13668

13769
// WalkArchiveEntries calls yield for every regular file in the archive,
13870
// enforcing the limits and skipping directories, symlinks, hardlinks, empty
13971
// entries, and path-traversal entries.
14072
func WalkArchiveEntries(path string, format ArchiveFormat, limits ArchiveLimits, yield func(name string, r io.Reader) error) error {
141-
var total int64
142-
count := 0
143-
visit := func(name string, r io.Reader) error {
144-
if !safeArchivePath(name) {
145-
return fmt.Errorf("%w: %q", ErrUnsafeEntry, name)
146-
}
147-
count++
148-
if count > limits.MaxEntries {
149-
return ErrTooManyEntries
150-
}
151-
if err := yield(name, &capReader{r: r, total: &total, max: limits.MaxTotalSize}); err != nil {
152-
return fmt.Errorf("processing entry %q: %w", name, err)
153-
}
154-
return nil
155-
}
156-
157-
switch format {
158-
case ArchiveZip:
159-
return walkZip(path, visit)
160-
case ArchiveTar:
161-
return walkTar(path, false, visit)
162-
case ArchiveTarGz:
163-
return walkTar(path, true, visit)
164-
default:
165-
return fmt.Errorf("unsupported archive format")
166-
}
167-
}
168-
169-
// safeArchivePath rejects absolute paths and any path that escapes the
170-
// extraction root via ".." path components. A filename that merely contains
171-
// ".." as a substring (e.g. "foo..bar.json") is accepted; only actual path
172-
// components equal to ".." are rejected.
173-
func safeArchivePath(name string) bool {
174-
normalized := strings.ReplaceAll(name, "\\", "/")
175-
// Reject absolute paths, including Windows drive-letter (e.g. "C:/x") and
176-
// UNC paths (which normalize to a leading "/").
177-
if strings.HasPrefix(normalized, "/") || hasWindowsDriveLetter(normalized) {
178-
return false
179-
}
180-
// Canonicalise against a virtual root and check that the result stays
181-
// within it. path.Clean will resolve ".." components so a path like
182-
// "a/../../etc/passwd" becomes "/etc/passwd" which does not start with
183-
// the virtual prefix "/root/"; a safe path like "a/b.txt" becomes
184-
// "/root/a/b.txt" which does.
185-
const root = "/root"
186-
clean := path.Clean(root + "/" + normalized)
187-
return strings.HasPrefix(clean, root+"/") || clean == root
188-
}
189-
190-
// hasWindowsDriveLetter reports whether name begins with a Windows drive-letter
191-
// prefix such as "C:" or "c:/", which denotes an absolute path on Windows.
192-
func hasWindowsDriveLetter(name string) bool {
193-
if len(name) < 2 || name[1] != ':' {
194-
return false
195-
}
196-
c := name[0]
197-
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
198-
}
199-
200-
func walkZip(p string, visit func(name string, r io.Reader) error) error {
201-
zr, err := zip.OpenReader(p)
202-
if err != nil {
203-
return fmt.Errorf("opening zip: %w", err)
204-
}
205-
defer zr.Close()
206-
207-
for _, f := range zr.File {
208-
// Skip directories, symlinks, and empty entries: they carry no file
209-
// content worth recording as a material. Empty-entry skipping is
210-
// intentional per the explode design (an empty evidence file produces
211-
// no material). Note: symlink detection relies on Unix mode bits stored
212-
// in the zip; archives written without Unix metadata won't carry the
213-
// symlink bit, so such a symlink would be treated as a regular file
214-
// (its content being the stored target path). Tar symlinks are detected
215-
// reliably via the typeflag below.
216-
if f.FileInfo().IsDir() || f.Mode()&os.ModeSymlink != 0 || f.UncompressedSize64 == 0 {
217-
continue
218-
}
219-
rc, err := f.Open()
220-
if err != nil {
221-
return fmt.Errorf("opening entry %q: %w", f.Name, err)
222-
}
223-
err = visit(f.Name, rc)
224-
rc.Close()
225-
if err != nil {
226-
return err
227-
}
228-
}
229-
return nil
230-
}
231-
232-
func walkTar(p string, gzipped bool, visit func(name string, r io.Reader) error) error {
233-
f, err := os.Open(p)
234-
if err != nil {
235-
return fmt.Errorf("opening tar: %w", err)
236-
}
237-
defer f.Close()
238-
239-
var src io.Reader = f
240-
if gzipped {
241-
gz, err := gzip.NewReader(f)
242-
if err != nil {
243-
return fmt.Errorf("opening gzip: %w", err)
244-
}
245-
defer gz.Close()
246-
src = gz
247-
}
248-
249-
tr := tar.NewReader(src)
250-
for {
251-
hdr, err := tr.Next()
252-
if errors.Is(err, io.EOF) {
253-
return nil
254-
}
255-
if err != nil {
256-
return fmt.Errorf("reading tar: %w", err)
257-
}
258-
// Only regular files become materials; directories, symlinks, hardlinks
259-
// and other special entries are skipped via the typeflag. Empty entries
260-
// are skipped intentionally (an empty evidence file produces no material).
261-
if hdr.Typeflag != tar.TypeReg || hdr.Size == 0 {
262-
continue
263-
}
264-
if err := visit(hdr.Name, tr); err != nil {
265-
return err
266-
}
267-
}
73+
return archiveio.WalkPath(path, format, limits, yield)
26874
}
26975

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

0 commit comments

Comments
 (0)