Skip to content
Open
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
7 changes: 5 additions & 2 deletions .github/workflows/advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,21 @@ jobs:
central/csaf-publish \
central/csaf-verify \
central/csaf-keygen \
central/release-advisory \
central/test_gen_advisory.py \
central/test_advisory_completeness.py \
central/test_advisory_overlay_draft.py \
central/test_csaf_publish.py
central/test_csaf_publish.py \
central/test_release_advisory.py

- name: Unit tests
run: |
python3 -m unittest \
central/test_gen_advisory.py \
central/test_advisory_completeness.py \
central/test_advisory_overlay_draft.py \
central/test_csaf_publish.py
central/test_csaf_publish.py \
central/test_release_advisory.py

- name: Completeness gate for every release directory
run: |
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ jobs:
central/csaf-publish \
central/csaf-verify \
central/csaf-keygen \
central/release-advisory \
central/test_csaf_publish.py \
central/test_release_advisory.py \
provenance/bomsh_verify.py \
tools/wolfssl-compliance-tooling-sync \
tests/test_gen_sbom.py \
Expand All @@ -74,7 +76,7 @@ jobs:
run: python -m unittest central/test_gen_advisory.py

- name: Run advisory completeness and publish unit tests
run: python -m unittest central/test_advisory_completeness.py central/test_advisory_overlay_draft.py central/test_csaf_publish.py
run: python -m unittest central/test_advisory_completeness.py central/test_advisory_overlay_draft.py central/test_csaf_publish.py central/test_release_advisory.py

- name: Run SBOM identity tests
run: python -m unittest tests/test_sbom_identity.py
Expand Down
23 changes: 23 additions & 0 deletions central/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ These tools run for wolfSSL only. Do not vendor them into a product.
- `advisory-vex-overlay.schema.json` — the per-CVE overlay schema.
- `advisory-vex-overlay.example.json` — an overlay example.

- `release-advisory` — one wolfSSL release, in two runs. The name is not
`wolfssl-compliance-tooling.sh`. That path is the SBOM driver.

python3 central/release-advisory draft --release 5.9.4 \
--changelog ../wolfssl/ChangeLog.md

`draft` writes missing overlay lines and stops. Read every REVIEW line.
`advisory-completeness` does not judge the determination.

python3 central/release-advisory publish --release 5.9.4 \
--changelog ../wolfssl/ChangeLog.md \
--gpg-key <40-hex primary fingerprint>

`publish` checks completeness, writes the bundle and the per-CVE
documents, and adds them to the provider tree (`csaf-publish --keep`).
Signing is `--gpg-key`. A separate `gpg --armor --detach-sign` does not
update `provider-metadata.json`. The command then prints the copy to the
website and the GitHub release update.

Only wolfSSL is accepted. One product per run. Other products stay in
the provider tree because `--keep` does not rebuild it from this release
alone.

The advisory tool is already multi-product. It keys the PURL and the CPE per
product. The security team makes the advisory from CVE records. No product needs
a build step for advisories.
106 changes: 105 additions & 1 deletion central/csaf-publish
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,82 @@ def _reset_tree(csaf_root):
shutil.rmtree(d)


def _read_index_lines(csaf_root):
index = csaf_root / 'index.txt'
if not index.is_file():
return []
lines = []
for ln in index.read_text().splitlines():
line = ln.strip()
if line and not line.startswith('#'):
lines.append(line)
return lines


def _read_changes(csaf_root):
path = csaf_root / 'changes.csv'
if not path.is_file():
return []
rows = []
with path.open(newline='') as fh:
for row in csv.reader(fh):
if len(row) >= 2 and row[0].strip():
rows.append((row[0].strip(), row[1].strip()))
return rows


def _merge_kept(csaf_root, prior_index, prior_changes, new_index, new_changes,
timestamp):
"""Keep indexed documents this run did not replace.

A later product, or a later wolfSSL release, must not delete the files
already in the provider tree. The new index is the old index plus this
run. A path this run writes replaces that one entry.
"""
new_set = set(new_index)
kept = []
for rel in prior_index:
if rel in new_set:
continue
if (csaf_root / rel).is_file():
kept.append(rel)
index = kept + list(new_index)
times = {rel: ts for rel, ts in prior_changes}
for rel, ts in new_changes:
times[rel] = ts
rows = [(rel, times.get(rel, timestamp)) for rel in index]
rows.sort(key=lambda r: r[1], reverse=True)
return index, rows


def _clear_signature(path):
"""Remove a detached signature for a file this unsigned run rewrote.

--keep does not reset the tree. A previous .asc would otherwise sit
beside the new bytes and no longer match them.
"""
asc = path.with_name(path.name + '.asc')
if asc.is_file():
asc.unlink()


def _drop_unindexed(csaf_root, indexed):
"""Remove a TLP document that is not in the index, and its sidecars."""
for tlp in ('white', 'green', 'amber', 'red'):
tlp_dir = csaf_root / tlp
if not tlp_dir.is_dir():
continue
for path in tlp_dir.glob('*/*.json'):
rel = path.relative_to(csaf_root).as_posix()
if rel in indexed:
continue
path.unlink()
for suffix in ('.sha256', '.sha512', '.asc'):
side = path.with_name(path.name + suffix)
if side.is_file():
side.unlink()


def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
Expand All @@ -247,14 +323,32 @@ def main():
'key id and a subkey fingerprint are rejected. '
'The secret key is not exported. Do not combine '
'with --key-file.')
p.add_argument('--keep', action='store_true',
help='Add this run to the existing provider tree. '
'Documents already listed in index.txt stay. '
'A document this run writes replaces the same path. '
'A .json file that is not in the index is removed. '
'Without this flag the tree is rebuilt from '
'--docs-dir only.')
args = p.parse_args()
if args.key_file and args.gpg_key:
sys.exit('ERROR: pass only one of --key-file and --gpg-key.')

docs_dir = pathlib.Path(args.docs_dir)
csaf_root = pathlib.Path(args.out_root) / '.well-known' / 'csaf'
csaf_root.mkdir(parents=True, exist_ok=True)
_reset_tree(csaf_root)
prior_index = []
prior_changes = []
if args.keep:
prior_index = _read_index_lines(csaf_root)
prior_changes = _read_changes(csaf_root)
Comment thread
sameehj marked this conversation as resolved.
Resolved
if not prior_index:
print('WARNING: --keep found no index.txt. This run is the '
'whole catalogue. Download the published tree into '
'--out-root first if that directory should already '
'contain it.', file=sys.stderr)
else:
_reset_tree(csaf_root)

timestamp = build_timestamp()

Expand Down Expand Up @@ -312,11 +406,19 @@ def main():
_sha_files(dest)
if signer is not None:
signer(dest)
else:
_clear_signature(dest)

index_lines.append(rel)
released = doc['document']['tracking'].get('current_release_date', timestamp)
changes_rows.append((rel, released))

if args.keep:
index_lines, changes_rows = _merge_kept(
csaf_root, prior_index, prior_changes, index_lines, changes_rows,
timestamp)
_drop_unindexed(csaf_root, set(index_lines))
Comment thread
sameehj marked this conversation as resolved.
Resolved

# index.txt
(csaf_root / 'index.txt').write_text('\n'.join(index_lines) + '\n')

Expand All @@ -334,6 +436,8 @@ def main():
_sha_files(md_path)
if signer is not None:
signer(md_path)
else:
_clear_signature(md_path)

signed = 'signed' if signer is not None else 'UNSIGNED'
print(f'Published {len(csaf_docs)} CSAF document(s) [{signed}] to {csaf_root}')
Expand Down
Loading
Loading