-
Notifications
You must be signed in to change notification settings - Fork 53
feat(cli): explode archive materials in chainloop att add #3254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
336dd0e
5b658e4
38800a6
002ada3
c5069f1
e0dfe32
12cf2f4
3a17c4e
820268a
2e1450d
aeb3102
e7a581b
f5bf99c
a61e165
8f4f513
43eddce
0693694
5948bea
713274f
daf76a3
8d2a365
a25c290
e358fb8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,8 +18,10 @@ package cmd | |
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "math" | ||
| "os" | ||
|
|
||
| "code.cloudfoundry.org/bytefmt" | ||
| "github.com/jedib0t/go-pretty/v6/table" | ||
| "github.com/muesli/reflow/wrap" | ||
| "github.com/spf13/cobra" | ||
|
|
@@ -40,6 +42,8 @@ func newAttestationAddCmd() *cobra.Command { | |
| var annotationsFlag []string | ||
| var noStrictValidation bool | ||
| var policyInputFromFileFlag []string | ||
| var maxExtractEntries int | ||
| var maxExtractSize string | ||
|
|
||
| // OCI registry credentials can be passed as flags or environment variables | ||
| var registryServer, registryUsername, registryPassword string | ||
|
|
@@ -74,6 +78,16 @@ func newAttestationAddCmd() *cobra.Command { | |
| chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \ | ||
| --policy-input-from-file ignored_paths=exception.csv:Path`, | ||
| RunE: func(cmd *cobra.Command, _ []string) error { | ||
| maxExtractSizeBytes, err := bytefmt.ToBytes(maxExtractSize) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid --max-extract-size %q: %w", maxExtractSize, err) | ||
| } | ||
| // Guard against the uint64->int64 cast wrapping negative, which would | ||
| // later surface as a misleading "archive too large" error. | ||
| if maxExtractSizeBytes > math.MaxInt64 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this shoudl not apply to all materials just when we know taht we want to untar smth. Customers can provide a regular zip file.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. As of e358fb8 explosion only applies to the SBOM/SARIF allowlist; a regular zip for any other kind is stored as a single material. |
||
| return fmt.Errorf("--max-extract-size %q is too large", maxExtractSize) | ||
| } | ||
|
|
||
| a, err := action.NewAttestationAdd( | ||
| &action.AttestationAddOpts{ | ||
| ActionsOpts: ActionOpts, | ||
|
|
@@ -85,6 +99,8 @@ func newAttestationAddCmd() *cobra.Command { | |
| RegistryPassword: registryPassword, | ||
| LocalStatePath: attestationLocalStatePath, | ||
| NoStrictValidation: noStrictValidation, | ||
| MaxExtractEntries: maxExtractEntries, | ||
| MaxExtractSize: int64(maxExtractSizeBytes), | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| }, | ||
| ) | ||
| if err != nil { | ||
|
|
@@ -122,21 +138,28 @@ func newAttestationAddCmd() *cobra.Command { | |
| return fmt.Errorf("loading resource: %w", err) | ||
| } | ||
| } | ||
| // TODO: take the material output and show render it | ||
| resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations, policyInputFiles) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| logger.Info().Msg("material added to attestation") | ||
| logger.Info().Int("materials", len(resp)).Msg("material(s) added to attestation") | ||
|
|
||
| policies, err := a.GetPolicyEvaluations(cmd.Context(), attestationID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| return output.EncodeOutput(flagOutputFormat, resp, func(s *action.AttestationStatusMaterial) error { | ||
| return displayMaterialInfo(s, policies[resp.Name]) | ||
| // The explode path can return several materials. EncodeOutput | ||
| // renders the whole slice as a single JSON array (a parseable | ||
| // document) and the table renderer per material. | ||
| return output.EncodeOutput(flagOutputFormat, resp, func(mats []*action.AttestationStatusMaterial) error { | ||
| for _, m := range mats { | ||
| if err := displayMaterialInfo(m, policies[m.Name]); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| }) | ||
| }, | ||
| ) | ||
|
|
@@ -166,6 +189,10 @@ func newAttestationAddCmd() *cobra.Command { | |
| cmd.Flags().StringVar(®istryUsername, "registry-username", "", fmt.Sprintf("registry username, ($%s)", registryUsernameEnvVarName)) | ||
| cmd.Flags().StringVar(®istryPassword, "registry-password", "", fmt.Sprintf("registry password, ($%s)", registryPasswordEnvVarName)) | ||
|
|
||
| // Archive extraction guards | ||
| cmd.Flags().IntVar(&maxExtractEntries, "max-extract-entries", 10000, "max number of files to extract when --value is an archive") | ||
| cmd.Flags().StringVar(&maxExtractSize, "max-extract-size", "1GiB", "max total uncompressed size to extract when --value is an archive") | ||
|
|
||
| if registryServer == "" { | ||
| registryServer = os.Getenv(registryServerEnvVarName) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| // | ||
| // 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 action | ||
|
|
||
| import ( | ||
| "archive/zip" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // writeTestZip creates a zip archive at dir/name containing a single file | ||
| // "entry.txt" and returns its path. | ||
| func writeTestZip(t *testing.T, dir, name string) string { | ||
| t.Helper() | ||
| path := filepath.Join(dir, name) | ||
| f, err := os.Create(path) | ||
| require.NoError(t, err) | ||
| defer f.Close() | ||
|
|
||
| w := zip.NewWriter(f) | ||
| entry, err := w.Create("entry.txt") | ||
| require.NoError(t, err) | ||
| _, err = entry.Write([]byte("hello")) | ||
| require.NoError(t, err) | ||
| require.NoError(t, w.Close()) | ||
| return path | ||
| } | ||
|
|
||
| func TestShouldExplode(t *testing.T) { | ||
| dir := t.TempDir() | ||
| zipPath := writeTestZip(t, dir, "s.zip") | ||
|
|
||
| // non-archive: a plain temp file with an unrecognised extension | ||
| plainPath := filepath.Join(dir, "plain.bin") | ||
| require.NoError(t, os.WriteFile(plainPath, []byte("not an archive"), 0600)) | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| kind string | ||
| value string | ||
| wantFormat materials.ArchiveFormat | ||
| }{ | ||
| // A non-ArchiveNone format means the value will be exploded. Only | ||
| // explodable kinds (SBOM, SARIF) explode; everything else is recorded | ||
| // whole even when the value is an archive. | ||
| {"explodable SBOM + archive", "SBOM_CYCLONEDX_JSON", zipPath, materials.ArchiveZip}, | ||
| {"explodable SARIF + archive", "SARIF", zipPath, materials.ArchiveZip}, | ||
| {"non-explodable ARTIFACT + archive", "ARTIFACT", zipPath, materials.ArchiveNone}, | ||
| {"non-explodable EVIDENCE + archive", "EVIDENCE", zipPath, materials.ArchiveNone}, | ||
| {"archive-native ZAP + archive", "ZAP_DAST_ZIP", zipPath, materials.ArchiveNone}, | ||
| {"no kind", "", zipPath, materials.ArchiveNone}, | ||
| {"explodable kind + non-archive", "SBOM_CYCLONEDX_JSON", plainPath, materials.ArchiveNone}, | ||
| // Non-file values must never return an error — even for an explodable kind | ||
| // the value here is not a file path at all. | ||
| {"explodable kind STRING-like non-file value", "SARIF", "hello world", materials.ArchiveNone}, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| format, err := shouldExplode(tc.kind, tc.value) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, tc.wantFormat, format) | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how do you know it's something to extract?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It no longer explodes on detection alone — only when the kind is in the SBOM/SARIF allowlist AND the value sniffs as an archive (e358fb8).