Skip to content
Merged

V0.2 #66

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
18 changes: 18 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Tests

on:
push:
branches: [v0.2, main]
pull_request:
branches: [v0.2, main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -e ".[dev]"
- run: pytest tests/ -v --tb=short
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ http://www.apache.org/licenses/

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

Copyright 2025 OWASP Foundation - AI SBOM Generator and contributors
Copyright 2026 OWASP Foundation - AI SBOM Generator and contributors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ The tool is also listed in the official **[CycloneDX Tool Center](https://cyclon
pip install -r requirements.txt
```

Or, if you prefer [uv](https://docs.astral.sh/uv/) for faster dependency management:
```bash
uv sync
```

### 2. Run Web Application
Start the local server at `http://localhost:8000`:
```bash
Expand Down
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ dependencies = [
"beautifulsoup4>=4.11.0",
"datasets>=2.0.0",
"fastapi>=0.104.0",
"flask>=2.3.0",
"gunicorn>=21.2.0",
"httpx>=0.25.0",
"huggingface_hub>=0.19.0",
"jinja2>=3.0.0",
"jsonschema>=4.17.0",
Expand All @@ -40,7 +43,8 @@ dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"ruff"
"ruff",
"gguf>=0.6.0"
]

[project.scripts]
Expand All @@ -64,3 +68,8 @@ testpaths = [
pythonpath = [
"."
]

[dependency-groups]
dev = [
"gguf>=0.6.0",
]
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ sentencepiece>=0.1.99
pytest>=7.0.0
pytest-mock>=3.10.0
pytest-cov>=4.0.0
gguf>=0.6.0
3 changes: 3 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def main():
test_models = [
"Qwen/Qwen3.5-397B-A17B",
"nvidia/personaplex-7b-v1",
"meta-llama/Llama-2-7b-chat-hf",
"unsloth/Qwen3.5-35B-A3B-GGUF",
"LocoreMind/LocoOperator-4B",
"Nanbeige/Nanbeige4.1-3B",
"zai-org/GLM-5",
"MiniMaxAI/MiniMax-M2.5",
Expand Down
122 changes: 59 additions & 63 deletions src/controllers/cli_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ..models.scoring import calculate_completeness_score
from ..models.scoring import calculate_completeness_score
from ..config import OUTPUT_DIR, TEMPLATES_DIR
from ..utils.formatter import export_aibom
import os
import shutil

Expand Down Expand Up @@ -32,72 +33,67 @@ def generate(self, model_id: str, output_file: Optional[str] = None, include_inf
reports = []
generated_aiboms = {}

for spec_version in versions_to_generate:
print(f" - Generating CycloneDX {spec_version}...")
try:
aibom = self.service.generate_aibom(
model_id,
include_inference=include_inference,
enable_summarization=enable_summarization,
spec_version=spec_version,
metadata_overrides={
"name": name,
"version": version,
"manufacturer": manufacturer
}
)
report = self.service.get_enhancement_report()

# Determine output filename
current_output_file = output_file
if not current_output_file:
normalized_id = self.service._normalise_model_id(model_id)
import os
os.makedirs("sboms", exist_ok=True)
suffix = f"_{spec_version.replace('.', '_')}"
current_output_file = os.path.join("sboms", f"{normalized_id.replace('/', '_')}_ai_sbom{suffix}.json")
elif spec_version != "1.6":
import os
base, ext = os.path.splitext(current_output_file)
current_output_file = f"{base}_{spec_version.replace('.', '_')}{ext}"

with open(current_output_file, 'w') as f:
json.dump(aibom, f, indent=2)

# Check for validation results in report (populated by scoring mechanism)
validation_data = report.get("final_score", {}).get("validation", {})
is_valid = validation_data.get("valid", True) # Default to true if not found to avoid noise? Or False?
# Actually, scoring.py populates this.
validation_errors = [i["message"] for i in validation_data.get("issues", [])]

# Ensure schema_validation key exists for our report structure
if "schema_validation" not in report:
report["schema_validation"] = {}
report["schema_validation"]["valid"] = is_valid
report["schema_validation"]["errors"] = validation_errors
report["schema_validation"]["error_count"] = len(validation_errors)
report["output_file"] = current_output_file
report["spec_version"] = spec_version
reports.append(report)
generated_aiboms[spec_version] = aibom

except Exception as e:
logger.error(f"Failed to generate {spec_version} SBOM: {e}")
print(f" ❌ Failed to generate {spec_version}: {e}")

# Summary and HTML Report (using 1.6 as primary)
if reports:
# Find primary report (1.6)
primary_report = next((r for r in reports if r.get("spec_version") == "1.6"), reports[0])
primary_aibom = generated_aiboms.get(primary_report["spec_version"])
output_file_primary = primary_report.get("output_file")
print(f" - Generating AIBOM model data...")
try:
primary_aibom = self.service.generate_aibom(
model_id,
include_inference=include_inference,
enable_summarization=enable_summarization,
metadata_overrides={
"name": name,
"version": version,
"manufacturer": manufacturer
}
)
primary_report = self.service.get_enhancement_report()

# Formatted AIBOM Strings
json_1_6 = export_aibom(primary_aibom, bom_type="cyclonedx", spec_version="1.6")
json_1_7 = export_aibom(primary_aibom, bom_type="cyclonedx", spec_version="1.7")

# Determine output filenames
normalized_id = self.service._normalise_model_id(model_id)
os.makedirs("sboms", exist_ok=True)

# Generate HTML for primary only
output_file_1_6 = output_file
if not output_file_1_6:
output_file_1_6 = os.path.join("sboms", f"{normalized_id.replace('/', '_')}_ai_sbom_1_6.json")

base, ext = os.path.splitext(output_file_1_6)
output_file_1_7 = f"{base.replace('_1_6', '')}_1_7{ext}" if '_1_6' in base else f"{base}_1_7{ext}"

with open(output_file_1_6, 'w') as f:
f.write(json_1_6)
with open(output_file_1_7, 'w') as f:
f.write(json_1_7)

# Check for validation results
validation_data = primary_report.get("final_score", {}).get("validation", {})
is_valid = validation_data.get("valid", True)
validation_errors = [i["message"] for i in validation_data.get("issues", [])]

if "schema_validation" not in primary_report:
primary_report["schema_validation"] = {}
primary_report["schema_validation"]["valid"] = is_valid
primary_report["schema_validation"]["errors"] = validation_errors
primary_report["schema_validation"]["error_count"] = len(validation_errors)

reports = [
{"spec_version": "1.6", "output_file": output_file_1_6, "schema_validation": primary_report["schema_validation"]},
{"spec_version": "1.7", "output_file": output_file_1_7, "schema_validation": primary_report["schema_validation"]}
]
output_file_primary = output_file_1_6

except Exception as e:
logger.error(f"Failed to generate SBOM: {e}", exc_info=True)
print(f" ❌ Failed to generate SBOM: {e}")
reports = []

if reports:
if output_file_primary:
try:
from jinja2 import Environment, FileSystemLoader, select_autoescape
from ..config import TEMPLATES_DIR
import os

env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
Expand All @@ -111,15 +107,15 @@ def generate(self, model_id: str, output_file: Optional[str] = None, include_inf

# Pre-serialize to preserve order
components_json = json.dumps(primary_aibom.get("components", []), indent=2)
aibom_json = json.dumps(primary_aibom, indent=2)

context = {
"request": None,
"filename": os.path.basename(output_file_primary),
"download_url": "#",
"aibom": primary_aibom,
"components_json": components_json,
"aibom_json": aibom_json,
"aibom_cdx_json_1_6": json_1_6,
"aibom_cdx_json_1_7": json_1_7,
"raw_aibom": primary_aibom,
"model_id": self.service._normalise_model_id(model_id),
"sbom_count": 0,
Expand Down
33 changes: 16 additions & 17 deletions src/controllers/web_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

from ..models.service import AIBOMService
from ..models.scoring import calculate_completeness_score
from ..config import TEMPLATES_DIR, OUTPUT_DIR, RECAPTCHA_SITE_KEY
from ..utils.analytics import log_sbom_generation, get_sbom_count
from ..utils.captcha import verify_recaptcha
from ..utils.formatter import export_aibom
from ..config import TEMPLATES_DIR, OUTPUT_DIR

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -50,8 +50,7 @@ def is_valid_hf_input(input_str: str) -> bool:
async def root(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"sbom_count": get_sbom_count(),
"recaptcha_site_key": RECAPTCHA_SITE_KEY
"sbom_count": get_sbom_count()
})

@router.get("/status")
Expand All @@ -63,17 +62,8 @@ async def generate_form(
request: Request,
model_id: str = Form(...),
include_inference: bool = Form(False),
use_best_practices: bool = Form(True),
g_recaptcha_response: Optional[str] = Form(None)
use_best_practices: bool = Form(True)
):
# Verify CAPTCHA
if not verify_recaptcha(g_recaptcha_response):
return templates.TemplateResponse("error.html", {
"request": request,
"error": "Security verification failed.",
"sbom_count": get_sbom_count()
})

# Security: Validate BEFORE sanitizing to prevent bypass attacks
# (e.g., <script>org/model</script> → &lt;script&gt;org/model&lt;/script&gt; could slip through)
if not is_valid_hf_input(model_id):
Expand Down Expand Up @@ -123,13 +113,21 @@ def _generate_task():
# Save file (non-blocking I/O)
filename = f"{normalized_id.replace('/', '_')}_ai_sbom_1_6.json"
filepath = os.path.join(OUTPUT_DIR, filename)
filepath_1_7 = os.path.join(OUTPUT_DIR, f"{normalized_id.replace('/', '_')}_ai_sbom_1_7.json")

def _save_task():
# Generate Formatted JSON strings
json_1_6 = export_aibom(aibom, bom_type="cyclonedx", spec_version="1.6")
json_1_7 = export_aibom(aibom, bom_type="cyclonedx", spec_version="1.7")

with open(filepath, "w") as f:
json.dump(aibom, f, indent=2)
f.write(json_1_6)
with open(filepath_1_7, "w") as f:
f.write(json_1_7)
log_sbom_generation(sanitized_model_id)
return json_1_6, json_1_7

await loop.run_in_executor(None, _save_task)
json_1_6, json_1_7 = await loop.run_in_executor(None, _save_task)

# Extract score
completeness_score = None
Expand All @@ -146,7 +144,8 @@ def _save_task():
"filename": filename,
"download_url": f"/output/{filename}",
"aibom": aibom,
"aibom_json": json.dumps(aibom, indent=2),
"aibom_cdx_json_1_6": json_1_6,
"aibom_cdx_json_1_7": json_1_7,
"components_json": json.dumps(aibom.get("components", []), indent=2),
"model_id": normalized_id,
"sbom_count": get_sbom_count(),
Expand Down
Loading