Skip to content

Commit 1ffc856

Browse files
authored
feat(cli): accept an archive of dranzer reports as one material (#3310)
1 parent 6002b9e commit 1ffc856

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)