Skip to content

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_report names a file, so drift must be an object;
  • drift is an object, so artifacts.drift_report must 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_declared shows was delivered;
  • a gap or partial register 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_status is not_affected with a recognised justification (the OpenVEX and CSAF vocabulary, such as vulnerable_code_not_present, or the CycloneDX analysis.justification vocabulary, such as code_not_reachable), or fixed with any justification, and vex_statement_id names the statement. The finding stays in vuln_scan.findings, in evidence/findings.json and in the pack. It is counted in vuln_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: a false_positive or free-text statement still leaves the gate population, but it keeps the run at pass_with_findings.
  • A limitation. A cross-check skipped because its input was not delivered is written as skipped: true with missing_input naming the input, and it is listed in limitations[] as {"check", "missing_input"}.
  • A declared register item. It keeps gap_register.ready false, 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:

{"project_path": "projects/device-gateway"}
  • Default is unchanged. No project_path (absent, empty or null) means the whole repository, scope is null, and every other part of the run behaves as it did before this input existed. Results written before the field stay valid: scope is additive and nullable, and the schema is still preloop.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 in incomplete.reason (for example project_path '../other-repo' escapes the checkout) rather than quietly auditing the whole repository. Do not put a rejected path into scope.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 in gap_register.not_checkable with a reason naming where the evidence actually lives. The platform rejects a scoped result whose gap_register pointers or scope.sbom_paths leave the path. Commit SHAs and prose are not path pointers and are left alone.
  • The verdict says what it covers. scope.covers is project, and the audit-report.md verdict 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_elements is replaced only when the platform measured the delivered bytes and the agent's passed: true contradicts that measurement (see below). counts_by_severity is 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: fail to pass_with_findings, or pass_with_findings to pass. fail never moves to pass in one correction. A fail is 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 nested sbom_audit.verdict still only move toward more severe.
  • Recorded. Every correction, in either direction, is on verdict_corrected with 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:

POST /api/v1/webhooks/flows/{flow_id}/{webhook_secret}
Content-Type: application/json

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:

{ "payload": { "release_ref": "v1.2.3", "workspace_files": [ ... ] } }
{ "payload": { "release_ref": "v1.2.3" }, "workspace_files": [ ... ] }

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 SUCCEEDED means the flow completed. A fail verdict is still a completed audit (execution succeeded, release denied). Do not treat a green execution as a clean pack.
  • Default --policy pass accepts only a clean pack. Use --policy pass_with_findings when your product policy allows findings. fail and unknown verdicts always deny release.
  • For SBOM Exploit Check, the helper requires result.status == "success" and result.gate.passed is true (boolean true, not 1 / "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.json and 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.
  • reportable is derived, not asserted. A candidate that claims reportable: true with affected.value: false is rejected.
  • actively_exploited: true with exploited_evidence: "none" is rejected: name the evidence or do not make the claim.
  • An affected.value of true or false with source: "unknown" is rejected. An unsourced call is "undetermined".
  • assessment must be undetermined whenever the KEV fetch failed (kev_snapshot_date: null), the scan did not complete, or any candidate's affectedness is undetermined. It may only be no_reportable_vulnerability when 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 reporting block states a reportability candidate and the deadlines that follow from it; deciding to file, and filing, are the manufacturer's.
  • result.json is persisted by the platform, and the evidence pack is captured as a size-capped tar.gz served by GET /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 as scope.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/querybatch with purl or name+ecosystem+version, no API key required. Results record osv_queried_at. Query form matters: one component per query object; strip purl qualifiers; Maven is group:artifact with 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 official cisagov/kev-data GitHub mirror and records the URL actually used. A failed primary fetch is not "zero KEV hits". KEV hits appear in art14_candidates as 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); null when 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 unmatchable and results never claim vulnerability absence for them.
  • The Release Security Audit additionally reports db_resolvable coverage (components in ecosystems the advisory databases actually index; pkg:generic and pkg:github are 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:

  1. 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.
  2. Product-level compliance repo. One dedicated repository per product receives the full pack under products/<product>/audits/<UTC timestamp>-<release_ref>/: a copy of result.json, the whole evidence/ directory, and a manifest.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_verified is always false); declared license; and an explicit open unknowns list.
  • Human risk decision: the agent calls the builtin request_approval tool once with a neutral dossier summary. It never recommends an outcome. Approval granted → accepted, denied → rejected, tool unavailable → pending (and the run reports error). 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 (same clone_path: compliance convention), a dated pair committed under products/<product>/components/<component>/: <UTC timestamp>-due-diligence.json and 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": "..."}
  ]
}