Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions specs/016-native-tools/contracts/scanner-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ pub struct ScanRequest {
pub target: PathBuf,
/// Language hint, where the scanner needs one.
pub lang: Option<String>,
/// Wall-clock budget. Exceeded ⇒ `Failed`, never a partial `Completed`.
/// Wall-clock budget for the **whole scan**, not an allowance each child draws afresh: the
/// `scan` tool turns it into a deadline and gives every child what is left of it, so a
/// multi-step scanner cannot run to a multiple of the ceiling its operator set. Exceeded ⇒
/// `Failed`, never a partial `Completed`.
pub timeout: Duration,
}
```
Expand Down Expand Up @@ -178,11 +181,12 @@ Consumes an operator-provisioned, version-pinned **bundle** — `codeql-bundle-<
`codeql-action` v4.37.3 pins `codeql-bundle-v2.26.1` / CLI `2.26.1` (`src/defaults.json`).

```text
probe : pin + bundle presence + a version pinned at all + is this language analysable
probe : pin + a version pinned at all + bundle presence + is this language analysable
no process is spawned to answer any of it
unpinned ⇒ Unavailable { BundleMismatch { expected: UNPINNED, found: None } }

preflight : codeql version --format=json → compare against grant.bundle_version
mismatch, unreadable, or unpinned ⇒ Unavailable { BundleMismatch { expected, found } }
mismatch or unreadable ⇒ Unavailable { BundleMismatch { expected, found } }

steps : database create <db> --language=<lang> --build-mode=none --source-root=<target>
database analyze <db> --format=sarif-latest --output=<out> <query-suite>
Expand All @@ -193,7 +197,9 @@ The granted binary **is** the bundle's CLI, so the inode pin already covers what
granted binary — otherwise the version verified belongs to a different CodeQL than the one that
scans. `bundle_version` accepts either spelling (`codeql-bundle-v2.26.1` or `2.26.1`), because the
operator has the bundle version to hand while the CLI only ever reports the CLI version. An
**unpinned** bundle yields no preflight and no command: nothing unverified is reached by any route.
**unpinned** bundle is refused by `probe` as `Unavailable` — nothing has run at that point, so it is
never a `Failed` — and yields no preflight and no command besides: nothing unverified is reached by
any route.
`grant.rules`, when set, names a query suite and replaces the default `codeql/<lang>-queries` pack.

The database lives in the per-call scratch directory and is removed with it — it is large, and it is
Expand Down
17 changes: 13 additions & 4 deletions specs/016-native-tools/contracts/tool-outcome.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ pub enum UnavailableReason {
NotGranted { name: String },
/// The binary at the granted path no longer matches its pinned identity (FR-009).
PinMismatch { path: PathBuf },
/// A required provisioned artefact is absent or the wrong version (FR-011).
/// A required provisioned artefact is absent, the wrong version, or unpinned (FR-011). The
/// unpinned case carries `expected: UNPINNED` — there is no version it was allowed to be — and
/// renders as the setting that would pin it rather than as a version comparison.
BundleMismatch { expected: String, found: Option<String> },
/// The requested language has no grammar in this build.
LanguageUnsupported { lang: String, compiled_in: Vec<String> },
/// The path is not inside a repository (US5 scenario 3).
NotARepository { path: PathBuf },
/// The requested language can only be analysed by observing a build, which bee will not do
/// (FR-011, US6 scenario 3). Distinct from `LanguageUnsupported`: that one is a build that
/// lacks a grammar, this one is a deliberate refusal no rebuild changes.
LanguageRequiresBuild { lang: String, supported: Vec<String> },
}
```

Expand All @@ -53,9 +59,12 @@ This is enforced three ways:

1. **Type-level.** Construction sites for `Completed` are confined to the success arm of each tool's
worker. `?` on any fallible step yields `Failed`; a probe failure yields `Unavailable`.
2. **Test.** `tests/scanner_adapter.rs` asserts each of the seven `UnavailableReason` variants is
produced by its triggering condition and that none of them serialises to something a caller could
mistake for a clean scan.
2. **Test.** `tests/scanner_adapter.rs` asserts each of the eight `UnavailableReason` variants is
produced by its triggering condition, and `tests/fail_closed_tools.rs` asserts via `every_reason()`
that none of them serialises to something a caller could mistake for a clean scan. `every_reason()`
holds exactly one entry per variant — the audit-slug check depends on it — so a second *spelling*
of a variant (the unpinned `BundleMismatch`, which renders through its own arm) earns its own test
beside that list rather than an extra entry in it.
3. **Rendering.** The model-facing and operator-facing renderings of `Unavailable` and `Failed` never
contain the phrase used for a clean result. A clean scan reads *"scanned N files, no findings"*;
an unavailable one reads *"did not run: <reason>"*.
Expand Down
54 changes: 44 additions & 10 deletions src/scanners/codeql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
use std::path::{Path, PathBuf};

