diff --git a/lib/analyzer/static-analyzer.ts b/lib/analyzer/static-analyzer.ts index 11c69b571..5ba10b533 100644 --- a/lib/analyzer/static-analyzer.ts +++ b/lib/analyzer/static-analyzer.ts @@ -165,6 +165,7 @@ export async function analyze( imageCreationTime, containerConfig, history, + provenanceAttestations, } = await archiveExtractor.extractImageContent( imageType, imagePath, @@ -324,6 +325,7 @@ export async function analyze( imageCreationTime, containerConfig, history, + provenanceAttestations, }; } diff --git a/lib/analyzer/types.ts b/lib/analyzer/types.ts index 78a1874ba..553fe1b6b 100644 --- a/lib/analyzer/types.ts +++ b/lib/analyzer/types.ts @@ -1,4 +1,5 @@ import { ImageName } from "../extractor/image"; +import { ProvenanceAttestation } from "../extractor/types"; import { BaseRuntime } from "../facts"; import { AutoDetectedUserInstructions, ManifestFile } from "../types"; import { @@ -103,6 +104,7 @@ export interface StaticAnalysis { comment?: string | null; empty_layer?: boolean | null; }> | null; + provenanceAttestations?: ProvenanceAttestation[]; } export interface StaticPackagesAnalysis extends StaticAnalysis { diff --git a/lib/extractor/index.ts b/lib/extractor/index.ts index ff01fdce8..b93d894fa 100644 --- a/lib/extractor/index.ts +++ b/lib/extractor/index.ts @@ -154,6 +154,7 @@ export async function extractImageContent( imageLabels: archiveContent.imageConfig.config?.Labels, containerConfig: archiveContent.imageConfig.config, history: archiveContent.imageConfig.history, + provenanceAttestations: archiveContent.provenanceAttestations, }; } diff --git a/lib/extractor/oci-archive/layer.ts b/lib/extractor/oci-archive/layer.ts index a203332ba..dcc9997af 100644 --- a/lib/extractor/oci-archive/layer.ts +++ b/lib/extractor/oci-archive/layer.ts @@ -13,10 +13,12 @@ import { ExtractedLayers, ExtractedLayersAndManifest, ImageConfig, + InTotoStatement, OciArchiveManifest, OciImageIndex, OciManifestInfo, OciPlatformInfo, + ProvenanceAttestation, } from "../types"; const debug = Debug("snyk"); @@ -56,7 +58,8 @@ export async function extractArchive( const metadata = await extractMetadata(ociArchiveFilesystemPath); // Determine which manifest and layers we need - const { manifest, imageConfig } = resolveManifestAndConfig(metadata, options); + const { manifest, imageConfig, provenanceAttestations } = + resolveManifestAndConfig(metadata, options); // Get the list of layer digests we need to extract const requiredLayerDigests = new Set( @@ -114,6 +117,7 @@ export async function extractArchive( layers: filteredLayers, manifest, imageConfig, + provenanceAttestations, }; } @@ -122,6 +126,7 @@ interface ArchiveMetadata { manifests: Record; indexFiles: Record; configs: ImageConfig[]; + rawBlobs: Record; } /** @@ -140,6 +145,7 @@ async function extractMetadata( const configs: ImageConfig[] = []; let mainIndexFile: OciImageIndex | undefined; const indexFiles: Record = {}; + const rawBlobs: Record = {}; tarExtractor.on("entry", async (header, stream, next) => { try { @@ -148,6 +154,9 @@ async function extractMetadata( if (isMainIndexFile(normalizedHeaderName)) { mainIndexFile = await streamToJson(stream); + debug( + `[provenance-poc] Raw index.json: ${JSON.stringify(mainIndexFile, null, 2)}`, + ); } else if ( isBlobPath(normalizedHeaderName) && (header.size === undefined || header.size <= MAX_JSON_SIZE_BYTES) @@ -158,6 +167,8 @@ async function extractMetadata( if (jsonContent !== undefined) { const digest = getDigestFromPath(normalizedHeaderName); + rawBlobs[digest] = jsonContent; + if (isArchiveManifest(jsonContent)) { manifests[digest] = jsonContent; } else if (isImageIndexFile(jsonContent)) { @@ -180,7 +191,7 @@ async function extractMetadata( }); tarExtractor.on("finish", () => { - resolve({ mainIndexFile, manifests, indexFiles, configs }); + resolve({ mainIndexFile, manifests, indexFiles, configs, rawBlobs }); }); tarExtractor.on("error", (error) => { @@ -357,6 +368,7 @@ function resolveManifestAndConfig( ): { manifest: OciArchiveManifest; imageConfig: ImageConfig; + provenanceAttestations: ProvenanceAttestation[]; } { const filteredConfigs = metadata.configs.filter((config) => { return config?.os !== "unknown" || config?.architecture !== "unknown"; @@ -391,7 +403,15 @@ function resolveManifestAndConfig( ); } - return { manifest, imageConfig }; + const provenanceAttestations = extractProvenanceAttestations(metadata); + + if (provenanceAttestations.length === 0) { + debug( + "[provenance-poc] No provenance attestations found in this image", + ); + } + + return { manifest, imageConfig, provenanceAttestations }; } function getManifest( @@ -517,6 +537,114 @@ function getImageConfig( ); } +function extractProvenanceAttestations( + metadata: ArchiveMetadata, +): ProvenanceAttestation[] { + const attestations: ProvenanceAttestation[] = []; + + debug( + `[provenance-poc] Scanning ${metadata.mainIndexFile!.manifests.length} descriptors in image index`, + ); + + for (const descriptor of metadata.mainIndexFile!.manifests) { + debug( + `[provenance-poc] Descriptor: digest=${descriptor.digest}, mediaType=${descriptor.mediaType}, ` + + `platform=${JSON.stringify(descriptor.platform)}, annotations=${JSON.stringify(descriptor.annotations)}`, + ); + + const isAttestationManifest = + descriptor.platform?.architecture === "unknown" && + descriptor.annotations?.["vnd.docker.reference.type"] === + "attestation-manifest"; + + if (!isAttestationManifest) { + continue; + } + + debug( + `[provenance-poc] Found attestation manifest descriptor: ${descriptor.digest}`, + ); + + const nestedManifest = metadata.rawBlobs[descriptor.digest]; + if (!nestedManifest) { + debug( + `[provenance-poc] Could not find blob for attestation manifest ${descriptor.digest}`, + ); + continue; + } + + debug( + `[provenance-poc] Attestation manifest content: ${JSON.stringify(nestedManifest, null, 2)}`, + ); + + const attestationManifest = nestedManifest as OciArchiveManifest; + if (!attestationManifest.layers || !Array.isArray(attestationManifest.layers)) { + debug( + `[provenance-poc] Attestation manifest ${descriptor.digest} has no layers array`, + ); + continue; + } + + const attestation: ProvenanceAttestation = { + attestationManifestDigest: descriptor.digest, + mediaType: descriptor.mediaType, + annotations: descriptor.annotations || {}, + provenanceLayers: [], + }; + + for (const layer of attestationManifest.layers) { + debug( + `[provenance-poc] Attestation layer: digest=${layer.digest}, mediaType=${layer.mediaType}, ` + + `annotations=${JSON.stringify(layer.annotations)}`, + ); + + const isProvenanceLayer = + layer.annotations?.["in-toto.io/kind"] === "provenance" || + layer.mediaType === "application/vnd.in-toto+json"; + + const provenanceLayer: ProvenanceAttestation["provenanceLayers"][number] = { + digest: layer.digest, + mediaType: layer.mediaType, + annotations: layer.annotations, + }; + + if (isProvenanceLayer) { + debug( + `[provenance-poc] Found provenance layer: ${layer.digest}`, + ); + + const inTotoBlob = metadata.rawBlobs[layer.digest]; + if (inTotoBlob) { + debug( + `[provenance-poc] In-toto statement content: ${JSON.stringify(inTotoBlob, null, 2)}`, + ); + provenanceLayer.inTotoStatement = inTotoBlob as InTotoStatement; + } else { + debug( + `[provenance-poc] Could not find blob for provenance layer ${layer.digest}`, + ); + } + } + + attestation.provenanceLayers.push(provenanceLayer); + } + + attestations.push(attestation); + } + + debug( + `[provenance-poc] Found ${attestations.length} provenance attestation(s)`, + ); + + if (attestations.length > 0) { + debug( + `[provenance-poc] Full attestation data: ${JSON.stringify(attestations, null, 2)}`, + ); + } + + return attestations; +} + function getBestMatchForPlatform( manifests: T[], platformInfo: OciPlatformInfo, diff --git a/lib/extractor/types.ts b/lib/extractor/types.ts index d07b1ea2f..facd2cb8e 100644 --- a/lib/extractor/types.ts +++ b/lib/extractor/types.ts @@ -36,6 +36,7 @@ export interface ExtractionResult { imageCreationTime?: string; containerConfig?: ContainerConfig | null; history?: HistoryEntry[] | null; + provenanceAttestations?: ProvenanceAttestation[]; } export interface ExtractedLayers { @@ -46,6 +47,7 @@ export interface ExtractedLayersAndManifest { layers: ExtractedLayers[]; manifest: DockerArchiveManifest | OciArchiveManifest; imageConfig: ImageConfig; + provenanceAttestations?: ProvenanceAttestation[]; } export interface DockerArchiveManifest { @@ -88,18 +90,25 @@ export interface ImageConfig { export interface OciArchiveLayer { digest: string; + mediaType?: string; + size?: number; + annotations?: Record; } export interface OciArchiveManifest { schemaVersion: string; - config: { digest: string }; + mediaType?: string; + config: { digest: string; mediaType?: string }; layers: OciArchiveLayer[]; + annotations?: Record; } export interface OciManifestInfo { digest: string; mediaType: string; + size?: number; platform?: OciPlatformInfo; + annotations?: Record; } export interface OciPlatformInfo { @@ -109,9 +118,37 @@ export interface OciPlatformInfo { } export interface OciImageIndex { + mediaType?: string; manifests: OciManifestInfo[]; } +export interface InTotoStatement { + _type?: string; + subject?: Array<{ + name?: string; + digest?: Record; + }>; + predicateType?: string; + predicate?: Record; +} + +/** + * https://github.com/opencontainers/image-spec/blob/main/image-index.md + * https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md + * https://slsa.dev/provenance/v1 + */ +export interface ProvenanceAttestation { + attestationManifestDigest: string; + mediaType: string; + annotations: Record; + provenanceLayers: Array<{ + digest: string; + mediaType?: string; + annotations?: Record; + inTotoStatement?: InTotoStatement; + }>; +} + export interface KanikoArchiveManifest { // Usually points to the JSON file in the archive that describes how the image was built. Config: string; diff --git a/lib/facts.ts b/lib/facts.ts index d8902705b..f55c9792b 100644 --- a/lib/facts.ts +++ b/lib/facts.ts @@ -3,6 +3,7 @@ import { ApplicationFiles } from "./analyzer/applications/types"; import { JarFingerprint } from "./analyzer/types"; import { DockerFileAnalysis } from "./dockerfile/types"; import { OCIDistributionMetadata } from "./extractor/oci-distribution-metadata"; +import { ProvenanceAttestation } from "./extractor/types"; import { AutoDetectedUserInstructions, ImageNameInfo, @@ -104,6 +105,11 @@ export interface OCIDistributionMetadataFact { data: OCIDistributionMetadata; } +export interface ProvenanceAttestationsFact { + type: "provenanceAttestations"; + data: ProvenanceAttestation[]; +} + export interface PlatformFact { type: "platform"; data: string; diff --git a/lib/index.ts b/lib/index.ts index 40323a4fc..6b4bdb9b8 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -11,6 +11,7 @@ import { DockerFileAnalysisErrorCode, UpdateDockerfileBaseImageNameErrorCode, } from "./dockerfile/types"; +import { ProvenanceAttestation, InTotoStatement } from "./extractor/types"; import * as facts from "./facts"; import { extractContent, scan } from "./scan"; import { @@ -45,4 +46,6 @@ export { UpdateDockerfileBaseImageNameErrorCode, Binary, parseDockerfile, + ProvenanceAttestation, + InTotoStatement, }; diff --git a/lib/response-builder.ts b/lib/response-builder.ts index d873de472..3276f0b9d 100644 --- a/lib/response-builder.ts +++ b/lib/response-builder.ts @@ -287,6 +287,17 @@ async function buildResponse( additionalFacts.push(metadataFact); } + if ( + depsAnalysis.provenanceAttestations && + depsAnalysis.provenanceAttestations.length > 0 + ) { + const provenanceAttestationsFact: facts.ProvenanceAttestationsFact = { + type: "provenanceAttestations", + data: depsAnalysis.provenanceAttestations, + }; + additionalFacts.push(provenanceAttestationsFact); + } + if (depsAnalysis.platform) { const platformFact: facts.PlatformFact = { type: "platform", diff --git a/lib/types.ts b/lib/types.ts index d4220560e..154edb116 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -74,6 +74,7 @@ export type FactType = | "baseRuntimes" | "loadedPackages" | "ociDistributionMetadata" + | "provenanceAttestations" | "containerConfig" | "platform" | "pluginVersion"