Extending strata SBOM β Custom Collectors and Parsersο
Create custom collectors and lockfile parsers to extend SBOM generation for your specific dependency formats and component sources.
Overviewο
strata SBOM has two plugin points:
1. Lockfile Parsers β zero-config drop-inο
Add support for new dependency manifest formats by dropping a Python file into .strata/lockfile_parsers/:
.strata/
βββ lockfile_parsers/
βββ cargo_lock.py # Custom Rust parser
βββ pdm_lock.py # Custom Python parser
βββ private_pip_index.py # Private package index parser
Auto-discovered on every strata build sbom run. No configuration needed.
2. Collectors β config-drivenο
Add a collector for non-standard dependency file types or custom component sources by declaring it in .strata/collectors.yaml:
collectors:
- name: my-custom-collector
path: .strata/collectors/my_collector.py
class: MyCollector
type: collector
Used by strata build sbom after built-in collectors (image, helm, terraform, ansible, deps).
Quick Startο
Option A: Add a Lockfile Parser (easiest)ο
For a new dependency manifest format (e.g., Pipenv.lock, private registry lock files):
# .strata/lockfile_parsers/pipenv_lock.py
from pathlib import Path
from typing import List
import json
from strata.builders.sbom.lockfile_parsers._base import LockfileParser, RawDependency
class PipenvLockParser(LockfileParser):
"""Parse Pipenv lock files."""
@property
def ecosystem(self) -> str:
return "pypi"
def filename_patterns(self) -> List[str]:
return ["Pipenv.lock"]
def parse(self, path: Path) -> List[RawDependency]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(str(exc)) from exc
deps: List[RawDependency] = []
for section in ["default", "develop"]:
packages = data.get(section) or {}
for name, info in packages.items():
version = info.get("version", "").lstrip("=")
deps.append(RawDependency(name=name, version=version or None))
return deps
Run:
strata build sbom -f deploy.yaml
# Custom parser is auto-discovered and used
Option B: Add a Collector (advanced)ο
For custom components that arenβt dependency files (e.g., Terraform variables, custom binaries):
# .strata/collectors/custom_binary_collector.py
from pathlib import Path
from typing import List
from strata.builders.sbom.base_sbom_collector import BaseSbomCollector
from strata.models.platform_artifact_model import PlatformArtifactModel
from strata.models.sbom_model import SbomComponentModel
class CustomBinaryCollector(BaseSbomCollector):
"""Collect custom binary components."""
def get_collector_name(self) -> str:
return "custom_binary"
def collect(
self,
platform: PlatformArtifactModel,
work_path: Path,
deployment_build_path: Path,
) -> List[SbomComponentModel]:
self._reset_warnings()
components: List[SbomComponentModel] = []
# Your custom collection logic here
# Extract components from platform artifact or scan filesystem
return components
Declare in .strata/collectors.yaml:
collectors:
- name: custom-binaries
path: .strata/collectors/custom_binary_collector.py
class: CustomBinaryCollector
type: collector
Lockfile Parser APIο
Base Class: LockfileParserο
from strata.builders.sbom.lockfile_parsers._base import LockfileParser, RawDependency
Implement these three methods:
1. ecosystem (property)ο
Return a purl type identifier:
@property
def ecosystem(self) -> str:
"""Return purl type: 'pypi', 'npm', 'golang', 'maven', 'gem', 'crate', etc."""
return "pypi"
Common purl types:
Ecosystem |
purl type |
Examples |
|---|---|---|
Python |
|
requests, flask, pandas |
JavaScript |
|
react, express, lodash |
Go |
|
github.com/sirupsen/logrus |
Java/Maven |
|
org.apache:commons-lang3 |
Ruby |
|
rails, sinatra, bundler |
Rust |
|
serde, tokio |
.NET |
|
Newtonsoft.Json, AutoMapper |
PHP |
|
symfony/console, laravel/framework |
See purl spec for the full list.
2. filename_patterns() methodο
Return a list of glob patterns matched against filenames (not full paths):
def filename_patterns(self) -> List[str]:
"""Return glob patterns to match manifest files."""
return ["requirements*.txt", "requirements-*.txt"]
Patterns are matched case-insensitively against the filename only, not the directory path. Examples:
["Pipenv.lock"] # Match only Pipenv.lock
["package-lock.json"] # npm lockfile
["requirements*.txt"] # requirements.txt, requirements-dev.txt, etc.
["go.sum"] # Go module dependencies
["Gemfile.lock"] # Ruby Bundler
["*.toml"] # Any TOML file (risky; use specific names)
3. parse(path: Path) -> List[RawDependency] methodο
Extract (name, version) pairs from the file. Must raise ValueError on parse failure β the collector catches it, logs a warning, and continues.
def parse(self, path: Path) -> List[RawDependency]:
"""Extract dependency pairs from the manifest file.
Raise ValueError on parse failure (file not found, invalid format, etc.).
Never raise other exceptions.
"""
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"Could not read {path}: {exc}") from exc
deps: List[RawDependency] = []
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
name, version = parse_line(line) # Your parsing logic
deps.append(RawDependency(name=name, version=version))
return deps
RawDependency β Return Typeο
class RawDependency(NamedTuple):
name: str # Package name
version: Optional[str] # Version or None if unpinned
Version handling:
Pinned:
RawDependency("flask", "2.3.1")β produces a versioned componentUnpinned:
RawDependency("flask", None)β component has no version; purl has no@versionRange/constraint:
RawDependency("flask", ">=2.0,<3.0")β version field contains the constraint string
Auto-Registrationο
Parsers auto-register when the module is imported β you donβt call any registration function:
class MyParser(LockfileParser):
"""Subclass of LockfileParser with all abstractmethods implemented."""
# This class is automatically registered into DEFAULT_REGISTRY
# when the module is imported.
...
# No need for:
# DEFAULT_REGISTRY.register(MyParser()) β Happens automatically
To skip auto-registration (for abstract base classes or test stubs):
class MyAbstractParser(LockfileParser, register=False):
"""This will NOT be registered."""
...
Collector APIο
Base Class: BaseSbomCollectorο
from strata.builders.sbom.base_sbom_collector import BaseSbomCollector
from strata.models.platform_artifact_model import PlatformArtifactModel
from strata.models.sbom_model import SbomComponentModel
Implement these three methods:
1. get_collector_name() methodο
Return a short identifier:
def get_collector_name(self) -> str:
"""Return identifier: 'image', 'helm', 'custom_scanner', etc."""
return "my_collector"
Used in logs and in the source_collector field of components.
2. collect() methodο
Extract components from the platform artifact:
def collect(
self,
platform: PlatformArtifactModel,
work_path: Path,
deployment_build_path: Path,
) -> List[SbomComponentModel]:
"""Extract components from the platform artifact.
Args:
platform: Assembled platform artifact model (contains spec.modules, etc.)
work_path: Workspace root directory
deployment_build_path: Deployment build directory (e.g., build/{deployment}/{version}/)
Returns:
List of SbomComponentModel instances. Empty list if no components found.
"""
self._reset_warnings() # Clear warnings from previous call
components: List[SbomComponentModel] = []
# Your extraction logic
# Use platform.spec, work_path, or deployment_build_path as needed
return components
3. Warnings handlingο
Collect non-fatal issues (missing files, parse errors) in the warnings list:
def collect(self, platform, work_path, deployment_build_path):
self._reset_warnings()
components: List[SbomComponentModel] = []
# ... collection logic ...
if missing_file:
self._warnings.append("Optional config file not found: /path/to/file")
return components
# After calling collect(), caller reads warnings:
components = my_collector.collect(...)
for warning in my_collector.get_warnings():
logger.warning("Collector warning", msg=warning)
SbomComponentModel β Return Typeο
class SbomComponentModel:
component_type: str # "container" | "library" | "framework"
name: str # Component name
version: Optional[str] # Version string or None
purl: str # Package URL (pkg:docker/..., pkg:helm/..., etc.)
properties: Dict[str, str] # Metadata (e.g., {"strata:tag-stability": "floating"})
source_collector: str # Collector name that produced this component
Example:
components.append(
SbomComponentModel(
component_type="container",
name="nginx",
version="1.25.0",
purl="pkg:docker/library/nginx@1.25.0",
properties={"strata:tag-stability": "floating"},
source_collector=self.get_collector_name(),
)
)
Complete Example: Python Private Index Parserο
Support a private package index with a custom lockfile format:
# .strata/lockfile_parsers/private_pypi.py
"""Parse private PyPI JSON index files."""
import json
from pathlib import Path
from typing import List
from strata.builders.sbom.lockfile_parsers._base import LockfileParser, RawDependency
class PrivatePyPiParser(LockfileParser):
"""Parse private PyPI JSON index (internal tool format).
File format:
{
"packages": {
"mylib": {"version": "1.0.2", "url": "https://private-index/mylib-1.0.2.tar.gz"},
"otherlib": {"version": "2.1.0", ...}
}
}
"""
@property
def ecosystem(self) -> str:
return "pypi"
def filename_patterns(self) -> List[str]:
return ["private-index.json"]
def parse(self, path: Path) -> List[RawDependency]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"Failed to parse {path.name}: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(f"Expected JSON object, got {type(data).__name__}")
packages = data.get("packages") or {}
deps: List[RawDependency] = []
for name, info in packages.items():
if not isinstance(info, dict):
continue
version = info.get("version")
deps.append(RawDependency(name=str(name), version=version or None))
return deps
Complete Example: Custom Collector β Terraform Variablesο
Extract version constraints from Terraform variable defaults:
# .strata/collectors/terraform_vars_collector.py
"""Collect Terraform provider version constraints."""
import re
from pathlib import Path
from typing import List
from strata.builders.sbom.base_sbom_collector import BaseSbomCollector
from strata.models.platform_artifact_model import PlatformArtifactModel
from strata.models.sbom_model import SbomComponentModel
class TerraformVarsCollector(BaseSbomCollector):
"""Extract version constraints from Terraform provider requirements.
Scans platform artifact for declared Terraform providers and their
required versions, producing a component per provider.
"""
def get_collector_name(self) -> str:
return "terraform_vars"
def collect(
self,
platform: PlatformArtifactModel,
work_path: Path,
deployment_build_path: Path,
) -> List[SbomComponentModel]:
self._reset_warnings()
components: List[SbomComponentModel] = []
if not platform.spec or not platform.spec.modules:
return components
seen_providers: set[str] = set()
# Iterate through all modules
for module in platform.spec.modules:
# Assume module has a 'terraform_providers' field
# (This is an example; adjust to your actual structure)
providers = getattr(module, "terraform_providers", None) or []
for provider in providers:
# provider: {"name": "aws", "version": "~> 5.0"}
provider_name = getattr(provider, "name", "")
provider_version = getattr(provider, "version", None)
if not provider_name:
continue
purl_key = f"terraform:{provider_name}"
if purl_key in seen_providers:
continue
seen_providers.add(purl_key)
components.append(
SbomComponentModel(
component_type="framework",
name=f"terraform-provider-{provider_name}",
version=provider_version,
purl=f"pkg:terraform/hashicorp/{provider_name}@{provider_version or 'unknown'}",
properties={"provider_type": "terraform"},
source_collector=self.get_collector_name(),
)
)
return components
Declare in .strata/collectors.yaml:
collectors:
- name: terraform-vars
path: .strata/collectors/terraform_vars_collector.py
class: TerraformVarsCollector
type: collector
Testing Custom Pluginsο
Testing Lockfile Parsersο
# tests/strata/sbom/test_private_pypi_parser.py
from pathlib import Path
import pytest
from strata.builders.sbom.lockfile_parsers.private_pypi import PrivatePyPiParser
class TestPrivatePyPiParser:
def test_parse_valid(self, tmp_path):
"""Parse a valid private PyPI index."""
parser = PrivatePyPiParser()
index_file = tmp_path / "private-index.json"
index_file.write_text('''{
"packages": {
"mylib": {"version": "1.0.2"},
"otherlib": {"version": "2.1.0"}
}
}''')
deps = parser.parse(index_file)
assert len(deps) == 2
assert deps[0].name == "mylib"
assert deps[0].version == "1.0.2"
def test_parse_missing_version(self, tmp_path):
"""Handle packages without explicit version."""
parser = PrivatePyPiParser()
index_file = tmp_path / "private-index.json"
index_file.write_text('''{
"packages": {"mylib": {}}
}''')
deps = parser.parse(index_file)
assert len(deps) == 1
assert deps[0].version is None
def test_parse_invalid_json(self, tmp_path):
"""Invalid JSON raises ValueError."""
parser = PrivatePyPiParser()
index_file = tmp_path / "private-index.json"
index_file.write_text("not valid json")
with pytest.raises(ValueError):
parser.parse(index_file)
def test_filename_patterns(self):
"""Parser matches correct filename patterns."""
parser = PrivatePyPiParser()
assert "private-index.json" in parser.filename_patterns()
Testing Collectorsο
# tests/strata/sbom/test_terraform_vars_collector.py
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from strata.builders.sbom.terraform_vars_collector import TerraformVarsCollector
from strata.models.platform_artifact_model import PlatformArtifactModel
class TestTerraformVarsCollector:
def test_collect_empty_platform(self, tmp_path):
"""Collector returns empty list when no modules."""
collector = TerraformVarsCollector()
# Mock a minimal platform with no modules
platform = MagicMock(spec=PlatformArtifactModel)
platform.spec = None
components = collector.collect(platform, tmp_path, tmp_path / "build")
assert len(components) == 0
def test_collect_providers(self, tmp_path):
"""Extract provider components."""
collector = TerraformVarsCollector()
# Build a mock platform with providers
provider1 = MagicMock()
provider1.name = "aws"
provider1.version = "~> 5.0"
provider2 = MagicMock()
provider2.name = "kubernetes"
provider2.version = "2.20.0"
module = MagicMock()
module.terraform_providers = [provider1, provider2]
platform = MagicMock(spec=PlatformArtifactModel)
platform.spec = MagicMock()
platform.spec.modules = [module]
components = collector.collect(platform, tmp_path, tmp_path / "build")
assert len(components) == 2
assert components[0].name == "terraform-provider-aws"
assert components[1].name == "terraform-provider-kubernetes"
Running Testsο
# Run tests in the workspace
uv run pytest tests/strata/sbom/ -v
# Run a specific test
uv run pytest tests/strata/sbom/test_private_pypi_parser.py::TestPrivatePyPiParser::test_parse_valid -v
Auto-Discovery Behaviorο
Lockfile Parsers (.strata/lockfile_parsers/)ο
Every *.py file (except those starting with _) in .strata/lockfile_parsers/ is:
Imported automatically on
strata build sbomAny
LockfileParsersubclass in the module is auto-registeredChanges take effect immediately (no CLI flag or config needed)
Behavior:
.strata/
βββ lockfile_parsers/
βββ cargo_lock.py β Auto-discovered and loaded
βββ pdm_lock.py β Auto-discovered and loaded
βββ _testing_helpers.py β Skipped (starts with _)
βββ __init__.py β Skipped (no subclasses expected)
Collectors (.strata/collectors/)ο
Collectors are loaded only if declared in .strata/collectors.yaml:
collectors:
- name: my-collector
path: .strata/collectors/my_collector.py
class: MyCollector
type: collector
Both of these work:
By file path (relative to workspace):
path: .strata/collectors/my_collector.py
By Python module (if installed in the environment):
module: my.custom.collectors
Lifecycle & Error Handlingο
Lockfile Parser Lifecycleο
Discovery β
strata build sbomscans.strata/lockfile_parsers/Import β Each
*.pymodule is importedAuto-register β
LockfileParser.__init_subclass__fires; subclass is added toDEFAULT_REGISTRYMatching β
DependencyFileCollectorfinds files matchingfilename_patterns()Parse β
parser.parse(file)is calledError handling β If
parse()raisesValueError, collector logs a warning and continues; no other exception types are caught
Parse errors are gracefully handled:
def parse(self, path: Path) -> List[RawDependency]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
# Raise ValueError β collector will catch it
raise ValueError(f"Invalid JSON: {exc}") from exc
# ... continue parsing ...
Collector Lifecycleο
Discovery β
.strata/collectors.yamlis readLoading β Class is imported, instance created
Validation β Instance must be a
BaseSbomCollectorsubclassExecution β
collector.collect(platform, work_path, deployment_build_path)is calledWarnings β After
collect(), caller readscollector.get_warnings()
Debugging & Troubleshootingο
Enable debug loggingο
export STRATA_VERBOSE=1
strata build sbom -f deploy.yaml
Youβll see logs like:
DEBUG Auto-discovered lockfile parser | file=private_pypi.py
DEBUG Loaded lockfile_parser plugin module | plugin=cargo-parser
DEBUG Scanning for dependency files | pattern=private-index.json
DEBUG Failed to parse dependency file | file=private-index.json error="Invalid JSON"
Check which plugins are loadedο
# In Python REPL or script
from strata.builders.sbom.lockfile_parsers import DEFAULT_REGISTRY
for parser in DEFAULT_REGISTRY.all_parsers():
print(f"Ecosystem: {parser.ecosystem}, Patterns: {parser.filename_patterns()}")
Test parse in isolationο
from pathlib import Path
from strata.builders.sbom.lockfile_parsers.private_pypi import PrivatePyPiParser
parser = PrivatePyPiParser()
path = Path("path/to/private-index.json")
try:
deps = parser.parse(path)
for dep in deps:
print(f"{dep.name}=={dep.version}")
except ValueError as exc:
print(f"Parse error: {exc}")
Verify collector is loadedο
# Check .strata/collectors.yaml syntax
strata validate .strata/collectors.yaml --deep
# Run SBOM build with debug output
export STRATA_VERBOSE=1
strata build sbom -f deploy.yaml 2>&1 | grep -i "custom\|collector"
Best Practicesο
Lockfile Parsersο
Be strict with filenames β Use exact names or narrow patterns:
["requirements.txt"]β exact match["requirements*.txt"]β good["*.txt"]β too broad, matches unrelated files
Raise
ValueErroron parse failure β Never raise other exception types:try: data = parse_file(path) except IOError as exc: raise ValueError(f"Could not read file: {exc}") from exc # β Correct except Exception as exc: raise exc # β Wrong β not caught by collector
Handle missing/unpinned versions β Use
version=None:deps.append(RawDependency(name="flask", version=None)) # β Unpinned
Use ecosystem names from purl spec β
pypi,npm,maven,crate,gem,nuget,composer,golang.
Collectorsο
Call
_reset_warnings()at the start ofcollect():def collect(self, ...): self._reset_warnings() # Clear warnings from previous call
Use
self._warnings.append(msg)for non-fatal issues:if file_not_found: self._warnings.append("Optional config not found: /path/to/file")
Deduplicate by purl β Check if youβve already seen a component:
seen_purls: set[str] = set() for component in components: if component.purl in seen_purls: continue seen_purls.add(component.purl)
Set accurate component types β Use
container,library, orframework:containerβ Docker imageslibraryβ Dependencies (npm, pip, maven, etc.)frameworkβ Infrastructure code (terraform modules, helm charts, ansible)
Include a meaningful
source_collectorname β Used in logs and component metadata.
Integration with strataο
Build Phaseο
When you run:
strata build sbom -f deploy/production.yaml
The build process:
Loads
platform.jsonartifact from the previousstrata build runInstantiates built-in collectors (image, helm, terraform, ansible, deps)
Loads custom collectors from
.strata/collectors.yamlDiscovers custom lockfile parsers from
.strata/lockfile_parsers/Calls
collect()on each collectorGenerates
sbom.json(CycloneDX format)Produces
platform.jsonwith SBOM reference inmetadata.sbom
Audit Exportο
Include SBOM in deployment manifests:
strata audit export --include-manifests --output audit.json
Each manifest includes a reference to the SBOM:
{
"metadata": {
"sbom": {
"path": "build/prod-1.0.0/sbom.json",
"format": "cyclonedx-1.6",
"component_count": 42
}
}
}
See Alsoο
SBOM Builder Architecture β Internal design reference
Testing Patterns β More testing examples
Configuration Schema β spec.modules, spec.repositories