Files
sovereign_ai/SovereignAI-Starter/scripts/generate_component_inventory.py
T

329 lines
11 KiB
Python

"""Generate a preliminary CycloneDX inventory from the local Python and Flutter locks."""
from __future__ import annotations
import argparse
import gzip
import hashlib
import importlib.metadata
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import unquote, urlparse
from uuid import uuid4
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def canonical_name(value: str) -> str:
return re.sub(r"[-_.]+", "-", value).lower()
def requirement_names(path: Path) -> set[str]:
names: set[str] = set()
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.split("#", 1)[0].strip()
match = re.match(r"([A-Za-z0-9_.-]+)", line)
if match:
names.add(canonical_name(match.group(1)))
return names
def python_components(root: Path) -> list[dict[str, object]]:
direct = requirement_names(root / "requirements.txt")
optional = requirement_names(root / "requirements-ocr.txt")
components: list[dict[str, object]] = []
seen: set[str] = set()
for distribution in importlib.metadata.distributions():
name = distribution.metadata.get("Name")
version = distribution.version
if not name or not version:
continue
key = canonical_name(name)
if key in seen:
continue
seen.add(key)
raw_license = (
distribution.metadata.get("License-Expression")
or distribution.metadata.get("License")
or ""
).strip()
if not raw_license:
raw_license = next(
(
value.removeprefix("License :: ")
for value in distribution.metadata.get_all("Classifier", [])
if value.startswith("License :: ")
),
"",
)
scope = (
"optional-ocr"
if key in optional
else "direct-runtime"
if key in direct
else "installed-transitive-or-development"
)
components.append(
component(
ecosystem="pypi",
name=name,
version=version,
scope=scope,
raw_license=raw_license,
license_source="installed Python distribution metadata",
)
)
return sorted(components, key=lambda item: str(item["name"]).lower())
def component(
*,
ecosystem: str,
name: str,
version: str,
scope: str,
raw_license: str,
license_source: str,
license_file: Path | None = None,
) -> dict[str, object]:
normalized = canonical_name(name) if ecosystem == "pypi" else name.lower()
result: dict[str, object] = {
"type": "library",
"bom-ref": f"pkg:{ecosystem}/{normalized}@{version}",
"name": name,
"version": version,
"purl": f"pkg:{ecosystem}/{normalized}@{version}",
"properties": [
{"name": "inventory.scope", "value": scope},
{"name": "inventory.license_source", "value": license_source},
],
}
if raw_license:
result["licenses"] = [{"license": {"name": raw_license}}]
else:
result["properties"].append(
{"name": "inventory.license_status", "value": "not-present-in-metadata"}
)
if license_file is not None:
result["properties"].extend(
[
{
"name": "inventory.license_file",
"value": license_file.name,
},
{
"name": "inventory.license_file_sha256",
"value": digest(license_file),
},
]
)
return result
def parse_lockfile(path: Path) -> list[dict[str, str]]:
packages: list[dict[str, str]] = []
current: dict[str, str] | None = None
package_header = re.compile(r"^ ([A-Za-z0-9_]+):$")
for line in path.read_text(encoding="utf-8").splitlines():
match = package_header.match(line)
if match:
if current is not None and current.get("version"):
packages.append(current)
current = {"name": match.group(1)}
continue
if current is None:
continue
for field in ("dependency", "source", "version"):
field_match = re.match(rf"^ {field}:\s*(.*?)\s*$", line)
if field_match:
current[field] = field_match.group(1).strip('"\'')
if current is not None and current.get("version"):
packages.append(current)
return packages
def package_roots(config_path: Path) -> dict[str, Path]:
config = json.loads(config_path.read_text(encoding="utf-8"))
roots: dict[str, Path] = {}
for package in config.get("packages", []):
uri = urlparse(package.get("rootUri", ""))
if uri.scheme == "file":
raw_path = unquote(uri.path)
if re.match(r"^/[A-Za-z]:/", raw_path):
raw_path = raw_path[1:]
roots[package["name"]] = Path(raw_path)
return roots
def flutter_license_file(package_root: Path) -> Path | None:
try:
files = {item.name.lower(): item for item in package_root.iterdir() if item.is_file()}
except OSError:
return None
for name in ("license", "license.txt", "license.md", "copying", "copying.txt"):
if name in files:
return files[name]
return None
def flutter_components(root: Path) -> list[dict[str, object]]:
app_root = root / "flutter_app"
packages = parse_lockfile(app_root / "pubspec.lock")
roots = package_roots(app_root / ".dart_tool" / "package_config.json")
components: list[dict[str, object]] = []
for package in packages:
name = package["name"]
version = package["version"]
scope = package.get("dependency", "transitive")
package_root = roots.get(name)
license_file = flutter_license_file(package_root) if package_root else None
declared = ""
license_source = "resolved pubspec.lock; license declaration unavailable"
if package.get("source") == "sdk" and package_root is not None:
sdk_license = package_root.parent.parent / "LICENSE"
if sdk_license.is_file():
license_file = sdk_license
declared = "Flutter SDK root license notice (BSD-style)"
license_source = "Flutter SDK root LICENSE inherited by SDK package"
if package_root:
pubspec = package_root / "pubspec.yaml"
if pubspec.exists() and not declared:
match = re.search(
r"(?m)^license:\s*(.*?)\s*$",
pubspec.read_text(encoding="utf-8", errors="replace"),
)
if match:
declared = match.group(1).strip('"\'')
license_source = "package pubspec.yaml declaration"
if not declared and license_file:
declared = f"See included {license_file.name} file; not automatically classified"
license_source = "package license file (hash recorded; human classification required)"
components.append(
component(
ecosystem="pub",
name=name,
version=version,
scope=scope,
raw_license=declared,
license_source=license_source,
license_file=license_file,
)
)
return sorted(components, key=lambda item: str(item["name"]).lower())
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--project-root",
type=Path,
default=Path(__file__).resolve().parents[1],
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output JSON path (default: sbom/component-inventory.json)",
)
parser.add_argument(
"--flutter-notices",
type=Path,
default=None,
help="Optional Flutter build's bundled NOTICES.Z archive for evidence/hash",
)
args = parser.parse_args()
root = args.project_root.resolve()
output = args.output or root / "sbom" / "component-inventory.json"
components = python_components(root) + flutter_components(root)
metadata: dict[str, object] = {
"timestamp": datetime.now(UTC).isoformat(),
"tools": [{"name": "generate_component_inventory.py"}],
"component": {
"type": "application",
"name": "SovereignAI-Starter",
"version": "1.0.0",
},
"properties": [
{
"name": "inventory.note",
"value": "Preliminary local environment inventory; not a legal approval or a complete commercial SBOM.",
},
{
"name": "inventory.python_requirements_sha256",
"value": digest(root / "requirements.txt"),
},
{
"name": "inventory.ocr_requirements_sha256",
"value": digest(root / "requirements-ocr.txt"),
},
{
"name": "inventory.flutter_lock_sha256",
"value": digest(root / "flutter_app" / "pubspec.lock"),
},
],
}
if args.flutter_notices:
notice_bytes = args.flutter_notices.read_bytes()
metadata["properties"].extend(
[
{
"name": "inventory.flutter_notices_sha256",
"value": hashlib.sha256(notice_bytes).hexdigest(),
},
{
"name": "inventory.flutter_notices_uncompressed_bytes",
"value": str(len(gzip.decompress(notice_bytes))),
},
]
)
document = {
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": f"urn:uuid:{uuid4()}",
"version": 1,
"metadata": metadata,
"components": components,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
python_count = sum(item["purl"].startswith("pkg:pypi/") for item in components)
pub_count = sum(item["purl"].startswith("pkg:pub/") for item in components)
missing = sum("licenses" not in item for item in components)
unclassified = sum(
any(
prop["name"] == "inventory.license_source"
and "human classification required" in prop["value"]
for prop in item["properties"]
)
for item in components
)
hashed_licenses = sum(
any(prop["name"] == "inventory.license_file_sha256" for prop in item["properties"])
for item in components
)
print(
json.dumps(
{
"output": str(output),
"python_components": python_count,
"flutter_components": pub_count,
"components_missing_license_evidence": missing,
"license_files_hashed": hashed_licenses,
"license_files_requiring_manual_classification": unclassified,
"complete_commercial_audit": False,
},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())