Skip to content

Commit 1c27b10

Browse files
MatteoFaridegenaro
andauthored
feat: add automatic OSCAL manifest generation (#2302)
Signed-off-by: Matteo Fari <matteofari06@gmail.com> Co-authored-by: Lou DeGenaro <lou.degenaro@gmail.com>
1 parent ccd695e commit 1c27b10

17 files changed

Lines changed: 2256 additions & 68 deletions

docs/tutorials/cli.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -627,10 +627,12 @@ The private key is used for signing. Keep it secret. The public key can be share
627627
```bash
628628
trestle sign \
629629
-f catalog.json \
630-
--key private.pem \
630+
--private-key private.pem \
631631
-o catalog.json.dsse
632632
```
633633

634+
By default, signing fails if the output envelope already exists. Pass `--overwrite` to replace an existing envelope.
635+
634636
For an encrypted private key, use `--key-password-env`. The option names an environment variable that contains the password, so the password is not passed as a command-line argument.
635637

636638
```bash
@@ -640,7 +642,7 @@ openssl pkey -in private-encrypted.pem -passin env:TRESTLE_KEY_PASSWORD -pubout
640642
chmod 600 private-encrypted.pem
641643
trestle sign \
642644
-f catalog.json \
643-
--key private-encrypted.pem \
645+
--private-key private-encrypted.pem \
644646
--key-password-env TRESTLE_KEY_PASSWORD \
645647
-o catalog.json.dsse
646648
```
@@ -664,11 +666,40 @@ trestle verify \
664666

665667
If signing used `--subject-name`, pass the same value during verification.
666668

669+
## `trestle generate-manifest`
670+
671+
Trestle generate-manifest creates a package manifest from any top-level OSCAL JSON model: assessment plan, assessment results, catalog, component definition, mapping collection, plan of action and milestones (POA&M), profile, or system security plan (SSP). It follows the model's local and remote OSCAL dependencies recursively and records normalized artifact paths relative to the generated manifest. Catalogs have no further dependencies.
672+
673+
The generate-manifest, sign-manifest, and verify-manifest commands are beta features. Enable them before use:
674+
675+
```bash
676+
trestle beta enable json-manifest-signing
677+
```
678+
679+
Generate a manifest from an SSP:
680+
681+
```bash
682+
trestle generate-manifest \
683+
-f system-security-plans/acme/system-security-plan.json \
684+
--include component-definitions/web/component-definition.json \
685+
-o package.json
686+
```
687+
688+
By default, generation fails if the output manifest already exists. Pass `--overwrite` to replace an existing manifest.
689+
690+
Use `--include` for other OSCAL JSON files that are part of the package but cannot be inferred from the primary model. Multiple files may be supplied as a comma-separated list. For example, component definitions require explicit inclusion when the primary model is an SSP because SSP assembly does not retain their source paths.
691+
692+
Discovery follows assessment-results to assessment-plan, assessment-plan and POA&M to SSP, SSP to profile or catalog, profile to profile or catalog, component-definition imports and control-implementation sources, and mapping-collection to its declared catalog or profile resources. Local dependencies must stay inside the directory containing the output manifest.
693+
694+
HTTPS and SFTP dependency references are fetched using Trestle's remote cache and copied as canonical JSON into the package's `remote` directory. Relative references in downloaded documents are resolved against their original remote URI. Review the generated manifest and downloaded artifacts before signing them.
695+
696+
Automatic discovery accepts at most 1,000 artifacts, 64 dependency levels, 50 MiB per remote artifact, and 500 MiB of remote artifacts in total. Loopback, link-local, cloud metadata, and private network endpoints are blocked. Use `--allow-private-uris` only when dependencies are hosted on a trusted private network. Loopback, link-local, and cloud metadata endpoints remain blocked when the option is used.
697+
667698
## `trestle sign-manifest`
668699

669700
Trestle sign-manifest writes a detached DSSE envelope for a JSON package manifest. The manifest lists related JSON artifacts. Trestle canonicalizes each artifact using RFC 8785, records each SHA-256 digest in an in-toto Statement, and signs the package Statement with a PEM private key.
670701

671-
The sign-manifest and verify-manifest commands are beta features. Enable them before use:
702+
Enable the package manifest beta feature before use:
672703

673704
```bash
674705
trestle beta enable json-manifest-signing
@@ -708,10 +739,12 @@ Sign the package manifest:
708739
```bash
709740
trestle sign-manifest \
710741
--manifest package.json \
711-
--key private.pem \
742+
--private-key private.pem \
712743
-o package.dsse
713744
```
714745

746+
By default, manifest signing fails if the output envelope already exists. Pass `--overwrite` to replace an existing envelope.
747+
715748
For an encrypted private key, use `--key-password-env` as with `trestle sign`.
716749

717750
The signed package Statement uses the [OSCAL package predicate](../predicates/oscal-package/v1.md).

tests/trestle/core/commands/beta_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ def test_beta_query_global_verbose_does_not_show_details(monkeypatch: MonkeyPatc
155155
assert 'Description: Sample beta feature' not in output
156156

157157

158+
def test_beta_query_aligns_feature_descriptions(monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]) -> None:
159+
"""Test query aligns descriptions after feature names of different lengths."""
160+
patch_beta_features(monkeypatch, sample_feature('short'), sample_feature('much-longer-feature-name'))
161+
monkeypatch.setenv(beta_features.TRESTLE_BETA_FEATURES_ENV, 'short,much-longer-feature-name')
162+
163+
execute_command_and_assert('trestle beta query', CmdReturnCodes.SUCCESS.value, monkeypatch)
164+
165+
output, _ = capsys.readouterr()
166+
feature_lines = [line for line in output.splitlines() if line.startswith(' [enabled]')]
167+
assert len({line.index('Sample beta feature') for line in feature_lines}) == 1
168+
169+
158170
def test_beta_unknown_feature(monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]) -> None:
159171
"""Test enabling an unknown beta feature fails."""
160172
patch_beta_features(monkeypatch, sample_feature())
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# -*- mode:python; coding:utf-8 -*-
2+
3+
# Copyright (c) 2026 The OSCAL Compass Authors. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""Tests for trestle generate-manifest command."""
17+
18+
import json
19+
import pathlib
20+
import sys
21+
from typing import Any, Dict, Tuple
22+
23+
import pytest
24+
from _pytest.monkeypatch import MonkeyPatch
25+
from cryptography.hazmat.primitives import serialization
26+
from cryptography.hazmat.primitives.asymmetric import ed25519
27+
28+
from tests import test_utils
29+
30+
from trestle.cli import Trestle
31+
from trestle.common import const
32+
from trestle.core.commands.common.return_codes import CmdReturnCodes
33+
from trestle.core.commands.generate_manifest import GenerateManifestCmd
34+
from trestle.core.commands.sign_manifest import SignManifestCmd
35+
from trestle.core.commands.verify_manifest import VerifyManifestCmd
36+
from trestle.core.signing_manifest import load_signing_manifest
37+
38+
39+
@pytest.fixture(autouse=True)
40+
def enable_json_manifest_signing_beta(monkeypatch: MonkeyPatch) -> None:
41+
"""Enable automatic package generation for command tests."""
42+
monkeypatch.setenv('TRESTLE_BETA_FEATURES', 'json-manifest-signing')
43+
44+
45+
def _read_json(path: pathlib.Path) -> Dict[str, Any]:
46+
return json.loads(path.read_text(encoding=const.FILE_ENCODING))
47+
48+
49+
def _write_json(path: pathlib.Path, data: Dict[str, Any]) -> pathlib.Path:
50+
path.parent.mkdir(parents=True, exist_ok=True)
51+
path.write_text(json.dumps(data), encoding=const.FILE_ENCODING)
52+
return path
53+
54+
55+
def _write_test_package(package_root: pathlib.Path) -> Tuple[pathlib.Path, pathlib.Path]:
56+
catalog = _read_json(test_utils.JSON_TEST_DATA_PATH / 'minimal_catalog.json')
57+
_write_json(package_root / 'catalogs/catalog.json', catalog)
58+
59+
profile = _read_json(test_utils.JSON_TEST_DATA_PATH / 'simple_test_profile.json')
60+
profile['profile']['imports'] = [{'href': '../catalogs/catalog.json', 'include-all': {}}]
61+
profile['profile'].pop('back_matter', None)
62+
_write_json(package_root / 'profiles/profile.json', profile)
63+
64+
ssp = _read_json(test_utils.TEST_DIR / 'data/author/ssp/ssp_example.json')
65+
ssp['system-security-plan']['import-profile']['href'] = '../../profiles/profile.json'
66+
ssp_path = _write_json(package_root / 'system-security-plans/acme/ssp.json', ssp)
67+
68+
component = _read_json(test_utils.TEST_DIR / 'data/validate/component-definitions/x1/component-definition.json')
69+
component_path = _write_json(package_root / 'component-definitions/web/component-definition.json', component)
70+
return ssp_path, component_path
71+
72+
73+
def _write_ed25519_key_pair(tmp_path: pathlib.Path) -> Tuple[pathlib.Path, pathlib.Path]:
74+
private_key = ed25519.Ed25519PrivateKey.generate()
75+
private_key_path = tmp_path / 'private.pem'
76+
public_key_path = tmp_path / 'public.pem'
77+
private_key_path.write_bytes(
78+
private_key.private_bytes(
79+
encoding=serialization.Encoding.PEM,
80+
format=serialization.PrivateFormat.PKCS8,
81+
encryption_algorithm=serialization.NoEncryption(),
82+
)
83+
)
84+
public_key_path.write_bytes(
85+
private_key.public_key().public_bytes(
86+
encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
87+
)
88+
)
89+
return private_key_path, public_key_path
90+
91+
92+
def test_generate_manifest_requires_beta_feature(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
93+
"""Generate-manifest should be blocked unless its beta feature is enabled."""
94+
monkeypatch.delenv('TRESTLE_BETA_FEATURES', raising=False)
95+
monkeypatch.setenv('XDG_CONFIG_HOME', str(tmp_path / 'xdg-config'))
96+
monkeypatch.setattr(
97+
sys,
98+
'argv',
99+
['trestle', 'generate-manifest', '-f', str(tmp_path / 'ssp.json'), '-o', str(tmp_path / 'package.json')],
100+
)
101+
102+
assert Trestle().run() == CmdReturnCodes.COMMAND_ERROR.value
103+
104+
105+
def test_generate_manifest_accepts_one_time_beta_and_explicit_include(
106+
tmp_path: pathlib.Path, monkeypatch: MonkeyPatch
107+
) -> None:
108+
"""The command should generate valid JSON with a one-time beta flag and explicit include."""
109+
monkeypatch.delenv('TRESTLE_BETA_FEATURES', raising=False)
110+
monkeypatch.setenv('XDG_CONFIG_HOME', str(tmp_path / 'xdg-config'))
111+
package_root = tmp_path / 'package'
112+
ssp_path, component_path = _write_test_package(package_root)
113+
output_path = package_root / 'package.json'
114+
monkeypatch.setattr(
115+
sys,
116+
'argv',
117+
[
118+
'trestle',
119+
'generate-manifest',
120+
'--beta',
121+
'-f',
122+
str(ssp_path),
123+
'--include',
124+
str(component_path),
125+
'--allow-private-uris',
126+
'-o',
127+
str(output_path),
128+
],
129+
)
130+
131+
assert Trestle().run() == CmdReturnCodes.SUCCESS.value
132+
manifest = load_signing_manifest(output_path)
133+
assert manifest.primary_artifact == 'system-security-plans/acme/ssp.json'
134+
assert [artifact.name for artifact in manifest.artifacts] == [
135+
'system-security-plans/acme/ssp.json',
136+
'profiles/profile.json',
137+
'catalogs/catalog.json',
138+
'component-definitions/web/component-definition.json',
139+
]
140+
assert not (tmp_path / 'xdg-config').exists()
141+
142+
143+
def test_generate_manifest_parses_comma_separated_includes(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
144+
"""The CLI should pass each comma-separated explicit artifact to discovery."""
145+
package_root = tmp_path / 'package'
146+
ssp_path, first_component = _write_test_package(package_root)
147+
second_component = package_root / 'component-definitions/database/component-definition.json'
148+
second_component.parent.mkdir(parents=True)
149+
second_component.write_text(first_component.read_text(encoding=const.FILE_ENCODING), encoding=const.FILE_ENCODING)
150+
output_path = package_root / 'package.json'
151+
monkeypatch.setattr(
152+
sys,
153+
'argv',
154+
[
155+
'trestle',
156+
'generate-manifest',
157+
'-f',
158+
str(ssp_path),
159+
'--include',
160+
f'{first_component},{second_component}',
161+
'-o',
162+
str(output_path),
163+
],
164+
)
165+
166+
assert Trestle().run() == CmdReturnCodes.SUCCESS.value
167+
assert _artifact_names(output_path)[-2:] == [
168+
'component-definitions/web/component-definition.json',
169+
'component-definitions/database/component-definition.json',
170+
]
171+
172+
173+
def test_generate_manifest_overwrites_existing_output(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
174+
"""The command should replace an existing output only when explicitly requested."""
175+
package_root = tmp_path / 'package'
176+
ssp_path, _ = _write_test_package(package_root)
177+
output_path = package_root / 'package.json'
178+
output_path.write_text('{"old":true}', encoding=const.FILE_ENCODING)
179+
monkeypatch.setattr(
180+
sys, 'argv', ['trestle', 'generate-manifest', '-f', str(ssp_path), '--overwrite', '-o', str(output_path)]
181+
)
182+
183+
assert Trestle().run() == CmdReturnCodes.SUCCESS.value
184+
assert _artifact_names(output_path) == [
185+
'system-security-plans/acme/ssp.json',
186+
'profiles/profile.json',
187+
'catalogs/catalog.json',
188+
]
189+
190+
191+
def _artifact_names(manifest_path: pathlib.Path) -> list[str]:
192+
return [artifact.name for artifact in load_signing_manifest(manifest_path).artifacts]
193+
194+
195+
def test_generate_sign_verify_real_nist_package(tmp_path: pathlib.Path) -> None:
196+
"""A discovered real NIST SSP, profile, and catalog package should sign and verify."""
197+
package_root = tmp_path / 'package'
198+
ssp_data = _read_json(test_utils.NIST_EXAMPLES / 'ssp/json/ssp-example.json')
199+
ssp_data['system-security-plan']['import-profile']['href'] = '../../profiles/nist-low/profile.json'
200+
ssp_path = _write_json(package_root / 'system-security-plans/acme/ssp.json', ssp_data)
201+
202+
profile_data = _read_json(test_utils.JSON_NIST_DATA_PATH / 'NIST_SP-800-53_rev5_LOW-baseline_profile.json')
203+
profile_data['profile']['imports'][0]['href'] = '../../catalogs/nist/catalog.json'
204+
_write_json(package_root / 'profiles/nist-low/profile.json', profile_data)
205+
catalog_data = _read_json(test_utils.JSON_NIST_DATA_PATH / test_utils.JSON_NIST_CATALOG_NAME)
206+
_write_json(package_root / 'catalogs/nist/catalog.json', catalog_data)
207+
208+
manifest_path = package_root / 'package.json'
209+
envelope_path = package_root / 'package.dsse'
210+
private_key_path, public_key_path = _write_ed25519_key_pair(tmp_path)
211+
212+
GenerateManifestCmd.generate_manifest(ssp_path, manifest_path)
213+
SignManifestCmd.sign_manifest(manifest_path, private_key_path, envelope_path)
214+
VerifyManifestCmd.verify_manifest(manifest_path, envelope_path, public_key_path)
215+
216+
assert _artifact_names(manifest_path) == [
217+
'system-security-plans/acme/ssp.json',
218+
'profiles/nist-low/profile.json',
219+
'catalogs/nist/catalog.json',
220+
]
221+
assert envelope_path.is_file()

0 commit comments

Comments
 (0)