Security audit presets (CRA evidence packs)¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
These Apache presets turn a CI-emitted SBOM and optional build evidence
into a versioned result.json plus a human-readable evidence pack. They
shipped as backend/presets/004 through 006 in 0.15.0; 007
(Component Due Diligence) and the gap register / evidence storage /
waiver fields landed in a later release. Clone a preset into a flow,
attach a webhook (or a schedule), and retain the pack. The YAML prompts
are the contract; this page describes that contract.
What this is not¶
This is not a conformity assessment, not a certification, not legal advice, not an SBOM generator, and not an Article 14 filing.
The Cyber Resilience Act is Regulation (EU) 2024/2847. Article 14 reporting duties apply from 11 Sep 2026. The full CRA applies from 11 Dec 2027. Preloop does not file CRA Article 14 reports. Preloop does not issue a Declaration of Conformity, a CE marking decision, or a "compliant" verdict. These presets also do not constitute an EU AI Act conformity assessment.
They produce machine-generated evidence for a human assessor. A manufacturer (or the notified body or assessor that manufacturer appoints) still has to do the assessment.
Every result.json and every markdown evidence file carries this line:
Machine-generated evidence for conformity assessment support. Not a conformity assessment, certification, or legal advice.
What you get¶
| Preset | What it does | result.json schema |
|---|---|---|
| SBOM Verify | Format validity, NTIA / CRA Annex I Part II minimum elements, completeness vs delivered build manifests, license flags, provenance consistency | preloop.cra.sbomaudit/v1 |
| SBOM Exploit Check | Components to CVEs via OSV.dev, known-exploited flags via CISA KEV, per-source screening matrix, severity gate | preloop.cra.vulnscan/v1 |
| Release Security Audit | Both of the above in one execution, plus drift vs a previous run's result.json, optional gap register and multi-repo evidence storage |
preloop.cra.releaseaudit/v1 |
| Component Due Diligence Record | Agent legwork on one integrated component; a human carries the risk decision via approval; the record can land in a compliance repo | preloop.cra.duediligence/v1 |
The audit presets follow the Observe / Eval pattern: no write tools
(except ask_user on the release audit for optional interactive waivers,
and request_approval on due diligence; the exploit check and release
audit additionally carry the read-only resolve_sbom_upstreams registry
lookup that enriches vendored Arduino/PlatformIO components for osv_git
screening); deterministic checks separated
from agent judgment (checks[] vs assessments[]); and the disclaimer
above on every artifact.
One-minute verdict cover. Every audit and review preset that writes
a human-readable report leads with a one-page cover so nobody has to
write a verdict summary by hand. On the audit family that is
audit-report.md (SBOM Verify and Release Security Audit),
vuln-report.md (SBOM Exploit Check), and dossier.md (Component Due
Diligence). The full-repo review presets
require the same cover on their report artifacts. The cover is a verdict
sentence first (matching result.json; on SBOM Exploit Check that is
status and gate.passed, because that schema has no top-level
verdict), then three labelled boxes in this order: What we checked /
What we did not check / What you should do next week. It is strictly one
page. The cover may only summarize findings already present in the body;
the "What we did not check" box is mandatory and may not be empty when
anything was out of scope. On SBOM Exploit Check that box includes
unscreened components by count from the source matrix. The machine
result.json contract is unchanged.
Retrieve the structured result with
GET /api/v1/flows/executions/{execution_id}/result. Download the
captured evidence tarball with
GET /api/v1/flows/executions/{id}/evidence. Operator runbook for
transport, receipts, and retention:
Evidence storage and retention.
result.json contract¶
The agent writes /workspace/result.json as its final action. The
runner captures that file as a first-class execution artifact. The
GET .../result response is:
{
"execution_id": "<uuid>",
"status": "SUCCEEDED | FAILED | STOPPED | TIMEOUT | CANCELLED | ...",
"result": { }
}
result is the body below. Unknown envelope fields are null, never
invented. checks[] are deterministic facts. assessments[] are agent
judgment. result.json stays under 200 KB; large listings live in
/workspace/evidence/ files referenced from artifacts.
git is null unless a repository was cloned into the workspace. On
the Release Security Audit, fill git only when exactly one repository
is checked out; with multiple checkouts set it to null (per-repo
identity lives in evidence_storage.code_repos).
Shared evidence envelope¶
Every schema includes:
{
"schema": "preloop.cra.sbomaudit/v1 | preloop.cra.vulnscan/v1 | preloop.cra.releaseaudit/v1 | preloop.cra.duediligence/v1",
"flow": "sbom-verify | sbom-exploit-check | release-security-audit | component-due-diligence",
"run_at": "ISO 8601 UTC",
"git": {"remote": "...", "commit": "...", "branch": "...", "dirty": false},
"tool_versions": {"<tool>": "<version or 'unavailable: reason'>"},
"inputs_declared": {"<input>": "<what was actually delivered>"},
"runner": {"kind": "hosted | self_hosted | null", "id": null},
"regime_profile": "cra",
"checks": [{"name": "...", "passed": true, "skipped": false, "missing_input": null, "details": "evidence"}],
"assessments": [{"topic": "...", "judgment": "agent judgment, marked as such"}],
"artifacts": {"<name>": "<workspace-relative path>"},
"disclaimer": "Machine-generated evidence for conformity assessment support. Not a conformity assessment, certification, or legal advice."
}
runner is the one envelope field the platform overwrites. The agent
writes nulls because it cannot see which runner leased its job; the
persist boundary stamps the execution's actual runner. See Keys the
control plane adds to the stored
result.
Incompletion envelope¶
Every schema above requires a full audit body, so a run that stops early (an input that never arrived, an interactive waiver question nobody answered) has nothing valid to write. It writes the incompletion envelope instead:
{
"schema": "preloop.cra.releaseaudit/v1",
"flow": "release-security-audit",
"run_at": "2026-09-08T10:15:00Z",
"regime_profile": "cra",
"verdict": "error",
"incomplete": {"reason": "the waiver approval did not resolve in time", "stage": "PHASE 2"},
"disclaimer": "Machine-generated evidence for conformity assessment support. Not a conformity assessment, certification, or legal advice."
}
incomplete.reason is required and must be prose. The schema's
completion signal must say error: verdict on SBOM Verify and Release
Security Audit, status on SBOM Exploit Check, both on Component Due
Diligence. git, tool_versions, inputs_declared, runner,
checks, assessments and artifacts may be included. Audit body
sections may not: a document that reports findings, a gate, or a
decision is claiming work, and claimed work is validated in full,
waiver authenticity included.
Two exceptions, on Release Security Audit only. The first is scope: a
run pointed at one project inside a repository has to say which project
the silence is about, and a project with no SBOM of its own is exactly
the case that stops early (see
Auditing one project inside a repository).
It is validated in full, like the block below.
The second is drift. Drift is
measured before the gate and before any waiver question, so a run that
dies waiting for a human has usually finished it. A release audit that
wrote evidence/drift-report.md and left drift null told a human
reader that 16 components had disappeared and told every machine reader
that nothing had changed. The rule now runs both ways and applies to
finished audits too:
artifacts.drift_reportnames a file, sodriftmust be an object;driftis an object, soartifacts.drift_reportmust name the file;- the block is validated in full, including
baseline.schema. Drift against an unidentified baseline is not drift.
An empty new_vulns list means "compared, nothing new". When the
baseline carries no vulnerability findings at all, say so in the report
and leave the lists empty: no baseline is not a clean baseline, and the
envelope's verdict: "error" already denies the release.
The platform stores the envelope as the result, fails the execution,
and denies the release with run did not complete: <reason>. Before
this, a graceful failure with no schema field was recorded as
cra_result_missing and the agent's explanation survived only under
result.raw, where nobody reads it. "The audit could not be completed
because a required human decision did not arrive" is itself a
compliance-relevant fact.
preloop.cra.sbomaudit/v1 (SBOM Verify)¶
Envelope plus:
{
"source": {
"format": "spdx | cyclonedx",
"spec_version": "SPDX-2.3",
"generator_tool": null,
"build_ref": null
},
"valid": true,
"minimum_elements": {"passed": false, "missing": ["supplier: 12 components"]},
"coverage": {
"components": 214,
"pct_with_version": 99.1,
"pct_with_license": 92.5,
"pct_with_identifier": 88.3,
"unmatched_vs_build": ["kernel-module-foo"]
},
"license_flags": [
{"component": "...", "license": "...", "flag": "deny | flag | missing"}
],
"delta": null,
"verdict": "pass | pass_with_findings | fail"
}
Verdict: fail = invalid SBOM, minimum elements absent, or a declared
SBOM digest that does not match the parsed file (missing inputs entirely
also yields fail); pass_with_findings = valid but findings exist
(coverage gaps, license flags, skipped cross-checks, provenance
contradictions); pass = clean. Build cross-checks are marked skipped
when no build manifests were delivered. delta is always null in
this standalone preset; only the Release Security Audit computes drift.
Provenance consistency. checks[] always carries a
provenance_consistency entry, passed or skipped. It compares what the
caller declared (generator, SBOM sha256, build ref, any "unmodified tool
output" claim) against what the file says: creator/tool metadata, the
digest of the bytes actually parsed, and machine-checkable signs of
post-processing such as byte-identical duplicate components or
dependency references with no matching bom-ref. A contradiction is a
finding about the declaration, recorded separately from findings about
the SBOM's content. Callers who reshape an SBOM before delivering it
(to fit a size limit, for instance) get told, instead of getting an
audit of the reshaped file that reads as an audit of their build.
This schema has no top-level status field. Completion is the
verdict. Artifacts: audit_report (evidence/audit-report.md),
findings (evidence/findings.json). The execution page Report tab reads
those evidence/ paths from the pack.
preloop.cra.vulnscan/v1 (SBOM Exploit Check)¶
Envelope plus:
{
"status": "success | error",
"source_sbom": {
"path": "sbom/image.spdx.json",
"format": "spdx",
"spec_version": "SPDX-2.3",
"build_ref": null
},
"db_versions": {
"osv_queried_at": "...",
"kev_snapshot_date": "...",
"epss_queried_at": null,
"nvd": "not used"
},
"inventory": {
"components": 214,
"matchable": 189,
"unmatchable": 25,
"source_matrix": {
"osv_purl": {
"kind": "database",
"screenable": 0,
"blind": 0,
"negative_control": {
"query": "<verbatim>",
"result": "<what came back>",
"method_blind": false
}
},
"osv_git": {
"kind": "database",
"screenable": 0,
"blind": 0,
"negative_control": {"query": "...", "result": "...", "method_blind": false}
},
"nvd_cpe": {
"kind": "heuristic",
"screenable": 0,
"blind": 0,
"negative_control": {"query": "...", "result": "...", "method_blind": false}
},
"osv_distro": {
"kind": "heuristic",
"screenable": 0,
"blind": 0,
"negative_control": {"query": "...", "result": "...", "method_blind": false}
},
"screened_by_no_source": 0
}
},
"findings": [
{
"id": "CVE-...",
"pkg": "...",
"version": "...",
"severity": "critical | high | medium | low | unknown",
"cvss": 8.1,
"epss": 0.42,
"kev": true,
"fix_version": "...",
"vex_status": null,
"sources": ["osv_purl | osv_git | nvd_cpe | osv_distro"],
"match_kind": "database | heuristic",
"aliases": ["<other ids for the same advisory>"]
}
],
"counts_by_severity": {
"critical": 0,
"high": 1,
"medium": 3,
"low": 2,
"unknown": 1
},
"art14_candidates": ["CVE-..."],
"reporting": {
"assessment": "no_reportable_vulnerability | reportable_candidate | undetermined",
"basis": "KEV snapshot 2026-09-10; component present at the vulnerable version",
"kev_snapshot_date": "2026-09-10",
"kev_source_url": "https://www.cisa.gov/.../known_exploited_vulnerabilities.json",
"candidates": [
{
"id": "CVE-...",
"actively_exploited": true,
"exploited_evidence": "kev | vendor_advisory | none",
"affected": {
"value": "true | false | undetermined",
"source": "vex | reachability | manual | unknown",
"detail": "<what settled it>"
},
"vex_status": null,
"reportable": true,
"discovered_at": "2026-09-11T09:14:00Z",
"deadlines": {
"early_warning_24h": "2026-09-12T09:14:00Z",
"notification_72h": "2026-09-14T09:14:00Z",
"final_report_14d": "2026-09-25T09:14:00Z"
},
"status": "none | drafted | submitted | out_of_scope"
}
],
"not_a_legal_determination": true
},
"gate": {"policy": "fail on KEV or CVSS >= 9.0 (default)", "passed": false},
"new_since_last_run": null
}
status is the required flow completion signal Preloop reads:
success when the scan completed and the report was written (regardless
of gate outcome or findings); error (plus a reason field) when the
scan could not be completed. A failed gate on a completed scan is still
"status": "success".
matchable means screenable by at least one database source
(osv_purl or osv_git). Heuristic-only coverage does not make a
component matchable. Heuristic-only findings (nvd_cpe / osv_distro
with no database-source confirmation) are reported and labeled but do
not enter the severity gate.
Default gate when the payload provides none: fail on any KEV-listed
finding or CVSS ≥ 9.0. VEX suppressions (OpenVEX or CycloneDX VEX) are
applied to the gate but always echoed in findings with vex_status.
new_since_last_run is always null in this standalone preset.
art14_candidates lists KEV-listed CVE ids as a prioritisation signal
for a human. It is not a report, and Preloop does not file Article 14
notifications. reporting is the block that turns that list into an
answer with a clock: see
CRA Article 14 reporting.
If a source's negative control comes back empty, that source is blind
(method_blind: true). Empty results from a blind source mean nothing.
A component every source is blind to is "not screenable by any
method", never "zero vulnerabilities".
Artifacts: findings (evidence/findings.json), source_matrix
(evidence/source-matrix.json), report (evidence/vuln-report.md).
The Report tab uses package, CVSS, KEV, fix and VEX columns when the
findings carry them.
preloop.cra.releaseaudit/v1 (Release Security Audit)¶
Envelope plus sbom_audit (SBOM body, not a copy of the standalone
envelope), vuln_scan (vuln body, not a copy of the standalone
envelope), and the fields below. The nested objects diverge from
the standalone presets: extra coverage counters, a per-source matrix,
and waiver fields on the gate.
{
"scope": {
"project_path": "projects/device-gateway",
"covers": "repository | project",
"status": "audited | not_checkable",
"reason": "<required when status is not_checkable>",
"sbom_paths": ["projects/device-gateway/sbom/image.spdx.json"]
},
"sbom_audit": {
"source": {
"format": "spdx | cyclonedx",
"spec_version": "...",
"generator_tool": null,
"build_ref": null
},
"valid": true,
"minimum_elements": {"passed": true, "missing": []},
"coverage": {
"components": 214,
"pct_with_version": 99.1,
"pct_with_license": 92.5,
"pct_with_license_concluded": 80.0,
"pct_with_license_declared": 92.5,
"pct_with_identifier": 88.3,
"db_resolvable": 40,
"not_db_resolvable": 174,
"unmatched_vs_build": null
},
"license_flags": [
{"component": "...", "license": "...", "flag": "deny | flag | missing"}
],
"verdict": "pass | pass_with_findings | fail"
},
"vuln_scan": {
"db_versions": {
"osv_queried_at": "...",
"kev_snapshot_date": null,
"epss_queried_at": null,
"nvd": "not used"
},
"inventory": {
"components": 214,
"matchable": 40,
"unmatchable": 174,
"db_resolvable": 40,
"not_db_resolvable": 174,
"by_ecosystem": {"generic": 174, "pypi": 10},
"negative_control": {
"query": "<control query as sent>",
"result": "<what came back>",
"method_blind": false
},
"source_matrix": {
"osv_purl": {
"kind": "database",
"screenable": 0,
"blind": 0,
"negative_control": {
"query": "<verbatim>",
"result": "...",
"method_blind": false
}
},
"osv_git": {
"kind": "database",
"screenable": 0,
"blind": 0,
"negative_control": {"query": "...", "result": "...", "method_blind": false}
},
"nvd_cpe": {
"kind": "heuristic",
"screenable": 0,
"blind": 0,
"negative_control": {"query": "...", "result": "...", "method_blind": false}
},
"osv_distro": {
"kind": "heuristic",
"screenable": 0,
"blind": 0,
"negative_control": {"query": "...", "result": "...", "method_blind": false}
},
"screened_by_no_source": 0
}
},
"findings": [
{
"id": "...",
"pkg": "...",
"version": "...",
"severity": "...",
"cvss": null,
"epss": null,
"kev": false,
"fix_version": null,
"vex_status": null,
"sources": ["osv_purl"],
"match_kind": "database | heuristic",
"waived": false,
"aliases": null
}
],
"counts_by_severity": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"unknown": 0
},
"art14_candidates": [],
"closed_by_vex": 0,
"reporting": {
"assessment": "no_reportable_vulnerability | reportable_candidate | undetermined",
"basis": "<one sentence naming the evidence>",
"kev_snapshot_date": "2026-09-10",
"kev_source_url": "<the URL actually fetched>",
"candidates": [],
"not_a_legal_determination": true
},
"gate": {
"policy": "<the policy applied>",
"passed": true,
"passed_before_waivers": true,
"vex_suppressed": [
{
"id": "<finding id>",
"vex_status": "not_affected | fixed | false_positive",
"vex_statement_id": "<statement id, verbatim>",
"vex_justification": "<justification, verbatim>",
"would_have_failed": "kev | cvss | unscored"
}
],
"waivers_applied": [
{
"id": "...",
"reason": "<verbatim>",
"author": "...",
"date": "...",
"approval_id": null
}
],
"unwaived_failures": [],
"waivers_invalid": [],
"waivers_unmatched": []
}
},
"drift": {
"baseline": {
"schema": "preloop.cra.releaseaudit/v1",
"run_at": "...",
"build_ref": "..."
},
"sbom_changes": {
"added": [],
"removed": [],
"upgraded": [],
"license_changes": []
},
"new_vulns": ["CVE-..."],
"resolved_vulns": [],
"new_kev": [],
"gate_transitions": ["gate: pass -> fail"],
"closed_by_vex": {"previous": null, "current": 0},
"limitations": {"previous": null, "current": ["build_manifest_cross_check"]},
"alert": true
},
"limitations": [
{"check": "build_manifest_cross_check", "missing_input": "build_manifest"}
],
"verdict": "pass | pass_with_findings | fail",
"gap_register": {
"ran": true,
"repo": {"remote": "...", "commit": "<40 hex>", "branch": "..."},
"items": [
{
"id": "cvd_policy",
"title": "...",
"status": "met | gap | partial | declared",
"evidence": "<file:line or commit SHA>"
}
],
"secrets_findings": [
{
"sha": "<40 hex>",
"path": "...",
"subject": "...",
"term": "...",
"kind": "...",
"status": "finding | not_a_finding",
"reason": "<required when not_a_finding>"
}
],
"secrets_findings_count": 0,
"not_checkable": ["<what the repository could not show>"],
"resolved": [{"sha": "...", "path": "...", "reason": "..."}],
"ready": false
},
"evidence_storage": {
"mode": "hybrid",
"product": "<product name>",
"compliance_repo": {
"remote": "...",
"commit": "<HEAD SHA at clone>",
"evidence_path": "products/<product>/audits/<run>/",
"committed": true
},
"code_repos": [
{
"remote": "...",
"commit": "<HEAD SHA>",
"branch": "...",
"stub_path": ".preloop/evidence/<stub filename>",
"committed": true
}
]
}
}
drift is null when no previous result.json was delivered, and only
then: when a drift report exists in the evidence pack, the block must
carry what it states (see Incompletion envelope).
gap_register is null when no repository was attached. evidence_storage
is null when product mode was skipped (no checkouts). scope is null
when the whole repository was the unit of audit (see
Auditing one project inside a repository).
Overall verdict: pass means: minimum elements passed, gate passed, no
open (non-VEX-closed) findings, no failed cross-checks, no gap/partial
register items. fail if the SBOM audit failed or the severity gate
failed after deterministic waiver application. Otherwise
pass_with_findings. If sbom_audit.verdict is fail, result.verdict
must be fail. A run with any applied waiver can never end better than
pass_with_findings.
What holds a run at pass_with_findings:
- a finding that is not closed by VEX (see below);
- a waived gate failure;
- a cross-check that ran and failed;
- a skipped check that does not name a missing input, or names one that
inputs_declaredshows was delivered; - a
gaporpartialregister item, or any secrets finding; - a heuristic or database source whose negative control came back blind, or a component that no source screened.
What does not hold it:
- A finding closed by VEX. A finding is closed when
vex_statusisnot_affectedwith a recognised justification (the OpenVEX and CSAF vocabulary, such asvulnerable_code_not_present, or the CycloneDXanalysis.justificationvocabulary, such ascode_not_reachable), orfixedwith any justification, andvex_statement_idnames the statement. The finding stays invuln_scan.findings, inevidence/findings.jsonand in the pack. It is counted invuln_scan.closed_by_vex.affected,under_investigation,false_positive, a free-text justification and a missing statement id leave it open. This is narrower than gate suppression: afalse_positiveor free-text statement still leaves the gate population, but it keeps the run atpass_with_findings. - A limitation. A cross-check skipped because its input was not
delivered is written as
skipped: truewithmissing_inputnaming the input, and it is listed inlimitations[]as{"check", "missing_input"}. - A
declaredregister item. It keepsgap_register.readyfalse, because a declaration is not evidence, but it is not an open gap. - License flags and SBOM coverage. They hold the nested
sbom_audit.verdict, as before, not the overall verdict.
vuln_scan.closed_by_vex and limitations are derived by the platform
at persist from the findings and checks[]. A value the agent wrote is
a claim: when it differs, the derived value replaces it and the change
is recorded on verdict_corrected. drift.closed_by_vex and
drift.limitations carry the baseline value (previous, read by the
agent from the previous result, null when that result predates these
fields) next to this run's value (current, stamped by the platform).
Gap-register gap and partial items appear in checks[] as
passed: false, named gap_register_<item id>, and move a clean run to
pass_with_findings. They never flip the severity gate or
sbom_audit.verdict. A failed check named after an item whose status is
met is a contradiction, not a mirror, and it holds the verdict like any
other failed cross-check. not_checkable is required.
secrets_findings_count must equal the SHA+path finding row count
(not the gitleaks count). A previous run's SHA+path set is a freeze
floor: dropping a row without resolved plus a reason fails platform
validation. gap_register.ready is true only when no item is gap or
partial and secrets_findings_count is 0. Stable item ids:
cvd_policy, security_contact, support_window,
article14_runbooks, update_and_signed_ota, secrets_hygiene,
default_credentials_provisioning, repo_hygiene, key_management,
ci_secret_scanning, ci_sbom_job, debug_leakage.
Waivers are human-authored inputs (waivers.json / waivers.yaml in
the seed, or payload waivers). The agent never authors a waiver. An
entry missing id, reason, author, or date is invalid and waives
nothing. Interactive collection (waiver_collection: "interactive")
uses the built-in ask_user channel once, batched, and asks for a
structured answer: the call carries one items row per unwaived gate
failure (id, package, severity, KEV badge) and an input_schema with a
waived array of {id, reason}. The console renders that as a form (a
checkbox and a reason box per finding), so nobody types JSON, and the
agent applies the returned array directly (from the in-process tool
result, or from the parked _answers_prompt block after a resume).
author and date are marked x-autofill and stamped by the platform
from the approval record, never typed and never model-authored. Persist
authenticates that stored tool_result / responses content. A
status=approved flag, or a CVE mentioned in the question, is not a waiver. Timeout fails closed.
The severity gate is KEV, CVSS >= 9.0, or a database-source finding
with no CVSS score at all, unless the trigger/CI payload sets
gate.fail_on_kev / gate.fail_on_cvss_gte (CVSS in [0, 10]) /
gate.fail_on_unscored. Agent gate.policy display text never changes
the threshold. There is no per-product policy table.
Unscored findings are gate-relevant by default. Go and Rust
advisories routinely reach OSV with no CVSS vector. Under a score-only
gate every one of them passes silently, and the pack then reads as
"screened and cleared" when it means "never scored". Unscored failures
are labeled UNSCORED on the cover and in the register, and they are
waivable like any other gate failure. Set
gate.fail_on_unscored: false in the payload (or
ReleasePolicy(fail_on_unscored=False) in CI) to opt out; the gate line
then says so. Heuristic-only findings are unaffected: they never enter
the gate, scored or not.
Interactive waivers wait on a human timescale. The preset sets
approval_window_seconds: 259200 (3 days), and while the question is
outstanding the execution is parked: the container and the runner are
released, the status is WAITING_FOR_HUMAN, and the flow's
timeout_seconds budget is paused (waiting costs no agent time). The
decision, from the console, mobile, the API or a public approval link,
resumes the same agent session with the answer attached. If nobody
answers before the window closes, the run is resumed with an explicit
expired answer so the agent finishes its report with the finding
unwaived, instead of the platform reporting a missing result.
Set the window per flow (approval_window_seconds, seconds, from 60 up
to the account cap, 30 days by default), or per call by passing
timeout_seconds to ask_user / request_approval; the call can only
ask for a shorter window than the flow allows. Approvers are re-notified
through their existing notification preferences at 50 percent and 90
percent of the window.
Agents that can natively continue a session (Claude, Codex, Gemini,
OpenCode) resume in place, with the workspace snapshot restored. An agent
kind that cannot resume a session is restarted from the beginning with
the decision in the trigger payload under
payload.answers.<request_id>, so a flow that is expensive to restart
should prefer a resumable harness for interactive waivers.
The severity gate is KEV or CVSS >= 9.0 unless the trigger/CI payload
sets gate.fail_on_kev / gate.fail_on_cvss_gte (CVSS in [0, 10]).
Agent gate.policy display text never changes the threshold. There is
no per-product policy table.
Heuristic sources stay labeled and never enter the severity gate.
pkg:generic and pkg:github are not db-resolvable by purl; they may
still be screenable on osv_git when a vcs_url qualifier is present.
Vendored Arduino/PlatformIO components without a vcs_url are first
resolved through the built-in resolve_sbom_upstreams tool (public
Arduino library index + PlatformIO registry, read-only): a resolution
requires a registry-confirmed name+version match and is never
fabricated. Resolution provenance is reported per SBOM in the additive
nullable inventory.upstream_resolution block (registry_resolved /
vcs_url_present / unresolved); unreachable registries leave the
affected components blind and recorded, never guessed.
When a repository is attached, the gap-register phase installs and
runs gitleaks (recommended pin 8.24.3, git mode, full history,
--redact) and zizmor (recommended pin 1.16.0, GitHub Actions
workflows) inside the execution sandbox. Untrusted-repo work runs in
the governed sandbox, never on the Preloop API server. Secret values
never appear in any artifact: findings are commit SHA plus path only.
Artifacts include audit_report, findings, source_matrix,
waivers (or null), sbom_findings, gap_register (or null),
drift_report.
Auditing one project inside a repository¶
A repository holding many independently built projects is not one
product. Pass a repository-relative project_path in the trigger
payload and that path becomes the unit of audit for the whole run, the
way target_repo_path and focus_paths already scope the code health
review:
- Default is unchanged. No
project_path(absent, empty ornull) means the whole repository,scopeisnull, and every other part of the run behaves as it did before this input existed. Results written before the field stay valid:scopeis additive and nullable, and the schema is stillpreloop.cra.releaseaudit/v1. - The path is validated first. It must be relative to the
repository root, carry no
..segment, and exist in the checkout. A bad path is a bad input: the run writes the incompletion envelope naming the rejected value inincomplete.reason(for exampleproject_path '../other-repo' escapes the checkout) rather than quietly auditing the whole repository. Do not put a rejected path intoscope.project_path; the validator refuses any scope block whose path escapes the checkout, so that envelope would be discarded. The scope block is not required for this case. - Discovery is scoped. SBOM lookup, the gap-register file walk, gitleaks, the history pickaxe and zizmor all run inside the path. An SBOM belonging to a sibling project is not this project's SBOM and is never read.
- Every pointer is inside the path. A repository-level file outside
the project (a root
SECURITY.md, a root workflow) is not this project's evidence: the item is recorded ingap_register.not_checkablewith a reason naming where the evidence actually lives. The platform rejects a scoped result whosegap_registerpointers orscope.sbom_pathsleave the path. Commit SHAs and prose are not path pointers and are left alone. - The verdict says what it covers.
scope.coversisproject, and theaudit-report.mdverdict sentence names the audited path and says the verdict covers that path only.
No SBOM for the project: not_checkable. This family verifies
SBOMs and refuses to generate them, so a project with no SBOM of its own
is an absence of evidence, not a clean bill of health. The run writes
the incompletion envelope with verdict:
"error" and a scope block that says so:
{
"schema": "preloop.cra.releaseaudit/v1",
"flow": "release-security-audit",
"run_at": "2026-09-15T10:00:00Z",
"regime_profile": "cra",
"verdict": "error",
"incomplete": {"reason": "no SBOM was delivered for projects/device-gateway", "stage": "PHASE 0"},
"scope": {
"project_path": "projects/device-gateway",
"covers": "project",
"status": "not_checkable",
"reason": "no SBOM available"
},
"disclaimer": "Machine-generated evidence for conformity assessment support. Not a conformity assessment, certification, or legal advice."
}
The word is not_checkable, in the result envelope, in the report and
here, and it is never skipped: a skipped check reads as a choice, and
this is a missing input. The reason is required and must be non-empty.
There is no fallback to reading manifests or lockfiles, because a
reconstructed inventory is not a verified SBOM. A not_checkable lens
can never carry pass or pass_with_findings, and it can never carry
an audit body either: a document reporting a gate or findings is
claiming work, and there was none to claim.
preloop.cra.duediligence/v1 (Component Due Diligence)¶
Envelope plus:
{
"component": {
"name": "...",
"version": "...",
"purl": null,
"supplier": null,
"homepage": null
},
"product": null,
"usage_context": null,
"evidence": {
"docs_examined": [
{
"title": "...",
"source": "<path or URL>",
"retrieved_at": "<ISO or null>",
"covers": "..."
}
],
"cve_history": {
"osv_queried_at": null,
"kev_snapshot_date": null,
"matchable": true,
"count": 0,
"kev_ids": [],
"notable": [{"id": "...", "severity": "...", "note": "..."}]
},
"maintenance": {
"latest_release": null,
"latest_release_date": null,
"signals": [
{"signal": "...", "source": "...", "retrieved_at": "..."}
]
},
"ce_declaration": {
"present": false,
"document": null,
"issuer": null,
"date": null,
"authenticity_verified": false
},
"license": {"declared": null, "flags": []},
"open_unknowns": ["<what could not be determined and why>"]
},
"decision": {
"outcome": "accepted | rejected | pending",
"decided_via": "preloop_approval",
"approval_operation": "<exact operation string sent to request_approval>",
"reviewer": null,
"note": "Reviewer identity and decision timestamp are in the Preloop approval audit trail for this execution."
},
"record": {
"compliance_repo": null,
"repo_commit": null,
"path": null,
"committed": false
},
"status": "success | error",
"verdict": "recorded | error"
}
status is the required flow completion signal. verdict is
recorded only when the legwork completed and a human decision
(accepted or rejected) was captured; otherwise error. A
rejected decision is still a successfully recorded decision. A
granted approval means one reviewer accepted the component's risk for
this product at this time; it is not a certification.
authenticity_verified is always false: the preset reports presence
of a supplier CE declaration document, never its authenticity.
reviewer is always null in the record. Reviewer identity lives in
Preloop's approval audit trail.
Keys the control plane adds to the stored result¶
The stored result is the agent's document plus a small number of keys
the platform owns. They are not part of any preloop.cra.* schema and
the agent cannot write them: an agent-authored copy is stripped or
renamed before the result is saved. Reading the raw JSON, you will see:
| Key | Written by | What it is |
|---|---|---|
runner |
control plane, over the agent's field | Where the run executed: {"kind": "hosted" \| "self_hosted", "id": <runner id or null>, "attested_by": "control_plane"}, plus pool when the run was queued to one. The agent cannot see which runner leased its job, so every preset tells it to write nulls and the persist boundary replaces them. |
container_termination |
the executor's runtime observation | How the sandbox ended: reason, oom_killed, exit_code. The only evidence that a run died rather than concluded. A result.json claim of this key is discarded. |
dossier_manifest |
control plane, on product-evidence runs | preloop.cra.dossier_manifest/v1: content digests over the raw agent result, the annotated result, the declared source inputs, artifact references, approvals and publication receipts. It is large (on a real 004 run, 39,815 bytes of a 92,796 byte result) because it restates identity in canonical, hashable form. See Product evidence. |
product_provenance |
control plane, from the trigger | The caller's declared product mapping, echoed onto the result so the dossier and the evidence receipt describe the same artefact. |
trusted_publication, evidence_upload, verification |
control plane | Publication receipts, evidence-archive receipt, and the runner-captured verification verdict. |
runner is a fact about the platform, not about the audit: it does not
enter any verdict or gate. container_termination is the field to read
first when a run has no audit body.
When the platform corrects a verdict¶
The verdict is derivable from the audit's own fields: an SBOM whose
minimum elements failed is a fail, coverage below 100 percent is at
least pass_with_findings, a failed severity gate is a fail. A run
that measures everything correctly and then writes the wrong label used
to be discarded whole, cra_result_invalid, evidence pack and all.
The persist boundary now rewrites the label and records what it did:
"verdict": "fail",
"verdict_corrected": [
{
"path": "result.verdict",
"submitted": "pass_with_findings",
"corrected": "fail",
"reason": "valid=True, minimum_elements.passed=False",
"corrected_by": "platform_contract_validator"
}
]
A release audit's overall label is recomputed from the rule in the release audit section, and that recomputation can also move it toward less severe:
"verdict": "pass",
"verdict_corrected": [
{
"path": "result.verdict",
"submitted": "pass_with_findings",
"corrected": "pass",
"reason": "pass means: minimum elements passed, gate passed, no open (non-VEX-closed) findings, no failed cross-checks, no gap/partial register items. Recomputed: no holding facts (closed_by_vex=26, limitations=3)",
"corrected_by": "platform_contract_validator"
}
]
Three limits make this safe to rely on:
- Only the label moves.
coverage,license_flags, finding severity and the gate stay as the agent wrote them.minimum_elementsis replaced only when the platform measured the delivered bytes and the agent'spassed: truecontradicts that measurement (see below).counts_by_severityis replaced only when it disagrees with the findings list. - Toward less severe, one step at most. A release audit label that
is more severe than its facts moves down by one step:
failtopass_with_findings, orpass_with_findingstopass.failnever moves topassin one correction. Afailis never moved at all when the severity gate, SBOM validity or the minimum-elements check failed, because that direction would be the platform clearing a release it was handed as denied. An agent who already failed minimum elements keeps that claim. The SBOM audit (preset 004) and the nestedsbom_audit.verdictstill only move toward more severe. - Recorded. Every correction, in either direction, is on
verdict_correctedwith the submitted value, the corrected value and the facts that decided it.
The corrected result is then re-validated in full. If anything else in
the contract is also wrong, the run fails closed exactly as before, with
the raw document under result.raw. Presets are not told about this:
the contract still requires the agent to write the correct verdict, and
the repair exists so one enum does not cost a complete, digest-verified
audit. A finding epss or cvss written as a string is coerced to a
number when that string is a finite value in range (epss from 0 to 1,
cvss from 0 to 10), recorded on verdict_corrected as the reported
string and the stored number, and re-validated; a string that does not
parse stays a contract failure that names the finding index and the value.
What our own SBOMs carry¶
The release SBOMs (scripts/generate_sbom.sh, stamped by
scripts/sbom_metadata.py) set a supplier on each component, not only on
the document. The name comes from installed Python METADATA (author, then
maintainer, then pyproject.toml in that wheel), from package.json
(author, maintainers, contributors, then the npm scope), or from a module or repository path (The Go Authors for std and
golang.org/x, the GitHub org, or host plus first path segment). The
path rule also applies to an npm repository field or a repository URL
already on the component when no person and no scope are present.
author is left as author. Each
derived component records preloop:supplier_source
(package_metadata_author, package_metadata_maintainer, npm_scope,
module_path, manual_override, or unresolved). manual_override is a
checked-in name for a distribution whose files name no person and no
repository. An npm component whose name is @types/* or ends in -types,
and whose installed directory contains no runtime file, also gets
preloop:types_only set to true. The release security audit may use
that property as evidence that a git-range match through the package
vcs_url does not describe code in the component. The property does not
suppress a finding on its own. A VEX statement does. The sbom job fails when
python -m preloop.cra measure reports passed: false.
The same stamp fills a declared licenses entry when a component has none
and a local source names exactly one SPDX license. Python METADATA uses
License-Expression, then a License value that is itself an SPDX expression
or a well-known license text, then a single mapped Classifier: License ::
entry, then a License-File whose text is one well-known license. npm uses
package.json license, or a one-element licenses array
when license is absent. Go modules use a LICENSE file in the module cache
when the text is one SPDX identifier or one well-known license header, and
the Go standard library is BSD-3-Clause. preloop:license_source names
that derivation (pypi_license_expression, pypi_license, pypi_classifier,
pypi_license_file, npm_license, npm_licenses, go_module_license, or
go_stdlib). A source
that names more than one license, or none that this stamp recognizes, is left
blank. The quality table the SBOM job prints shows declared license coverage
before and after the stamp (lic0, lic1).
OpenVEX documents live in security/vex/. The release workflow copies
every security/vex/*.openvex.json into the SBOM artifact and the GitHub
release, next to the CycloneDX files. preloop-cli.openvex.json covers
the CLI golang.org/x/crypto statements. preloop-frontend.openvex.json
covers undici-types (declarations only) and lodash.camelcase (present
in the test-runner install tree, absent from the shipped bundle).
What the platform measures itself¶
minimum_elements on an SBOM audit used to be the agent's own claim. The
platform now measures the delivered SBOM bytes (CycloneDX 1.4 to 1.6 and
SPDX 2.2 and 2.3, including gzip) and stores that object on the result as
minimum_elements_measured (on the nested sbom_audit for a release
audit). The denominator is every component except the document's root
product. A supplier is supplier.name (SPDX: supplier other than
NOASSERTION). Author, authors, publisher and manufacturer are counted
separately and do not satisfy supplier. A unique identifier is a purl or
a CPE.
If the agent reports passed: true and the measurement finds missing
elements, the platform replaces minimum_elements with the measured
object, keeps the agent's claim on verdict_corrected, and lets the
existing verdict floor move the label to fail. That replacement is the
measurement of the delivered bytes becoming the authority. The agent's
field was a claim, not a measurement the platform is rewriting. If the
agent is already stricter than the measurement, the agent's value stays.
If no SBOM seeds are reachable, the platform does not guess: the same key
records why measurement was skipped, and the rest of the contract behaves
as before.
counts_by_severity is arithmetic over the findings list the agent
submitted. When that aggregate is the only contract failure, the platform
recomputes it, records each key as reported versus derived on
verdict_corrected, and re-validates in full. Finding severity is not
edited.
A count mismatch together with any other failure still fails closed, and
the other failure is what is reported. An agent's evidence-pack prose that
repeats the wrong number is left as the agent wrote it.
Corrections move a verdict toward fail, replace a passing
minimum-elements claim that the bytes contradict, replace an aggregate
that does not match the findings, or replace closed_by_vex and
limitations with the values derived from the findings and checks. The
one correction toward less severe is the bounded release-audit
recomputation described in
When the platform corrects a verdict.
It never clears a release whose gate, SBOM validity or minimum-elements
check failed.
CI runbook¶
One copy-paste path: clone the preset, fire the webhook after the build
with inline workspace_files, poll /result, gate on the verdict,
retain the evidence tarball.
1. Clone the preset and attach a webhook¶
In the console, clone Release Security Audit (or SBOM Verify / SBOM Exploit Check). Presets ship without a bound model; clone fails closed with a 422 if the account has no usable model for that agent. Enable a webhook trigger and copy the webhook URL. The inbound path is:
The webhook itself is unauthenticated; the secret in the URL is the
credential. The JSON body becomes the trigger payload (see
webhook triggers). A 200 response includes
execution_id so CI can poll. execution_url is the console page, not
the API.
GET /api/v1/flows/executions/{execution_id} and
GET /api/v1/flows/executions/{execution_id}/result require an account
API token (Authorization: Bearer, same PRELOOP_TOKEN as
trigger a flow from CI). The token needs view_flows.
2. Deliver the SBOM inline¶
Prefer inline workspace_files
(base64; 96 KiB encoded per file, 1 MiB encoded across all files, 50
files). Payload field names are conventions the prompt understands; the
agent also searches /workspace for SBOM-shaped files.
The seed budget is not shared with the rendered prompt. Seeds travel
in the container environment and the launch command references them by
name, so a preset with a long prompt gets the same seed allowance as one
with a short prompt. The trigger endpoints validate the declaration
before an execution exists: 400 for a malformed one, 413 for an
oversized one, in both cases naming the cap, the actual size and the
overage.
URL delivery is allowed (sbom.urls and similar) but is hostile input:
anyone holding the webhook secret can inject them, and the exec
sandbox needs egress to the vulnerability sources, so the runner does
not guarantee egress filtering. The prompts instruct the agent to
fetch only http(s) URLs and to refuse targets resolving to loopback,
private-range, link-local, or cloud metadata addresses (e.g.
169.254.169.254), but this is prompt-level hardening, not a network
policy. Prefer inline workspace_files where integrity matters; a
platform-level egress allowlist is an open question.
Example payload (Release Security Audit):
{
"release_ref": "v1.2.3",
"product": "example-product",
"build": {
"image_name": "example-image",
"image_hash": "sha256:...",
"toolchain": "yocto-5.0"
},
"sbom": {"paths": ["sbom/image.spdx.json"]},
"manifests": {"license_manifest_path": "manifests/license.manifest"},
"license_policy_path": "policy/licenses.yaml",
"gate": {"fail_on_kev": true, "fail_on_cvss_gte": 7.0, "fail_on_unscored": true},
"previous_result_path": "previous/result.json",
"workspace_files": [
{"path": "sbom/image.spdx.json", "content_base64": "..."},
{"path": "manifests/license.manifest", "content_base64": "..."},
{"path": "previous/result.json", "content_base64": "..."}
]
}
Where workspace_files goes in the body¶
A webhook body is the trigger payload, so the example above is
delivered exactly as written. The manual trigger endpoint
(POST /api/v1/flows/{flow_id}/trigger) is different: its body is the
whole trigger event, and flow inputs conventionally sit under a
payload object. Both of these are accepted and mean the same thing:
The lookup is payload first, then the top level, and it is the same
for product_provenance. Declaring workspace_files in both places
is a 400: two lists describe two different runs, and neither reading
of the request is more correct than the other.
Until preloop/preloop#509 only the first shape seeded anything. The
second was accepted with 200, stored on the execution, and seeded
nothing, so the agent started with an empty /workspace and spent a
whole run finding out. If you are reading an execution from before that
fix, check _workspace_file_paths on the trigger snapshot: no such key
means no files were seeded, whatever the request said.
Top-level keys are otherwise free-form and usable as
{{template}} variables, with one exception: keys the platform writes
itself (_resume, _answers, _answers_prompt, _feedback_prompt,
_ci_failure, _workspace_file_paths, _subject) are rejected with a
400 naming the key rather than being quietly accepted.
Recommended CI job outputs to retain and deliver: the SPDX / CycloneDX
file(s), the license manifest (e.g. Yocto license.manifest), image or
package manifests for cross-checks, and the previous audit's
result.json if you want drift. Optional: VEX file, license policy,
human-authored waivers.json.
If the encoded workspace_files would exceed a cap, do not silently
truncate and do not reshape the artefact to fit. An SBOM edited to fit a
transport produces findings about the edit rather than about the product,
and nothing in the evidence pack would let a reader tell the difference.
Gzip the artefacts and decompress them in a setup command, fail the job,
or switch to URL delivery with the trust warning above in mind.
3. GitHub Actions (python -m preloop.cra.ci)¶
Store PRELOOP_URL (API origin, no trailing slash), the full webhook URL
(including the secret) as PRELOOP_CRA_WEBHOOK_URL, and
PRELOOP_TOKEN (account API token with view_flows) as repository
secrets. The webhook URL is a bearer secret: do not print it, and do
not retry the POST after an ambiguous network or HTTP 5xx error.
The helper validates result.json against the CRA contracts, requires a
bounded gzip evidence archive (the durable FLOW_EVIDENCE_MAX_BYTES /
FLOW_ARTIFACT_EXPANDED_MAX_BYTES caps), binds the controller digest,
and compares packed SBOM/finding content rather than schema/verdict/status
alone. Default legacy capture may omit result.json from the tarball;
those packs are accepted only with a matching controller digest plus the
authenticated API result. Release is denied on fail or unknown
verdicts. Default policy is a clean pass only. pass_with_findings is
an explicit --policy pass_with_findings choice. There is no
failure-bypass mode.
See Evidence storage and retention for transport, receipts, and retention.
Overall deadline is the selected flow's timeout_seconds plus a 120s
startup buffer (Release Security Audit is 7200s). Set
PRELOOP_CRA_TIMEOUT_SECONDS or --timeout when the flow does not
report a timeout.
This job assumes a prior step wrote sbom/image.spdx.json and that the
job image can import preloop (the instance's Python environment, or
pip install of the same release).
# .github/workflows/cra-evidence.yml
name: CRA evidence pack
on:
push:
tags: ["v*"]
jobs:
evidence:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Replace with your real SBOM-producing build, or download it
# from the build job's artifacts.
- name: Trigger Release Security Audit
env:
PRELOOP_DISABLE_TELEMETRY: "true"
PRELOOP_URL: ${{ secrets.PRELOOP_URL }}
PRELOOP_CRA_WEBHOOK_URL: ${{ secrets.PRELOOP_CRA_WEBHOOK_URL }}
PRELOOP_TOKEN: ${{ secrets.PRELOOP_TOKEN }}
run: |
set -euo pipefail
test -f sbom/image.spdx.json
python3 -m preloop.cra.ci \
--workspace-file sbom/image.spdx.json \
--workspace-file manifests/license.manifest \
--workspace-file previous/result.json \
--artifacts-dir artifacts \
--policy pass
- uses: actions/upload-artifact@v4
if: always()
with:
name: cra-evidence
path: artifacts/
Generic invocation (same contract, no Actions):
export PRELOOP_DISABLE_TELEMETRY=true
python3 -m preloop.cra.ci \
--webhook-url "$PRELOOP_CRA_WEBHOOK_URL" \
--api-url "$PRELOOP_URL" \
--token "$PRELOOP_TOKEN" \
--payload payload.json \
--artifacts-dir artifacts \
--policy pass
Gating notes:
- Execution status
SUCCEEDEDmeans the flow completed. Afailverdict is still a completed audit (execution succeeded, release denied). Do not treat a green execution as a clean pack. - Default
--policy passaccepts only a clean pack. Use--policy pass_with_findingswhen your product policy allows findings.failand unknown verdicts always deny release. - For SBOM Exploit Check, the helper requires
result.status == "success"andresult.gate.passed is true(boolean true, not1/"true"). Findings still need--policy pass_with_findings. - Schema validation and a verified evidence receipt are required. Operator policy cannot turn missing, empty, HTML, or corrupt evidence into acceptance.
- Retain
result.jsonand the evidence tarball even when the gate fails, including FAILED / TIMEOUT executions. The helper does not retrigger the webhook.
Scheduled re-audits¶
CRA vulnerability handling continues through the support period even
when you ship nothing. Attach a schedule trigger (cron / interval) to a
cloned Release Security Audit or SBOM Exploit Check flow that
re-delivers the last released SBOM (plus the previous result.json
for drift). Each run produces a dated evidence pack; the run-over-run
trail is the auditable record.
CRA Article 14 reporting¶
From 11 September 2026 a manufacturer must report an actively exploited
vulnerability in its product: an early warning within 24 hours of becoming
aware, a vulnerability notification within 72 hours, and a final report within
14 days. A list of KEV-listed CVE ids does not answer that question, and it
carries no clock. The reporting block does both.
Preloop does not file anything. There is no submission client for the ENISA single reporting platform and none is planned here: the filing decision and the filing itself stay with the manufacturer, who signs it. What the platform does is tell you, in one place and in one sentence, that a clock is running and when it stops.
The operational side (who files, the fill-in templates for the three reports,
the decision points, and a rehearsal checklist) lives in the
Article 14 reporting runbooks. This
section documents the machine-readable reporting block those runbooks read
from.
The block¶
reporting lives next to art14_candidates: at the top level in
preloop.cra.vulnscan/v1, under vuln_scan in preloop.cra.releaseaudit/v1.
| Field | Meaning |
|---|---|
assessment |
no_reportable_vulnerability, reportable_candidate or undetermined |
basis |
One sentence naming the evidence that decided it |
kev_snapshot_date |
The dateReleased of the KEV catalogue actually fetched, or null when the fetch failed |
kev_source_url |
The URL actually fetched (cisa.gov or the cisagov/kev-data mirror) |
candidates[] |
One entry per actively exploited vulnerability |
not_a_legal_determination |
Always true |
Per candidate:
| Field | Meaning |
|---|---|
id |
CVE or advisory id |
actively_exploited |
Exploitation evidence exists |
exploited_evidence |
kev, vendor_advisory or none: what the run actually read |
affected |
{value, source, detail}: the product determination, true / false / "undetermined", sourced from vex, reachability, manual or unknown |
vex_status |
The VEX status on the finding, when there is one |
reportable |
Exactly actively_exploited AND affected.value == true |
discovered_at |
When the organisation became aware, in UTC |
deadlines |
early_warning_24h, notification_72h, final_report_14d |
status |
none, drafted, submitted or out_of_scope (with status_reason) |
Rules the validator enforces¶
- The block is required whenever any finding is KEV-listed, and every KEV-listed finding must appear as a candidate. An absent block reads as "nothing to report", which is the failure this exists to prevent.
reportableis derived, not asserted. A candidate that claimsreportable: truewithaffected.value: falseis rejected.actively_exploited: truewithexploited_evidence: "none"is rejected: name the evidence or do not make the claim.- An
affected.valueoftrueorfalsewithsource: "unknown"is rejected. An unsourced call is"undetermined". assessmentmust beundeterminedwhenever the KEV fetch failed (kev_snapshot_date: null), the scan did not complete, or any candidate's affectedness is undetermined. It may only beno_reportable_vulnerabilitywhen every candidate was determined not affected or not exploited.- The three deadlines must equal
discovered_at + 24h / + 72h / + 14d, all measured from the same instant, in UTC (one second of serialization drift is tolerated). Chained or hand-written deadlines are rejected.
The clock starts once¶
discovered_at is the earliest time the vulnerability was seen, not the
time of the run that reported it. When the drift baseline already carried the
id, the baseline's timestamp is used. A nightly re-audit therefore never
restarts a 24 hour clock, and a deadline that has already passed keeps reading
as passed.
VEX is what clears a report¶
A delivered VEX statement of not_affected with a justification (for example
vulnerable_code_not_present) sets affected.value to false with
source: "vex", which makes the candidate non-reportable and, on its own,
takes the finding out of the severity gate (recorded in gate.vex_suppressed
with the statement id and the justification). This is the one determination
that most often distinguishes "on KEV" from "reportable".
A waiver never clears a report. Waiving a gate failure is a release decision by a named human; it says nothing about whether the product is affected by an exploited vulnerability.
SBOM Verify says "undetermined", loudly¶
preloop.cra.sbomaudit/v1 carries the same block hard-coded to:
{
"assessment": "undetermined",
"basis": "SBOM verification does not screen for vulnerabilities; run preset 005 or 006",
"kev_snapshot_date": null,
"kev_source_url": null,
"candidates": [],
"not_a_legal_determination": true
}
Any other assessment is rejected, and candidates must be empty. An operator who runs only SBOM Verify must be able to see that the question has not been asked, which is not the same as the answer being no.
In the audit report¶
evidence/audit-report.md opens the Article 14 box immediately after "What we
did NOT check", before the VEX and waiver sections, with one sentence in the
same place every time:
Reportable under CRA Article 14? No / Candidate, see
reporting/ Undetermined, scan incomplete. Not legal advice.
Each candidate is then written out in plain words with the clock as dates and times, so a duty officer reading the cover page can act without parsing JSON.
The webhook¶
When a candidate is reportable, the platform emits
cra.reportable_vulnerability through
outbound event webhooks, one event per candidate, with the
deadlines the run computed. The event id is deterministic on
(execution, CVE), so a redelivery or a re-emit is the same event and
receivers deduplicate on X-Preloop-Event-Id. occurred_at is
discovered_at, not the send time: the envelope timestamp and the clock in
the payload agree.
The payload carries not_a_legal_determination: true and
filing_is_manufacturer_responsibility: true. Routing it into a ticket queue
automates a notification, never a filing.
Honest limits¶
- Verification is bounded by delivered build evidence: these flows cannot prove an SBOM is complete beyond cross-checks against the manifests you provide, and cannot guarantee vulnerability absence.
- No Declaration of Conformity, CE marking decision, "compliant"
verdict, legal product classification, or Article 14 filing. Evidence
in, human assessment out. Preloop does not file CRA Article 14
reports and has no client for the ENISA single reporting platform.
The
reportingblock states a reportability candidate and the deadlines that follow from it; deciding to file, and filing, are the manufacturer's. result.jsonis persisted by the platform, and the evidence pack is captured as a size-capped tar.gz served byGET /api/v1/flows/executions/{id}/evidence. Long-horizon retention of oversized packs and artifact signing are open platform questions, not claimed by these presets.- Validators / scanners are installed at run time, so the toolchain is
not bit-for-bit fixed across runs. Every run records the exact resolved
tool versions (
tool_versions) and source snapshot dates (db_versions), and the payload can pin versions (e.g."tools": {"spdx-tools": "0.8.2"}) which the prompt honors. Shipping pinned tools in the runner image is the stronger fix; what it would cost, and which tool, is measured in Scanner binaries in the agent image. For vulnerability results, database churn, not tool versions, is the dominant source of run-to-run variance, which is why snapshot dates are always recorded. - These presets verify SBOMs. They never generate one. SBOM creation
belongs to the build toolchain (Yocto / OpenEmbedded
create-spdx, AOSP SBOM tooling, CycloneDX build plugins). If no SBOM was delivered, the run errors rather than inventing one. Scoped to one project inside a repository, the same limit reads asscope.status: "not_checkable"with its reason: no manifest fallback, no neighbour's SBOM, and no passing verdict. Whether an agent image should carry a generator at all, and what the claim would become if it did, is decided in Scanner binaries in the agent image.
Vulnerability sources (honest notes)¶
- OSV.dev is the primary source:
POST https://api.osv.dev/v1/querybatchwith purl or name+ecosystem+version, no API key required. Results recordosv_queried_at. Query form matters: one component per query object; strip purl qualifiers; Maven isgroup:artifactwith a colon. A malformed query returns an empty set that looks like a clean component, which is why each source has a negative control. - CISA KEV catalog supplies known-exploited flags; the catalog
release date is recorded as
kev_snapshot_date. If cisa.gov returns 403, the release-audit prompt falls back to the officialcisagov/kev-dataGitHub mirror and records the URL actually used. A failed primary fetch is not "zero KEV hits". KEV hits appear inart14_candidatesas a prioritisation signal for a human, not legal advice, and Preloop does not file Article 14 reports. - EPSS scores are best-effort (
api.first.org);nullwhen unreachable. - NVD is a fallback only: the public API without a key is
rate-limited to roughly 5 requests per 30 seconds, so runs do not
enumerate NVD for a whole SBOM. How (and whether) NVD was used is
recorded in
db_versions.nvd. NVD CPE name+version search is a labeled heuristic, never presented as a database match. - Components without a version or a purl / CPE identifier cannot be
reliably matched; they are counted as
unmatchableand results never claim vulnerability absence for them. - The Release Security Audit additionally reports
db_resolvablecoverage (components in ecosystems the advisory databases actually index;pkg:genericandpkg:githubare not db-resolvable by purl) and runs a mandatory negative control: a known-vulnerable component queried in the same style as the inventory. If the control comes back empty the method is blind for that class (method_blind: true) and those components are reported as "not screenable by this method", never as "zero vulnerabilities". - If the sandbox has no egress to these sources, the run reports an error rather than fabricating findings or their absence.
Evidence pack layout¶
/workspace/evidence/
audit-report.md # human-readable audit (004 / 006); opens with the one-minute cover
findings.json # full vulnerability findings (exploit check / release audit)
source-matrix.json # component x source screening matrix (exploit check / release audit)
waivers.json # waiver entries seen (release audit, when any existed)
sbom-findings.json # full SBOM verification findings (release audit)
vuln-report.md # human-readable vuln report (005); opens with the one-minute cover
drift-report.md # delta vs previous run (release audit, when baseline given)
gap-register.md # file-presence / hygiene register (release audit, when a repo is attached)
dossier.md # due-diligence dossier (007); opens with the one-minute cover
facts.json # machine-readable due-diligence facts (component due diligence)
In product mode the Release Security Audit additionally copies
result.json and evidence/ into the compliance repo and writes
per-repo stubs. See
Evidence storage architecture.
Evidence storage architecture (multi-repo products)¶
Authorities and auditors think in products, but a product usually
spans several code repositories (firmware, companion app, cloud
backend). Preloop flows already attach any number of git repositories
(git_clone_config.repositories[], each with its own clone_path), and
the Release Security Audit preset uses that to store evidence in a
hybrid layout:
- Per-repo stub. Each audited code repo receives one small
(< 2 KB), diffable, dated file at
.preloop/evidence/<UTC timestamp>-release-security-audit.json(preloop.cra.repostub/v1): run date, verdict, gate outcome, severity counts, the repo's own HEAD commit SHA, and a pointer to the product-level pack. It rides the same PR discipline as code, so the evidence trail is tamper-evident with the code history, and it deliberately carries no findings detail, so code repos never accrue artifact bloat. - Product-level compliance repo. One dedicated repository per
product receives the full pack under
products/<product>/audits/<UTC timestamp>-<release_ref>/: a copy ofresult.json, the wholeevidence/directory, and amanifest.json(preloop.cra.evidencepack/v1) listing every constituent code repo with its remote and HEAD commit SHA. The manifest SHAs and the stub SHAs must agree. That cross-reference is the spine of the audit trail.
Why hybrid, rather than everything in the code repos or everything in a database:
- Authorities think product-level. One repo answers "show me the evidence for this product", across firmware / app / cloud, in one place.
- Access control. Auditors and legal can be granted the compliance repo without any source access.
- Retention outlives repo churn. CRA-style retention runs for years after release; code repos get renamed, split, and archived. The compliance repo persists, and its records reference code repos by commit SHA, which survives renames.
- No artifact bloat in code repos, while each repo still carries a tamper-evident, diffable trace of every audit that covered it.
Configuring product mode¶
Clone the Release Security Audit preset into a flow and attach the
product's repositories plus the compliance repo. The flow config names
the compliance repo by the clone_path: compliance convention (a
payload field compliance_repo_path can override the path per run):
{
"enabled": true,
"repositories": [
{
"repository_url": "https://git.example.com/example-product/firmware.git",
"clone_path": "firmware"
},
{
"repository_url": "https://git.example.com/example-product/companion-app.git",
"clone_path": "companion-app"
},
{
"repository_url": "https://git.example.com/example-product/product-compliance.git",
"clone_path": "compliance"
}
],
"create_pull_request": true
}
The agent writes the stubs and the pack and commits locally on the
branch the platform prepared; pushing and PR / MR creation happen in the
platform's post-execution step, per repository, gated by this flow
config. The agent never runs git push. With no repositories attached
the phase is skipped and the preset behaves exactly as before
(artifact-only); result.json stays preloop.cra.releaseaudit/v1 and
gains only an additive, nullable evidence_storage section describing
what was written where and whether each commit succeeded.
The same pattern for SBOM Verify and Exploit Check (spec)¶
The standalone presets remain artifact-only for now. When they adopt
product mode they will follow the identical pattern, changing only the
flow slug in the stub filename
(…-sbom-verify.json / …-sbom-exploit-check.json), the result_schema
field, and the pack directory (products/<product>/audits/… with the
per-preset artifact set). The compliance-repo convention
(clone_path: compliance), the stub / manifest schemas
(preloop.cra.repostub/v1, preloop.cra.evidencepack/v1), the SHA
cross-reference rule, and the commit discipline are shared: one storage
architecture, three producers.
Honest limits of the storage design¶
- Tamper evidence comes from git history (and whatever branch protection / signing you enforce on the compliance repo). Records are not independently signed or timestamped by Preloop.
- The SHA cross-reference proves which code the audit saw checked out; it does not prove the delivered SBOM was built from those SHAs. That link is only as strong as the build metadata your CI delivers.
- Retention is your repo's retention: the design assumes you keep the compliance repo for the support period; Preloop does not enforce it.
Component Due Diligence Record¶
CRA-style due diligence applies to every integrated component, commercial and open source, and the decisions must be stored, not just made: expect to answer how you decided a component was appropriate, what documentation you checked, and what was known at the time. This preset splits the work honestly:
- Agent legwork (facts, sources cited): documentation actually
delivered or fetched; CVE history via OSV.dev with CISA KEV
cross-check; maintenance signals (release cadence, activity,
deprecation notices) from cited public sources; presence of a
supplier CE declaration document (never its authenticity:
authenticity_verifiedis alwaysfalse); declared license; and an explicit open unknowns list. - Human risk decision: the agent calls the builtin
request_approvaltool once with a neutral dossier summary. It never recommends an outcome. Approval granted →accepted, denied →rejected, tool unavailable →pending(and the run reportserror). Reviewer identity and the decision timestamp live in Preloop's approval audit trail; the record references the approval and never invents a name. - Stored record:
result.json(preloop.cra.duediligence/v1) plus, when the flow attaches a compliance repo (sameclone_path: complianceconvention), a dated pair committed underproducts/<product>/components/<component>/:<UTC timestamp>-due-diligence.jsonand the human-readable dossier beside it.
Trigger it manually or by webhook, one component per run:
{
"component": {
"name": "libexample",
"version": "1.4.2",
"purl": "pkg:generic/libexample@1.4.2",
"supplier": "Example Components Ltd"
},
"product": "example-product",
"usage_context": "TLS transport in the firmware update client",
"workspace_files": [
{"path": "docs/security-policy.pdf", "content_base64": "..."},
{"path": "docs/ce-declaration.pdf", "content_base64": "..."}
]
}