Skip to content

Commit 8b46f92

Browse files
committed
Add option to exclude base image components
1 parent 63dafd9 commit 8b46f92

3 files changed

Lines changed: 188 additions & 1 deletion

File tree

src/Microsoft.ComponentDetection.Orchestrator/Commands/ScanSettings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ public class ScanSettings : BaseSettings
8686
[Description("Whether or not to cleanup files that are created during detection, based on the rules provided in each detector. Defaults to 'true'.")]
8787
public bool? CleanupCreatedFiles { get; set; }
8888

89+
[CommandOption("--FilterBaseImageComponents")]
90+
[Description("When enabled, filters out components that originate exclusively from base image layers when scanning containers.")]
91+
public bool FilterBaseImageComponents { get; set; }
92+
8993
/// <inheritdoc />
9094
public override ValidationResult Validate()
9195
{

src/Microsoft.ComponentDetection.Orchestrator/Services/GraphTranslation/DefaultGraphTranslationService.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,74 @@ public ScanResult GenerateScanResultFromProcessingResult(
4141

4242
ReconcileDependencyGraphIds(dependencyGraphs, mergedComponents);
4343

44+
var componentsToOutput = mergedComponents;
45+
if (settings.FilterBaseImageComponents)
46+
{
47+
componentsToOutput = FilterOutBaseImageComponents(componentsToOutput, detectorProcessingResult.ContainersDetailsMap);
48+
}
49+
4450
return new DefaultGraphScanResult
4551
{
46-
ComponentsFound = mergedComponents.Select(x => this.ConvertToContract(x)).ToList(),
52+
ComponentsFound = componentsToOutput.Select(x => this.ConvertToContract(x)).ToList(),
4753
ContainerDetailsMap = detectorProcessingResult.ContainersDetailsMap,
4854
DependencyGraphs = dependencyGraphs,
4955
SourceDirectory = settings.SourceDirectory.ToString(),
5056
};
5157
}
5258

59+
/// <summary>
60+
/// Filters out components that originate exclusively from base image layers.
61+
/// A component is removed only if it has container layer references and every referenced
62+
/// layer across all containers has <see cref="DockerLayer.IsBaseImage"/> set to true.
63+
/// Components with no container references or with at least one non-base-image layer are retained.
64+
/// </summary>
65+
/// <param name="components">The list of detected components to filter.</param>
66+
/// <param name="containerDetailsMap">The map of container details with layer information.</param>
67+
/// <returns>A filtered list of components excluding those exclusively from base image layers.</returns>
68+
internal static List<DetectedComponent> FilterOutBaseImageComponents(
69+
List<DetectedComponent> components,
70+
Dictionary<int, ContainerDetails> containerDetailsMap)
71+
{
72+
if (containerDetailsMap == null || containerDetailsMap.Count == 0)
73+
{
74+
return components;
75+
}
76+
77+
return components.Where(component => !IsExclusivelyFromBaseImage(component, containerDetailsMap)).ToList();
78+
}
79+
80+
private static bool IsExclusivelyFromBaseImage(DetectedComponent component, Dictionary<int, ContainerDetails> containerDetailsMap)
81+
{
82+
// Components without container layer references are not from a container scan - keep them.
83+
if (component.ContainerLayerIds == null || component.ContainerLayerIds.Count == 0)
84+
{
85+
return false;
86+
}
87+
88+
foreach (var (containerDetailId, layerIndices) in component.ContainerLayerIds)
89+
{
90+
if (!containerDetailsMap.TryGetValue(containerDetailId, out var containerDetails) || containerDetails.Layers == null)
91+
{
92+
// If we can't resolve the container details, assume it's not a base image component.
93+
return false;
94+
}
95+
96+
var layersList = containerDetails.Layers.ToList();
97+
98+
foreach (var layerIndex in layerIndices)
99+
{
100+
var layer = layersList.FirstOrDefault(l => l.LayerIndex == layerIndex);
101+
if (layer == null || !layer.IsBaseImage)
102+
{
103+
// Layer not found or not from base image. Keep this component.
104+
return false;
105+
}
106+
}
107+
}
108+
109+
return true;
110+
}
111+
53112
private static ConcurrentHashSet<string> MergeTargetFrameworks(ConcurrentHashSet<string> left, ConcurrentHashSet<string> right)
54113
{
55114
if (left == null && right == null)

test/Microsoft.ComponentDetection.Orchestrator.Tests/Services/DefaultGraphTranslationServiceTests.cs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,4 +564,128 @@ public void GenerateScanResult_MultipleRichAndBare_BareGraphDataAbsorbedByAllRic
564564
// Both rich entries should have the bare graph's file path (package.json)
565565
lodashResults.Should().OnlyContain(c => c.LocationsFoundAt.Any(l => l.Contains("package.json")));
566566
}
567+
568+
[TestMethod]
569+
public void FilterBaseImageComponents_RemovesComponentsExclusivelyFromBaseImageLayers()
570+
{
571+
var singleFileRecorder = this.componentRecorder.CreateSingleFileComponentRecorder(Path.Join(this.sourceDirectory.FullName, "/file1"));
572+
573+
var baseImageComponent = new DetectedComponent(new NpmComponent("base-pkg", "1.0.0"), containerDetailsId: 1, containerLayerId: 0);
574+
singleFileRecorder.RegisterUsage(baseImageComponent);
575+
576+
var containerDetailsMap = new Dictionary<int, ContainerDetails>
577+
{
578+
[1] = new ContainerDetails
579+
{
580+
Id = 1,
581+
Layers = [new DockerLayer { LayerIndex = 0, IsBaseImage = true }, new DockerLayer { LayerIndex = 1, IsBaseImage = false }],
582+
},
583+
};
584+
585+
var processingResult = new DetectorProcessingResult
586+
{
587+
ResultCode = ProcessingResultCode.Success,
588+
ContainersDetailsMap = containerDetailsMap,
589+
ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)],
590+
};
591+
592+
var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult(
593+
processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory, FilterBaseImageComponents = true });
594+
595+
result.ComponentsFound.Should().BeEmpty();
596+
}
597+
598+
[TestMethod]
599+
public void FilterBaseImageComponents_RetainsComponentsWithMixedLayers()
600+
{
601+
var singleFileRecorder = this.componentRecorder.CreateSingleFileComponentRecorder(Path.Join(this.sourceDirectory.FullName, "/file1"));
602+
603+
var mixedComponent = new DetectedComponent(new NpmComponent("mixed-pkg", "1.0.0"), containerDetailsId: 1, containerLayerId: 0);
604+
mixedComponent.ContainerLayerIds[1] = [0, 1];
605+
singleFileRecorder.RegisterUsage(mixedComponent);
606+
607+
var containerDetailsMap = new Dictionary<int, ContainerDetails>
608+
{
609+
[1] = new ContainerDetails
610+
{
611+
Id = 1,
612+
Layers = [new DockerLayer { LayerIndex = 0, IsBaseImage = true }, new DockerLayer { LayerIndex = 1, IsBaseImage = false }],
613+
},
614+
};
615+
616+
var processingResult = new DetectorProcessingResult
617+
{
618+
ResultCode = ProcessingResultCode.Success,
619+
ContainersDetailsMap = containerDetailsMap,
620+
ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)],
621+
};
622+
623+
var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult(
624+
processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory, FilterBaseImageComponents = true });
625+
626+
result.ComponentsFound.Should().HaveCount(1);
627+
((NpmComponent)result.ComponentsFound.Single().Component).Name.Should().Be("mixed-pkg");
628+
}
629+
630+
[TestMethod]
631+
public void FilterBaseImageComponents_RetainsComponentsWithNoContainerReferences()
632+
{
633+
var singleFileRecorder = this.componentRecorder.CreateSingleFileComponentRecorder(Path.Join(this.sourceDirectory.FullName, "/file1"));
634+
635+
var filesystemComponent = new DetectedComponent(new NpmComponent("fs-pkg", "2.0.0"));
636+
singleFileRecorder.RegisterUsage(filesystemComponent);
637+
638+
var containerDetailsMap = new Dictionary<int, ContainerDetails>
639+
{
640+
[1] = new ContainerDetails
641+
{
642+
Id = 1,
643+
Layers = [new DockerLayer { LayerIndex = 0, IsBaseImage = true }],
644+
},
645+
};
646+
647+
var processingResult = new DetectorProcessingResult
648+
{
649+
ResultCode = ProcessingResultCode.Success,
650+
ContainersDetailsMap = containerDetailsMap,
651+
ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)],
652+
};
653+
654+
var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult(
655+
processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory, FilterBaseImageComponents = true });
656+
657+
result.ComponentsFound.Should().HaveCount(1);
658+
((NpmComponent)result.ComponentsFound.Single().Component).Name.Should().Be("fs-pkg");
659+
}
660+
661+
[TestMethod]
662+
public void FilterBaseImageComponents_NoOpWhenFlagIsDisabled()
663+
{
664+
var singleFileRecorder = this.componentRecorder.CreateSingleFileComponentRecorder(Path.Join(this.sourceDirectory.FullName, "/file1"));
665+
666+
var baseImageComponent = new DetectedComponent(new NpmComponent("base-pkg", "1.0.0"), containerDetailsId: 1, containerLayerId: 0);
667+
singleFileRecorder.RegisterUsage(baseImageComponent);
668+
669+
var containerDetailsMap = new Dictionary<int, ContainerDetails>
670+
{
671+
[1] = new ContainerDetails
672+
{
673+
Id = 1,
674+
Layers = [new DockerLayer { LayerIndex = 0, IsBaseImage = true }],
675+
},
676+
};
677+
678+
var processingResult = new DetectorProcessingResult
679+
{
680+
ResultCode = ProcessingResultCode.Success,
681+
ContainersDetailsMap = containerDetailsMap,
682+
ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)],
683+
};
684+
685+
var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult(
686+
processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory, FilterBaseImageComponents = false });
687+
688+
result.ComponentsFound.Should().HaveCount(1);
689+
((NpmComponent)result.ComponentsFound.Single().Component).Name.Should().Be("base-pkg");
690+
}
567691
}

0 commit comments

Comments
 (0)