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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,14 @@ SCANOSS_TIMEOUT_SECONDS=300
# CDXGEN_SPEC_VERSION=1.5 # set 1.6 to emit CycloneDX 1.6
# CDXGEN_FETCH_LICENSE=false # true → cdxgen resolves component licenses (slower)
#
# CDXGEN_VERSION=12.3.3 # set by Dockerfile.worker's ENV at image-build time, NOT
# # an operator-facing setting — do not set in .env. Names the
# # cdxgen release pinned in the worker image; folds into the
# # S8 dependency-set fingerprint (tasks/scan_source.py) so an
# # upgraded worker's first scan on an otherwise-unchanged tree
# # is never treated as reusable against a fingerprint an older
# # worker wrote.
#
# Post-cdxgen license enrichment. When cdxgen emits a component with
# no SPDX license — the common case for a bare requirements.txt / go.mod with
# no installed packages — the pipeline asks the component's PUBLIC registry
Expand Down
83 changes: 83 additions & 0 deletions apps/backend/alembic/versions/0070_scan_dependency_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 TRUSCA contributors
"""scan dependency fingerprint

Revision ID: 0070
Revises: 0069
Create Date: 2026-08-24

Phase: concurrency-scaling-plan-2026-08-22.md §3.2 S8 (unit 29), schema step
PR: (opened together with the follow-on reuse-decision revision)
Kind: schema (additive, one nullable column; no data migration)
Forward-only: yes

What:
``scans.dependency_fingerprint``: a SHA-256 hex digest (64 chars, fixed
length) over the scan's manifest/lockfile hashes, the cdxgen scanner
version, and the scan-time config that shapes the generated SBOM. Nullable
``VARCHAR(64)``, no default.

Why:
S8 skips re-running cdxgen when a commit's dependency set has not changed
since the project's last successful scan, reusing that scan's preserved
SBOM for vulnerability re-matching instead (the reuse machinery already
exists: ``tasks.vulnerability_rematch.preserved_tarball_has_sbom`` +
``rematch_scan_findings``). Deciding "has not changed" needs something to
compare against, and nothing on the ``scans`` row currently records what a
scan's dependency set actually was. This migration adds the one column the
comparison needs; the fingerprint is computed and written by
``models.scan_fingerprint.compute_scan_fingerprint`` at scan-success time
(wired into the pipeline in a follow-on change, see the plan's §7.1 unit
29 "머지 단위: 연속").

This is a schema-only step by design (plan §8: "스키마를 건드리는 단위 ...
forward-only 마이그레이션이고 다운그레이드를 두지 않는다"). The column is
added and populated going forward; no backfill is attempted for scans that
already succeeded, because their preserved source tree is gone by the time
this migration runs for most of them (retained latest-succeeded-per-project
only, see migration 0051's rationale) and a fingerprint computed from
nothing would be indistinguishable from one computed from a since-deleted
tree that no longer matches. NULL is deliberately not a value the reuse
decision (a later revision) will treat as a match.

Why a column and not a table:
One scalar per scan, read for exactly one comparison ("does this scan's
fingerprint match the prior succeeded scan's for the same (project_id,
ref)?"), never filtered or aggregated across scans. A satellite table would
need its own FK, index, and cascade-delete wiring to answer a question a
single column already answers.

Index:
None. The lookup this column supports is "the latest succeeded scan for
this (project_id, ref)", already served by the existing partial index
``ix_scans_project_ref`` (status = 'succeeded'). The reuse decision reads
that row's ``dependency_fingerprint`` scalar directly; it does not search
BY fingerprint, so no new index earns its write cost.

Reversal:
Forward-only, and additive. With the column unread by anything outside the
(not-yet-wired) reuse decision, every existing code path is unaffected.
"""

from __future__ import annotations

import sqlalchemy as sa

from alembic import op

revision: str = "0070"
down_revision: str | None = "0069"
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
op.add_column(
"scans",
sa.Column("dependency_fingerprint", sa.String(length=64), nullable=True),
)


