Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
branches: [master]
pull_request:

# This workflow only reads the repo and runs tests; no write access needed.
permissions:
contents: read

Expand Down Expand Up @@ -48,6 +49,7 @@ jobs:
tests/test_gen_sbom.py \
tests/test_sbom.py \
tests/test_sbom_identity.py \
tests/test_low_findings.py \
tests/test_iar_sbom.py \
tests/test_wolfglass_sync.py

Expand All @@ -63,6 +65,9 @@ jobs:
- name: Run SBOM identity tests
run: python -m unittest tests/test_sbom_identity.py

- name: Run low-findings tests
run: python -m unittest tests/test_low_findings.py

- name: Run IAR frontend tests
run: python -m unittest tests/test_iar_sbom.py

Expand Down
40 changes: 36 additions & 4 deletions share/frontends/compdb_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,40 @@ def entry_tokens(entry):
return []


def macro_name(d):
"""Macro name of a -D/-U operand: the text before the first '=' or '(',
so a function-like 'F(a)=a' is keyed as 'F'."""
return re.split(r'[=(]', d, 1)[0]


def extract_defines(tokens):
defs = []
"""Net -D macros for one compile command, honouring a later -U that cancels
an earlier -D of the same name (the compiler applies them in order, so a
macro undefined on the command line was not compiled in)."""
active = {} # macro name -> full 'NAME' or 'NAME=VALUE' token; last -D wins
i = 0
while i < len(tokens):
t = tokens[i]
if t == '-D' and i + 1 < len(tokens):
defs.append(tokens[i + 1])
d = tokens[i + 1]
active[macro_name(d)] = d
i += 2
continue
if t.startswith('-D'):
defs.append(t[2:])
d = t[2:]
active[macro_name(d)] = d
i += 1
continue
if t == '-U' and i + 1 < len(tokens):
active.pop(macro_name(tokens[i + 1]), None)
i += 2
continue
if t.startswith('-U'):
active.pop(macro_name(t[2:]), None)
i += 1
continue
i += 1
return defs
return list(active.values())


def main():
Expand Down Expand Up @@ -108,6 +129,11 @@ def main():
inc = [re.compile(p) for p in args.include]
exc = [re.compile(p) for p in args.exclude]

# When --root is given, only hash sources inside it; a compile_commands.json
# is untrusted input, so a crafted entry naming an absolute path outside the
# product must not pull an unrelated file (and its path) into the SBOM.
root_abs = os.path.realpath(args.root) if args.root else None

srcs, defines, seen = [], set(), set()
for entry in db:
fpath = entry.get('file')
Expand All @@ -119,6 +145,12 @@ def main():
fpath = os.path.normpath(fpath)
if not fpath.lower().endswith(SRC_EXTS):
continue
if root_abs:
real = os.path.realpath(fpath)
if os.path.commonpath([real, root_abs]) != root_abs:
sys.stderr.write(
f"WARNING: skipping source outside --root: {fpath}\n")
continue
if inc and not any(r.search(fpath) for r in inc):
continue
if exc and any(r.search(fpath) for r in exc):
Expand Down
63 changes: 55 additions & 8 deletions share/gen-sbom
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl."""

import argparse
import errno
import hashlib
import io
import json
import os
import re
import stat
import subprocess
import sys
import tempfile
import uuid
from datetime import datetime, timezone

Expand Down Expand Up @@ -344,6 +347,48 @@ def build_timestamp():
return dt, dt.strftime('%Y-%m-%dT%H:%M:%SZ')


def write_json_atomic(obj, path):
"""Write `obj` as JSON to `path` atomically via a sibling temp file and
os.replace, so a crash or ENOSPC mid-write cannot leave a truncated
document that a later CI step would pick up as valid."""
try:
st = os.lstat(path)
except FileNotFoundError:
st = None
# Only a new path or a plain regular file gets the temp+replace dance. A
# symlink (including /dev/stdout), device or fifo is written through
# directly so the link target is updated rather than the link replaced.
if st is not None and not stat.S_ISREG(st.st_mode):
with open(path, 'w') as f:
json.dump(obj, f, indent=2)
f.write('\n')
return
# os.replace only needs a writable directory; keep refusing a read-only
# output the way a plain open(path, 'w') does.
if st is not None and not os.access(path, os.W_OK):
raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), path)
if st is not None:
mode = stat.S_IMODE(st.st_mode)
else:
umask = os.umask(0)
os.umask(umask)
mode = 0o666 & ~umask
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.',
prefix=os.path.basename(path) + '.')
try:
with os.fdopen(fd, 'w') as f:
json.dump(obj, f, indent=2)
f.write('\n')
os.chmod(tmp, mode)
os.replace(tmp, path)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise


# Known metadata for optional external dependencies. Version is detected
# at runtime via pkg-config; falls back to None. Each entry must describe
# the *linked artefact* (so vulnerability scanners like OSV / Grype / Trivy
Expand Down Expand Up @@ -1046,11 +1091,17 @@ def _collect_srcs(srcs_args, srcs_file):
continue
paths.append(stripped)

# Dedup on the normalized absolute path, not the raw string, so the same
# path spelled two ways (foo.c and ./foo.c) collapses to one entry instead
# of tripping srcs_merkle_hash's duplicate-basename guard. Symlinks are
# not resolved: an alias with its own basename stays a distinct entry.
# The first spelling is kept for opening/hashing.
seen = set()
deduped = []
for p in paths:
if p not in seen:
seen.add(p)
key = os.path.normpath(os.path.abspath(p))
if key not in seen:
seen.add(key)
deduped.append(p)

if not deduped:
Expand Down Expand Up @@ -1939,12 +1990,8 @@ def main():
)

try:
with open(args.cdx_out, 'w') as f:
json.dump(cdx, f, indent=2)
f.write('\n')
with open(args.spdx_out, 'w') as f:
json.dump(spdx, f, indent=2)
f.write('\n')
write_json_atomic(cdx, args.cdx_out)
write_json_atomic(spdx, args.spdx_out)
except OSError as e:
sys.exit(f"ERROR: cannot write SBOM output: {e}")

Expand Down
5 changes: 5 additions & 0 deletions share/sbom-driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ def scrub_defines(text):

def read_version(version_file, version_macro):
"""Read a version string from a header, e.g. LIBWOLFBOOT_VERSION_STRING."""
# An empty macro would make re.escape('') a zero-width prefix, so the pattern
# degrades to the first quoted string in the header (a copyright year, an
# unrelated define) -- return "" rather than a silently wrong version.
if not version_macro:
return ""
if not version_file or not os.path.isfile(version_file):
return ""
pat = re.compile(re.escape(version_macro) + r'\s*"([^"]*)"')
Expand Down
8 changes: 6 additions & 2 deletions share/validate_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,14 @@ def main(argv):
try:
with open(path) as f:
d = json.load(f)
except FileNotFoundError:
fail(path, "file not found")
except OSError as e:
fail(path, f"cannot read file: {e}")
except json.JSONDecodeError as e:
fail(path, f"invalid JSON: {e}")
except UnicodeDecodeError as e:
fail(path, f"not UTF-8 text: {e}")
if not isinstance(d, dict):
fail(path, "top-level JSON is not an object")
if "bomFormat" in d or path.endswith(".cdx.json"):
validate_cyclonedx(path, d, args.name_prefix,
args.min_properties, args.require_dep_version)
Expand Down
Loading
Loading