Skip to content
Draft
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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,32 @@ jobs:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" # for embedded postgresql
run: cargo clippy --all-targets --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic -D clippy::todo -D clippy::unimplemented

# ADR 00022: every Cargo feature must also be built with the feature off.
# Lib only: dev-dependencies pull in the default features again.
- name: Clippy (semantic-validation off)
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" # for embedded postgresql
run: cargo clippy -p trustify-module-ingestor --no-default-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic -D clippy::todo -D clippy::unimplemented

test-semantic-validation-off:
name: Test (semantic-validation off)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-rust
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
- name: Cache Theseus Postgresql Installation
uses: actions/cache@v6
with:
path: ~/.theseus/postgresql
key: ${{ runner.os }}-theseus-postgresql-${{ hashFiles('**/Cargo.lock') }}
- name: Test ingestor library without semantic validation
run: cargo test -p trustify-module-ingestor --no-default-features --lib
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" # for embedded postgresql

test:
runs-on: ubuntu-24.04

Expand Down Expand Up @@ -161,6 +187,7 @@ jobs:
- supply-chain
- check
- test
- test-semantic-validation-off
if: always()
steps:
- name: Success
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions common/auth/src/authenticator/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub const DEFAULT_SCOPE_MAPPINGS: &[(&str, &[&str])] = &[
"read.metadata",
"read.sbom",
"read.sbomGroup",
"read.validation",
"read.weakness",
"read.systemInformation",
],
Expand Down
3 changes: 3 additions & 0 deletions common/auth/src/permission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ permission! {
#[strum(serialize = "delete.sbomGroup")]
DeleteSbomGroup,

#[strum(serialize = "read.validation")]
ReadValidation,

#[strum(serialize = "upload.dataset")]
UploadDataset,

Expand Down
1 change: 1 addition & 0 deletions docs/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ The CLI alternative provides predefined scope mappings that cannot be customized
"read.importer",
"read.metadata",
"read.sbom",
"read.validation",
"read.weakness",
"read.systemInformation",
],
Expand Down
1 change: 1 addition & 0 deletions entity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub mod sbom_package_license;
pub mod source_document;
pub mod status;
pub mod user_preferences;
pub mod validation_report;
pub mod version_range;
pub mod version_scheme;
pub mod versioned_purl;
Expand Down
216 changes: 216 additions & 0 deletions entity/src/validation_report.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
use sea_orm::entity::prelude::*;

/// The result of one semantic validator run against one document.
///
/// Rows are append-only: a run inserts a row only when its result differs from
/// the latest stored one for the same `(document_sha256, validator)`. The
/// current result is therefore the row with the greatest `created_at`, and
/// older rows are the history of how that verdict changed.
///
/// Documents rejected by a `verify` validator are recorded with
/// `blocked = true`. Those never reach storage, so there is no `source_document`
/// row to reference — the digest is the only link to the document.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "validation_report")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
/// SHA-256 of the raw document, as lowercase hex.
pub document_sha256: String,
/// The [`crate::validation_report::Model::validator`] name from configuration.
pub validator: String,
/// Whether the validator gated ingestion or only reported.
pub mode: Mode,
/// Whether the document met the validator's threshold.
pub outcome: Outcome,
/// True when this run rejected the document, so nothing was ingested.
pub blocked: bool,
/// Highest severity across the findings; `None` when there are none.
pub max_severity: Option<Severity>,
/// Number of findings produced, before truncation.
pub finding_count: i32,
/// The findings, as a JSON array; truncated to the configured caps.
pub findings: serde_json::Value,
/// True when `findings` holds fewer entries than `finding_count`.
pub truncated: bool,
/// Digest of the result, used to detect that a re-run changed nothing.
pub content_hash: String,
/// Digest of the validator configuration that produced this result.
pub config_fingerprint: String,
/// How the document entered the system.
pub ingest_source: IngestSource,
/// Name of the importer, when `ingest_source` is [`IngestSource::Importer`].
pub importer_name: Option<String>,
pub created_at: time::OffsetDateTime,
}