def downgrade() -> None:
# Forward-only (CLAUDE.md §6).
raise NotImplementedError("0070 is forward-only")
27 changes: 27 additions & 0 deletions apps/backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,33 @@ def cdxgen_fetch_license() -> bool:
}


def cdxgen_scanner_version() -> str:
"""The cdxgen version baked into this worker image (``CDXGEN_VERSION``).

S8 (concurrency-scaling-plan-2026-08-22.md §3.2): the scan pipeline
fingerprints its dependency set to decide whether a fresh commit can
reuse a prior scan's SBOM instead of re-running cdxgen. Two trees with
byte-identical manifests can still produce different SBOMs after a
cdxgen upgrade, so the fingerprint must fold in the scanner version (an
upgraded worker's first scan on an otherwise-unchanged tree must never
read as "unchanged" against a fingerprint an older worker wrote).

``Dockerfile.worker`` sets ``ENV CDXGEN_VERSION=<pinned version>`` at
image-build time (see the ``cdxgen`` install stage there); this accessor
reads that at call time (rule #11) rather than caching it, so a value
baked into one image build is never carried over by a stale in-process
cache after a hot-swap. Returns ``"unknown"`` when unset, the same
non-placeholder convention :func:`slsa_builder_version` uses for
``TRUSTEDOSS_VERSION``, which still participates in the fingerprint
hash (an "unknown"-tagged scan's fingerprint is simply never treated as
matching a version-tagged one unless both literally say "unknown").
"""
raw = os.getenv("CDXGEN_VERSION")
if raw is None or raw.strip() == "":
return "unknown"
return raw.strip()


def license_fetch_enabled() -> bool:
"""Whether the post-cdxgen license fetcher enriches unlicensed components.

Expand Down
31 changes: 31 additions & 0 deletions apps/backend/models/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,37 @@ class Scan(Base):
input_document: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, nullable=True
)
# Dependency-set fingerprint (S8, concurrency-scaling-plan-2026-08-22.md
# §3.2, migration 0070). SHA-256 hex digest over three inputs that
# together determine the bytes of the SBOM this scan would generate: the
# scanned tree's manifest/lockfile hashes (the same inventory recorded in
# ``input_manifests`` above; see ``services.scan_inputs.
# collect_manifest_inventory``), the cdxgen scanner version, and the
# scan-time config that shapes cdxgen's output (spec version,
# license-fetch toggle, runtime-scope filter toggles). Computed by
# ``models.scan_fingerprint.compute_scan_fingerprint``.
#
# NULL means "not computed": every scan before this migration, every
# container / SBOM-ingest scan (neither has a source tree to
# fingerprint), and any scan whose manifest walk was truncated or could
# not hash one of its own files in full (the same bounds
# ``collect_manifest_inventory`` already enforces). A NULL fingerprint is
# never equal to another NULL: two scans that were not fingerprinted have
# not been shown to share a dependency set, they simply were not compared.
#
# Deliberately excludes vulnerability-DB state and license policy: both
# are inputs to the vulnerability-matching stage, not to SBOM generation,
# and the reuse decision this fingerprint exists to support (a later
# revision) always re-runs matching regardless of whether the SBOM itself
# is reused; see the plan's "판단" paragraph under S8.
#
# Index: none. The natural lookup is "the latest succeeded scan for this
# (project_id, ref)", already served by ``ix_scans_project_ref`` below;
# the caller reads that row's ``dependency_fingerprint`` scalar and
# compares in application code rather than searching BY fingerprint.
dependency_fingerprint: Mapped[str | None] = mapped_column(
String(64), nullable=True
)
# DT-style ref-keyed retention (scan-retention). Normalized git ref this
# scan targets — ``refs/heads/main`` → ``main``, ``refs/pull/12/merge`` →
# ``pr-12`` (see ``services.scan_service.normalize_ref``). NULL when the
Expand Down
189 changes: 189 additions & 0 deletions apps/backend/models/scan_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 TRUSCA contributors
"""
Dependency-set fingerprint for ``scans.dependency_fingerprint``.

