|
16 | 16 | package materials |
17 | 17 |
|
18 | 18 | import ( |
19 | | - "archive/tar" |
20 | | - "archive/zip" |
21 | | - "bytes" |
22 | | - "compress/gzip" |
23 | | - "errors" |
24 | 19 | "fmt" |
25 | 20 | "io" |
26 | | - "io/fs" |
27 | | - "os" |
28 | 21 | "path" |
29 | 22 | "strings" |
30 | | - "syscall" |
31 | 23 |
|
32 | 24 | schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" |
| 25 | + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/archiveio" |
33 | 26 | ) |
34 | 27 |
|
| 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 | + |
35 | 33 | // ArchiveFormat identifies a supported archive container. |
36 | | -type ArchiveFormat int |
| 34 | +type ArchiveFormat = archiveio.Format |
37 | 35 |
|
38 | 36 | 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 |
43 | 41 | ) |
44 | 42 |
|
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 |
96 | 45 |
|
97 | 46 | var ( |
98 | 47 | // ErrTooManyEntries is returned when an archive has more qualifying entries |
99 | 48 | // than the configured maximum. |
100 | | - ErrTooManyEntries = errors.New("archive exceeds the maximum number of entries") |
| 49 | + ErrTooManyEntries = archiveio.ErrTooManyEntries |
101 | 50 | // ErrArchiveTooLarge is returned when the running uncompressed size of an |
102 | 51 | // archive exceeds the configured maximum. |
103 | | - ErrArchiveTooLarge = errors.New("archive exceeds the maximum uncompressed size") |
| 52 | + ErrArchiveTooLarge = archiveio.ErrArchiveTooLarge |
104 | 53 | // 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 |
106 | 55 | ) |
107 | 56 |
|
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) |
112 | 61 | } |
113 | 62 |
|
114 | 63 | // DefaultArchiveLimits returns the safe defaults: 10000 entries and 1 GiB |
115 | 64 | // total uncompressed size. |
116 | 65 | 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() |
135 | 67 | } |
136 | 68 |
|
137 | 69 | // WalkArchiveEntries calls yield for every regular file in the archive, |
138 | 70 | // enforcing the limits and skipping directories, symlinks, hardlinks, empty |
139 | 71 | // entries, and path-traversal entries. |
140 | 72 | 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) |
268 | 74 | } |
269 | 75 |
|
270 | 76 | // explodableKinds is the allowlist of material kinds whose archive value is |
271 | 77 | // expanded into one material per entry. Every other kind (ARTIFACT, EVIDENCE, |
272 | 78 | // 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. |
275 | 95 | var explodableKinds = map[string]struct{}{ |
276 | 96 | schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON.String(): {}, |
277 | 97 | schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON.String(): {}, |
|
0 commit comments