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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- [BE] 🧾 **Lockfile 기반 CycloneDX SBOM 생성기 (#953 2차 증분)**: `app/release/sbom.py`를 추가했습니다. 저장소가 이미 커밋하는 lockfile을 순수 텍스트/JSON 파싱만으로 CycloneDX 1.6 `bom`으로 만듭니다 — `pip`/`npm` 실행·의존성 해석·네트워크 없음. `parse_pip_lock(text)`은 `name==version` 요구사항과 뒤따르는 `--hash=sha256:` 값을 수집(주석·옵션 줄 skip), `parse_npm_lock(obj)`은 `package-lock.json` v2/v3의 `packages` 맵을 순회(root `""`·버전 없는 workspace link skip, `@scope/` 유지, `integrity` → hash), `build_sbom(*, pip_lock, npm_lock, component_name, component_version, generated_at)`은 둘을 병합·`purl` 중복 제거·`(type, name, version)` 정렬해 CycloneDX 봉투로 감쌉니다. 빈 메타데이터/비-dict npm_lock은 첫 문제 필드명을 담은 `ValueError`. 테스트 16종(작은 리터럴 픽스처, 실제 lockfile 미사용). `docs/doctoring/release-manifest.md`에 계약 기록, OWASP CycloneDX 1.6·NTIA(2021) SBOM 최소 요소 인용.
- [BE] 📦 **릴리스 증거 매니페스트 조립기 (#953 1차 증분)**: `app/release/manifest.py`의 `build_release_manifest(*, source_commit, backend_version, frontend_version, migration_revisions, dependency_lock_digests, included_prs, known_limitations, generated_at) -> dict`를 추가했습니다. 호출자가 이미 수집한 릴리스 사실(commit·버전·Alembic revision·lock 파일 sha256 다이제스트·포함 PR·알려진 한계·생성 시각)을 검증·정규화해 불변·JSON 직렬화 가능한 단일 매니페스트로 만듭니다. git·네트워크·파일시스템 접근 없음. 검증 실패 시 첫 문제 필드명을 담은 `ValueError`. `migration_revisions`/`included_prs`는 정렬·중복 제거, digest는 `^sha256:[0-9a-f]{64}$` 강제. `is_ga_candidate = len(known_limitations) == 0`(한계가 하나라도 있으면 GA 후보 아님 — 정직성 규칙). 테스트 12종. `docs/doctoring/release-manifest.md`에 필드 계약·후속 증분(SBOM·서명 provenance·operability baseline·마이그레이션 리허설) 기록, NIST SP 800-218·SLSA v1.2 인용.
- [BE] 🔒 **Cryptography 50+ 보안 경계 갱신**: `pyproject.toml`과 두 hash-locked 요구사항 파일을 동일한 Cryptography 50+ 해석으로 정합화하여 PKCS#7 오류·타이밍 구분으로 인한 CVE-2026-69247 완화를 실제 설치·검증 경로에 반영했습니다.
- [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다.
Expand Down
230 changes: 230 additions & 0 deletions backend/app/release/sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""Build a CycloneDX 1.6 SBOM from the lockfiles already in the repo.

A software bill of materials (SBOM) lists every third-party component that
ships inside a release, so a buyer's security team can match it against
vulnerability feeds and license policy. This module produces one **from the
lockfiles the repo already commits** -- the hash-locked pip/uv requirements
lock and the npm ``package-lock.json`` -- by pure text/JSON parsing. It never
runs ``pip``/``npm``, never resolves a dependency graph, and never touches
the network: whatever the lockfile pins is exactly what the SBOM reports.

Public functions:

* :func:`parse_pip_lock` -- turn a requirements lock's text into component
dicts (name, version, ``pkg:pypi`` purl, SHA-256 hashes).
* :func:`parse_npm_lock` -- turn a parsed ``package-lock.json`` (v2/v3, which
carries a ``packages`` map) into component dicts (name, version,
``pkg:npm`` purl, integrity hash).
* :func:`build_sbom` -- merge both into one CycloneDX 1.6 ``bom`` document
with the components de-duplicated by purl and stably sorted.

Deferred (tracked on issue #953): signing the SBOM, attaching it to the
release manifest built by :mod:`app.release.manifest`, and emitting VEX
(exploitability) statements.

References (APA 7th):

* OWASP Foundation. (2024). *CycloneDX specification 1.6*.
https://cyclonedx.org/docs/1.6/
* National Telecommunications and Information Administration. (2021). *The
minimum elements for a software bill of materials (SBOM)*. U.S. Department
of Commerce.
https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom
Comment on lines +25 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Research artifact requirement is unmet

The repository requires substantive feature PRs to attach redistributable papers or explain citation-only treatment. This change provides links without either follow-up.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"""

from __future__ import annotations

import re
from typing import Any

SBOM_SPEC_VERSION = "1.6"
"""The CycloneDX schema version emitted by :func:`build_sbom`."""

_PIP_HASH_RE = re.compile(r"--hash=sha256:([0-9a-fA-F]{64})")
_PIP_NAME_VERSION_RE = re.compile(r"^([A-Za-z0-9._-]+)\s*==\s*([^\s;]+)")


def _require_non_empty_str(value: object, field: str) -> str:
"""Return ``value`` unchanged, or raise :class:`ValueError` naming ``field``."""

if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-empty string")
return value


def _logical_lines(text: str) -> list[str]:
"""Join ``\\``-continued lines so one requirement is one string."""

joined = text.replace("\\\n", " ").replace("\\\r\n", " ")
return joined.splitlines()


def parse_pip_lock(text: str) -> list[dict[str, Any]]:
"""Parse a pip / uv requirements lock into CycloneDX component dicts.

Recognises ``name==version`` requirements (the form every hash-locked
lockfile uses) and collects the ``--hash=sha256:<hex>`` values that
follow, whether on the same line or on ``\\``-continued lines. Blank
lines, ``#`` comments, and option lines (anything starting with ``-``)
are skipped.

Args:
text: The full lockfile text.

Returns:
One dict per requirement: ``{"type": "library", "name", "version",
"purl": "pkg:pypi/<name>@<version>", "hashes": [{"alg": "SHA-256",
"content": <hex>}, ...]}``. Order follows the file.
"""

components: list[dict[str, Any]] = []
for raw in _logical_lines(text):
line = raw.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
match = _PIP_NAME_VERSION_RE.match(line)
if match is None:
continue
name, version = match.group(1), match.group(2)
hashes = [
{"alg": "SHA-256", "content": h.lower()}
for h in _PIP_HASH_RE.findall(line)
]
components.append(
{
"type": "library",
"name": name,
"version": version,
"purl": f"pkg:pypi/{name}@{version}",
"hashes": hashes,
}
)
return components


def _npm_name_from_key(key: str) -> str | None:
"""Return the package name for a ``package-lock.json`` ``packages`` key.

``"node_modules/foo"`` -> ``"foo"``; ``"node_modules/@scope/bar"`` ->
``"@scope/bar"``; nested ``".../node_modules/baz"`` -> ``"baz"``. Keys
without a ``node_modules/`` segment (the root ``""`` and workspace
entries) return ``None`` so the caller skips them.
"""

marker = "node_modules/"
if marker not in key:
return None
return key.rsplit(marker, 1)[1]


def _npm_hashes(integrity: object) -> list[dict[str, str]]:
"""Turn an npm ``integrity`` string (``sha512-<b64>``) into hash dicts."""

if not isinstance(integrity, str) or "-" not in integrity:
return []
algo, _, content = integrity.partition("-")
alg_map = {"sha512": "SHA-512", "sha384": "SHA-384", "sha256": "SHA-256"}
if algo not in alg_map or not content:
return []
return [{"alg": alg_map[algo], "content": content}]
Comment on lines +125 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 npm hashes use the wrong encoding

For every standard npm integrity value, _npm_hashes copies Base64 into a CycloneDX field that requires hexadecimal. Schema validation rejects the generated SBOM.

Prompt for agents
Update backend/app/release/sbom.py so _npm_hashes parses npm Subresource Integrity digests and emits the decoded digest as hexadecimal, as required by CycloneDX. Support the declared SHA-256, SHA-384, and SHA-512 algorithms, reject malformed Base64 safely, and add tests using real integrity values that assert the expected hex output and schema-compatible lengths.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



def parse_npm_lock(obj: dict[str, Any]) -> list[dict[str, Any]]:
"""Parse a parsed ``package-lock.json`` (v2/v3) into component dicts.

Walks ``obj["packages"]``, skips the root key ``""`` and any entry with
no ``version`` (workspace links, bundled placeholders), and emits one
component per installed ``node_modules`` package.

Args:
obj: The already-``json.load``ed lockfile. Must be a dict; a
``packages`` key is expected (an absent one yields ``[]``).

Returns:
One dict per package: ``{"type": "library", "name", "version",
"purl": "pkg:npm/<name>@<version>", "hashes": [...]}``.

Raises:
ValueError: If ``obj`` is not a dict.
"""

if not isinstance(obj, dict):
raise ValueError("npm_lock must be a parsed JSON object (dict)")
packages = obj.get("packages")
if not isinstance(packages, dict):
return []

components: list[dict[str, Any]] = []
for key, entry in packages.items():
if key == "" or not isinstance(entry, dict):
continue
name = _npm_name_from_key(key)
version = entry.get("version")
if name is None or not isinstance(version, str) or not version:
continue
components.append(
{
"type": "library",
"name": name,
"version": version,
"purl": f"pkg:npm/{name}@{version}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Scoped npm packages get invalid identifiers

For scoped dependencies, parse_npm_lock leaves the leading @ unescaped instead of %40. Package URL consumers can reject or misidentify those components.

Prompt for agents
Generate npm purls in backend/app/release/sbom.py according to the Package URL npm rules rather than interpolating package names directly. At minimum, percent-encode the leading @ in scoped package namespaces as %40 while preserving the scope/name separator. Prefer a standards-aware purl builder or equivalent encoding that also handles reserved characters in names and versions. Add tests for scoped package purls.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"hashes": _npm_hashes(entry.get("integrity")),
}
)
return components


def build_sbom(
*,
pip_lock: str,
npm_lock: dict[str, Any],
component_name: str,
component_version: str,
generated_at: str,
) -> dict[str, Any]:
"""Merge the pip and npm components into one CycloneDX 1.6 ``bom``.

Args:
pip_lock: Requirements-lock text (see :func:`parse_pip_lock`).
npm_lock: Parsed ``package-lock.json`` (see :func:`parse_npm_lock`).
component_name: Name of the application this SBOM describes.
component_version: Its version string.
generated_at: SBOM timestamp, recorded verbatim (an ISO-8601 string
is expected; this function does not parse it).

Returns:
``{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1,
"metadata": {"timestamp", "component": {...}}, "components": [...]}``
with components de-duplicated by ``purl`` and sorted by
``(type, name, version)``.

Raises:
ValueError: If ``component_name``, ``component_version``, or
``generated_at`` is blank, or if ``npm_lock`` is not a dict.
"""

name = _require_non_empty_str(component_name, "component_name")
version = _require_non_empty_str(component_version, "component_version")
timestamp = _require_non_empty_str(generated_at, "generated_at")

merged: dict[str, dict[str, Any]] = {}
for component in [*parse_pip_lock(pip_lock), *parse_npm_lock(npm_lock)]:
merged.setdefault(component["purl"], component)

components = sorted(
merged.values(), key=lambda c: (c["type"], c["name"], c["version"])
)
return {
"bomFormat": "CycloneDX",
"specVersion": SBOM_SPEC_VERSION,
"version": 1,
"metadata": {
"timestamp": timestamp,
"component": {
"type": "application",
"name": name,
"version": version,
},
},
"components": components,
}
Loading