S8 (concurrency-scaling-plan-2026-08-22.md §3.2) skips re-running cdxgen when
a commit's dependency set has not changed since the project's last successful
scan on the same ref, reusing that scan's preserved SBOM for vulnerability
re-matching instead. This module computes the fingerprint that comparison
needs. It does NOT decide whether to reuse anything: that decision (reading
two fingerprints and choosing a pipeline path) belongs to a later revision
that also has to make that choice at a point in the pipeline this module has
no visibility into. This module is one pure function: given the same
inputs, always the same digest.

What must be true of the inputs, and why:

Lockfile / manifest hashes decide the dependency SET. This deliberately
reuses ``services.scan_inputs.collect_manifest_inventory`` (the same
per-ecosystem catalog of manifest AND lockfile names already gathered for
scan provenance, ``scans.input_manifests``) rather than a narrower
"lockfiles only" list. Several ecosystems this product scans have no
separate lockfile at all (Maven's ``pom.xml``, a bare ``requirements.txt``,
Gradle without dependency locking): for those, the manifest IS the
authoritative declaration of what gets resolved, and a fingerprint that
ignored it would call an SBOM reusable after a dependency version changed
in exactly those ecosystems. Where a lockfile does exist (``package-lock.
json``, ``poetry.lock``, ``go.sum``, ...) it is already in the same
catalog, so nothing is lost by not special-casing it.

Scanner version and scan config decide what cdxgen does WITH that
dependency set. Two scans that saw byte-identical lockfiles can still
produce different SBOMs if the scanner was upgraded or a toggle that
shapes its output changed in between; the plan calls this out explicitly
as the accuracy requirement S8 must not violate. Both are supplied by the
caller (this module has no access to ``core.config`` or the filesystem by
design, see "Division of responsibility" below), so the pipeline is the
single place that decides which config keys count as "shapes SBOM output"
and stays free to add one without this module changing.