/// Whether a validator gates ingestion or only records findings.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
EnumIter,
DeriveActiveEnum,
serde::Serialize,
serde::Deserialize,
strum::Display,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "validation_mode")]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum Mode {
/// Findings are recorded but never block ingestion.
#[sea_orm(string_value = "report")]
Report,
/// A failing outcome blocks ingestion.
#[sea_orm(string_value = "verify")]
Verify,
}

/// Whether a validator considered the document acceptable.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
EnumIter,
DeriveActiveEnum,
serde::Serialize,
serde::Deserialize,
strum::Display,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "validation_outcome")]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum Outcome {
/// No finding met the validator's blocking threshold.
#[sea_orm(string_value = "passed")]
Passed,
/// At least one finding met the validator's blocking threshold.
#[sea_orm(string_value = "failed")]
Failed,
}

/// Severity of a validation finding.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
EnumIter,
DeriveActiveEnum,
serde::Serialize,
serde::Deserialize,
strum::Display,
)]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "validation_severity"
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum Severity {
#[sea_orm(string_value = "info")]
Info,
#[sea_orm(string_value = "warning")]
Warning,
#[sea_orm(string_value = "error")]
Error,
#[sea_orm(string_value = "fatal")]
Fatal,
}

/// How a document entered the system.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
EnumIter,
DeriveActiveEnum,
serde::Serialize,
serde::Deserialize,
strum::Display,
)]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "validation_ingest_source"
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum IngestSource {
/// Uploaded through the API.
#[sea_orm(string_value = "api")]
Api,
/// Fetched by an importer run.
#[sea_orm(string_value = "importer")]
Importer,
/// Loaded as part of a dataset archive.
#[sea_orm(string_value = "dataset")]
Dataset,
/// Ingested by internal code, including tests.
#[sea_orm(string_value = "internal")]
Internal,
}

/// Relation to [`super::source_document`] by digest.
///
/// This is a query-time join only — there is deliberately no foreign key in the
/// schema, because reports are also written for rejected documents, which never
/// get a `source_document` row.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::source_document::Entity"
from = "Column::DocumentSha256"
to = "super::source_document::Column::Sha256")]
SourceDocument,
}

impl Related<super::source_document::Entity> for Entity {
fn to() -> RelationDef {
Relation::SourceDocument.def()
}
}

impl ActiveModelBehavior for ActiveModel {}

#[cfg(test)]
mod test {
use super::*;
use serde_json::json;
use test_log::test;

#[test]
fn severity_ordering_and_strings() {
assert!(Severity::Fatal > Severity::Error);
assert!(Severity::Error > Severity::Warning);
assert!(Severity::Warning > Severity::Info);

for (s, severity) in [
("info", Severity::Info),
("warning", Severity::Warning),
("error", Severity::Error),
("fatal", Severity::Fatal),
] {
assert_eq!(severity.to_string(), s);
assert_eq!(json!(severity), json!(s));
}
}

#[test]
fn enum_strings() {
assert_eq!(json!(Mode::Verify), json!("verify"));
assert_eq!(json!(Outcome::Failed), json!("failed"));
assert_eq!(json!(IngestSource::Importer), json!("importer"));
}
}
58 changes: 55 additions & 3 deletions etc/validators/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Example semantic validators

Ready-to-use configuration and rulesets for the ingestion-time semantic
validators described in ADR 00020. Validation is **disabled by default**;
validators described in ADR 00021. Validation is **disabled by default**;
enabling it is opt-in via a config file.

## Layout

