Skip to content

Commit ca10eea

Browse files
authored
feat(cli): explode archive materials in chainloop att add (#3254)
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent ff93ea4 commit ca10eea

9 files changed

Lines changed: 1263 additions & 30 deletions

File tree

app/cli/cmd/attestation_add.go

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ package cmd
1818
import (
1919
"errors"
2020
"fmt"
21+
"math"
2122
"os"
2223

24+
"code.cloudfoundry.org/bytefmt"
2325
"github.com/jedib0t/go-pretty/v6/table"
2426
"github.com/muesli/reflow/wrap"
2527
"github.com/spf13/cobra"
@@ -40,6 +42,8 @@ func newAttestationAddCmd() *cobra.Command {
4042
var annotationsFlag []string
4143
var noStrictValidation bool
4244
var policyInputFromFileFlag []string
45+
var maxExtractEntries int
46+
var maxExtractSize string
4347

4448
// OCI registry credentials can be passed as flags or environment variables
4549
var registryServer, registryUsername, registryPassword string
@@ -79,6 +83,16 @@ func newAttestationAddCmd() *cobra.Command {
7983
--policy-input-from-file trusted-binaries-signed:ignored_paths=exception.csv:Path \
8084
--policy-input-from-file trusted-binaries-vendor-keys:third_party_paths=exception.csv:Path`,
8185
RunE: func(cmd *cobra.Command, _ []string) error {
86+
maxExtractSizeBytes, err := bytefmt.ToBytes(maxExtractSize)
87+
if err != nil {
88+
return fmt.Errorf("invalid --max-extract-size %q: %w", maxExtractSize, err)
89+
}
90+
// Guard against the uint64->int64 cast wrapping negative, which would
91+
// later surface as a misleading "archive too large" error.
92+
if maxExtractSizeBytes > math.MaxInt64 {
93+
return fmt.Errorf("--max-extract-size %q is too large", maxExtractSize)
94+
}
95+
8296
a, err := action.NewAttestationAdd(
8397
&action.AttestationAddOpts{
8498
ActionsOpts: ActionOpts,
@@ -90,6 +104,8 @@ func newAttestationAddCmd() *cobra.Command {
90104
RegistryPassword: registryPassword,
91105
LocalStatePath: attestationLocalStatePath,
92106
NoStrictValidation: noStrictValidation,
107+
MaxExtractEntries: maxExtractEntries,
108+
MaxExtractSize: int64(maxExtractSizeBytes),
93109
},
94110
)
95111
if err != nil {
@@ -127,21 +143,28 @@ func newAttestationAddCmd() *cobra.Command {
127143
return fmt.Errorf("loading resource: %w", err)
128144
}
129145
}
130-
// TODO: take the material output and show render it
131146
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations, policyInputFiles)
132147
if err != nil {
133148
return err
134149
}
135150

136-
logger.Info().Msg("material added to attestation")
151+
logger.Info().Int("materials", len(resp)).Msg("material(s) added to attestation")
137152

138153
policies, err := a.GetPolicyEvaluations(cmd.Context(), attestationID)
139154
if err != nil {
140155
return err
141156
}
142157

143-
return output.EncodeOutput(flagOutputFormat, resp, func(s *action.AttestationStatusMaterial) error {
144-
return displayMaterialInfo(s, policies[resp.Name])
158+
// The explode path can return several materials. EncodeOutput
159+
// renders the whole slice as a single JSON array (a parseable
160+
// document) and the table renderer per material.
161+
return output.EncodeOutput(flagOutputFormat, resp, func(mats []*action.AttestationStatusMaterial) error {
162+
for _, m := range mats {
163+
if err := displayMaterialInfo(m, policies[m.Name]); err != nil {
164+
return err
165+
}
166+
}
167+
return nil
145168
})
146169
},
147170
)
@@ -171,6 +194,10 @@ func newAttestationAddCmd() *cobra.Command {
171194
cmd.Flags().StringVar(&registryUsername, "registry-username", "", fmt.Sprintf("registry username, ($%s)", registryUsernameEnvVarName))
172195
cmd.Flags().StringVar(&registryPassword, "registry-password", "", fmt.Sprintf("registry password, ($%s)", registryPasswordEnvVarName))
173196

197+
// Archive extraction guards
198+
cmd.Flags().IntVar(&maxExtractEntries, "max-extract-entries", 10000, "max number of files to extract when --value is an archive")
199+
cmd.Flags().StringVar(&maxExtractSize, "max-extract-size", "1GiB", "max total uncompressed size to extract when --value is an archive")
200+
174201
if registryServer == "" {
175202
registryServer = os.Getenv(registryServerEnvVarName)
176203
}

app/cli/cmd/output/output.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type tabulatedData interface {
5656
*action.APITokenItem |
5757
[]*action.APITokenItem |
5858
*action.AttestationStatusMaterial |
59+
[]*action.AttestationStatusMaterial |
5960
*action.ListMembershipResult |
6061
*action.PolicyLintResult
6162
}

app/cli/documentation/cli-reference.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ Options
263263
--attestation-id string Unique identifier of the in-progress attestation
264264
-h, --help help for add
265265
--kind string kind of the material to be recorded: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "OSSF_SCORECARD_JSON" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"]
266+
--max-extract-entries int max number of files to extract when --value is an archive (default 10000)
267+
--max-extract-size string max total uncompressed size to extract when --value is an archive (default "1GiB")
266268
--name string name of the material as shown in the contract
267269
--no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)
268270
--policy-input-from-file stringArray feed a policy input from a column of a CSV or JSON file, in the format [<policy>:]<input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); an optional <policy>: prefix scopes the input to a single policy (matched by name or ref), otherwise it applies to every declaring policy; <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.

app/cli/pkg/action/attestation_add.go

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ type AttestationAddOpts struct {
4242
LocalStatePath string
4343
// NoStrictValidation skips strict schema validation
4444
NoStrictValidation bool
45+
// MaxExtractEntries limits the number of entries extracted from an archive.
46+
// Zero defaults to materials.DefaultArchiveLimits().MaxEntries.
47+
MaxExtractEntries int
48+
// MaxExtractSize limits the total uncompressed bytes extracted from an archive.
49+
// Zero defaults to materials.DefaultArchiveLimits().MaxTotalSize.
50+
MaxExtractSize int64
4551
}
4652

4753
type newCrafterOpts struct {
@@ -56,6 +62,8 @@ type AttestationAdd struct {
5662
casCAPath string
5763
connectionInsecure bool
5864
localStatePath string
65+
maxExtractEntries int
66+
maxExtractSize int64
5967
*newCrafterOpts
6068
}
6169

@@ -69,19 +77,31 @@ func NewAttestationAdd(cfg *AttestationAddOpts) (*AttestationAdd, error) {
6977
opts = append(opts, crafter.WithNoStrictValidation(cfg.NoStrictValidation))
7078
}
7179

80+
defaults := materials.DefaultArchiveLimits()
81+
maxEntries := cfg.MaxExtractEntries
82+
if maxEntries <= 0 {
83+
maxEntries = defaults.MaxEntries
84+
}
85+
maxSize := cfg.MaxExtractSize
86+
if maxSize <= 0 {
87+
maxSize = defaults.MaxTotalSize
88+
}
89+
7290
return &AttestationAdd{
7391
ActionsOpts: cfg.ActionsOpts,
7492
newCrafterOpts: &newCrafterOpts{cpConnection: cfg.CPConnection, opts: opts},
7593
casURI: cfg.CASURI,
7694
casCAPath: cfg.CASCAPath,
7795
connectionInsecure: cfg.ConnectionInsecure,
7896
localStatePath: cfg.LocalStatePath,
97+
maxExtractEntries: maxEntries,
98+
maxExtractSize: maxSize,
7999
}, nil
80100
}
81101

82102
var ErrAttestationNotInitialized = errors.New("attestation not yet initialized")
83103

84-
func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string, policyInputFiles []*PolicyInputFromFile) (*AttestationStatusMaterial, error) {
104+
func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string, policyInputFiles []*PolicyInputFromFile) ([]*AttestationStatusMaterial, error) {
85105
// initialize the crafter. If attestation-id is provided we assume the attestation is performed using remote state
86106
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
87107
if err != nil {
@@ -133,6 +153,31 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
133153
// 3. If materialType is not empty, add material contract free with materialType and materialName
134154
addOpts := runtimeInputAddOpts(runtimeInputs)
135155

156+
// Explode path: --kind set, value is a (non-archive-native) archive.
157+
format, err := shouldExplode(materialType, materialValue)
158+
if err != nil {
159+
return nil, fmt.Errorf("detecting archive: %w", err)
160+
}
161+
if format != materials.ArchiveNone {
162+
if len(policyInputFiles) > 0 {
163+
action.Logger.Warn().Msg("--policy-input-from-file is ignored when expanding an archive; evidence cross-links are not recorded for exploded materials")
164+
}
165+
limits := materials.ArchiveLimits{MaxEntries: action.maxExtractEntries, MaxTotalSize: action.maxExtractSize}
166+
mts, err := crafter.AddMaterialsFromArchive(ctx, attestationID, materialType, materialName, materialValue, format, casBackend, annotations, limits, addOpts...)
167+
if err != nil {
168+
return nil, fmt.Errorf("adding materials from archive: %w", err)
169+
}
170+
results := make([]*AttestationStatusMaterial, 0, len(mts))
171+
for _, mt := range mts {
172+
r, err := attMaterialToAction(mt)
173+
if err != nil {
174+
return nil, fmt.Errorf("converting material to action: %w", err)
175+
}
176+
results = append(results, r)
177+
}
178+
return results, nil
179+
}
180+
136181
var mt *api.Attestation_Material
137182
switch {
138183
case materialName == "" && materialType == "":
@@ -176,7 +221,21 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
176221
return nil, fmt.Errorf("converting material to action: %w", err)
177222
}
178223

179-
return materialResult, nil
224+
return []*AttestationStatusMaterial{materialResult}, nil
225+
}
226+
227+
// shouldExplode decides whether an att-add should explode the value into many
228+
// materials: only when the kind is explodable (SBOM/SARIF) and the value is a
229+
// supported archive. It returns ArchiveNone for every other kind so a regular
230+
// zip provided as e.g. ARTIFACT or EVIDENCE is recorded whole.
231+
func shouldExplode(materialType, value string) (materials.ArchiveFormat, error) {
232+
// Only explode kinds that have a meaningful "bundle of the same kind"
233+
// archive form (SBOM, SARIF). Any other kind — including ARTIFACT and
234+
// EVIDENCE — records the archive whole even when the value is a zip/tar.
235+
if !materials.IsExplodableKind(materialType) {
236+
return materials.ArchiveNone, nil
237+
}
238+
return materials.DetectArchive(value)
180239
}
181240

182241
// runtimeInputAddOpts wraps the runtime inputs as crafter add options, or
@@ -315,29 +374,14 @@ func policyInputEvidenceNames(materialName string, policyInputFiles []*PolicyInp
315374
return names
316375
}
317376

318-
// sanitizeMaterialNamePart lower-cases s and collapses every run of characters
319-
// outside [a-z0-9] into a single "-", trimming leading/trailing "-", so the
320-
// result is a valid material-name component. Falls back to "input" if nothing
321-
// usable remains.
377+
// sanitizeMaterialNamePart sanitizes s into a valid material-name component via
378+
// materials.SanitizeMaterialName, falling back to "input" if nothing usable
379+
// remains.
322380
func sanitizeMaterialNamePart(s string) string {
323-
var b strings.Builder
324-
pendingHyphen := false
325-
for _, r := range strings.ToLower(s) {
326-
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
327-
if pendingHyphen && b.Len() > 0 {
328-
b.WriteByte('-')
329-
}
330-
b.WriteRune(r)
331-
pendingHyphen = false
332-
} else {
333-
pendingHyphen = true
334-
}
335-
}
336-
337-
if b.Len() == 0 {
338-
return "input"
381+
if name := materials.SanitizeMaterialName(s); name != "" {
382+
return name
339383
}
340-
return b.String()
384+
return "input"
341385
}
342386

343387
// GetPolicyEvaluations is a Wrapper around the getPolicyEvaluations
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package action
17+
18+
import (
19+
"archive/zip"
20+
"os"
21+
"path/filepath"
22+
"testing"
23+
24+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
)
28+
29+
// writeTestZip creates a zip archive at dir/name containing a single file
30+
// "entry.txt" and returns its path.
31+
func writeTestZip(t *testing.T, dir, name string) string {
32+
t.Helper()
33+
path := filepath.Join(dir, name)
34+
f, err := os.Create(path)
35+
require.NoError(t, err)
36+
defer f.Close()
37+
38+
w := zip.NewWriter(f)
39+
entry, err := w.Create("entry.txt")
40+
require.NoError(t, err)
41+
_, err = entry.Write([]byte("hello"))
42+
require.NoError(t, err)
43+
require.NoError(t, w.Close())
44+
return path
45+
}
46+
47+
func TestShouldExplode(t *testing.T) {
48+
dir := t.TempDir()
49+
zipPath := writeTestZip(t, dir, "s.zip")
50+
51+
// non-archive: a plain temp file with an unrecognised extension
52+
plainPath := filepath.Join(dir, "plain.bin")
53+
require.NoError(t, os.WriteFile(plainPath, []byte("not an archive"), 0600))
54+
55+
tests := []struct {
56+
name string
57+
kind string
58+
value string
59+
wantFormat materials.ArchiveFormat
60+
}{
61+
// A non-ArchiveNone format means the value will be exploded. Only
62+
// explodable kinds (SBOM, SARIF) explode; everything else is recorded
63+
// whole even when the value is an archive.
64+
{"explodable SBOM + archive", "SBOM_CYCLONEDX_JSON", zipPath, materials.ArchiveZip},
65+
{"explodable SARIF + archive", "SARIF", zipPath, materials.ArchiveZip},
66+
{"non-explodable ARTIFACT + archive", "ARTIFACT", zipPath, materials.ArchiveNone},
67+
{"non-explodable EVIDENCE + archive", "EVIDENCE", zipPath, materials.ArchiveNone},
68+
{"archive-native ZAP + archive", "ZAP_DAST_ZIP", zipPath, materials.ArchiveNone},
69+
{"no kind", "", zipPath, materials.ArchiveNone},
70+
{"explodable kind + non-archive", "SBOM_CYCLONEDX_JSON", plainPath, materials.ArchiveNone},
71+
// Non-file values must never return an error — even for an explodable kind
72+
// the value here is not a file path at all.
73+
{"explodable kind STRING-like non-file value", "SARIF", "hello world", materials.ArchiveNone},
74+
}
75+
for _, tc := range tests {
76+
t.Run(tc.name, func(t *testing.T) {
77+
format, err := shouldExplode(tc.kind, tc.value)
78+
require.NoError(t, err)
79+
assert.Equal(t, tc.wantFormat, format)
80+
})
81+
}
82+
}

0 commit comments

Comments
 (0)