Skip to content

Commit a07df35

Browse files
authored
feat: discover slsa v1 provenances for npm packages (#639)
This PR adds support for SLSA v1 provenances when retrieved from npm, and support for the in-toto v1 payloads such provenances contain. Signed-off-by: Ben Selwyn-Smith <[email protected]>
1 parent 7dc41bd commit a07df35

File tree

7 files changed

+389
-24
lines changed

7 files changed

+389
-24
lines changed

src/macaron/slsa_analyzer/package_registry/npm_registry.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""The module provides abstractions for the npm package registry."""
@@ -135,9 +135,10 @@ def download_attestation_payload(self, url: str, download_path: str) -> bool:
135135
* SLSA with "https://slsa.dev/provenance/v0.2" predicateType
136136
* SLSA with "https://slsa.dev/provenance/v1" predicateType
137137
138-
For now we download the SLSA provenance v0.2 in this method.
138+
For now we download the SLSA provenance v0.2 or v1 in this method.
139139
140-
Here is an example SLSA v0.2 provenance: https://registry.npmjs.org/-/npm/v1/attestations/@sigstore/[email protected]
140+
An example SLSA v0.2 provenance: https://registry.npmjs.org/-/npm/v1/attestations/@sigstore/[email protected]
141+
An example SLSA v1 provenance: https://registry.npmjs.org/-/npm/v1/attestations/@sigstore/[email protected]
141142
142143
Parameters
143144
----------
@@ -174,7 +175,7 @@ def download_attestation_payload(self, url: str, download_path: str) -> bool:
174175
if not att.get("predicateType"):
175176
logger.debug("predicateType attribute is missing for %s", url)
176177
continue
177-
if att.get("predicateType") != "https://slsa.dev/provenance/v0.2":
178+
if att.get("predicateType") not in ["https://slsa.dev/provenance/v0.2", "https://slsa.dev/provenance/v1"]:
178179
logger.debug("predicateType %s is not accepted. Skipping...", att.get("predicateType"))
179180
continue
180181
if not (bundle := att.get("bundle")):

src/macaron/slsa_analyzer/provenance/intoto/__init__.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""In-toto provenance schemas and validation."""
@@ -69,10 +69,6 @@ class InTotoV1Payload(NamedTuple):
6969
def validate_intoto_payload(payload: dict[str, JsonType]) -> InTotoPayload:
7070
"""Validate the schema of an in-toto provenance payload.
7171
72-
TODO: Consider using the in-toto-attestation package (https://github.com/in-toto/attestation/tree/main/python),
73-
which contains Python bindings for in-toto attestation.
74-
See issue: https://github.com/oracle/macaron/issues/426.
75-
7672
Parameters
7773
----------
7874
payload : dict[str, JsonType]
@@ -110,6 +106,16 @@ def validate_intoto_payload(payload: dict[str, JsonType]) -> InTotoPayload:
110106
except ValidateInTotoPayloadError as error:
111107
raise error
112108

113-
# TODO: add support for version 1.
109+
if type_ == "https://in-toto.io/Statement/v1":
110+
# The type must always be this value for version v1.
111+
# See specification: https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md.
112+
113+
try:
114+
if v1.validate_intoto_statement(payload):
115+
return InTotoV1Payload(statement=payload)
116+
117+
raise ValidateInTotoPayloadError("Unexpected error while validating the in-toto statement.")
118+
except ValidateInTotoPayloadError as error:
119+
raise error
114120

115121
raise ValidateInTotoPayloadError("Invalid value for the attribute '_type' of the provenance payload.")

src/macaron/slsa_analyzer/provenance/intoto/v01/__init__.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""This module handles in-toto version 0.1 attestations."""
@@ -39,10 +39,6 @@ def validate_intoto_statement(payload: dict[str, JsonType]) -> TypeGuard[InTotoV
3939
4040
Specification: https://github.com/in-toto/attestation/tree/main/spec/v0.1.0#statement.
4141
42-
TODO: Consider using the in-toto-attestation package (https://github.com/in-toto/attestation/tree/main/python),
43-
which contains Python bindings for in-toto attestation.
44-
See issue: https://github.com/oracle/macaron/issues/426.
45-
4642
Parameters
4743
----------
4844
payload : dict[str, JsonType]
@@ -64,9 +60,9 @@ def validate_intoto_statement(payload: dict[str, JsonType]) -> TypeGuard[InTotoV
6460
raise ValidateInTotoPayloadError(
6561
"The attribute '_type' of the in-toto statement is missing.",
6662
)
67-
if not isinstance(type_, str):
63+
if not isinstance(type_, str) or not type_ == "https://in-toto.io/Statement/v0.1":
6864
raise ValidateInTotoPayloadError(
69-
"The value of attribute '_type' in the in-toto statement is invalid: expecting a string.",
65+
"The value of attribute '_type' in the in-toto statement must be: 'https://in-toto.io/Statement/v0.1'",
7066
)
7167

7268
subjects_payload = payload.get("subject")
@@ -107,10 +103,6 @@ def validate_intoto_subject(subject: JsonType) -> TypeGuard[InTotoV01Subject]:
107103
108104
See specification: https://github.com/in-toto/attestation/tree/main/spec/v0.1.0#statement.
109105
110-
TODO: Consider using the in-toto-attestation package (https://github.com/in-toto/attestation/tree/main/python),
111-
which contains Python bindings for in-toto attestation.
112-
See issue: https://github.com/oracle/macaron/issues/426.
113-
114106
Parameters
115107
----------
116108
subject : JsonType
Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,40 @@
1-
# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""This module handles in-toto version 1 attestations."""
55

6-
from typing import TypedDict
6+
from __future__ import annotations
7+
8+
from collections.abc import Callable
9+
from typing import TypedDict, TypeGuard
10+
11+
from macaron.slsa_analyzer.provenance.intoto.errors import ValidateInTotoPayloadError
12+
from macaron.util import JsonType
13+
14+
# The full list of cryptographic algorithms supported in SLSA v1 provenance. These are used as keys within the digest
15+
# set of the resource descriptors within the subject.
16+
# See: https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md
17+
VALID_ALGORITHMS = [
18+
"sha256",
19+
"sha224",
20+
"sha384",
21+
"sha512",
22+
"sha512_224",
23+
"sha512_256",
24+
"sha3_224",
25+
"sha3_256",
26+
"sha3_384",
27+
"sha3_512",
28+
"shake128",
29+
"shake256",
30+
"blake2b",
31+
"blake2s",
32+
"ripemd160",
33+
"sm3",
34+
"gost",
35+
"sha1",
36+
"md5",
37+
]
738

839

940
class InTotoV1Statement(TypedDict):
@@ -12,3 +43,171 @@ class InTotoV1Statement(TypedDict):
1243
This is the type of the payload in a version 1 in-toto attestation.
1344
Specification: https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md.
1445
"""
46+
47+
_type: str
48+
subject: list[InTotoV1ResourceDescriptor]
49+
predicateType: str # noqa: N815
50+
predicate: dict[str, JsonType] | None
51+
52+
53+
class InTotoV1ResourceDescriptor(TypedDict):
54+
"""An in-toto resource descriptor.
55+
56+
Specification: https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md
57+
"""
58+
59+
name: str | None
60+
uri: str | None
61+
digest: dict[str, str] | None
62+
content: str | None
63+
download_location: str | None
64+
media_type: str | None
65+
annotations: dict[str, JsonType] | None
66+
67+
68+
def validate_intoto_statement(payload: dict[str, JsonType]) -> TypeGuard[InTotoV1Statement]:
69+
"""Validate the statement of an in-toto attestation.
70+
71+
Specification: https://github.com/in-toto/attestation/tree/main/spec/v1/statement.md.
72+
73+
Parameters
74+
----------
75+
payload : dict[str, JsonType]
76+
The JSON statement after being base64-decoded.
77+
78+
Returns
79+
-------
80+
TypeGuard[InTotoStatement]
81+
``True`` if the attestation statement is valid, in which case its type is narrowed to an
82+
``InTotoStatement``; ``False`` otherwise.
83+
84+
Raises
85+
------
86+
ValidateInTotoPayloadError
87+
When the payload does not follow the expected schema.
88+
"""
89+
type_ = payload.get("_type")
90+
if type_ is None:
91+
raise ValidateInTotoPayloadError(
92+
"The attribute '_type' of the in-toto statement is missing.",
93+
)
94+
if not isinstance(type_, str) or not type_ == "https://in-toto.io/Statement/v1":
95+
raise ValidateInTotoPayloadError(
96+
"The value of attribute '_type' in the in-toto statement must be: 'https://in-toto.io/Statement/v1'",
97+
)
98+
99+
subjects_payload = payload.get("subject")
100+
if subjects_payload is None:
101+
raise ValidateInTotoPayloadError(
102+
"The attribute 'subject' of the in-toto statement is missing.",
103+
)
104+
if not isinstance(subjects_payload, list):
105+
raise ValidateInTotoPayloadError(
106+
"The value of attribute 'subject' in the in-toto statement is invalid: expecting a list.",
107+
)
108+
109+
for subject_json in subjects_payload:
110+
validate_intoto_subject(subject_json)
111+
112+
predicate_type = payload.get("predicateType")
113+
if predicate_type is None:
114+
raise ValidateInTotoPayloadError(
115+
"The attribute 'predicateType' of the in-toto statement is missing.",
116+
)
117+
118+
if not isinstance(predicate_type, str):
119+
raise ValidateInTotoPayloadError(
120+
"The value of attribute 'predicateType' in the in-toto statement is invalid: expecting a string."
121+
)
122+
123+
predicate = payload.get("predicate")
124+
if predicate is not None and not isinstance(predicate, dict):
125+
raise ValidateInTotoPayloadError(
126+
"The value attribute 'predicate' in the in-toto statement is invalid: expecting an object.",
127+
)
128+
129+
return True
130+
131+
132+
def validate_intoto_subject(subject: JsonType) -> TypeGuard[InTotoV1ResourceDescriptor]:
133+
"""Validate a single subject in the in-toto statement.
134+
135+
See specification: https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md
136+
137+
Parameters
138+
----------
139+
subject : JsonType
140+
The JSON element representing a single subject.
141+
142+
Returns
143+
-------
144+
TypeGuard[InTotoV1ResourceDescriptor]
145+
``True`` if the subject element is valid, in which case its type is narrowed to an
146+
``InTotoV1ResourceDescriptor``; ``False`` otherwise.
147+
148+
Raises
149+
------
150+
ValidateInTotoPayloadError
151+
When the payload does not follow the expecting schema.
152+
"""
153+
if not isinstance(subject, dict):
154+
raise ValidateInTotoPayloadError(
155+
"A subject in the in-toto statement is invalid: expecting an object.",
156+
)
157+
158+
# At least one of 'uri', 'digest', and 'content' must be valid and present.
159+
uri = _validate_property(subject, "uri", lambda x: isinstance(x, str))
160+
content = _validate_property(subject, "content", lambda x: isinstance(x, str))
161+
digest = _validate_property(subject, "digest", is_valid_digest_set)
162+
if not any([uri, content, digest]):
163+
raise ValidateInTotoPayloadError(
164+
"One of 'uri', 'digest', or 'content' must be present and valid within 'subject'."
165+
)
166+
167+
_validate_property(subject, "name", lambda x: isinstance(x, str))
168+
_validate_property(subject, "downloadLocation", lambda x: isinstance(x, str))
169+
_validate_property(subject, "mediaType", lambda x: isinstance(x, str))
170+
_validate_property(subject, "annotations", lambda x: isinstance(x, dict))
171+
172+
return True
173+
174+
175+
def is_valid_digest_set(digest: JsonType) -> bool:
176+
"""Validate the digest set.
177+
178+
Specification for the digest set: https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md.
179+
180+
Parameters
181+
----------
182+
digest : JsonType
183+
The digest set.
184+
185+
Returns
186+
-------
187+
bool:
188+
``True`` if the digest is valid according to the spec.
189+
"""
190+
if not isinstance(digest, dict):
191+
return False
192+
for key in digest:
193+
if key not in VALID_ALGORITHMS:
194+
return False
195+
if not isinstance(digest[key], str):
196+
return False
197+
return True
198+
199+
200+
def _validate_property(
201+
object_: dict[str, JsonType],
202+
key: str,
203+
validator: Callable[[JsonType], bool],
204+
) -> JsonType:
205+
"""Validate the existence and type of target within the passed Json object."""
206+
value = object_.get(key)
207+
if not value:
208+
return None
209+
210+
if not validator(value):
211+
raise ValidateInTotoPayloadError(f"The attribute {key} of the in-toto subject is invalid.")
212+
213+
return value

tests/slsa_analyzer/package_registry/test_npm_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""Tests for the npm registry."""
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) 2024 - 2024, Oracle and/or its affiliates. All rights reserved.
2+
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.

0 commit comments

Comments
 (0)