-
Notifications
You must be signed in to change notification settings - Fork 2
fix(feature): add SHA-256 integrity verification for direct tar downloads #89
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
Changes from all commits
01a10da
bfafb7f
ce701b0
50973f4
71c3692
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 | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -283,6 +283,70 @@ func downloadLayer(img v1.Image, id, destFile string) error { | |||||||||||||||||||||||||||||||||||||||||||
| return writeLayerToFile(data, destFile) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // verifyCacheIntegrity checks a cached tarball against its stored | ||||||||||||||||||||||||||||||||||||||||||||
| // SHA-256 sidecar. Returns true when the cache is safe to use. | ||||||||||||||||||||||||||||||||||||||||||||
| func verifyCacheIntegrity(featureFolder, id string) bool { | ||||||||||||||||||||||||||||||||||||||||||||
| hashFile := filepath.Join(featureFolder, "feature.sha256") | ||||||||||||||||||||||||||||||||||||||||||||
| storedBytes, err := os.ReadFile(filepath.Clean(hashFile)) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Warnf( | ||||||||||||||||||||||||||||||||||||||||||||
| "No integrity hash for cached feature (backward compat): featureId=%s", | ||||||||||||||||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| return true | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| tarball := filepath.Join(featureFolder, "feature.tgz") | ||||||||||||||||||||||||||||||||||||||||||||
| computed, err := hash.File(tarball) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("Failed to hash cached tarball: error=%v, featureId=%s", err, id) | ||||||||||||||||||||||||||||||||||||||||||||
| return false | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if computed != strings.TrimSpace(string(storedBytes)) { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf( | ||||||||||||||||||||||||||||||||||||||||||||
| "Integrity check failed for cached feature: featureId=%s", | ||||||||||||||||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| return false | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| log.Debugf("Integrity check passed for cached feature: featureId=%s", id) | ||||||||||||||||||||||||||||||||||||||||||||
| return true | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // storeIntegrityHash computes and persists the SHA-256 of a downloaded tarball. | ||||||||||||||||||||||||||||||||||||||||||||
| func storeIntegrityHash(featureFolder, tarballPath, id string) { | ||||||||||||||||||||||||||||||||||||||||||||
| computed, err := hash.File(tarballPath) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("Failed to compute tarball hash: error=%v, featureId=%s", err, id) | ||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| hashFile := filepath.Join(featureFolder, "feature.sha256") | ||||||||||||||||||||||||||||||||||||||||||||
| if err := os.WriteFile(hashFile, []byte(computed), 0o600); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("Failed to write hash sidecar: error=%v, featureId=%s", err, id) | ||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| log.Infof("Feature tarball integrity: featureId=%s, sha256=%s", id, computed) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| func extractTarball(downloadFile, dest string) error { | ||||||||||||||||||||||||||||||||||||||||||||
| file, err := os.Open(filepath.Clean(downloadFile)) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("open tarball: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| defer func() { _ = file.Close() }() | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+335
to
+340
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. Lint Gate failure: add a Same pipeline failure as the test file. 🔧 Proposed fix func extractTarball(downloadFile, dest string) error {
- file, err := os.Open(downloadFile)
+ file, err := os.Open(downloadFile) //nolint:gosec // downloadFile is derived from the internal feature cache dir, not user input
if err != nil {
return fmt.Errorf("open tarball: %w", err)
}🧰 Tools🪛 GitHub Check: Lint Gate[failure] 336-336: 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if err := extract.Extract(file, dest); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| _ = os.RemoveAll(dest) | ||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("extract folder: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| func processDirectTarFeature( | ||||||||||||||||||||||||||||||||||||||||||||
| id string, | ||||||||||||||||||||||||||||||||||||||||||||
| httpHeaders map[string]string, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -299,45 +363,36 @@ func processDirectTarFeature( | |||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // feature already exists? | ||||||||||||||||||||||||||||||||||||||||||||
| featureFolder, err := getFeaturesTempFolder(id) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("resolve feature cache dir: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| featureExtractedFolder := filepath.Join(featureFolder, "extracted") | ||||||||||||||||||||||||||||||||||||||||||||
| _, err = os.Stat(featureExtractedFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| if err == nil && !forceDownload { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| return featureExtractedFolder, nil | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Check cache — verify integrity if present. | ||||||||||||||||||||||||||||||||||||||||||||
| _, statErr := os.Stat(featureExtractedFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| if statErr == nil && !forceDownload { | ||||||||||||||||||||||||||||||||||||||||||||
| if verifyCacheIntegrity(featureFolder, id) { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| return featureExtractedFolder, nil | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| _ = os.RemoveAll(featureFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+372
to
381
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. Regression: extracted-folder sanity check ( Previously the direct-tar path went through 🛡️ Proposed fix — reinstate the feature.json presence check // Check cache — verify integrity if present.
- _, statErr := os.Stat(featureExtractedFolder)
- if statErr == nil && !forceDownload {
- if verifyCacheIntegrity(featureFolder, id) {
- log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder)
- return featureExtractedFolder, nil
- }
- _ = os.RemoveAll(featureFolder)
+ if !forceDownload {
+ if _, statErr := os.Stat(featureExtractedFolder); statErr == nil {
+ _, fErr := os.Stat(filepath.Join(featureExtractedFolder, config.DEVCONTAINER_FEATURE_FILE_NAME))
+ if fErr == nil && verifyCacheIntegrity(featureFolder, id) {
+ log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder)
+ return featureExtractedFolder, nil
+ }
+ _ = os.RemoveAll(featureFolder)
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // download feature tarball | ||||||||||||||||||||||||||||||||||||||||||||
| // Download feature tarball. | ||||||||||||||||||||||||||||||||||||||||||||
| downloadFile := filepath.Join(featureFolder, "feature.tgz") | ||||||||||||||||||||||||||||||||||||||||||||
| err = downloadFeatureFromURL(id, downloadFile, httpHeaders) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("failed to download feature tarball: error=%v, url=%s", err, id) | ||||||||||||||||||||||||||||||||||||||||||||
| return "", err | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // extract file | ||||||||||||||||||||||||||||||||||||||||||||
| file, err := os.Open(downloadFile) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("failed to open downloaded tarball: error=%v, file=%s", err, downloadFile) | ||||||||||||||||||||||||||||||||||||||||||||
| return "", err | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| defer func() { _ = file.Close() }() | ||||||||||||||||||||||||||||||||||||||||||||
| storeIntegrityHash(featureFolder, downloadFile, id) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // extract tar.gz | ||||||||||||||||||||||||||||||||||||||||||||
| err = extract.Extract(file, featureExtractedFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf( | ||||||||||||||||||||||||||||||||||||||||||||
| "failed to extract tarball: error=%v, destination=%s", | ||||||||||||||||||||||||||||||||||||||||||||
| err, | ||||||||||||||||||||||||||||||||||||||||||||
| featureExtractedFolder, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| _ = os.RemoveAll(featureExtractedFolder) | ||||||||||||||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("extract folder: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||
| if err := extractTarball(downloadFile, featureExtractedFolder); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("failed to extract tarball: error=%v, featureId=%s", err, id) | ||||||||||||||||||||||||||||||||||||||||||||
| return "", err | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| log.Infof( | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,74 @@ | ||||||||||||||
| package feature | ||||||||||||||
|
|
||||||||||||||
| import ( | ||||||||||||||
| "os" | ||||||||||||||
| "path/filepath" | ||||||||||||||
| "testing" | ||||||||||||||
|
|
||||||||||||||
| "github.com/devsy-org/devsy/pkg/hash" | ||||||||||||||
| "github.com/stretchr/testify/suite" | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| type IntegrityTestSuite struct { | ||||||||||||||
| suite.Suite | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func TestIntegrityTestSuite(t *testing.T) { | ||||||||||||||
| suite.Run(t, new(IntegrityTestSuite)) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // createTestTarball writes a fake tarball file and returns its path. | ||||||||||||||
| func createTestTarball(dir string) (string, error) { | ||||||||||||||
| tarball := filepath.Join(dir, "feature.tgz") | ||||||||||||||
| return tarball, os.WriteFile(tarball, []byte("fake-tarball-content"), 0o600) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func (s *IntegrityTestSuite) TestStoreIntegrityHash_WritesCorrectSidecar() { | ||||||||||||||
| dir := s.T().TempDir() | ||||||||||||||
| tarball, err := createTestTarball(dir) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
|
|
||||||||||||||
| storeIntegrityHash(dir, tarball, "test-feature") | ||||||||||||||
|
|
||||||||||||||
| hashFile := filepath.Join(dir, "feature.sha256") | ||||||||||||||
| stored, err := os.ReadFile(filepath.Clean(hashFile)) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
|
Comment on lines
+33
to
+35
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. Lint Gate failure: add a The pipeline's Lint Gate fails at line 34 on 🔧 Proposed fix hashFile := filepath.Join(dir, "feature.sha256")
- stored, err := os.ReadFile(hashFile)
+ stored, err := os.ReadFile(hashFile) //nolint:gosec // test-controlled path under t.TempDir()
s.Require().NoError(err)📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Check: Lint Gate[failure] 34-34: 🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| expected, err := hash.File(tarball) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
| s.Equal(expected, string(stored)) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func (s *IntegrityTestSuite) TestVerifyCacheIntegrity_ValidHash() { | ||||||||||||||
| dir := s.T().TempDir() | ||||||||||||||
| tarball, err := createTestTarball(dir) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
|
|
||||||||||||||
| computed, err := hash.File(tarball) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
|
|
||||||||||||||
| hashFile := filepath.Join(dir, "feature.sha256") | ||||||||||||||
| s.Require().NoError(os.WriteFile(hashFile, []byte(computed), 0o600)) | ||||||||||||||
|
|
||||||||||||||
| s.True(verifyCacheIntegrity(dir, "test-feature")) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func (s *IntegrityTestSuite) TestVerifyCacheIntegrity_CorruptedHash() { | ||||||||||||||
| dir := s.T().TempDir() | ||||||||||||||
| _, err := createTestTarball(dir) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
|
|
||||||||||||||
| hashFile := filepath.Join(dir, "feature.sha256") | ||||||||||||||
| s.Require().NoError(os.WriteFile(hashFile, []byte("bad-hash"), 0o600)) | ||||||||||||||
|
|
||||||||||||||
| s.False(verifyCacheIntegrity(dir, "test-feature")) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func (s *IntegrityTestSuite) TestVerifyCacheIntegrity_MissingHashFile() { | ||||||||||||||
| dir := s.T().TempDir() | ||||||||||||||
| _, err := createTestTarball(dir) | ||||||||||||||
| s.Require().NoError(err) | ||||||||||||||
|
|
||||||||||||||
| // No .sha256 file — backward compat: should return true. | ||||||||||||||
| s.True(verifyCacheIntegrity(dir, "test-feature")) | ||||||||||||||
| } | ||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.