github.com/google/osv-scalibr@v0.4.1/detector/misc/dockersocket/dockersocket_test.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 //go:build !windows 16 17 package dockersocket 18 19 import ( 20 "fmt" 21 "io" 22 "io/fs" 23 "strings" 24 "syscall" 25 "testing" 26 "testing/fstest" 27 "time" 28 29 "github.com/google/go-cmp/cmp" 30 "github.com/google/osv-scalibr/inventory" 31 "github.com/google/osv-scalibr/packageindex" 32 "github.com/google/osv-scalibr/plugin" 33 ) 34 35 // Helper functions for generating expected test issues 36 37 func expectSocketWorldReadable(perms fs.FileMode) string { 38 return fmt.Sprintf("Docker socket is world-readable (permissions: %03o)", perms.Perm()) 39 } 40 41 func expectSocketWorldWritable(perms fs.FileMode) string { 42 return fmt.Sprintf("Docker socket is world-writable (permissions: %03o)", perms.Perm()) 43 } 44 45 func expectSocketNonRootOwner(uid uint32) string { 46 return fmt.Sprintf("Docker socket owner is not root (uid: %d)", uid) 47 } 48 49 func expectInsecureTCPBinding(host string) string { 50 return fmt.Sprintf("Insecure TCP binding in daemon.json: %q (consider using TLS)", host) 51 } 52 53 func expectInsecureSystemdBinding(path, line string) string { 54 return fmt.Sprintf("Insecure TCP binding in %q: %q (missing TLS)", path, line) 55 } 56 57 // fakeFileInfo implements fs.FileInfo for testing 58 type fakeFileInfo struct { 59 name string 60 size int64 61 mode fs.FileMode 62 modTime time.Time 63 isDir bool 64 sys any 65 } 66 67 func (f fakeFileInfo) Name() string { return f.name } 68 func (f fakeFileInfo) Size() int64 { return f.size } 69 func (f fakeFileInfo) Mode() fs.FileMode { return f.mode } 70 func (f fakeFileInfo) ModTime() time.Time { return f.modTime } 71 func (f fakeFileInfo) IsDir() bool { return f.isDir } 72 func (f fakeFileInfo) Sys() any { return f.sys } 73 74 // fakeFile implements fs.File for testing 75 type fakeFile struct { 76 *fstest.MapFile 77 78 info fakeFileInfo 79 offset int 80 } 81 82 func (f fakeFile) Stat() (fs.FileInfo, error) { 83 return f.info, nil 84 } 85 86 func (f fakeFile) Close() error { 87 return nil 88 } 89 90 func (f *fakeFile) Read(b []byte) (int, error) { 91 if f.offset >= len(f.Data) { 92 return 0, io.EOF 93 } 94 n := copy(b, f.Data[f.offset:]) 95 f.offset += n 96 return n, nil 97 } 98 99 func TestDockerSocketPermissions(t *testing.T) { 100 tests := []struct { 101 name string 102 socketPerms fs.FileMode 103 uid uint32 104 gid uint32 105 wantIssues []string 106 }{ 107 { 108 name: "secure socket permissions", 109 socketPerms: 0660, // rw-rw---- 110 uid: 0, // root 111 gid: 999, // docker group 112 wantIssues: nil, 113 }, 114 { 115 name: "world-readable socket", 116 socketPerms: 0664, // rw-rw-r-- 117 uid: 0, 118 gid: 999, 119 wantIssues: []string{expectSocketWorldReadable(0664)}, 120 }, 121 { 122 name: "world-writable socket", 123 socketPerms: 0666, // rw-rw-rw- 124 uid: 0, 125 gid: 999, 126 wantIssues: []string{expectSocketWorldReadable(0666), expectSocketWorldWritable(0666)}, 127 }, 128 { 129 name: "non-root owner", 130 socketPerms: 0660, 131 uid: 1000, // non-root 132 gid: 999, 133 wantIssues: []string{expectSocketNonRootOwner(1000)}, 134 }, 135 } 136 137 for _, tt := range tests { 138 t.Run(tt.name, func(t *testing.T) { 139 stat := &syscall.Stat_t{ 140 Uid: tt.uid, 141 Gid: tt.gid, 142 } 143 144 fsys := fstest.MapFS{} 145 146 // Override the file with our custom info 147 file := fakeFile{ 148 MapFile: &fstest.MapFile{Data: []byte{}}, 149 info: fakeFileInfo{ 150 name: "docker.sock", 151 mode: tt.socketPerms, 152 modTime: time.Now(), 153 sys: stat, 154 }, 155 } 156 157 // Create a custom filesystem that returns our fake file 158 customFS := &testFS{ 159 MapFS: fsys, 160 sockFile: file, 161 } 162 163 d := &Detector{} 164 finding, err := d.ScanFS(t.Context(), customFS, &packageindex.PackageIndex{}) 165 166 if err != nil { 167 t.Errorf("ScanFS() returned error: %v", err) 168 } 169 170 var actualIssues []string 171 if len(finding.GenericFindings) > 0 { 172 // Extract issues from the Extra field 173 extra := finding.GenericFindings[0].Target.Extra 174 if extra != "" { 175 actualIssues = strings.Split(extra, "; ") 176 } 177 } 178 179 // Filter to only socket-related issues for this test 180 var socketIssues []string 181 for _, issue := range actualIssues { 182 if strings.Contains(issue, "Docker socket") { 183 socketIssues = append(socketIssues, issue) 184 } 185 } 186 187 if diff := cmp.Diff(tt.wantIssues, socketIssues); diff != "" { 188 t.Errorf("Socket permissions test mismatch (-want +got):\n%s", diff) 189 } 190 }) 191 } 192 } 193 194 // testFS wraps fstest.MapFS to return our custom file for docker.sock 195 type testFS struct { 196 fstest.MapFS 197 198 sockFile fakeFile 199 } 200 201 func (t *testFS) Open(name string) (fs.File, error) { 202 if name == "var/run/docker.sock" { 203 return &t.sockFile, nil 204 } 205 return t.MapFS.Open(name) 206 } 207 208 func TestDockerDaemonConfig(t *testing.T) { 209 tests := []struct { 210 name string 211 config string 212 wantIssues []string 213 }{ 214 { 215 name: "secure config - no hosts", 216 config: `{}`, 217 wantIssues: nil, 218 }, 219 { 220 name: "secure config - unix socket only", 221 config: `{"hosts": ["unix:///var/run/docker.sock"]}`, 222 wantIssues: nil, 223 }, 224 { 225 name: "insecure config - tcp without tls", 226 config: `{"hosts": ["tcp://0.0.0.0:2375"]}`, 227 wantIssues: []string{expectInsecureTCPBinding("tcp://0.0.0.0:2375")}, 228 }, 229 { 230 name: "mixed config - both secure and insecure", 231 config: `{"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2375"]}`, 232 wantIssues: []string{expectInsecureTCPBinding("tcp://0.0.0.0:2375")}, 233 }, 234 { 235 name: "multiple insecure hosts", 236 config: `{"hosts": ["tcp://0.0.0.0:2375", "tcp://127.0.0.1:2376"]}`, 237 wantIssues: []string{expectInsecureTCPBinding("tcp://0.0.0.0:2375"), expectInsecureTCPBinding("tcp://127.0.0.1:2376")}, 238 }, 239 { 240 name: "invalid json", 241 config: `{invalid json}`, 242 wantIssues: nil, // Should not error on invalid JSON 243 }, 244 } 245 246 for _, tt := range tests { 247 t.Run(tt.name, func(t *testing.T) { 248 fsys := fstest.MapFS{ 249 "etc/docker/daemon.json": &fstest.MapFile{ 250 Data: []byte(tt.config), 251 }, 252 } 253 254 d := &Detector{} 255 finding, err := d.ScanFS(t.Context(), fsys, &packageindex.PackageIndex{}) 256 257 if err != nil { 258 t.Errorf("ScanFS() returned error: %v", err) 259 } 260 261 var actualIssues []string 262 if len(finding.GenericFindings) > 0 { 263 // Extract issues from the Extra field 264 extra := finding.GenericFindings[0].Target.Extra 265 if extra != "" { 266 actualIssues = strings.Split(extra, "; ") 267 } 268 } 269 270 // Filter to only daemon config related issues for this test 271 var daemonIssues []string 272 for _, issue := range actualIssues { 273 if strings.Contains(issue, "daemon.json") { 274 daemonIssues = append(daemonIssues, issue) 275 } 276 } 277 278 if diff := cmp.Diff(tt.wantIssues, daemonIssues); diff != "" { 279 t.Errorf("Daemon config test mismatch (-want +got):\n%s", diff) 280 } 281 }) 282 } 283 } 284 285 func TestSystemdServiceConfig(t *testing.T) { 286 tests := []struct { 287 name string 288 serviceFile string 289 wantIssues []string 290 }{ 291 { 292 name: "secure_service_-_unix_socket_only", 293 serviceFile: `[Unit] 294 Description=Docker Application Container Engine 295 296 [Service] 297 ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sock 298 299 [Install] 300 WantedBy=multi-user.target`, 301 wantIssues: nil, 302 }, 303 { 304 name: "insecure_service_-_tcp_without_tls", 305 serviceFile: `[Unit] 306 Description=Docker Application Container Engine 307 308 [Service] 309 ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 310 311 [Install] 312 WantedBy=multi-user.target`, 313 wantIssues: []string{expectInsecureSystemdBinding("etc/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375")}, 314 }, 315 { 316 name: "secure_service_-_tcp_with_tls", 317 serviceFile: `[Unit] 318 Description=Docker Application Container Engine 319 320 [Service] 321 ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2376 --tls --tlscert=/path/to/cert.pem --tlskey=/path/to/key.pem 322 323 [Install] 324 WantedBy=multi-user.target`, 325 wantIssues: nil, 326 }, 327 { 328 name: "multiple_ExecStart_lines_-_some_insecure", 329 serviceFile: `[Unit] 330 Description=Docker Application Container Engine 331 332 [Service] 333 ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sock 334 ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 335 336 [Install] 337 WantedBy=multi-user.target`, 338 wantIssues: []string{expectInsecureSystemdBinding("etc/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375")}, 339 }, 340 } 341 342 for _, tt := range tests { 343 t.Run(tt.name, func(t *testing.T) { 344 fsys := fstest.MapFS{ 345 "etc/systemd/system/docker.service": &fstest.MapFile{ 346 Data: []byte(tt.serviceFile), 347 }, 348 } 349 350 d := &Detector{} 351 finding, err := d.ScanFS(t.Context(), fsys, &packageindex.PackageIndex{}) 352 353 if err != nil { 354 t.Errorf("ScanFS() returned error: %v", err) 355 } 356 357 var actualIssues []string 358 if len(finding.GenericFindings) > 0 { 359 // Extract issues from the Extra field 360 extra := finding.GenericFindings[0].Target.Extra 361 if extra != "" { 362 actualIssues = strings.Split(extra, "; ") 363 } 364 } 365 366 // Filter to only systemd service related issues for this test 367 var systemdIssues []string 368 for _, issue := range actualIssues { 369 if strings.Contains(issue, "systemd") || strings.Contains(issue, ".service") { 370 systemdIssues = append(systemdIssues, issue) 371 } 372 } 373 374 if diff := cmp.Diff(tt.wantIssues, systemdIssues); diff != "" { 375 t.Errorf("Systemd service test mismatch (-want +got):\n%s", diff) 376 } 377 }) 378 } 379 } 380 381 func TestSystemdServiceConfig_MultiplePaths(t *testing.T) { 382 // Test that the detector checks all possible systemd service paths 383 insecureService := `[Unit] 384 Description=Docker Application Container Engine 385 386 [Service] 387 ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 388 389 [Install] 390 WantedBy=multi-user.target` 391 392 tests := []struct { 393 name string 394 files map[string]string 395 wantIssues []string 396 }{ 397 { 398 name: "service_in_/etc/systemd/system", 399 files: map[string]string{ 400 "etc/systemd/system/docker.service": insecureService, 401 }, 402 wantIssues: []string{expectInsecureSystemdBinding("etc/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375")}, 403 }, 404 { 405 name: "service_in_/lib/systemd/system", 406 files: map[string]string{ 407 "lib/systemd/system/docker.service": `[Service] 408 ExecStart=/usr/bin/dockerd -H tcp://127.0.0.1:2376`, 409 }, 410 wantIssues: []string{expectInsecureSystemdBinding("lib/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://127.0.0.1:2376")}, 411 }, 412 { 413 name: "service_in_/usr/lib/systemd/system", 414 files: map[string]string{ 415 "usr/lib/systemd/system/docker.service": `[Service] 416 ExecStart=/usr/bin/dockerd -H tcp://192.168.1.1:2377`, 417 }, 418 wantIssues: []string{expectInsecureSystemdBinding("usr/lib/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://192.168.1.1:2377")}, 419 }, 420 { 421 name: "multiple_service_files_with_issues", 422 files: map[string]string{ 423 "etc/systemd/system/docker.service": insecureService, 424 "lib/systemd/system/docker.service": `[Service] 425 ExecStart=/usr/bin/dockerd -H tcp://10.0.0.1:2378`, 426 }, 427 wantIssues: []string{ 428 expectInsecureSystemdBinding("etc/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375"), 429 expectInsecureSystemdBinding("lib/systemd/system/docker.service", "ExecStart=/usr/bin/dockerd -H tcp://10.0.0.1:2378"), 430 }, 431 }, 432 } 433 434 for _, tt := range tests { 435 t.Run(tt.name, func(t *testing.T) { 436 fsys := fstest.MapFS{} 437 for path, content := range tt.files { 438 fsys[path] = &fstest.MapFile{Data: []byte(content)} 439 } 440 441 d := &Detector{} 442 finding, err := d.ScanFS(t.Context(), fsys, &packageindex.PackageIndex{}) 443 444 if err != nil { 445 t.Errorf("ScanFS() returned error: %v", err) 446 } 447 448 var actualIssues []string 449 if len(finding.GenericFindings) > 0 { 450 // Extract issues from the Extra field 451 extra := finding.GenericFindings[0].Target.Extra 452 if extra != "" { 453 actualIssues = strings.Split(extra, "; ") 454 } 455 } 456 457 // Filter to only systemd service related issues for this test 458 var systemdIssues []string 459 for _, issue := range actualIssues { 460 if strings.Contains(issue, "systemd") || strings.Contains(issue, ".service") { 461 systemdIssues = append(systemdIssues, issue) 462 } 463 } 464 465 if diff := cmp.Diff(tt.wantIssues, systemdIssues); diff != "" { 466 t.Errorf("Multiple paths test mismatch (-want +got):\n%s", diff) 467 } 468 }) 469 } 470 } 471 472 func TestScanFS_NoDocker(t *testing.T) { 473 // Test with no Docker installation (no socket, no config files) 474 fsys := fstest.MapFS{} 475 476 d := &Detector{} 477 finding, err := d.ScanFS(t.Context(), fsys, &packageindex.PackageIndex{}) 478 479 if err != nil { 480 t.Errorf("ScanFS() returned error: %v", err) 481 } 482 483 if len(finding.GenericFindings) != 0 { 484 t.Errorf("ScanFS() returned findings when no Docker is installed, got: %v", finding) 485 } 486 } 487 488 func TestScanFS_Integration(t *testing.T) { 489 tests := []struct { 490 name string 491 setupFS func() fs.FS 492 wantFindingCount int 493 wantSeverity inventory.SeverityEnum 494 wantIssuesContain []string 495 }{ 496 { 497 name: "socket_with_world-readable_and_insecure_daemon_config", 498 setupFS: func() fs.FS { 499 stat := &syscall.Stat_t{Uid: 0, Gid: 999} 500 fsys := fstest.MapFS{ 501 "etc/docker/daemon.json": &fstest.MapFile{ 502 Data: []byte(`{"hosts": ["tcp://0.0.0.0:2375"]}`), 503 }, 504 } 505 return &testFS{ 506 MapFS: fsys, 507 sockFile: fakeFile{ 508 MapFile: &fstest.MapFile{Data: []byte{}}, 509 info: fakeFileInfo{ 510 name: "docker.sock", 511 mode: 0664, // world-readable 512 modTime: time.Now(), 513 sys: stat, 514 }, 515 }, 516 } 517 }, 518 wantFindingCount: 1, 519 wantSeverity: inventory.SeverityHigh, 520 wantIssuesContain: []string{ 521 "Docker socket is world-readable", 522 "Insecure TCP binding in daemon.json", 523 }, 524 }, 525 { 526 name: "multiple_insecure_systemd_services", 527 setupFS: func() fs.FS { 528 insecureService := `[Service] 529 ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375` 530 return fstest.MapFS{ 531 "etc/systemd/system/docker.service": &fstest.MapFile{Data: []byte(insecureService)}, 532 "lib/systemd/system/docker.service": &fstest.MapFile{Data: []byte(insecureService)}, 533 } 534 }, 535 wantFindingCount: 1, 536 wantSeverity: inventory.SeverityHigh, 537 wantIssuesContain: []string{ 538 "Insecure TCP binding in \"etc/systemd/system/docker.service\"", 539 "Insecure TCP binding in \"lib/systemd/system/docker.service\"", 540 }, 541 }, 542 { 543 name: "comprehensive_security_issues", 544 setupFS: func() fs.FS { 545 stat := &syscall.Stat_t{Uid: 1000, Gid: 999} // non-root owner 546 fsys := fstest.MapFS{ 547 "etc/docker/daemon.json": &fstest.MapFile{ 548 Data: []byte(`{"hosts": ["tcp://0.0.0.0:2375", "tcp://127.0.0.1:2376"]}`), 549 }, 550 "etc/systemd/system/docker.service": &fstest.MapFile{ 551 Data: []byte(`[Service] 552 ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2377`), 553 }, 554 } 555 return &testFS{ 556 MapFS: fsys, 557 sockFile: fakeFile{ 558 MapFile: &fstest.MapFile{Data: []byte{}}, 559 info: fakeFileInfo{ 560 name: "docker.sock", 561 mode: 0666, // world-readable and writable 562 modTime: time.Now(), 563 sys: stat, 564 }, 565 }, 566 } 567 }, 568 wantFindingCount: 1, 569 wantSeverity: inventory.SeverityHigh, 570 wantIssuesContain: []string{ 571 "Docker socket is world-readable", 572 "Docker socket is world-writable", 573 "Docker socket owner is not root", 574 "tcp://0.0.0.0:2375", 575 "tcp://127.0.0.1:2376", 576 "tcp://0.0.0.0:2377", 577 }, 578 }, 579 } 580 581 for _, tt := range tests { 582 t.Run(tt.name, func(t *testing.T) { 583 d := &Detector{} 584 finding, err := d.ScanFS(t.Context(), tt.setupFS(), &packageindex.PackageIndex{}) 585 586 if err != nil { 587 t.Errorf("ScanFS() returned error: %v", err) 588 } 589 590 if len(finding.GenericFindings) != tt.wantFindingCount { 591 t.Errorf("ScanFS() expected %d findings, got %d", tt.wantFindingCount, len(finding.GenericFindings)) 592 } 593 594 if tt.wantFindingCount > 0 && len(finding.GenericFindings) > 0 { 595 if finding.GenericFindings[0].Adv.Sev != tt.wantSeverity { 596 t.Errorf("ScanFS() expected %v severity, got %v", tt.wantSeverity, finding.GenericFindings[0].Adv.Sev) 597 } 598 599 // Check that all expected issue substrings are present in the target extra field 600 extra := finding.GenericFindings[0].Target.Extra 601 for _, expectedSubstring := range tt.wantIssuesContain { 602 if !contains(extra, expectedSubstring) { 603 t.Errorf("ScanFS() expected issues to contain %q, but got: %s", expectedSubstring, extra) 604 } 605 } 606 } 607 }) 608 } 609 } 610 611 // Helper function to check if a string contains a substring 612 func contains(s, substr string) bool { 613 return strings.Contains(s, substr) 614 } 615 616 func TestDetectorInterface(t *testing.T) { 617 d := New() 618 619 if d.Name() != Name { 620 t.Errorf("Name() = %q, want %q", d.Name(), Name) 621 } 622 623 if d.Version() != 0 { 624 t.Errorf("Version() = %d, want 0", d.Version()) 625 } 626 627 if len(d.RequiredExtractors()) != 0 { 628 t.Errorf("RequiredExtractors() = %v, want empty slice", d.RequiredExtractors()) 629 } 630 631 reqs := d.Requirements() 632 if reqs.OS != plugin.OSUnix { 633 t.Errorf("Requirements().OS = %q, want %q", reqs.OS, plugin.OSUnix) 634 } 635 636 // Test DetectedFinding 637 finding := d.DetectedFinding() 638 if len(finding.GenericFindings) != 1 { 639 t.Errorf("DetectedFinding() expected 1 finding, got %d", len(finding.GenericFindings)) 640 } 641 642 expectedID := &inventory.AdvisoryID{ 643 Publisher: "SCALIBR", 644 Reference: "docker-socket-exposure", 645 } 646 647 if diff := cmp.Diff(expectedID, finding.GenericFindings[0].Adv.ID); diff != "" { 648 t.Errorf("DetectedFinding() ID mismatch (-want +got):\n%s", diff) 649 } 650 }