use super::{probe_binary, validate_target, ScanRequest, ScannerAdapter, ScannerGrant};
use crate::tools::outcome::UnavailableReason;
use crate::tools::outcome::{UnavailableReason, UNPINNED};

pub struct CodeQl;

Expand Down Expand Up @@ -113,20 +113,33 @@ impl ScannerAdapter for CodeQl {
}

/// Four questions, none of which costs a process: is the pinned binary still the pinned binary,
/// is the bundle where the operator said, was a version pinned at all, and is this a language
/// bee will analyse.
/// was a version pinned at all, is the bundle where the operator said, and is this a language
/// bee will analyse. Each is a reason the scan *could not run* — so each is `Unavailable`, and
/// none of them is reported as a scan that ran and broke.
fn probe(&self, grant: &ScannerGrant, req: &ScanRequest) -> Result<(), UnavailableReason> {
probe_binary(grant)?;

// Was a version pinned at all. This is a refusal to *run*, not a run that broke — nothing
// has executed at this point — so it is `Unavailable`, and it belongs here rather than in
// `steps`, which is reached only after this method has already passed the request as
// answerable (contract `scanner-adapter.md`).
if grant.bundle_version.is_none() {
return Err(UnavailableReason::BundleMismatch {
expected: UNPINNED.to_string(),
found: None,
});
}

// A bundle path is optional — the granted binary is the bundle's CLI, so the pin already
// covers what actually runs. When the operator *does* name one, it has to be the bundle the
// granted binary came out of, or the version verified below belongs to a different CodeQL
// than the one that will do the scanning.
if let Some(bundle) = &grant.bundle {
// Unwrap-free: the no-pin case returned above, so a pin is present by construction.
let expected = grant
.bundle_version
.clone()
.unwrap_or_else(|| "(unpinned)".to_string());
.unwrap_or_else(|| UNPINNED.to_string());
if !bundle.exists() {
return Err(UnavailableReason::BundleMismatch {
expected,
Expand Down Expand Up @@ -233,12 +246,14 @@ impl ScannerAdapter for CodeQl {
) -> Result<Vec<Vec<String>>, String> {
validate_target(&req.target)?;

// `probe` refuses an unpinned bundle as `Unavailable` before anything reaches here, which is
// where that refusal is *stated* — this is the same belt-and-braces the language check below
// gets, for the same reason: `steps` authors a command line, so it re-checks what it is
// about to write rather than trusting a caller to have asked first.
if grant.bundle_version.is_none() {
return Err(
"no bundle version pinned for codeql: set `[security.scanners.codeql] \
bundle_version` to the provisioned bundle's version (for example \
`codeql-bundle-v2.26.1`). Running an unverified analysis bundle is refused, \
because its queries decide what counts as a finding."
"no bundle version pinned for codeql: refusing to build a command for an \
unverified analysis bundle"
.to_string(),
);
}
Expand Down Expand Up @@ -399,6 +414,23 @@ mod tests {
assert!(err.to_string().contains("2.26.1"));
}

/// The refusal an unpinned bundle earns is "could not run", not "ran and broke". Nothing has
/// executed when it fires, and the two render with different prefixes and different audit slugs
/// — so the distinction is the operator's, not a detail of where the check happened to live.
#[test]
fn an_unpinned_bundle_could_not_run_rather_than_ran_and_broke() {
let mut f = fixture();
f.grant.bundle_version = None;
let err = CodeQl.probe(&f.grant, &req(&f, "python")).unwrap_err();
let UnavailableReason::BundleMismatch { expected, found } = &err else {
panic!("expected a bundle mismatch, got {err:?}");
};
assert_eq!(expected, UNPINNED);
assert!(found.is_none());
assert_eq!(err.slug(), "bundle_mismatch");
assert!(err.to_string().contains("bundle_version"), "{err}");
}

#[test]
fn a_binary_outside_the_configured_bundle_is_refused() {
let mut f = fixture();
Expand Down Expand Up @@ -511,11 +543,13 @@ mod tests {
f.grant.bundle_version = None;
// Nothing to verify against...
assert!(CodeQl.preflight(&f.grant).is_none());
// ...and therefore nothing to run.
// ...and therefore nothing to run. The sentence naming `bundle_version` belongs to the
// `Unavailable` that `probe` returns (asserted above); what `steps` owes is a refusal to
// author a command line, whichever route reached it.
let err = CodeQl
.steps(&f.grant, &req(&f, "python"), &f.scratch, &f.out)
.unwrap_err();
assert!(err.contains("bundle_version"), "{err}");
assert!(err.contains("unverified analysis bundle"), "{err}");
}

#[test]
Expand Down
21 changes: 21 additions & 0 deletions src/tools/outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ pub const UNAVAILABLE_PREFIX: &str = "did not run:";
/// The phrase every "ran and broke" renders with.
pub const FAILED_PREFIX: &str = "failed:";

/// The `expected` an adapter reports when the operator pinned no version at all.
///
/// A missing pin is a *kind* of bundle mismatch — there is no version the bundle is allowed to be —
/// so it travels as [`UnavailableReason::BundleMismatch`] rather than a variant of its own
/// (contract `scanner-adapter.md`: "mismatch, unreadable, or unpinned ⇒ `BundleMismatch`"). It reads
/// nothing like a version, so it cannot be confused with one, and [`UnavailableReason`]'s `Display`
/// gives it the sentence an operator can act on.
pub const UNPINNED: &str = "(unpinned)";

/// The outcome of one security-tool invocation. `T` is the tool's success payload — `Vec<Finding>`,
/// `Vec<Match>`, `Severity`, and so on.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -106,6 +115,18 @@ impl std::fmt::Display for UnavailableReason {
path.display()
)
}
// No pin at all comes first: with nothing to compare against, neither of the two
// sentences below is true — a bundle may well be present, and there is no expected
// version to name.
UnavailableReason::BundleMismatch { expected, .. } if expected == UNPINNED => {
write!(
f,
"no bundle version is pinned: set the scanner's `bundle_version` to the \
provisioned bundle's version (for example `codeql-bundle-v2.26.1`). Running \
an unverified analysis bundle is refused, because its queries decide what \
counts as a finding"
)
}
UnavailableReason::BundleMismatch { expected, found } => match found {
Some(found) => write!(
f,
Expand Down
24 changes: 16 additions & 8 deletions src/tools/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ impl Tool for ScanTool {
lang: args.lang,
timeout: budget,
};
// The budget is a ceiling on the *scan*, not an allowance each child draws afresh. A CodeQL
// scan is a version check plus two children; handing each one the full budget would let a
// scan run to three times the limit its operator set, which is not a limit. So the budget
// becomes a deadline here, and every child gets what is left of it.
let deadline = std::time::Instant::now() + budget;
let remaining = || deadline.saturating_duration_since(std::time::Instant::now());

// ── 3. Can this scanner answer this request at all? ─────────────────────────────────────
// Before argv construction and before any spawn, so unavailability costs no process — and,
Expand Down Expand Up @@ -250,14 +256,16 @@ impl Tool for ScanTool {

// ── 4. Preflight: ask the binary what it is, in scope, before trusting its answers ──────
if let Some(argv) = adapter.preflight(grant) {
let probe = match run_child_timed(sandbox, &program, &argv, budget).await {
let probe = match run_child_timed(sandbox, &program, &argv, remaining()).await {
Ok(r) => r,
Err(ChildError::TimedOut(d)) => {
Err(ChildError::TimedOut(_)) => {
cleanup();
// The budget, not the slice of it this child got: the operator set the former
// and would have to work backwards from the latter.
return failed(format!(
"{} did not answer a version check within {}s",
"{} timed out on a version check within its {}s scan budget",
args.scanner,
d.as_secs()
budget.as_secs()
));
}
Err(ChildError::Spawn(e)) => {
Expand Down Expand Up @@ -287,18 +295,18 @@ impl Tool for ScanTool {
// analysed, and analysing it anyway would produce an empty report — a clean scan by
// accident, which is the one outcome this tool may never manufacture (FR-012).
for (i, argv) in steps.iter().enumerate() {
let run = match run_child_timed(sandbox, &program, argv, budget).await {
let run = match run_child_timed(sandbox, &program, argv, remaining()).await {
Ok(r) => r,
Err(ChildError::TimedOut(d)) => {
Err(ChildError::TimedOut(_)) => {
// No partial answer: whatever it wrote is by definition incomplete, and
// presenting an incomplete scan as a scan is the failure this feature is built
// against.
let _ = std::fs::remove_file(&report_path);
cleanup();
return failed(format!(
"{} timed out after {}s; no partial findings are reported",
"{} timed out within its {}s scan budget; no partial findings are reported",
args.scanner,
d.as_secs()
budget.as_secs()
));
}
Err(ChildError::Spawn(e)) => {
Expand Down
Loading
Loading