| File | Purpose |
|------|---------|
| `validators.yaml` | The validator set. Referenced by `--validators-config` / `TRUSTD_VALIDATORS_CONFIG`. |
| `validators.yaml` | The validator set and the storage settings. Referenced by `--validators-config` / `TRUSTD_VALIDATORS_CONFIG`. |
| `rules/csaf-mandatory.json` | CSAF 2.0 required-field checks (JSON ruleset). |
| `rules/spdx-min.json` | SPDX 2.2/2.3 minimum-element checks (JSON ruleset). |
| `rules/cyclonedx-min.json` | CycloneDX minimum checks (JSON ruleset). |
Expand All @@ -27,7 +27,45 @@ TRUSTD_VALIDATORS_CONFIG=etc/validators/validators.yaml trustd api
`rules` paths in `validators.yaml` are resolved relative to the process
working directory. Use absolute paths in production deployments.

To view associated logs set:
## Top-level settings

Alongside `validators`, the file carries two settings that apply to all of them:

- `persist_reports` — defaults to `true`. When `false`, nothing is written to
the `validation_report` table and every result is logged at `debug` instead.
This overrides the per-validator `persist` setting.
- `caps.max_findings` / `caps.max_findings_bytes` — bound how much of a report
is stored, defaulting to 200 findings and 64 KiB. Validator output is
untrusted and unbounded, and a loose ruleset against a large document can
otherwise produce megabytes per report. A report that hits either limit is
stored with the surplus findings dropped and `truncated` set, while
`finding_count` still reports everything the validator found.

## Stored results

Every validator result for a document that is ingested is recorded, in both
`report` and `verify` mode. A document that `verify` **rejects** is recorded
too, with `blocked` set — it never reaches storage, so this row is the only
trace that the instance refused it.

The table is append-only. A validator that runs again against an unchanged
document with an unchanged configuration writes nothing; a row appears only
when the result or the validator configuration differs from the last one
stored. Re-running an importer over documents that have not changed therefore
does not grow the table, and when a ruleset does change, the previous verdict
is still there next to the new one.

Reports are read back through `GET /api/v3/validation`, which is filterable and
paginated, and `GET /api/v3/validation/{key}`, where the key is a digest
(`sha256:<hex>`) or the `urn:uuid:<uuid>` of an ingested SBOM or advisory.
Rejected documents are only reachable by digest, or by filtering the list on
`blocked`. Both endpoints require the `read.validation` permission.

Reports are *not* stored for `validate_named`, the explicit by-name invocation
used by internal callers: that is an ad-hoc query, not an ingestion.

To view the logs instead — which is all you get with `persist_reports: false`
— set:

```bash
RUST_LOG=trustify_module_ingestor=debug
Expand Down Expand Up @@ -57,6 +95,16 @@ Each validator declares:
`basic`.
- `run_on_ingest` — defaults to `true`; set it to `false` to register a validator
for explicit invocation from internal code without running it on every ingest.
- `persist` — defaults to `true`; set it to `false` to log this validator's
results instead of storing them.
- `revalidate` — defaults to `always`: a document is validated again even when
it has been seen before. Set it to `on_change` to skip the run when neither
the document nor the validator configuration has changed since the stored
result. Only safe for backends whose verdict depends entirely on inputs we
can fingerprint, which excludes `conforma`: its policy lives on the remote
server and can change without any change here, so `on_change` would serve a
stale verdict. Reports whose stored findings were truncated are revalidated
to preserve the complete findings in the ingest response.
- `backend.url` — for the `conforma` backend, the base URL of the remote
`ec validate input --server` instance. The client posts to
`/v1/validate/input`; `backend.timeout_seconds` defaults to `120`. The server's
Expand Down Expand Up @@ -97,6 +145,10 @@ ec validate input --server --server-address 0.0.0.0 --policy policy.yaml
Conforma loads the policy at server startup; restart that instance to apply policy
changes.

Changing a ruleset file counts as a configuration change: the fingerprint
recorded with each result covers the ruleset contents, not just the path in
`validators.yaml`.

In the provided config, `csaf-spec` runs official CSAF specification tests
in `report` mode. `csaf-mandatory` and `cyclonedx-min` run scheck-based
checks in `report` mode (observability only) while `spdx-min` runs in
Expand Down
Loading
Loading