Vulnerability-DB state and license policy are NOT inputs here on purpose.
Both are inputs to the vulnerability-MATCHING stage, not to SBOM
generation, and the reuse design this fingerprint exists to support always
re-runs matching regardless of whether the SBOM itself is reused (plan
§3.2: "둘 다 SBOM이 아니라 매칭 단계의 입력이므로 매칭을 다시 도는 설계와
어긋나지 않는다").

Division of responsibility (why this lives in ``models/`` and stays pure):
The obvious home for a hash-computation helper in this codebase is
``services/`` (see ``services.remediation_pr_service``'s
``change_fingerprint``, the closest existing precedent). This module is
colocated with the ``Scan`` model instead, and takes its scanner-version
and scan-config inputs as plain arguments rather than reading
``core.config`` or invoking cdxgen itself, because computing and WRITING
the fingerprint at scan-success time (the part that needs
``core.config.cdxgen_spec_version`` and friends, and a place in
``tasks.scan_source``'s pipeline) is a follow-on change outside this
session's scope. Keeping this function pure and dependency-free means that
follow-on change can import it from wherever it ends up living without
this module needing to move first.
"""

from __future__ import annotations

import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any, Final

#: Bumped whenever the hashing shape changes: which fields are hashed, in
#: what order, or how they are serialized. A code change that touches this
#: module without also changing any lockfile byte, scanner version, or scan
#: config must still produce a fingerprint that differs from what an older
#: worker would have written for the identical scan; otherwise the very
#: first scan a new worker processes after an unrelated fingerprint-format
#: change could read as "unchanged" against a fingerprint an old worker
#: wrote under a different, incompatible scheme.
FINGERPRINT_SCHEMA_VERSION: Final = 1

#: JSON-serializable scalar types a scan-config value may hold. Anything else
#: is coerced to ``str`` (see ``_normalize_config``) rather than rejected;
#: this function must never raise on a config shape it was not expecting,
#: the same defensive posture ``services.scan_inputs`` takes throughout.
_ConfigScalar = bool | int | float | str


def compute_scan_fingerprint(
*,
manifest_inventory: Mapping[str, Any] | None,
scanner_version: str,
scan_config: Mapping[str, Any],
) -> str | None:
"""Return a deterministic SHA-256 hex digest, or None when one cannot be trusted.

``manifest_inventory`` is the exact shape
``services.scan_inputs.collect_manifest_inventory`` returns (and
``scans.input_manifests`` stores): ``{"files": [{"path", "size",
"sha256"}, ...], "count", "truncated"}``. Passing the already-collected
inventory instead of a source directory keeps this function filesystem-
free: the walk, its bounds, and its skip-list live in exactly one place
(``scan_inputs.py``) and this function trusts that place's judgment about
what counts as a dependency declaration.

Returns ``None`` (deliberately, not a digest of "nothing") when the
inventory cannot be trusted to describe the WHOLE tree:

- ``manifest_inventory`` is ``None``: no manifest/lockfile was found
(or the scan has no source tree, e.g. container / SBOM-ingest scans).
There is no dependency-set identity to fingerprint.
- ``manifest_inventory["truncated"]`` is true: the walk stopped at
``scan_inputs.MAX_ENTRIES`` before covering the tree. A file that
changed past the cutoff would go undetected, which is exactly the
failure mode a fingerprint exists to prevent.
- any recorded file has ``sha256`` of ``None``: that file was too large
to hash (``scan_inputs.MAX_HASH_BYTES``) or could not be read.
"Unknown content" and "unchanged content" must never collide.

A ``None`` return must never be treated as equal to another ``None`` by
a caller comparing two scans' fingerprints: two un-fingerprinted scans
have not been shown to share a dependency set, they simply were not
compared. Callers enforce that; this function only refuses to assert a
digest it cannot stand behind.

Deterministic: two calls with equal arguments (independent of Python
dict insertion order; every mapping is sorted before serializing)
always return the same digest. Two calls where any manifest hash, the
scanner version, or any scan-config value differs return different
digests with overwhelming probability (SHA-256 preimage resistance).
"""
if not manifest_inventory:
return None
if manifest_inventory.get("truncated"):
return None

files = manifest_inventory.get("files")
if not isinstance(files, Sequence) or isinstance(files, str | bytes) or not files:
return None

entries: list[tuple[str, str]] = []
for entry in files:
if not isinstance(entry, Mapping):
return None
path = entry.get("path")
digest = entry.get("sha256")
if not isinstance(path, str) or not path:
return None
if not isinstance(digest, str) or not digest:
# Too large to hash, or unreadable this pass (scan_inputs._sha256
# returns None in both cases). "Unchanged" cannot be claimed
# about a file whose content this scan never actually read.
return None
entries.append((path, digest))

# Sort defensively even though collect_manifest_inventory already sorts
# its output: this function's determinism must not depend on a caller
# upholding an invariant it cannot verify from the shape alone.
entries.sort(key=lambda pair: pair[0])

payload = {
"schema": FINGERPRINT_SCHEMA_VERSION,
"files": entries,
"scanner_version": scanner_version,
"scan_config": _normalize_config(scan_config),
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _normalize_config(scan_config: Mapping[str, Any]) -> dict[str, Any]:
"""Coerce scan-config values to JSON-serializable scalars.

A config value this function was not told to expect (a nested dict, a
custom object) is stringified rather than dropped or raising: dropping
it would silently narrow the fingerprint's inputs, and this function has
no basis to decide such a value is safe to ignore.
"""
normalized: dict[str, Any] = {}
for key, value in scan_config.items():
if value is None or isinstance(value, bool | int | float | str):
normalized[key] = value
else:
normalized[key] = str(value)
return normalized


__all__ = [
"FINGERPRINT_SCHEMA_VERSION",
"compute_scan_fingerprint",
]
Loading
Loading