Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/analyzer/static-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export async function analyze(
imageCreationTime,
containerConfig,
history,
provenanceAttestations,
} = await archiveExtractor.extractImageContent(
imageType,
imagePath,
Expand Down Expand Up @@ -324,6 +325,7 @@ export async function analyze(
imageCreationTime,
containerConfig,
history,
provenanceAttestations,
};
}

Expand Down
2 changes: 2 additions & 0 deletions lib/analyzer/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ImageName } from "../extractor/image";
import { ProvenanceAttestation } from "../extractor/types";
import { BaseRuntime } from "../facts";
import { AutoDetectedUserInstructions, ManifestFile } from "../types";
import {
Expand Down Expand Up @@ -103,6 +104,7 @@ export interface StaticAnalysis {
comment?: string | null;
empty_layer?: boolean | null;
}> | null;
provenanceAttestations?: ProvenanceAttestation[];
}

export interface StaticPackagesAnalysis extends StaticAnalysis {
Expand Down
1 change: 1 addition & 0 deletions lib/extractor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export async function extractImageContent(
imageLabels: archiveContent.imageConfig.config?.Labels,
containerConfig: archiveContent.imageConfig.config,
history: archiveContent.imageConfig.history,
provenanceAttestations: archiveContent.provenanceAttestations,
};
}

Expand Down
134 changes: 131 additions & 3 deletions lib/extractor/oci-archive/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
ExtractedLayers,
ExtractedLayersAndManifest,
ImageConfig,
InTotoStatement,
OciArchiveManifest,
OciImageIndex,
OciManifestInfo,
OciPlatformInfo,
ProvenanceAttestation,
} from "../types";

const debug = Debug("snyk");
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -114,6 +117,7 @@ export async function extractArchive(
layers: filteredLayers,
manifest,
imageConfig,
provenanceAttestations,
};
}

Expand All @@ -122,6 +126,7 @@ interface ArchiveMetadata {
manifests: Record<string, OciArchiveManifest>;
indexFiles: Record<string, OciImageIndex>;
configs: ImageConfig[];
rawBlobs: Record<string, unknown>;
}

/**
Expand All @@ -140,6 +145,7 @@ async function extractMetadata(
const configs: ImageConfig[] = [];
let mainIndexFile: OciImageIndex | undefined;
const indexFiles: Record<string, OciImageIndex> = {};
const rawBlobs: Record<string, unknown> = {};

tarExtractor.on("entry", async (header, stream, next) => {
try {
Expand All @@ -148,6 +154,9 @@ async function extractMetadata(

if (isMainIndexFile(normalizedHeaderName)) {
mainIndexFile = await streamToJson<OciImageIndex>(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)
Expand All @@ -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)) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would mainIndexFile ever be undefined?

);

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 =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just for provenance attestation? Are there other types that could end up with unknown architecture and "attestation-manifest" annotation?

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would malformed attestations be an issue here if they can't be casted to 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<T>(
manifests: T[],
platformInfo: OciPlatformInfo,
Expand Down
39 changes: 38 additions & 1 deletion lib/extractor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ExtractionResult {
imageCreationTime?: string;
containerConfig?: ContainerConfig | null;
history?: HistoryEntry[] | null;
provenanceAttestations?: ProvenanceAttestation[];
}

export interface ExtractedLayers {
Expand All @@ -46,6 +47,7 @@ export interface ExtractedLayersAndManifest {
layers: ExtractedLayers[];
manifest: DockerArchiveManifest | OciArchiveManifest;
imageConfig: ImageConfig;
provenanceAttestations?: ProvenanceAttestation[];
}

export interface DockerArchiveManifest {
Expand Down Expand Up @@ -88,18 +90,25 @@ export interface ImageConfig {

export interface OciArchiveLayer {
digest: string;
mediaType?: string;
size?: number;
annotations?: Record<string, string>;
}

export interface OciArchiveManifest {
schemaVersion: string;
config: { digest: string };
mediaType?: string;
config: { digest: string; mediaType?: string };
layers: OciArchiveLayer[];
annotations?: Record<string, string>;
}

export interface OciManifestInfo {
digest: string;
mediaType: string;
size?: number;
platform?: OciPlatformInfo;
annotations?: Record<string, string>;
}

export interface OciPlatformInfo {
Expand All @@ -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<string, string>;
}>;
predicateType?: string;
predicate?: Record<string, unknown>;
}

/**
* 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<string, string>;
provenanceLayers: Array<{
digest: string;
mediaType?: string;
annotations?: Record<string, string>;
inTotoStatement?: InTotoStatement;
}>;
}

export interface KanikoArchiveManifest {
// Usually points to the JSON file in the archive that describes how the image was built.
Config: string;
Expand Down
6 changes: 6 additions & 0 deletions lib/facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -104,6 +105,11 @@ export interface OCIDistributionMetadataFact {
data: OCIDistributionMetadata;
}

export interface ProvenanceAttestationsFact {
type: "provenanceAttestations";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data: ProvenanceAttestation[];
}

export interface PlatformFact {
type: "platform";
data: string;
Expand Down
3 changes: 3 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -45,4 +46,6 @@ export {
UpdateDockerfileBaseImageNameErrorCode,
Binary,
parseDockerfile,
ProvenanceAttestation,
InTotoStatement,
};
11 changes: 11 additions & 0 deletions lib/response-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type FactType =
| "baseRuntimes"
| "loadedPackages"
| "ociDistributionMetadata"
| "provenanceAttestations"
| "containerConfig"
| "platform"
| "pluginVersion"
Expand Down
Loading