From 5dcfaa5e468ff984c4e3399edd962cf2361814b6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:06:00 +0000 Subject: [PATCH 01/14] fix(assail): execute declared configuration and build input adapters Use one discovery/execution registry, real configuration and markup parsers, and bounded build-script/Forth checks. Preserve unsupported dialects and parse errors as coverage gaps; record each adapter's actual depth. Exercise malformed, duplicate, executable-data and unsupported controls. Completion remains independent of severity, proof and execution evidence. --- Cargo.lock | 22 ++ Cargo.toml | 6 +- docs/input-coverage.adoc | 128 +++++++++ docs/php-coverage.adoc | 7 +- src/assail/analyzer.rs | 74 +++++- src/assail/build_inputs.rs | 332 ++++++++++++++++++++++++ src/assail/configuration.rs | 356 ++++++++++++++++++++++++++ src/assail/configuration_scripts.rs | 184 +++++++++++++ src/assail/coverage.rs | 28 +- src/assail/forth.rs | 129 ++++++++++ src/assail/input_adapters.rs | 90 +++++++ src/assail/mod.rs | 6 + src/assail/templates.rs | 287 +++++++++++++++++++++ tests/build_input_coverage_tests.rs | 139 ++++++++++ tests/configuration_coverage_tests.rs | 218 ++++++++++++++++ tests/coverage_php_tests.rs | 2 +- tests/template_coverage_tests.rs | 104 ++++++++ 17 files changed, 2095 insertions(+), 17 deletions(-) create mode 100644 docs/input-coverage.adoc create mode 100644 src/assail/build_inputs.rs create mode 100644 src/assail/configuration.rs create mode 100644 src/assail/configuration_scripts.rs create mode 100644 src/assail/forth.rs create mode 100644 src/assail/input_adapters.rs create mode 100644 src/assail/templates.rs create mode 100644 tests/build_input_coverage_tests.rs create mode 100644 tests/configuration_coverage_tests.rs create mode 100644 tests/template_coverage_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 3c51b81..402cee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3068,6 +3068,8 @@ dependencies = [ "tempfile", "toml_edit", "tree-sitter", + "tree-sitter-css", + "tree-sitter-html", "tree-sitter-php", "ureq", "walkdir", @@ -4301,6 +4303,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-css" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index a412e58..447a091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ repository = "https://github.com/hyperpolymath/panic-attack" [dependencies] tree-sitter = "=0.27.0" tree-sitter-php = "=0.25.1" +tree-sitter-html = "=0.23.2" +tree-sitter-css = "=0.25.0" clap = { version = "4.6", features = ["derive"] } clap_complete = "4.6" clap_complete_nushell = "4.6" @@ -42,7 +44,7 @@ sha2 = "0.11" hex = "0.4" getrandom = "0.4" walkdir = "2.5" -toml_edit = { version = "0.23", default-features = false, features = ["parse"], optional = true } +toml_edit = { version = "0.23", default-features = false, features = ["parse"] } dirs = "7.0" ed25519-dalek = { version = "3.0", optional = true } ureq = { version = "3.3", optional = true } @@ -50,7 +52,7 @@ ureq = { version = "3.3", optional = true } [features] default = [] signing = ["ed25519-dalek"] -http = ["ureq", "dep:toml_edit"] +http = ["ureq"] sarif = [] # Native GUI viewer (eframe/egui). Opt-in because eframe raises MSRV above the # 1.90 baseline; default builds remain pure-CLI and MSRV-clean. diff --git a/docs/input-coverage.adoc b/docs/input-coverage.adoc new file mode 100644 index 0000000..57a4cda --- /dev/null +++ b/docs/input-coverage.adoc @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Configuration, template and build input coverage +:toc: + +== Contract + +The discovery registry and execution dispatcher select the same input adapter. +Discovery is an inventory, not an acknowledgement that checks ran. An input is +`analyzed` only after its bounded read and all selected adapter checks finish. +Its record retains a BLAKE3 digest of the actual bytes and the adapter's depth. +Read, parse and unsupported-dialect errors remain explicit gaps. Other successfully +analysed inputs and their findings remain in the report. + +`coverage.status == "complete"` means that every candidate in the declared +inventory finished its stated adapter. It does not mean complete semantic +security coverage. Parsing establishes a syntactic input for local checks; it +does not establish application-schema validity, deployment safety, an execution +trace, interprocedural dataflow or a successful build. A completed adapter can +produce critical findings. `--fail-on-incomplete` checks completion only; a +consumer's severity gate is independent and must still inspect active findings. + +The existing named build/vendor pruning, explicit symlink exclusions, 16 MiB +read bound and unsupported-format inventory remain. No test-directory exclusion +is added. XML, Swift, F# and other selected formats without adapters remain gaps. +Ordinary F# in an ambiguous `.fs` file is not silently treated as Forth. +Containerfile/Dockerfile variants and `.just` files now enter the inventory too. +The registry is bounded, not a universal detector of custom source formats. + +== Declared adapters + +[cols="1,3,3",options="header"] +|=== +|Input |Checks that run |Remaining limits + +|JSON, YAML, TOML +|Real parsing, unique-key enforcement, recursive credential-field, disabled +certificate-verification, privilege, plaintext-endpoint and Cargo revision +checks. All YAML documents are inspected with the parser's resource budgets. +|No complete application schema, deployment, secret-validity or dataflow proof. +Schema namespace strings are not endpoints; nested objects remain checked. +Possible credential values are not copied into findings. + +|Actions YAML and package.json +|Recognised workflow and composite `run` steps use existing shell, Python or +JavaScript checks. Step, job and workflow shell precedence is observed. Known +non-Windows runner defaults and container jobs select shell checks; unresolved +or unimplemented interpreters leave gaps. npm scripts receive POSIX-style shell +heuristics, with the unresolved runtime shell stated in findings and coverage. +|No Actions expression expansion, referenced-action loading, matrix evaluation +or execution. npm can use cmd.exe or externally configured script-shell; these +checks do not establish which runtime shell runs. Generic fields called `run` +are not automatically executable code. Source fields are named without invented +physical line numbers. + +|HTML, HTM +|Pinned tree-sitter-html parsing; duplicate-attribute, executable-URL, +plaintext-resource and remote-script integrity checks. Inline JavaScript/event +handlers use existing JS heuristics. Styles use the CSS parser. Declared JSON +script bodies use the JSON checks. +|No browser, template expansion, dataflow or fetched resource checks. Recovered +parse errors, unsupported embedded types and encoded executable/URL attributes +whose interpretation is not implemented remain gaps. + +|CSS +|Pinned tree-sitter-css parsing, expression-function and insecure/executable +URL checks, including embedded style blocks. +|No computed style, browser execution or complete CSS escape resolution. + +|Containerfile, Dockerfile and their dotted variants +|Logical instruction extraction, FROM/stage-reference and remote ADD digest +checks, explicit-root-user checks, JSON exec-vector validation, and existing +interpreter heuristics for executable source blocks. Plain exec argv is not +misrepresented as shell source. +|No full Docker grammar, image resolution, image contents or build execution. +Heredocs, ONBUILD, unknown instructions/interpreters and unsupported escape +directives remain gaps. Declarative instructions have instruction-level +validation, not full application semantics. + +|Justfile, justfile, .just +|Recipe/declaration extraction and existing shell/Python/JavaScript checks for +recipe bodies, respecting explicit shebangs and known default interpreters. +|No complete Just grammar, import resolution, interpolation or task execution. +Unknown interpreters, executable top-level substitutions and unhandled script +declarations remain gaps. Imported .just files are inventoried separately when +they are inside the declared traversal. + +|Forth .fs, .fth, .forth, .4th +|Word-level evaluate/system/raw-memory hazards with comments and quoted data +masked. Ambiguous .fs requires a definition witness; unfinished lexical forms +remain gaps. +|No Forth grammar, dictionary/stack interpretation or execution proof. A lexical +witness distinguishes this adapter's bounded scope; it is not a type check. +|=== + +HTML and CSS grammars are exact dependencies `tree-sitter-html = 0.23.2` and +`tree-sitter-css = 0.25.0`. Cargo.lock is generated through Cargo and contains +their registry checksums. Both grammar packages use MIT licensing. Existing +toml_edit is now available to normal assail builds as well as Patch Bridge. + +The existing 49 source-language dispatchers keep their previous depth; adding +structured input adapters does not upgrade those heuristic checks into parsers. +See link:php-coverage.adoc[PHP and schema 2.6 coverage] for PHP and the common +inventory/report contract. + +== Regression evidence + +`configuration_coverage_tests`, `template_coverage_tests` and +`build_input_coverage_tests` exercise real adapter entry points through assail. +They include planted hazards, benign comment/string/data controls, malformed +and duplicate structures, unsupported interpreters, non-pinned dependencies, +direct-exec distinctions and preservation of unrelated unsupported candidates. +`coverage_php_tests` continues to test the shared inventory and CLI boundary. + +Repository scans are additional evidence about those exact bytes, not a reason +to remove failing candidates. Real malformed configuration must be repaired at +the consumer. An empty report placeholder is not a clean scan result. A proof +fixture's unfinished theorem remains a finding even when its path is under +`tests/`; reviewed fixture intent and actual consuming-test execution evidence +are separate from scanner completion. + +== Primary format references + +* https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax[GitHub Actions workflow syntax and shell defaults] +* https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax[GitHub Actions composite metadata] +* https://docs.npmjs.com/cli/v11/using-npm/scripts/[npm script execution and runtime shell] +* https://github.com/tree-sitter/tree-sitter-html/tree/v0.23.2[tree-sitter HTML grammar 0.23.2] +* https://github.com/tree-sitter/tree-sitter-css/tree/v0.25.0[tree-sitter CSS grammar 0.25.0] diff --git a/docs/php-coverage.adoc b/docs/php-coverage.adoc index f2fc875..73a736c 100644 --- a/docs/php-coverage.adoc +++ b/docs/php-coverage.adoc @@ -75,7 +75,7 @@ their results are not upgraded to AST/dataflow evidence by PHP's parser. The fixtures reproduce empty WordPress crypto implementations and the Aegis algorithm-substitution pattern from the 25 October 2026 audit. CLI tests cover -clean PHP without finding counters, malformed PHP, unsupported config input, +clean PHP without finding counters, malformed PHP, unsupported source input, empty roots, symlink cycles and legacy report decoding. Next: contextual PHP taint, Composer/WordPress bootstrap and symbol resolution, @@ -87,3 +87,8 @@ this change does not claim native parsing for them. Do not infer PHP engine patch status from these source checks. Runtime provenance, startup configuration and deployment isolation require independent evidence. +Configuration, template and build-file adapters are described separately in +link:input-coverage.adoc[Input coverage]. Unsupported source controls still +exercise the same inventory boundary; adding an adapter does not exclude a +previously failing candidate or upgrade parser acceptance into a security proof. + diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index b632af7..3bc48a9 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -505,7 +505,73 @@ impl Analyzer { acc.record_file(&rel_path, &raw_bytes, &format!("{:?}", file_lang)); } + let configuration = super::input_adapters::Adapter::detect(file); match file_lang { + Language::Unknown => { + let Some(format) = configuration else { + coverage.mark( + file, + "unsupported", + Some("no selected input adapter".into()), + Some(&raw_bytes), + ); + continue; + }; + match format.analyze(&content, &rel_path) { + Ok(analysis) => { + file_weak_points.extend(analysis.findings); + for script in analysis.scripts { + let start = file_weak_points.len(); + match script.language { + Language::JavaScript => self.analyze_javascript( + &script.content, + &mut file_stats, + &mut file_weak_points, + &rel_path, + )?, + Language::Shell => self.analyze_shell( + &script.content, + &mut file_stats, + &mut file_weak_points, + &rel_path, + )?, + Language::Python => self.analyze_python( + &script.content, + &mut file_stats, + &mut file_weak_points, + &rel_path, + )?, + _ => anyhow::bail!("unimplemented embedded input adapter"), + } + self.analyze_cross_language( + &script.content, + &mut file_weak_points, + &rel_path, + )?; + for finding in &mut file_weak_points[start..] { + finding.description.push_str(&format!( + " (embedded {:?} input at {})", + script.language, script.origin + )); + } + } + } + Err(error) => { + let status = if error.is::() { + "unsupported" + } else { + "parse_error" + }; + coverage.mark( + file, + status, + Some(format!("{error:#}")), + Some(&raw_bytes), + ); + continue; + } + } + } Language::Php => match super::php::analyze(&content, &rel_path) { Ok(findings) => file_weak_points.extend(findings), Err(error) => { @@ -734,8 +800,12 @@ impl Analyzer { } } - // Cross-language security checks (run on all files) - self.analyze_cross_language(&content, &mut file_weak_points, &rel_path)?; + // Structured configuration uses parsed keys and values; treating + // comments or schema identifiers as executable source creates false + // findings. Source-language adapters retain their existing checks. + if configuration.is_none() { + self.analyze_cross_language(&content, &mut file_weak_points, &rel_path)?; + } coverage.mark(file, "analyzed", None, Some(&raw_bytes)); // Accumulate global stats diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs new file mode 100644 index 0000000..6c47002 --- /dev/null +++ b/src/assail/build_inputs.rs @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Build-input checks and extraction. No recipe, import, build, interpolation or +//! command is executed. Unhandled dialects remain explicit coverage gaps. + +use super::input_adapters::{Analysis, EmbeddedScript, UnsupportedInput}; +use crate::types::{Language, Severity, WeakPoint, WeakPointCategory}; +use anyhow::{anyhow, ensure, Result}; +use std::collections::BTreeSet; + +fn unsupported(message: &str) -> anyhow::Error { + UnsupportedInput(message.into()).into() +} + +fn finding(file: &str, line: usize, category: WeakPointCategory, reason: &str) -> WeakPoint { + WeakPoint { + file: Some(file.into()), + line: Some(line as u32), + category, + location: Some(format!("{file}:{line}")), + severity: Severity::High, + description: reason.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + } +} + +fn interpreter(command: &str) -> Result { + let name = command.rsplit('/').next().unwrap_or(command); + match name { + "sh" | "bash" | "zsh" | "fish" | "dash" => Ok(Language::Shell), + "python" | "python3" => Ok(Language::Python), + "node" | "nodejs" => Ok(Language::JavaScript), + _ => Err(unsupported(&format!( + "embedded interpreter {name:?} has no selected adapter" + ))), + } +} + +fn command_input(text: &str, line: usize, shell: Language, result: &mut Analysis) -> Result<()> { + if text.starts_with('[') { + let argv: Vec = serde_json::from_str(text)?; + ensure!(!argv.is_empty(), "empty build command vector"); + let name = argv[0].rsplit('/').next().unwrap_or(&argv[0]); + // A JSON exec vector is not a shell string. Only an explicit inline + // interpreter invocation supplies executable source for these checks. + if matches!( + name, + "sh" | "bash" | "zsh" | "fish" | "dash" | "python" | "python3" | "node" | "nodejs" + ) { + if let Some(index) = argv.iter().position(|arg| arg == "-c" || arg == "-e") { + let source = argv + .get(index + 1) + .ok_or_else(|| anyhow!("inline interpreter option has no source"))?; + result.scripts.push(EmbeddedScript { + content: source.clone(), + language: interpreter(name)?, + origin: format!("source block beginning at line {line}"), + }); + } + } + } else { + result.scripts.push(EmbeddedScript { + content: text.into(), + language: shell, + origin: format!("source block beginning at line {line}"), + }); + } + Ok(()) +} + +pub(super) fn container(content: &str, file: &str) -> Result { + let mut instructions = Vec::new(); + let mut current = String::new(); + let mut first_line = 0; + for (offset, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + if line.starts_with("# escape=") && line != "# escape=\\" { + return Err(unsupported("non-backslash Containerfile escape directive")); + } + continue; + } + if current.is_empty() { + first_line = offset + 1; + } + let continuation = line.ends_with('\\'); + current.push_str(line.strip_suffix('\\').unwrap_or(line)); + if continuation { + current.push(' '); + } else { + instructions.push((first_line, std::mem::take(&mut current))); + } + } + ensure!(current.is_empty(), "unfinished Containerfile continuation"); + let mut result = Analysis::default(); + let mut stages = BTreeSet::new(); + let mut shell = Language::Shell; + let mut seen_from = false; + for (line, instruction) in instructions { + let (name, args) = instruction + .split_once(char::is_whitespace) + .ok_or_else(|| anyhow!("Containerfile instruction without arguments at line {line}"))?; + let name = name.to_ascii_uppercase(); + let args = args.trim(); + ensure!( + !args.is_empty(), + "empty Containerfile instruction at line {line}" + ); + if !seen_from && name != "FROM" && name != "ARG" { + return Err(anyhow!( + "Containerfile instruction before FROM at line {line}" + )); + } + match name.as_str() { + "FROM" => { + let fields: Vec<_> = args + .split_whitespace() + .filter(|field| !field.starts_with("--platform=")) + .collect(); + ensure!( + fields.len() == 1 || fields.len() == 3 && fields[1].eq_ignore_ascii_case("as"), + "invalid FROM instruction at line {line}" + ); + let image = fields[0]; + let pinned = image.rsplit_once("@sha256:").is_some_and(|(_, digest)| { + digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) + }); + if image != "scratch" && !stages.contains(&image.to_ascii_lowercase()) && !pinned { + result.findings.push(finding(file, line, WeakPointCategory::SupplyChain, "Container base image lacks a complete SHA256 digest; local stage aliases are accepted")); + } + if fields.len() == 3 { + stages.insert(fields[2].to_ascii_lowercase()); + } + seen_from = true; + shell = Language::Shell; + } + "SHELL" => { + let argv: Vec = serde_json::from_str(args)?; + shell = interpreter( + argv.first() + .ok_or_else(|| anyhow!("empty SHELL instruction"))?, + )?; + } + "RUN" | "CMD" | "ENTRYPOINT" => { + let mut command = args; + while command.starts_with("--") { + command = command + .split_once(char::is_whitespace) + .ok_or_else(|| anyhow!("build command has only options"))? + .1 + .trim_start(); + } + if command.contains("<<") { + return Err(unsupported( + "Containerfile heredoc extraction is not implemented", + )); + } + command_input(command, line, shell, &mut result)?; + } + "HEALTHCHECK" => { + if !args.eq_ignore_ascii_case("NONE") { + let (_, command) = args + .split_once("CMD ") + .ok_or_else(|| anyhow!("HEALTHCHECK has no CMD"))?; + command_input(command.trim(), line, shell, &mut result)?; + } + } + "ADD" => { + let checksum = args + .split_whitespace() + .find_map(|arg| arg.strip_prefix("--checksum=sha256:")); + let pinned = checksum.is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if (args.contains("https://") || args.contains("http://")) && !pinned { + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "Remote ADD has no SHA256 checksum", + )); + } + } + "USER" => { + if matches!(args.split(':').next(), Some("root" | "0")) { + result.findings.push(finding( + file, + line, + WeakPointCategory::ExcessivePermissions, + "Container explicitly selects the root user", + )); + } + } + "ONBUILD" => { + return Err(unsupported( + "deferred ONBUILD instruction analysis is not implemented", + )) + } + "ARG" | "ENV" | "LABEL" | "COPY" | "WORKDIR" | "EXPOSE" | "VOLUME" | "STOPSIGNAL" + | "MAINTAINER" => {} + _ => { + return Err(unsupported(&format!( + "unknown Containerfile instruction {name}" + ))) + } + } + } + ensure!(seen_from, "Containerfile contains no FROM instruction"); + Ok(result) +} + +fn recipe_script( + lines: &[(usize, String)], + default: Language, + result: &mut Analysis, +) -> Result<()> { + if lines.is_empty() { + return Ok(()); + } + let mut language = default; + let script_mode = lines + .first() + .is_some_and(|(_, text)| text.starts_with("#!")); + if let Some(first) = lines.first().filter(|(_, text)| text.starts_with("#!")) { + let mut words = first.1[2..].split_whitespace(); + let first = words + .next() + .ok_or_else(|| anyhow!("empty recipe interpreter"))?; + let executable = if first.ends_with("/env") { + words + .next() + .ok_or_else(|| anyhow!("env recipe interpreter missing"))? + } else { + first + }; + language = interpreter(executable)?; + } + result.scripts.push(EmbeddedScript { + content: lines + .iter() + .map(|(_, text)| { + if script_mode { + text.as_str() + } else { + text.trim_start_matches('@') + } + }) + .collect::>() + .join("\n"), + language, + origin: format!("source block beginning at line {}", lines[0].0), + }); + Ok(()) +} + +pub(super) fn just(content: &str, _file: &str) -> Result { + let mut result = Analysis::default(); + let mut default = Language::Shell; + let mut in_recipe = false; + let mut body = Vec::new(); + let mut indentation = None; + for (offset, raw) in content.lines().enumerate() { + let text = raw.trim(); + if text.is_empty() { + continue; + } + if raw.starts_with(char::is_whitespace) { + ensure!( + in_recipe, + "indented Just input outside a recipe at line {}", + offset + 1 + ); + let leading = raw.len() - raw.trim_start().len(); + let prefix = *indentation.get_or_insert(leading); + ensure!( + leading >= prefix, + "inconsistent Just recipe indentation at line {}", + offset + 1 + ); + body.push((offset + 1, raw[prefix..].to_string())); + continue; + } + if text.starts_with('#') { + continue; + } + recipe_script(&body, default, &mut result)?; + body.clear(); + indentation = None; + in_recipe = false; + if let Some(value) = text.strip_prefix("set shell :=") { + let argv: Vec = serde_json::from_str(value.trim())?; + default = interpreter(argv.first().ok_or_else(|| anyhow!("empty Just shell"))?)?; + } else if text.starts_with("set ") { + if text.contains("interpreter") || text.contains("windows-shell") { + return Err(unsupported( + "additional Just interpreter setting requires an adapter", + )); + } + } else if text.starts_with("import ") + || text.starts_with("import? ") + || text.starts_with("mod ") + || text.starts_with("mod? ") + || text.starts_with("alias ") + { + // Inventory covers local *.just files independently. Imports and + // variable interpolation are never executed or presented as checked. + } else if text.starts_with('[') && text.ends_with(']') { + if text.contains("script") { + return Err(unsupported( + "Just script attributes require interpreter extraction", + )); + } + } else if text.contains(":=") { + if text.contains('`') || text.contains("shell(") { + return Err(unsupported( + "executable Just variable expression is not extracted", + )); + } + } else if text.contains(':') { + in_recipe = true; + } else { + return Err(unsupported(&format!( + "unrecognised Just declaration at line {}", + offset + 1 + ))); + } + } + recipe_script(&body, default, &mut result)?; + Ok(result) +} diff --git a/src/assail/configuration.rs b/src/assail/configuration.rs new file mode 100644 index 0000000..887e86c --- /dev/null +++ b/src/assail/configuration.rs @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Structured configuration analysis. Parsing and checks must both finish before +//! an input can be acknowledged as analysed. These are local configuration +//! checks, not application-schema, deployment, or dataflow proofs. + +use super::input_adapters::Analysis; +use crate::types::{AttackAxis, Severity, WeakPoint, WeakPointCategory}; +use anyhow::{anyhow, Context, Result}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Number, Value}; +use std::fmt; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Format { + Json, + Yaml, + Toml, +} + +impl Format { + /// The same registry is used by discovery and execution. Recognising a + /// candidate does not acknowledge its bytes: only successful analysis does. + pub(super) fn detect(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "json" => Some(Self::Json), + "yaml" | "yml" => Some(Self::Yaml), + "toml" => Some(Self::Toml), + _ => None, + } + } + + pub(super) fn description(self) -> &'static str { + match self { + Self::Json => "JSON parser and recursive configuration security checks; package.json scripts receive POSIX-style shell heuristics; duplicate keys rejected; no platform-shell, application-schema or dataflow proof", + Self::Yaml => "YAML budgeted parser, recursive configuration security checks and recognized Actions run-step interpreter heuristics; duplicate keys rejected; no expression expansion, application-schema or dataflow proof", + Self::Toml => "TOML parser and recursive configuration security checks; duplicate keys rejected; no application-schema or dataflow proof", + } + } + + pub(super) fn analyze(self, content: &str, file: &str) -> Result> { + Ok(self.analyze_input(content, file)?.findings) + } + + pub(super) fn analyze_input(self, content: &str, file: &str) -> Result { + let documents: Vec = match self { + Self::Json => vec![ + serde_json::from_str::(content) + .context("JSON configuration parse failed")? + .0, + ], + Self::Yaml => { + // Keep the parser's default depth, expansion and alias budgets. + // The custom visitor also prevents duplicate keys being hidden + // when normalising a mapping to serde_json::Value. + let documents: Vec = serde_saphyr::from_str_multiple(content) + .context("YAML configuration parse failed")?; + documents.into_iter().map(|document| document.0).collect() + } + Self::Toml => { + let document: toml_edit::DocumentMut = + content.parse().context("TOML configuration parse failed")?; + vec![table_value(document.as_table())?] + } + }; + + let mut analysis = Analysis::default(); + for (index, document) in documents.iter().enumerate() { + let field = format!("document[{index}]"); + inspect_value(document, &field, file, &mut analysis.findings); + super::configuration_scripts::extract( + self, + document, + &field, + file, + &mut analysis.scripts, + )?; + } + Ok(analysis) + } +} + +/// `serde_json::Value` silently overwrites duplicate object keys. A contradictory +/// first value must not disappear before security checks run, including nested +/// objects and objects inside arrays. +struct UniqueValue(Value); + +impl<'de> Deserialize<'de> for UniqueValue { + fn deserialize>(deserializer: D) -> Result { + struct UniqueVisitor; + impl<'de> Visitor<'de> for UniqueVisitor { + type Value = UniqueValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("configuration with unique string mapping keys") + } + + fn visit_unit(self) -> Result { + Ok(UniqueValue(Value::Null)) + } + + fn visit_none(self) -> Result { + self.visit_unit() + } + + fn visit_bool(self, value: bool) -> Result { + Ok(UniqueValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .map(UniqueValue) + .ok_or_else(|| E::custom("non-finite configuration number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(UniqueValue(Value::String(value.into()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(UniqueValue(Value::String(value))) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(value) = seq.next_element::()? { + values.push(value.0); + } + Ok(UniqueValue(Value::Array(values))) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut values = Map::new(); + while let Some(key) = map.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom("duplicate configuration mapping key")); + } + values.insert(key, map.next_value::()?.0); + } + Ok(UniqueValue(Value::Object(values))) + } + } + deserializer.deserialize_any(UniqueVisitor) + } +} + +fn table_value(table: &toml_edit::Table) -> Result { + table + .iter() + .map(|(key, value)| Ok((key.to_owned(), item_value(value)?))) + .collect::>>() + .map(Value::Object) +} + +fn item_value(item: &toml_edit::Item) -> Result { + match item { + toml_edit::Item::None => Ok(Value::Null), + toml_edit::Item::Value(value) => toml_value(value), + toml_edit::Item::Table(table) => table_value(table), + toml_edit::Item::ArrayOfTables(tables) => tables + .iter() + .map(table_value) + .collect::>>() + .map(Value::Array), + } +} + +fn toml_value(value: &toml_edit::Value) -> Result { + match value { + toml_edit::Value::String(value) => Ok(Value::String(value.value().clone())), + toml_edit::Value::Integer(value) => Ok(Value::Number((*value.value()).into())), + toml_edit::Value::Float(value) => Number::from_f64(*value.value()) + .map(Value::Number) + .ok_or_else(|| anyhow!("non-finite configuration number")), + toml_edit::Value::Boolean(value) => Ok(Value::Bool(*value.value())), + toml_edit::Value::Datetime(value) => Ok(Value::String(value.value().to_string())), + toml_edit::Value::Array(values) => values + .iter() + .map(toml_value) + .collect::>>() + .map(Value::Array), + toml_edit::Value::InlineTable(table) => table + .iter() + .map(|(key, value)| Ok((key.to_owned(), toml_value(value)?))) + .collect::>>() + .map(Value::Object), + } +} + +fn finding( + file: &str, + field: &str, + category: WeakPointCategory, + severity: Severity, + reason: &str, +) -> WeakPoint { + WeakPoint { + file: Some(file.into()), + line: None, + category, + location: Some(file.into()), + severity, + description: format!("CONFIG: {reason} at {field} in {file}"), + recommended_attack: vec![AttackAxis::Network], + suppressed: false, + test_context: None, + } +} + +fn key_name(key: &str) -> String { + key.chars() + .filter(|c| *c != '_' && *c != '-') + .flat_map(char::to_lowercase) + .collect() +} + +fn is_reference(value: &str) -> bool { + let value = value.trim(); + (value.starts_with("${") && value.ends_with('}')) + || (value.starts_with("{{") && value.ends_with("}}")) +} + +fn inspect_value(value: &Value, field: &str, file: &str, findings: &mut Vec) { + match value { + Value::Object(map) => { + for (key, value) in map { + let child = format!("{field}/{}", key.replace('~', "~0").replace('/', "~1")); + let normal = key_name(key); + if matches!( + normal.as_str(), + "password" + | "passwd" + | "apikey" + | "apisecret" + | "secretkey" + | "accesstoken" + | "privatekey" + ) { + if let Some(secret) = value + .as_str() + .filter(|secret| !secret.is_empty() && !is_reference(secret)) + { + // Do not repeat a possible credential in the report. + if secret.len() >= 8 { + findings.push(finding( + file, + &child, + WeakPointCategory::HardcodedSecret, + Severity::Critical, + "literal credential in a configuration field", + )); + } + } + } + if matches!( + normal.as_str(), + "verifyssl" + | "sslverify" + | "verifytls" + | "tlsverify" + | "verifycertificates" + | "rejectunauthorized" + ) && value == &Value::Bool(false) + || matches!( + normal.as_str(), + "insecureskipverify" | "trustallcertificates" + ) && value == &Value::Bool(true) + { + findings.push(finding( + file, + &child, + WeakPointCategory::InsecureProtocol, + Severity::High, + "certificate verification is disabled", + )); + } + if matches!(normal.as_str(), "privileged" | "allowprivilegeescalation") + && value == &Value::Bool(true) + { + findings.push(finding( + file, + &child, + WeakPointCategory::ExcessivePermissions, + Severity::High, + "privileged execution is enabled", + )); + } + // JSON Schema/JSON-LD identifiers are names, not configured + // network endpoints. All other nested scalars remain checked. + if !(value.is_string() + && matches!( + key.as_str(), + "$schema" + | "$id" + | "$ref" + | "@id" + | "@type" + | "@context" + | "@vocab" + | "xmlns" + )) + { + inspect_value(value, &child, file, findings); + } + } + let immutable_rev = map.get("rev").and_then(Value::as_str).is_some_and(|rev| { + rev.len() == 40 && rev.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if file.ends_with("Cargo.toml") && map.contains_key("git") && !immutable_rev { + findings.push(finding( + file, + field, + WeakPointCategory::SupplyChain, + Severity::High, + "git dependency has no immutable revision", + )); + } + } + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + inspect_value(value, &format!("{field}/{index}"), file, findings); + } + } + Value::String(text) => { + if let Some(rest) = text.strip_prefix("http://") { + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + let loopback = matches!( + authority.split(':').next(), + Some("localhost" | "127.0.0.1" | "0.0.0.0") + ) || authority == "[::1]" + || authority.starts_with("[::1]:"); + if !loopback { + findings.push(finding( + file, + field, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "plaintext HTTP endpoint", + )); + } + } + } + _ => {} + } +} diff --git a/src/assail/configuration_scripts.rs b/src/assail/configuration_scripts.rs new file mode 100644 index 0000000..c8e5642 --- /dev/null +++ b/src/assail/configuration_scripts.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Bounded extraction of executable configuration fields. This does not expand +//! Actions expressions, load referenced actions, resolve npm's runtime shell or +//! execute anything. Unrecognised Actions interpreters remain coverage gaps. + +use super::configuration::Format; +use super::input_adapters::{EmbeddedScript, UnsupportedInput}; +use crate::types::Language; +use anyhow::{anyhow, ensure, Result}; +use serde_json::Value; +use std::path::Path; + +fn unsupported(reason: &str) -> anyhow::Error { + UnsupportedInput(reason.into()).into() +} + +fn explicit_shell(value: &Value) -> Result { + let text = value + .as_str() + .ok_or_else(|| anyhow!("Actions shell is not a string"))?; + let executable = text + .split_whitespace() + .next() + .ok_or_else(|| anyhow!("Actions shell is empty"))?; + match executable.rsplit('/').next().unwrap_or(executable) { + "bash" | "sh" | "zsh" | "dash" => Ok(Language::Shell), + "python" | "python3" => Ok(Language::Python), + "node" | "nodejs" => Ok(Language::JavaScript), + _ => Err(unsupported( + "Actions run-step interpreter has no selected adapter", + )), + } +} + +fn default_shell(document: &Value) -> Option<&Value> { + document.get("defaults")?.get("run")?.get("shell") +} + +fn inferred_shell(job: &Value) -> Result { + // Actions' unspecified Linux/macOS runner shell is sh/bash; a container + // job defaults to sh. Dynamic, Windows and unqualified self-hosted runner + // declarations cannot be assigned this interpreter without evidence. + if job.get("container").is_some() { + return Ok(Language::Shell); + } + let Some(runner) = job.get("runs-on") else { + return Err(unsupported( + "Actions run step has no resolvable default interpreter", + )); + }; + let posix = |label: &str| { + label.starts_with("ubuntu-") + || label.starts_with("macos-") + || label == "linux" + || label == "macOS" + }; + let known = runner.as_str().is_some_and(posix) + || runner.as_array().is_some_and(|labels| { + labels.iter().any(|label| label.as_str().is_some_and(posix)) + && labels.iter().all(|label| { + label.as_str().is_some_and(|s| { + !s.contains("${{") && !s.to_ascii_lowercase().contains("windows") + }) + }) + }); + if known { + Ok(Language::Shell) + } else { + Err(unsupported( + "Actions runner does not establish a supported default shell", + )) + } +} + +fn steps( + values: &Value, + job: Option<&Value>, + workflow: &Value, + field: &str, + scripts: &mut Vec, +) -> Result<()> { + let values = values + .as_array() + .ok_or_else(|| anyhow!("Actions steps is not an array"))?; + for (index, step) in values.iter().enumerate() { + let map = step + .as_object() + .ok_or_else(|| anyhow!("Actions step is not a mapping"))?; + let Some(run) = map.get("run") else { continue }; + let source = run + .as_str() + .ok_or_else(|| anyhow!("Actions run field is not a string"))?; + ensure!( + !map.contains_key("uses"), + "Actions step has both run and uses" + ); + let shell = map + .get("shell") + .or_else(|| job.and_then(default_shell)) + .or_else(|| job.and_then(|_| default_shell(workflow))); + let language = match shell { + Some(value) => explicit_shell(value)?, + None => match job { + Some(job) => inferred_shell(job)?, + None => { + return Err(unsupported( + "composite action run step lacks its required shell", + )) + } + }, + }; + scripts.push(EmbeddedScript { + content: source.into(), + language, + origin: format!("configuration field {field}/{index}/run"), + }); + } + Ok(()) +} + +pub(super) fn extract( + format: Format, + document: &Value, + field: &str, + file: &str, + scripts: &mut Vec, +) -> Result<()> { + if format == Format::Yaml { + // Recognise the workflow structure, not arbitrary keys named `run`. + // This also works when a single workflow is scanned as the root file. + if document.get("on").is_some() && document.get("jobs").is_some() { + let jobs = document["jobs"] + .as_object() + .ok_or_else(|| anyhow!("Actions jobs is not a mapping"))?; + for (name, job) in jobs { + if let Some(values) = job.get("steps") { + steps( + values, + Some(job), + document, + &format!("{field}/jobs/{name}/steps"), + scripts, + )?; + } + } + } + if document.pointer("/runs/using").and_then(Value::as_str) == Some("composite") { + let values = document + .pointer("/runs/steps") + .ok_or_else(|| anyhow!("composite action has no steps"))?; + steps( + values, + None, + document, + &format!("{field}/runs/steps"), + scripts, + )?; + } + } + if format == Format::Json + && Path::new(file).file_name().and_then(|s| s.to_str()) == Some("package.json") + { + if let Some(values) = document.get("scripts") { + let values = values + .as_object() + .ok_or_else(|| anyhow!("package.json scripts is not a mapping"))?; + for (name, value) in values { + let source = value + .as_str() + .ok_or_else(|| anyhow!("package.json script is not a string"))?; + // This is intentionally POSIX-style lexical checking. npm can + // select cmd.exe or an externally configured script-shell; the + // coverage description expressly does not claim to resolve it. + scripts.push(EmbeddedScript { + content: source.into(), + language: Language::Shell, + origin: format!("package.json script {field}/scripts/{name} (POSIX-style heuristics; runtime shell unresolved)"), + }); + } + } + } + Ok(()) +} diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs index 409e2b9..8c0b29d 100644 --- a/src/assail/coverage.rs +++ b/src/assail/coverage.rs @@ -215,7 +215,8 @@ fn unsupported_candidate(path: &Path) -> bool { ) } -/// Inventory a file or directory tree and return sorted paths with known languages. +/// Inventory a file or directory tree and return sorted paths with registered +/// language or input adapters. /// Named build/vendor subdirectories are recorded without enumerating their /// contents; symlinks and other non-regular inputs are excluded. Selected unknown /// source/config formats are unsupported; other unknown formats are excluded. @@ -233,7 +234,7 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> ); let mut report = AnalysisCoverage { mode: "static_assail".into(), - scope: "Explicit regular-file inventory; named build/vendor directories and symlinks excluded without following them. Unknown source/config formats are unsupported; other extensions are outside declared scope. Non-PHP adapters are heuristic, not parse or dataflow proofs. No dynamic attacks executed.".into(), + scope: "Explicit regular-file inventory; named build/vendor directories and symlinks excluded without following them. Selected unknown source/config formats are unsupported; other extensions are outside declared scope. Complete means every candidate finished its stated adapter, whose parsing and heuristic depth is recorded per input; it is not complete semantic security coverage, a dataflow proof or an execution result. No dynamic attacks executed.".into(), ..Default::default() }; let mut excluded = Vec::new(); @@ -282,13 +283,14 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> } let path = entry.path(); let language = Language::detect_path(path); + let configuration = super::input_adapters::Adapter::detect(path); let (status, reason, kind) = if !entry.file_type().is_file() { ( "excluded", Some("non-regular input; symlinks are not followed".into()), "non_regular", ) - } else if language != Language::Unknown { + } else if language != Language::Unknown || configuration.is_some() { files.push(path.to_path_buf()); ("pending", None, "file") } else if unsupported_candidate(path) { @@ -311,14 +313,18 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> kind: kind.into(), status: status.into(), language, - analysis: (language != Language::Unknown).then(|| { - if language == Language::Php { - "tree-sitter-php 0.25.1; local T0 syntax checks; no cross-file taint" - } else { - "language and cross-language heuristics; no parser coverage claim" - } - .into() - }), + analysis: configuration + .map(|format| format.description().into()) + .or_else(|| { + (language != Language::Unknown).then(|| { + if language == Language::Php { + "tree-sitter-php 0.25.1; local T0 syntax checks; no cross-file taint" + } else { + "language and cross-language heuristics; no parser coverage claim" + } + .into() + }) + }), reason, content_blake3: None, }); diff --git a/src/assail/forth.rs b/src/assail/forth.rs new file mode 100644 index 0000000..9811ac0 --- /dev/null +++ b/src/assail/forth.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Forth lexical hazard probes. These do not validate stack effects or grammar. + +use super::input_adapters::{Analysis, UnsupportedInput}; +use crate::types::{Severity, WeakPoint, WeakPointCategory}; +use anyhow::{ensure, Result}; + +fn words(content: &str) -> Result> { + let mut result = Vec::new(); + let mut offset = 0; + let mut line = 1; + let bytes = content.as_bytes(); + while offset < bytes.len() { + if bytes[offset].is_ascii_whitespace() { + if bytes[offset] == b'\n' { + line += 1; + } + offset += 1; + continue; + } + let start = offset; + while offset < bytes.len() && !bytes[offset].is_ascii_whitespace() { + offset += 1; + } + let word = content[start..offset].to_ascii_lowercase(); + match word.as_str() { + "\\" => { + while offset < bytes.len() && bytes[offset] != b'\n' { + offset += 1; + } + } + "(" => { + while offset < bytes.len() && bytes[offset] != b')' { + if bytes[offset] == b'\n' { + line += 1; + } + offset += 1; + } + ensure!( + offset < bytes.len(), + "unterminated Forth parenthesised comment" + ); + if offset < bytes.len() { + offset += 1; + } + } + ".\"" | "s\"" | "c\"" | "s\\\"" | ".\\\"" | "abort\"" => { + let mut closed = false; + while offset < bytes.len() { + if bytes[offset] == b'\n' { + line += 1; + } + if bytes[offset] == b'\\' && offset + 1 < bytes.len() { + offset += 2; + continue; + } + if bytes[offset] == b'"' { + offset += 1; + closed = true; + break; + } + offset += 1; + } + ensure!(closed, "unterminated Forth string"); + } + _ => result.push((line, word)), + } + } + Ok(result) +} + +pub(super) fn analyze(content: &str, file: &str) -> Result { + let words = words(content)?; + // .fs is shared with F#. Never infer Forth merely from that suffix. + if std::path::Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("fs")) + && !(words.iter().any(|(_, word)| word == ":") && words.iter().any(|(_, word)| word == ";")) + { + return Err(UnsupportedInput( + "ambiguous .fs input has no recognised Forth colon definition; F# is unsupported" + .into(), + ) + .into()); + } + let mut result = Analysis::default(); + let mut definition_name = false; + for (line, word) in words { + if definition_name { + definition_name = false; + continue; + } + if word == ":" { + definition_name = true; + continue; + } + let hazard = match word.as_str() { + "system" => Some(( + WeakPointCategory::CommandInjection, + "Forth SYSTEM executes an external command", + )), + "evaluate" => Some(( + WeakPointCategory::DynamicCodeExecution, + "Forth EVALUATE interprets source supplied at runtime", + )), + "!" | "c!" | "2!" | "move" | "fill" | "erase" => Some(( + WeakPointCategory::UnsafeCode, + "Forth raw memory write requires address and size validation", + )), + _ => None, + }; + if let Some((category, description)) = hazard { + result.findings.push(WeakPoint { + file: Some(file.into()), + line: Some(line as u32), + category, + location: Some(format!("{file}:{line}")), + severity: Severity::High, + description: description.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + }); + } + } + Ok(result) +} diff --git a/src/assail/input_adapters.rs b/src/assail/input_adapters.rs new file mode 100644 index 0000000..81fbdce --- /dev/null +++ b/src/assail/input_adapters.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! One registry for non-source input discovery, execution and coverage depth. + +use super::configuration; +use crate::types::WeakPoint; +use anyhow::Result; +use std::path::Path; + +#[derive(Debug)] +pub(super) struct UnsupportedInput(pub String); + +impl std::fmt::Display for UnsupportedInput { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} +impl std::error::Error for UnsupportedInput {} + +pub(super) struct EmbeddedScript { + pub content: String, + pub language: crate::types::Language, + /// Exact structural field, or the beginning of the source block. Parsers + /// that do not retain source spans must not invent a physical line number. + pub origin: String, +} + +#[derive(Default)] +pub(super) struct Analysis { + pub findings: Vec, + pub scripts: Vec, +} + +#[derive(Clone, Copy)] +pub(super) enum Adapter { + Configuration(configuration::Format), + Html, + Css, + Container, + Just, + Forth, +} + +impl Adapter { + pub(super) fn detect(path: &Path) -> Option { + if let Some(format) = configuration::Format::detect(path) { + return Some(Self::Configuration(format)); + } + let name = path.file_name()?.to_str()?; + if name == "Containerfile" + || name == "Dockerfile" + || name.starts_with("Containerfile.") + || name.starts_with("Dockerfile.") + { + return Some(Self::Container); + } + if matches!(name, "Justfile" | "justfile") { + return Some(Self::Just); + } + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "html" | "htm" => Some(Self::Html), + "css" => Some(Self::Css), + "just" => Some(Self::Just), + "fs" | "fth" | "forth" | "4th" => Some(Self::Forth), + _ => None, + } + } + + pub(super) fn description(self) -> &'static str { + match self { + Self::Configuration(format) => format.description(), + Self::Html => "tree-sitter-html 0.23.2; markup security checks, embedded CSS parser and JavaScript heuristics; no browser, template-expansion or dataflow proof", + Self::Css => "tree-sitter-css 0.25.0; CSS function and URL security checks; no computed-style or browser execution proof", + Self::Container => "Containerfile instruction, JSON-exec and image-reference checks plus extracted shell/Python/JavaScript heuristics; no image resolution or build execution", + Self::Just => "Just recipe/declaration extraction and interpreter-specific shell/Python/JavaScript heuristics; no import, interpolation or task execution", + Self::Forth => "Forth word-level hazard checks with comment/string masking; ambiguous .fs rejected; no stack, grammar or execution proof", + } + } + + pub(super) fn analyze(self, content: &str, file: &str) -> Result { + match self { + Self::Configuration(format) => format.analyze_input(content, file), + Self::Html => super::templates::html(content, file), + Self::Css => super::templates::css(content, file), + Self::Container => super::build_inputs::container(content, file), + Self::Just => super::build_inputs::just(content, file), + Self::Forth => super::forth::analyze(content, file), + } + } +} diff --git a/src/assail/mod.rs b/src/assail/mod.rs index 2d9f4c1..3a62846 100644 --- a/src/assail/mod.rs +++ b/src/assail/mod.rs @@ -6,9 +6,15 @@ //! Pre-analyzes target programs to identify weak points and recommend attacks pub mod analyzer; +mod build_inputs; +mod configuration; +mod configuration_scripts; pub mod coverage; +mod forth; +mod input_adapters; pub mod patterns; pub mod php; +mod templates; use crate::kanren::core::{FactDB, LogicEngine}; use crate::kanren::crosslang::CrossLangAnalyzer; diff --git a/src/assail/templates.rs b/src/assail/templates.rs new file mode 100644 index 0000000..34ab533 --- /dev/null +++ b/src/assail/templates.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Parser-backed checks for markup and styles. A recovered error tree is a +//! coverage gap. Embedded scripts keep the existing JavaScript heuristic depth. + +use super::input_adapters::{Analysis, EmbeddedScript, UnsupportedInput}; +use crate::types::{Language, Severity, WeakPoint, WeakPointCategory}; +use anyhow::{anyhow, ensure, Result}; +use std::collections::BTreeMap; +use tree_sitter::{Node, Parser, Tree}; + +fn parsed(content: &str, language: tree_sitter::Language) -> Result { + let mut parser = Parser::new(); + parser.set_language(&language)?; + let tree = parser + .parse(content, None) + .ok_or_else(|| anyhow!("markup parser returned no tree"))?; + ensure!( + !tree.root_node().has_error(), + "markup parser recovered a syntax error; semantic checks are incomplete" + ); + Ok(tree) +} + +fn text<'a>(node: Node<'_>, source: &'a str) -> Result<&'a str> { + Ok(node.utf8_text(source.as_bytes())?) +} + +fn finding( + file: &str, + node: Node<'_>, + category: WeakPointCategory, + severity: Severity, + description: &str, +) -> WeakPoint { + WeakPoint { + file: Some(file.into()), + // Assail bounds each input to 16 MiB, so its line count fits u32. + line: Some(node.start_position().row as u32 + 1), + category, + location: Some(format!("{}:{}", file, node.start_position().row + 1)), + severity, + description: description.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + } +} + +pub(super) fn html(content: &str, file: &str) -> Result { + let tree = parsed(content, tree_sitter_html::LANGUAGE.into())?; + let mut result = Analysis::default(); + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + match node.kind() { + "start_tag" | "self_closing_tag" => { + let mut tag = String::new(); + let mut attributes = BTreeMap::new(); + for child in node.named_children(&mut node.walk()) { + if child.kind() == "tag_name" { + tag = text(child, content)?.to_ascii_lowercase(); + } + if child.kind() != "attribute" { + continue; + } + let mut name = String::new(); + let mut value = String::new(); + for part in child.named_children(&mut child.walk()) { + match part.kind() { + "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), + "attribute_value" | "quoted_attribute_value" => { + value = text(part, content)?.trim_matches(['\'', '"']).to_owned() + } + _ => {} + } + } + ensure!( + !attributes.contains_key(&name), + "duplicate HTML attribute {name}; browser interpretation is ambiguous" + ); + if name.starts_with("on") && name.len() > 2 { + ensure_attribute_decoded(&value)?; + result.scripts.push(EmbeddedScript { + content: value.clone(), + language: Language::JavaScript, + origin: format!( + "source block beginning at line {}", + child.start_position().row + 1 + ), + }); + } + if name == "style" && !value.is_empty() { + let inline = css(&format!("element {{{value}}}"), file)?; + for mut finding in inline.findings { + finding.line = Some(child.start_position().row as u32 + 1); + finding.location = + Some(format!("{file}:{}", child.start_position().row + 1)); + result.findings.push(finding); + } + } + if matches!( + name.as_str(), + "src" | "href" | "action" | "formaction" | "poster" + ) { + ensure_attribute_decoded(&value)?; + let lower = value.trim().to_ascii_lowercase(); + if lower.starts_with("javascript:") || lower.starts_with("vbscript:") { + result.findings.push(finding( + file, + child, + WeakPointCategory::DynamicCodeExecution, + Severity::High, + "HTML attribute contains an executable URL scheme", + )); + } + if lower.starts_with("http://") { + result.findings.push(finding( + file, + child, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "HTML resource or navigation uses plaintext HTTP", + )); + } + } + attributes.insert(name, value); + } + if tag == "script" + && attributes.get("src").is_some_and(|src| { + src.starts_with("https://") + || src.starts_with("http://") + || src.starts_with("//") + }) + && !attributes.contains_key("integrity") + { + result.findings.push(finding( + file, + node, + WeakPointCategory::SupplyChain, + Severity::Medium, + "Remote script has no subresource-integrity attribute", + )); + } + } + "raw_text" => match node.parent().map(|parent| parent.kind()) { + Some("script_element") => { + let kind = element_type(node.parent().expect("parent matched"), content)?; + match kind.as_str() { + "" | "module" | "text/javascript" | "application/javascript" => { + result.scripts.push(EmbeddedScript { + content: text(node, content)?.into(), + language: Language::JavaScript, + origin: format!( + "source block beginning at line {}", + node.start_position().row + 1 + ), + }) + } + "application/json" | "application/ld+json" | "importmap" => { + result.findings.extend( + super::configuration::Format::Json + .analyze(text(node, content)?, file)?, + ) + } + _ => { + return Err(UnsupportedInput(format!( + "embedded script type {kind:?} has no selected adapter" + )) + .into()) + } + } + } + Some("style_element") => { + let kind = element_type(node.parent().expect("parent matched"), content)?; + if !kind.is_empty() && kind != "text/css" { + return Err(UnsupportedInput(format!( + "embedded style type {kind:?} is not CSS" + )) + .into()); + } + let nested = css(text(node, content)?, file)?; + for mut finding in nested.findings { + let line = finding.line.unwrap_or(1) + node.start_position().row as u32; + finding.line = Some(line); + finding.location = Some(format!("{file}:{line}")); + result.findings.push(finding); + } + } + _ => {} + }, + _ => {} + } + pending.extend(node.named_children(&mut node.walk())); + } + Ok(result) +} + +fn ensure_attribute_decoded(value: &str) -> Result<()> { + // The grammar does not decode character references. Refuse a sensitive + // attribute whose interpreted bytes differ instead of checking only its + // raw spelling and acknowledging an unexamined executable URL or script. + let named_entity = value.split('&').skip(1).any(|tail| { + tail.split_once(';').is_some_and(|(name, _)| { + !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric()) + }) + }); + if value.contains("&#") || named_entity { + return Err(UnsupportedInput( + "HTML character-reference expansion in executable/URL attributes is not implemented" + .into(), + ) + .into()); + } + Ok(()) +} + +fn element_type(element: Node<'_>, content: &str) -> Result { + for start in element + .named_children(&mut element.walk()) + .filter(|node| node.kind() == "start_tag") + { + for attribute in start + .named_children(&mut start.walk()) + .filter(|node| node.kind() == "attribute") + { + let mut name = String::new(); + let mut value = String::new(); + for part in attribute.named_children(&mut attribute.walk()) { + match part.kind() { + "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), + "attribute_value" | "quoted_attribute_value" => { + value = text(part, content)? + .trim_matches(['\'', '"']) + .to_ascii_lowercase() + } + _ => {} + } + } + if name == "type" { + return Ok(value); + } + } + } + Ok(String::new()) +} + +pub(super) fn css(content: &str, file: &str) -> Result { + let tree = parsed(content, tree_sitter_css::LANGUAGE.into())?; + let mut result = Analysis::default(); + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + if node.kind() == "call_expression" { + let value = text(node, content)?.to_ascii_lowercase(); + if value.starts_with("expression(") { + result.findings.push(finding( + file, + node, + WeakPointCategory::DynamicCodeExecution, + Severity::High, + "CSS expression() embeds executable code in legacy browser engines", + )); + } + if value.starts_with("url(") + && (value.contains("http://") || value.contains("javascript:")) + { + result.findings.push(finding( + file, + node, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "CSS URL uses a plaintext or executable scheme", + )); + } + } + if node.kind() == "import_statement" && text(node, content)?.contains("http://") { + result.findings.push(finding( + file, + node, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "CSS import uses plaintext HTTP", + )); + } + pending.extend(node.named_children(&mut node.walk())); + } + Ok(result) +} diff --git a/tests/build_input_coverage_tests.rs b/tests/build_input_coverage_tests.rs new file mode 100644 index 0000000..f2cbb8a --- /dev/null +++ b/tests/build_input_coverage_tests.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +use panic_attack::assail; +use panic_attack::types::{AssailReport, WeakPointCategory}; + +fn scan(name: &str, source: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, source).unwrap(); + assail::analyze(&path).unwrap() +} + +#[test] +fn container_variants_check_image_pins_and_actual_shell_inputs() { + let report = scan("Containerfile.full", "FROM example.invalid/base:latest AS builder\nRUN chmod 777 /work\nFROM builder\nCMD [\"echo\",\"eval is a literal argument\"]\n"); + assert_eq!(report.coverage.status, "complete"); + assert_eq!( + report + .weak_points + .iter() + .filter(|finding| finding.category == WeakPointCategory::SupplyChain) + .count(), + 1 + ); + assert!(report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::ExcessivePermissions)); + assert!(!report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::CommandInjection)); + assert!(report.coverage.inputs[0].content_blake3.is_some()); +} + +#[test] +fn digest_pinned_images_are_not_floating_and_exec_interpreters_are_analyzed() { + let source = format!( + "FROM example.invalid/base@sha256:{}\nRUN [\"bash\",\"-c\",\"eval untrusted\"]\n", + "a".repeat(64) + ); + let report = scan("Dockerfile", &source); + assert_eq!(report.coverage.status, "complete"); + assert!(!report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::SupplyChain)); + assert!(report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::CommandInjection)); +} + +#[test] +fn invalid_or_unhandled_build_dialects_remain_gaps() { + for source in [ + "RUN echo no-base\n", + "FROM scratch\nRUN [\"bash\",]\n", + "FROM scratch\nRUN echo incomplete \\\n", + "FROM scratch\nRUN < +use panic_attack::assail; +use panic_attack::types::{AssailReport, WeakPointCategory}; + +fn scan(name: &str, content: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, content).unwrap(); + assail::analyze(&path).unwrap() +} + +#[test] +fn discovered_configurations_execute_their_declared_adapters() { + for (name, content, adapter) in [ + ( + "config.json", + r#"{"enabled":true,"endpoint":"https://example.org"}"#, + "JSON", + ), + ( + "config.YAML", + "enabled: true\nendpoint: https://example.org\n", + "YAML", + ), + ( + "config.toml", + "enabled = true\nendpoint = 'https://example.org'\n", + "TOML", + ), + ] { + let report = scan(name, content); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert_eq!(report.coverage.candidate_files, 1); + assert_eq!(report.coverage.analyzed_files, 1); + let input = &report.coverage.inputs[0]; + assert!(input.analysis.as_deref().unwrap().starts_with(adapter)); + assert_eq!( + input.content_blake3.as_deref(), + Some(blake3::hash(content.as_bytes()).to_hex().as_str()) + ); + assert!( + report.weak_points.is_empty(), + "{name}: {:?}", + report.weak_points + ); + } +} + +#[test] +fn malformed_and_duplicate_configurations_remain_coverage_gaps() { + for (name, content) in [ + ("malformed.json", "{\"enabled\":"), + ( + "nested.json", + r#"{"items":[{"verify_ssl":false,"verify_ssl":true}]}"#, + ), + ("malformed.yml", "enabled: [\n"), + ( + "duplicate.yml", + "nested:\n verify_ssl: false\n verify_ssl: true\n", + ), + ("malformed.toml", "enabled = [\n"), + ( + "duplicate.toml", + "[nested]\nverify_ssl = false\n[nested]\nverify_ssl = true\n", + ), + ] { + let report = scan(name, content); + assert!(report.coverage.incomplete(), "{name}"); + assert_eq!(report.coverage.candidate_files, 1); + assert_eq!(report.coverage.analyzed_files, 0); + assert_eq!(report.coverage.inputs[0].status, "parse_error", "{name}"); + assert!(report.coverage.inputs[0].content_blake3.is_some()); + assert!(report.coverage.inputs[0].reason.is_some()); + } +} + +#[test] +fn checks_reach_nested_arrays_and_quoted_configuration_keys() { + for (name, content) in [ + ("config.json", r#"{"nested":[{"password":"fixture-literal-credential","verify_ssl":false,"privileged":true,"endpoint":"http://remote.invalid"}]}"#), + ("config.yml", "nested:\n - password: fixture-literal-credential\n verify_ssl: false\n privileged: true\n endpoint: http://remote.invalid\n"), + ("config.toml", "[[nested]]\npassword = 'fixture-literal-credential'\nverify_ssl = false\nprivileged = true\nendpoint = 'http://remote.invalid'\n"), + ] { + let report = scan(name, content); + assert_eq!(report.coverage.status, "complete", "{name}"); + for category in [WeakPointCategory::HardcodedSecret, WeakPointCategory::InsecureProtocol, WeakPointCategory::ExcessivePermissions] { + assert!(report.weak_points.iter().any(|finding| finding.category == category), "{name}: {category:?}"); + } + assert!(report.weak_points.iter().all(|finding| !finding.description.contains("fixture-literal-credential"))); + } +} + +#[test] +fn all_yaml_documents_are_checked() { + let report = scan("config.yaml", "enabled: true\n---\nverify_tls: false\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(report.weak_points.iter().any(|finding| finding.category + == WeakPointCategory::InsecureProtocol + && finding.description.contains("document[1]"))); +} + +#[test] +fn comments_references_and_scalar_namespace_identifiers_are_not_credentials_or_endpoints() { + let report = scan("config.yml", "# password: fixture-comment-only\n# url: http://comment.invalid\npassword: '${{ secrets.PASSWORD }}'\n$schema: http://json-schema.org/draft-07/schema#\nendpoint: http://localhost:9099\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(report.weak_points.is_empty(), "{:?}", report.weak_points); + let report = scan( + "nested.json", + r#"{"@context":{"verify_tls":false},"endpoint":"http://localhost.attacker.invalid"}"#, + ); + assert_eq!( + report.weak_points.len(), + 2, + "nested objects and lookalike hosts must remain checked" + ); +} + +#[test] +fn cargo_revision_checks_cover_nested_dependencies_and_reject_fake_pins() { + for rev in ["", "rev = ''", "rev = 'main'", "rev = false"] { + let report = scan( + "Cargo.toml", + &format!("[workspace.dependencies.example]\ngit = 'https://example.org/repo'\n{rev}\n"), + ); + assert_eq!(report.coverage.status, "complete"); + assert!(report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::SupplyChain)); + } + let report = scan("Cargo.toml", "[workspace.dependencies.example]\ngit = 'https://example.org/repo'\nrev = '0123456789abcdef0123456789abcdef01234567'\n"); + assert!(!report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::SupplyChain)); +} + +#[test] +fn supported_configuration_does_not_hide_other_unsupported_candidates() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("config.json"), "{\"enabled\":true}").unwrap(); + std::fs::write(dir.path().join("unknown.swift"), "let enabled = true").unwrap(); + let report = assail::analyze(dir.path()).unwrap(); + assert_eq!(report.coverage.status, "partial"); + assert_eq!(report.coverage.candidate_files, 2); + assert_eq!(report.coverage.analyzed_files, 1); + assert!(report + .coverage + .inputs + .iter() + .any(|input| input.status == "unsupported")); +} + +#[test] +fn executable_configuration_fields_use_existing_interpreter_checks() { + let report = scan("workflow.yml", "on: push\ndefaults:\n run:\n shell: python\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - run: eval(user_input)\n - shell: bash\n run: eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete"); + for (language, category) in [ + ("Python", WeakPointCategory::DynamicCodeExecution), + ("Shell", WeakPointCategory::CommandInjection), + ] { + assert!(report.weak_points.iter().any(|f| f.category == category + && f.description.contains(&format!("embedded {language}")) + && f.description + .contains("configuration field document[0]/jobs/check/steps/"))); + } + let report = scan("package.json", r#"{"scripts":{"build":"eval \"$INPUT\""}}"#); + assert_eq!(report.coverage.status, "complete"); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::CommandInjection + && f.description.contains("runtime shell unresolved"))); + let report = scan( + "action.yml", + "runs:\n using: composite\n steps:\n - shell: bash\n run: eval \"$INPUT\"\n", + ); + assert_eq!(report.coverage.status, "complete"); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::CommandInjection)); +} + +#[test] +fn actions_defaults_obey_scope_and_unknown_interpreters_remain_gaps() { + let report = scan("workflow.yml", "on: push\ndefaults:\n run:\n shell: pwsh\njobs:\n check:\n defaults:\n run:\n shell: bash\n runs-on: windows-latest\n steps:\n - run: echo safe\n"); + assert_eq!( + report.coverage.status, "complete", + "job shell overrides workflow and runner defaults" + ); + for settings in [ + "runs-on: windows-latest", + "runs-on: '${{ matrix.os }}'", + "runs-on: self-hosted", + "runs-on: ubuntu-latest\n defaults:\n run:\n shell: pwsh", + ] { + let report = scan( + "workflow.yml", + &format!( + "on: push\njobs:\n check:\n {settings}\n steps:\n - run: echo safe\n" + ), + ); + assert!(report.coverage.incomplete(), "{settings}"); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + } + let report = scan("data.yaml", "example:\n run: eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete"); + assert!( + !report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::CommandInjection), + "arbitrary data is not a workflow command" + ); +} diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs index fe41435..9e3e00f 100644 --- a/tests/coverage_php_tests.rs +++ b/tests/coverage_php_tests.rs @@ -29,7 +29,7 @@ fn malformed_php_and_unsupported_inputs_are_visible() { ) .unwrap(); std::fs::write(dir.path().join("bad.php"), " +use panic_attack::assail; +use panic_attack::types::{AssailReport, WeakPointCategory}; + +fn scan(name: &str, source: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, source).unwrap(); + assail::analyze(&path).unwrap() +} + +#[test] +fn clean_markup_and_css_have_hashed_parser_coverage_without_findings() { + for (name, source) in [ + ("index.html", "

Example

"), + ("style.css", "/* expression(alert(1)) url(http://comment.invalid) */ p { content: 'expression(alert(1))'; color: blue; }"), + ] { + let report = scan(name, source); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert!(report.coverage.inputs[0].analysis.as_deref().unwrap().starts_with("tree-sitter-")); + assert_eq!(report.coverage.inputs[0].content_blake3.as_deref(), Some(blake3::hash(source.as_bytes()).to_hex().as_str())); + assert!(report.weak_points.is_empty(), "{name}: {:?}", report.weak_points); + } +} + +#[test] +fn markup_checks_distinguish_uri_attributes_from_text_and_comments() { + let report = scan("index.html", "

javascript:example()

run"); + assert_eq!(report.coverage.status, "complete"); + assert_eq!( + report + .weak_points + .iter() + .filter(|f| f.category == WeakPointCategory::DynamicCodeExecution) + .count(), + 1 + ); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::SupplyChain)); +} + +#[test] +fn embedded_javascript_and_css_are_actually_analyzed() { + let report = scan("index.html", ""); + assert_eq!( + report.coverage.status, "complete", + "{:?}", + report.coverage.inputs + ); + assert!(report + .weak_points + .iter() + .any(|f| f.description.contains("CSS expression"))); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::InsecureProtocol)); + assert!(report + .weak_points + .iter() + .any(|f| f.description.contains("embedded JavaScript"))); +} + +#[test] +fn malformed_or_ambiguous_markup_does_not_receive_complete_coverage() { + for (name, source) in [ + ("bad.css", "p { color: rgb(; }"), + ( + "bad.html", + "x", + ), + ("bad-style.html", ""), + ] { + let report = scan(name, source); + assert!(report.coverage.incomplete(), "{name}"); + assert_eq!(report.coverage.inputs[0].status, "parse_error"); + assert!(report.coverage.inputs[0].content_blake3.is_some()); + } +} + +#[test] +fn script_types_and_encoded_attributes_cannot_silently_bypass_an_adapter() { + let report = scan( + "data.html", + "", + ); + assert_eq!(report.coverage.status, "complete"); + assert!(!report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::DynamicCodeExecution)); + for content in [ + "", + "run", + "", + ] { + let report = scan("unknown.html", content); + assert!(report.coverage.incomplete(), "{content}"); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + } +} From ff278a373b523c69f93e99590112f5fac205b083 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:11:01 +0000 Subject: [PATCH 02/14] fix(ci): repin startup-failing reusable callers to canonical standards Three unchanged callers at the retired owner/ref produced zero-job failures on the coverage PR. Point their existing job ids and permissions to the reviewed canonical a797d308 closure. No inherited secrets added. Keep actions.lock untouched until the official canonical recovery runner produces a candidate for this exact source commit. --- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/rust-ci.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 08448a5..4c698b6 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -17,4 +17,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 720ee31..c7cdcac 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -20,4 +20,4 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 73b1435..43cadab 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,7 +3,7 @@ # This workflow is managed by gh actions-lock. # This workflow is managed by gh actions-lock. # Rust CI — thin wrapper calling the shared estate reusable in -# hyperpolymath/standards. Configure once, propagate everywhere. +# metadatastician/standards. Configure once, propagate everywhere. # See: docs/CI-REUSABLE-WORKFLOWS.adoc in standards. name: Rust CI @@ -18,4 +18,4 @@ permissions: jobs: rust-ci: - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) + uses: metadatastician/standards/.github/workflows/rust-ci-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # canonical standards: reviewed reusable closure From 459a03d2004f070520eec1bd0a2dde1f288b1e35 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:28:20 +0000 Subject: [PATCH 03/14] fix(assail): close reproduced input adapter review gaps --- docs/input-coverage.adoc | 21 +- src/assail/analyzer.rs | 36 +--- src/assail/build_inputs.rs | 295 ++++++++++++++++++++++++++-- src/assail/configuration.rs | 95 +++++---- src/assail/coverage.rs | 6 +- src/assail/forth.rs | 53 ++++- src/assail/input_adapters.rs | 13 +- src/assail/templates.rs | 86 +++++++- tests/build_input_coverage_tests.rs | 2 +- tests/input_adapter_review_tests.rs | 280 ++++++++++++++++++++++++++ 10 files changed, 783 insertions(+), 104 deletions(-) create mode 100644 tests/input_adapter_review_tests.rs diff --git a/docs/input-coverage.adoc b/docs/input-coverage.adoc index 57a4cda..e01016a 100644 --- a/docs/input-coverage.adoc +++ b/docs/input-coverage.adoc @@ -69,7 +69,8 @@ URL checks, including embedded style blocks. |Containerfile, Dockerfile and their dotted variants |Logical instruction extraction, FROM/stage-reference and remote ADD digest -checks, explicit-root-user checks, JSON exec-vector validation, and existing +checks, COPY source-stage/image checks, literal ENV/ARG/LABEL credential fields, +explicit-root-user checks, JSON exec-vector validation, and existing interpreter heuristics for executable source blocks. Plain exec argv is not misrepresented as shell source. |No full Docker grammar, image resolution, image contents or build execution. @@ -103,6 +104,19 @@ structured input adapters does not upgrade those heuristic checks into parsers. See link:php-coverage.adoc[PHP and schema 2.6 coverage] for PHP and the common inventory/report contract. +Registered build-file names take precedence over coincidental source suffixes, +and supported ASCII extensions are recognised even when the rest of a Unix +filename is non-UTF-8. Discovery and execution use that same choice. The shared +literal-credential check also covers named HTML attributes, CSS declarations, +Just literals and Forth string constants; it never scans comment text as those +fields. A field match is a review signal, not proof that a credential is live. + +Container exec forms recognise clustered shell flags; script arguments remain +arguments. Malformed JSON command text follows BuildKit's shell fallback, while +valid arrays containing non-string elements remain errors. SRI checking requires +a supported algorithm and correctly sized base64 metadata, without fetching or +verifying the remote resource. Encoded inline style attributes remain unsupported. + == Regression evidence `configuration_coverage_tests`, `template_coverage_tests` and @@ -126,3 +140,8 @@ are separate from scanner completion. * https://docs.npmjs.com/cli/v11/using-npm/scripts/[npm script execution and runtime shell] * https://github.com/tree-sitter/tree-sitter-html/tree/v0.23.2[tree-sitter HTML grammar 0.23.2] * https://github.com/tree-sitter/tree-sitter-css/tree/v0.25.0[tree-sitter CSS grammar 0.25.0] +* https://github.com/moby/buildkit/blob/master/frontend/dockerfile/parser/line_parsers.go[BuildKit command-form parsing] +* https://www.gnu.org/software/bash/manual/html_node/Invoking-Bash.html[Bash invocation and option processing] +* https://www.w3.org/TR/sri/[Subresource Integrity metadata] +* https://forth-standard.org/standard/core/Sq[Forth plain strings] +* https://forth-standard.org/standard/core/Seq[Forth escaped strings] diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index 3bc48a9..ada784f 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -497,15 +497,20 @@ impl Analyzer { let mut file_weak_points = Vec::new(); - // Dispatch to language-specific analyzer - let file_lang = Language::detect_path(file); + // Registered build/configuration names take precedence over a + // coincidental language suffix, exactly as they do in discovery. + let configuration = super::input_adapters::Adapter::detect(file); + let file_lang = if configuration.is_some() { + Language::Unknown + } else { + Language::detect_path(file) + }; // Record this file into the attestation accumulator (zero-cost when None) if let Some(ref mut acc) = accumulator { acc.record_file(&rel_path, &raw_bytes, &format!("{:?}", file_lang)); } - let configuration = super::input_adapters::Adapter::detect(file); match file_lang { Language::Unknown => { let Some(format) = configuration else { @@ -5189,31 +5194,10 @@ impl Analyzer { .to_path_buf() }; - // ── Cargo.toml: git deps without explicit rev= ──────────────────── + // Cargo git revisions are checked once by the structured per-file + // adapter, including nested manifests. Keep the independent lock check. let cargo_toml_path = project_root.join("Cargo.toml"); if let Some(content) = read_bounded(&cargo_toml_path, MANIFEST_FILE_READ_LIMIT) { - let git_dep_count = - content.matches("git = \"").count() + content.matches("git=\"").count(); - let rev_count = content.matches("rev = \"").count() + content.matches("rev=\"").count(); - if git_dep_count > 0 && rev_count < git_dep_count { - let unpinned = git_dep_count - rev_count; - weak_points.push(WeakPoint { - file: None, - line: None, - category: WeakPointCategory::SupplyChain, - location: Some("Cargo.toml".to_string()), - severity: Severity::High, - description: format!( - "{} git dependency/ies in Cargo.toml without explicit `rev =` — \ - build is not reproducible", - unpinned - ), - recommended_attack: vec![], - suppressed: false, - test_context: None, - }); - } - // ── Cargo.lock absent when Cargo.toml declares [lib] or [[bin]] ─── let has_lib = content.contains("[lib]"); let has_bin = content.contains("[[bin]]"); diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs index 6c47002..689e210 100644 --- a/src/assail/build_inputs.rs +++ b/src/assail/build_inputs.rs @@ -38,9 +38,116 @@ fn interpreter(command: &str) -> Result { } } +fn inline_source(name: &str, argv: &[String]) -> Result> { + let language = interpreter(name)?; + let mut index = 1; + let mut shell_command = false; + while let Some(arg) = argv.get(index) { + if arg == "--" { + return if shell_command { + Ok(Some( + argv.get(index + 1) + .ok_or_else(|| anyhow!("shell -c has no source"))? + .clone(), + )) + } else { + Ok(None) + }; + } + if !arg.starts_with(['-', '+']) || arg == "-" { + return Ok(shell_command.then(|| arg.clone())); + } + if language == Language::Shell { + match arg.as_str() { + "--noprofile" | "--norc" | "--posix" | "--restricted" | "--verbose" | "--login" => { + } + "--rcfile" | "--init-file" => { + index += 1; + ensure!(argv.get(index).is_some(), "shell option lacks its argument"); + } + _ if !arg.starts_with("--") => { + let flags = &arg[1..]; + for (offset, flag) in flags.char_indices() { + match flag { + 'c' if arg.starts_with('-') => shell_command = true, + 'o' | 'O' => { + if offset + 1 != flags.len() { + return Err(unsupported( + "attached named shell option is not extracted", + )); + } + index += 1; + ensure!( + argv.get(index).is_some(), + "named shell option lacks its argument" + ); + } + 'a' | 'b' | 'e' | 'f' | 'h' | 'k' | 'm' | 'n' | 'p' | 't' | 'u' + | 'v' | 'x' | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' | 'i' | 'l' | 'r' + | 's' | 'D' => {} + _ => return Err(unsupported("unrecognised shell invocation option")), + } + } + } + _ => return Err(unsupported("unrecognised shell invocation option")), + } + } else { + let option = if language == Language::Python { + "-c" + } else { + "-e" + }; + if arg == option + || language == Language::JavaScript + && matches!(arg.as_str(), "--eval" | "--print" | "-p") + { + return Ok(Some( + argv.get(index + 1) + .ok_or_else(|| anyhow!("inline interpreter option has no source"))? + .clone(), + )); + } + if let Some(source) = arg.strip_prefix(option).filter(|s| !s.is_empty()) { + return Ok(Some(source.into())); + } + if language == Language::JavaScript { + if let Some(source) = arg + .strip_prefix("--eval=") + .or_else(|| arg.strip_prefix("--print=")) + .or_else(|| arg.strip_prefix("-p").filter(|s| !s.is_empty())) + { + return Ok(Some(source.into())); + } + } + if matches!(arg.as_str(), "-m" | "--version" | "--help") { + return Ok(None); + } + return Err(unsupported( + "inline interpreter invocation options require an adapter", + )); + } + index += 1; + } + ensure!(!shell_command, "shell -c has no source"); + Ok(None) +} + fn command_input(text: &str, line: usize, shell: Language, result: &mut Analysis) -> Result<()> { - if text.starts_with('[') { - let argv: Vec = serde_json::from_str(text)?; + // BuildKit's parseMaybeJSON falls back to shell form for malformed JSON + // (including the POSIX `[` command), but rejects non-string array members. + let array = text + .starts_with('[') + .then(|| serde_json::from_str::(text).ok()) + .flatten(); + if let Some(serde_json::Value::Array(values)) = array { + let argv: Vec = values + .into_iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or_else(|| anyhow!("exec array contains a non-string argument")) + }) + .collect::>()?; ensure!(!argv.is_empty(), "empty build command vector"); let name = argv[0].rsplit('/').next().unwrap_or(&argv[0]); // A JSON exec vector is not a shell string. Only an explicit inline @@ -49,12 +156,9 @@ fn command_input(text: &str, line: usize, shell: Language, result: &mut Analysis name, "sh" | "bash" | "zsh" | "fish" | "dash" | "python" | "python3" | "node" | "nodejs" ) { - if let Some(index) = argv.iter().position(|arg| arg == "-c" || arg == "-e") { - let source = argv - .get(index + 1) - .ok_or_else(|| anyhow!("inline interpreter option has no source"))?; + if let Some(source) = inline_source(name, &argv)? { result.scripts.push(EmbeddedScript { - content: source.clone(), + content: source, language: interpreter(name)?, origin: format!("source block beginning at line {line}"), }); @@ -70,6 +174,96 @@ fn command_input(text: &str, line: usize, shell: Language, result: &mut Analysis Ok(()) } +fn image_pinned(image: &str) -> bool { + image.rsplit_once("@sha256:").is_some_and(|(name, digest)| { + !name.is_empty() && digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) + }) +} + +/// Declarative instruction words, with quotes/backslashes removed but no +/// environment expansion or command execution. Comments were removed earlier. +fn field_words(text: &str) -> Result> { + let mut words = Vec::new(); + let mut word = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in text.chars() { + if escaped { + word.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if let Some(delimiter) = quote { + if ch == delimiter { + quote = None; + } else { + word.push(ch); + } + } else if ch == '\'' || ch == '"' { + quote = Some(ch); + } else if ch.is_whitespace() { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + } else { + word.push(ch); + } + } + ensure!( + quote.is_none() && !escaped, + "unfinished declarative instruction word" + ); + if !word.is_empty() { + words.push(word); + } + Ok(words) +} + +fn literal_fields( + args: &str, + allow_unassigned: bool, + file: &str, + line: usize, + result: &mut Analysis, +) -> Result<()> { + let words = field_words(args)?; + if words.is_empty() { + return Ok(()); + } + let fields = if !allow_unassigned && !words[0].contains('=') { + ensure!(words.len() > 1, "declarative instruction has no value"); + vec![(words[0].clone(), words[1..].join(" "))] + } else { + let mut fields = Vec::new(); + for word in words { + if let Some((name, value)) = word.split_once('=') { + fields.push((name.into(), value.into())); + } else { + ensure!( + allow_unassigned, + "declarative assignment has no equals sign" + ); + } + } + fields + }; + for (name, value) in fields { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("build field at line {line}"), + &name, + &value, + ) { + result.findings.push(finding); + } + } + Ok(()) +} + pub(super) fn container(content: &str, file: &str) -> Result { let mut instructions = Vec::new(); let mut current = String::new(); @@ -98,6 +292,7 @@ pub(super) fn container(content: &str, file: &str) -> Result { let mut stages = BTreeSet::new(); let mut shell = Language::Shell; let mut seen_from = false; + let mut stage_count = 0usize; for (line, instruction) in instructions { let (name, args) = instruction .split_once(char::is_whitespace) @@ -124,9 +319,7 @@ pub(super) fn container(content: &str, file: &str) -> Result { "invalid FROM instruction at line {line}" ); let image = fields[0]; - let pinned = image.rsplit_once("@sha256:").is_some_and(|(_, digest)| { - digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) - }); + let pinned = image_pinned(image); if image != "scratch" && !stages.contains(&image.to_ascii_lowercase()) && !pinned { result.findings.push(finding(file, line, WeakPointCategory::SupplyChain, "Container base image lacks a complete SHA256 digest; local stage aliases are accepted")); } @@ -134,6 +327,7 @@ pub(super) fn container(content: &str, file: &str) -> Result { stages.insert(fields[2].to_ascii_lowercase()); } seen_from = true; + stage_count += 1; shell = Language::Shell; } "SHELL" => { @@ -183,6 +377,47 @@ pub(super) fn container(content: &str, file: &str) -> Result { )); } } + "COPY" => { + let words = field_words(args)?; + let mut source = None; + let mut fields = words.iter(); + while let Some(field) = fields.next().filter(|field| field.starts_with("--")) { + let value = if field == "--from" { + Some( + fields + .next() + .ok_or_else(|| anyhow!("COPY --from has no value"))? + .as_str(), + ) + } else { + field.strip_prefix("--from=") + }; + if let Some(value) = value { + ensure!( + source.is_none() && !value.is_empty(), + "duplicate or empty COPY --from" + ); + source = Some(value); + } + } + if let Some(source) = source { + if let Ok(index) = source.parse::() { + ensure!(index < stage_count, "COPY stage index does not exist"); + } else if !stages.contains(&source.to_ascii_lowercase()) + && !image_pinned(source) + { + if source.contains('$') || !source.contains([':', '/', '@']) { + return Err(unsupported("COPY source is an unresolved external image or named build context")); + } + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "COPY external image lacks a complete SHA256 digest", + )); + } + } + } "USER" => { if matches!(args.split(':').next(), Some("root" | "0")) { result.findings.push(finding( @@ -198,8 +433,10 @@ pub(super) fn container(content: &str, file: &str) -> Result { "deferred ONBUILD instruction analysis is not implemented", )) } - "ARG" | "ENV" | "LABEL" | "COPY" | "WORKDIR" | "EXPOSE" | "VOLUME" | "STOPSIGNAL" - | "MAINTAINER" => {} + "ARG" | "ENV" | "LABEL" => { + literal_fields(args, name == "ARG", file, line, &mut result)? + } + "WORKDIR" | "EXPOSE" | "VOLUME" | "STOPSIGNAL" | "MAINTAINER" => {} _ => { return Err(unsupported(&format!( "unknown Containerfile instruction {name}" @@ -255,7 +492,7 @@ fn recipe_script( Ok(()) } -pub(super) fn just(content: &str, _file: &str) -> Result { +pub(super) fn just(content: &str, file: &str) -> Result { let mut result = Analysis::default(); let mut default = Language::Shell; let mut in_recipe = false; @@ -289,11 +526,22 @@ pub(super) fn just(content: &str, _file: &str) -> Result { body.clear(); indentation = None; in_recipe = false; - if let Some(value) = text.strip_prefix("set shell :=") { + let setting = text + .strip_prefix("set") + .filter(|rest| rest.starts_with(char::is_whitespace)) + .map(str::trim_start); + let shell_setting = setting + .and_then(|rest| rest.strip_prefix("shell")) + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix(":=")); + if let Some(value) = shell_setting { let argv: Vec = serde_json::from_str(value.trim())?; default = interpreter(argv.first().ok_or_else(|| anyhow!("empty Just shell"))?)?; - } else if text.starts_with("set ") { - if text.contains("interpreter") || text.contains("windows-shell") { + } else if let Some(setting) = setting { + if setting.starts_with("shell") + || setting.contains("interpreter") + || setting.contains("windows-shell") + { return Err(unsupported( "additional Just interpreter setting requires an adapter", )); @@ -318,6 +566,21 @@ pub(super) fn just(content: &str, _file: &str) -> Result { "executable Just variable expression is not extracted", )); } + let (name, value) = text.split_once(":=").expect("assignment matched"); + let value = value.trim(); + if value.len() >= 2 + && (value.starts_with('"') && value.ends_with('"') + || value.starts_with('\'') && value.ends_with('\'')) + { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("Just declaration at line {}", offset + 1), + name.trim().trim_start_matches("export ").trim(), + &value[1..value.len() - 1], + ) { + result.findings.push(finding); + } + } } else if text.contains(':') { in_recipe = true; } else { diff --git a/src/assail/configuration.rs b/src/assail/configuration.rs index 887e86c..70d2596 100644 --- a/src/assail/configuration.rs +++ b/src/assail/configuration.rs @@ -231,37 +231,44 @@ fn is_reference(value: &str) -> bool { || (value.starts_with("{{") && value.ends_with("}}")) } +/// Reused only for actual parsed/declarative literal fields, not comment text. +/// The value is deliberately absent from the returned diagnostic. +pub(super) fn literal_credential( + file: &str, + field: &str, + key: &str, + value: &str, +) -> Option { + let name = key_name(key); + if matches!( + name.as_str(), + "password" | "passwd" | "apikey" | "apisecret" | "secretkey" | "accesstoken" | "privatekey" + ) && value.len() >= 8 + && !is_reference(value) + { + Some(finding( + file, + field, + WeakPointCategory::HardcodedSecret, + Severity::Critical, + "literal credential in a named field", + )) + } else { + None + } +} + fn inspect_value(value: &Value, field: &str, file: &str, findings: &mut Vec) { match value { Value::Object(map) => { for (key, value) in map { let child = format!("{field}/{}", key.replace('~', "~0").replace('/', "~1")); let normal = key_name(key); - if matches!( - normal.as_str(), - "password" - | "passwd" - | "apikey" - | "apisecret" - | "secretkey" - | "accesstoken" - | "privatekey" - ) { - if let Some(secret) = value - .as_str() - .filter(|secret| !secret.is_empty() && !is_reference(secret)) - { - // Do not repeat a possible credential in the report. - if secret.len() >= 8 { - findings.push(finding( - file, - &child, - WeakPointCategory::HardcodedSecret, - Severity::Critical, - "literal credential in a configuration field", - )); - } - } + if let Some(finding) = value + .as_str() + .and_then(|secret| literal_credential(file, &child, key, secret)) + { + findings.push(finding); } if matches!( normal.as_str(), @@ -332,23 +339,27 @@ fn inspect_value(value: &Value, field: &str, file: &str, findings: &mut Vec { - if let Some(rest) = text.strip_prefix("http://") { - let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); - let loopback = matches!( - authority.split(':').next(), - Some("localhost" | "127.0.0.1" | "0.0.0.0") - ) || authority == "[::1]" - || authority.starts_with("[::1]:"); - if !loopback { - findings.push(finding( - file, - field, - WeakPointCategory::InsecureProtocol, - Severity::Medium, - "plaintext HTTP endpoint", - )); - } + Value::String(text) + if text + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) => + { + let rest = &text[7..]; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + let authority = authority.to_ascii_lowercase(); + let loopback = matches!( + authority.split(':').next(), + Some("localhost" | "127.0.0.1" | "0.0.0.0") + ) || authority == "[::1]" + || authority.starts_with("[::1]:"); + if !loopback { + findings.push(finding( + file, + field, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "plaintext HTTP endpoint", + )); } } _ => {} diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs index 8c0b29d..8224553 100644 --- a/src/assail/coverage.rs +++ b/src/assail/coverage.rs @@ -282,8 +282,12 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> continue; } let path = entry.path(); - let language = Language::detect_path(path); let configuration = super::input_adapters::Adapter::detect(path); + let language = if configuration.is_some() { + Language::Unknown + } else { + Language::detect_path(path) + }; let (status, reason, kind) = if !entry.file_type().is_file() { ( "excluded", diff --git a/src/assail/forth.rs b/src/assail/forth.rs index 9811ac0..79a5555 100644 --- a/src/assail/forth.rs +++ b/src/assail/forth.rs @@ -6,7 +6,10 @@ use super::input_adapters::{Analysis, UnsupportedInput}; use crate::types::{Severity, WeakPoint, WeakPointCategory}; use anyhow::{ensure, Result}; -fn words(content: &str) -> Result> { +/// The final flag distinguishes literal data from executable words. Keeping the +/// data lets named string constants receive credential checks without executing +/// or interpreting words embedded in those strings. +fn words(content: &str) -> Result> { let mut result = Vec::new(); let mut offset = 0; let mut line = 1; @@ -46,16 +49,24 @@ fn words(content: &str) -> Result> { } } ".\"" | "s\"" | "c\"" | "s\\\"" | ".\\\"" | "abort\"" => { + let escapes = matches!(word.as_str(), "s\\\"" | ".\\\""); + let string_start = offset; + let first_line = line; + let mut string_end = offset; let mut closed = false; while offset < bytes.len() { if bytes[offset] == b'\n' { line += 1; } - if bytes[offset] == b'\\' && offset + 1 < bytes.len() { + if escapes && bytes[offset] == b'\\' && offset + 1 < bytes.len() { + if bytes[offset + 1] == b'\n' { + line += 1; + } offset += 2; continue; } if bytes[offset] == b'"' { + string_end = offset; offset += 1; closed = true; break; @@ -63,8 +74,13 @@ fn words(content: &str) -> Result> { offset += 1; } ensure!(closed, "unterminated Forth string"); + result.push(( + first_line, + content[string_start..string_end].trim().into(), + true, + )); } - _ => result.push((line, word)), + _ => result.push((line, word, false)), } } Ok(result) @@ -77,7 +93,12 @@ pub(super) fn analyze(content: &str, file: &str) -> Result { .extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| extension.eq_ignore_ascii_case("fs")) - && !(words.iter().any(|(_, word)| word == ":") && words.iter().any(|(_, word)| word == ";")) + && !(words + .iter() + .any(|(_, word, literal)| !literal && word == ":") + && words + .iter() + .any(|(_, word, literal)| !literal && word == ";")) { return Err(UnsupportedInput( "ambiguous .fs input has no recognised Forth colon definition; F# is unsupported" @@ -87,7 +108,27 @@ pub(super) fn analyze(content: &str, file: &str) -> Result { } let mut result = Analysis::default(); let mut definition_name = false; - for (line, word) in words { + for (index, (line, word, literal)) in words.iter().enumerate() { + if *literal { + if let (Some((_, binding, false)), Some((_, name, false))) = + (words.get(index + 1), words.get(index + 2)) + { + if matches!( + binding.as_str(), + "constant" | "2constant" | "value" | "2value" + ) { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("Forth string binding at line {line}"), + name, + word, + ) { + result.findings.push(finding); + } + } + } + continue; + } if definition_name { definition_name = false; continue; @@ -114,7 +155,7 @@ pub(super) fn analyze(content: &str, file: &str) -> Result { if let Some((category, description)) = hazard { result.findings.push(WeakPoint { file: Some(file.into()), - line: Some(line as u32), + line: Some(*line as u32), category, location: Some(format!("{file}:{line}")), severity: Severity::High, diff --git a/src/assail/input_adapters.rs b/src/assail/input_adapters.rs index 81fbdce..4dbc438 100644 --- a/src/assail/input_adapters.rs +++ b/src/assail/input_adapters.rs @@ -46,15 +46,16 @@ impl Adapter { if let Some(format) = configuration::Format::detect(path) { return Some(Self::Configuration(format)); } - let name = path.file_name()?.to_str()?; - if name == "Containerfile" - || name == "Dockerfile" - || name.starts_with("Containerfile.") - || name.starts_with("Dockerfile.") + let name = path.file_name()?; + let bytes = name.as_encoded_bytes(); + if bytes == b"Containerfile" + || bytes == b"Dockerfile" + || bytes.starts_with(b"Containerfile.") + || bytes.starts_with(b"Dockerfile.") { return Some(Self::Container); } - if matches!(name, "Justfile" | "justfile") { + if matches!(name.to_str(), Some("Justfile" | "justfile")) { return Some(Self::Just); } match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { diff --git a/src/assail/templates.rs b/src/assail/templates.rs index 34ab533..2fe9c08 100644 --- a/src/assail/templates.rs +++ b/src/assail/templates.rs @@ -90,6 +90,7 @@ pub(super) fn html(content: &str, file: &str) -> Result { }); } if name == "style" && !value.is_empty() { + ensure_attribute_decoded(&value)?; let inline = css(&format!("element {{{value}}}"), file)?; for mut finding in inline.findings { finding.line = Some(child.start_position().row as u32 + 1); @@ -123,22 +124,32 @@ pub(super) fn html(content: &str, file: &str) -> Result { )); } } + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("HTML attribute at line {}", child.start_position().row + 1), + name.strip_prefix("data-").unwrap_or(&name), + &value, + ) { + result.findings.push(finding); + } attributes.insert(name, value); } if tag == "script" && attributes.get("src").is_some_and(|src| { - src.starts_with("https://") - || src.starts_with("http://") + src.to_ascii_lowercase().starts_with("https://") + || src.to_ascii_lowercase().starts_with("http://") || src.starts_with("//") }) - && !attributes.contains_key("integrity") + && !attributes + .get("integrity") + .is_some_and(|value| usable_integrity(value)) { result.findings.push(finding( file, node, WeakPointCategory::SupplyChain, Severity::Medium, - "Remote script has no subresource-integrity attribute", + "Remote script has no syntactically usable supported integrity digest", )); } } @@ -195,6 +206,50 @@ pub(super) fn html(content: &str, file: &str) -> Result { Ok(result) } +// SRI metadata needs a correctly sized supported base64 digest. This checks +// syntax only: it never fetches a resource or proves that its bytes match. +fn usable_integrity(metadata: &str) -> bool { + metadata.split_ascii_whitespace().any(|token| { + let expression = token.split('?').next().unwrap_or(token); + let Some((algorithm, value)) = expression.split_once('-') else { + return false; + }; + let bytes = match algorithm.to_ascii_lowercase().as_str() { + "sha256" => 32usize, + "sha384" => 48, + "sha512" => 64, + _ => return false, + }; + let digits = value.trim_end_matches('='); + let padding = value.len() - digits.len(); + if digits.len() != (bytes * 8).div_ceil(6) + || padding > 2 + || (padding > 0 && value.len() % 4 != 0) + { + return false; + } + let sextet = |byte: u8| match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' | b'-' => Some(62), + b'/' | b'_' => Some(63), + _ => None, + }; + if !digits.bytes().all(|b| sextet(b).is_some()) { + return false; + } + let Some(last) = digits.bytes().last().and_then(sextet) else { + return false; + }; + match bytes % 3 { + 1 => last & 15 == 0, + 2 => last & 3 == 0, + _ => padding == 0, + } + }) +} + fn ensure_attribute_decoded(value: &str) -> Result<()> { // The grammar does not decode character references. Refuse a sensitive // attribute whose interpreted bytes differ instead of checking only its @@ -249,6 +304,23 @@ pub(super) fn css(content: &str, file: &str) -> Result { let mut result = Analysis::default(); let mut pending = vec![tree.root_node()]; while let Some(node) = pending.pop() { + if node.kind() == "declaration" { + let mut name = String::new(); + for child in node.named_children(&mut node.walk()) { + if child.kind() == "property_name" { + name = text(child, content)?.trim_start_matches('-').to_owned(); + } else if matches!(child.kind(), "plain_value" | "string_value") { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("CSS declaration at line {}", node.start_position().row + 1), + &name, + text(child, content)?.trim_matches(['\'', '"']), + ) { + result.findings.push(finding); + } + } + } + } if node.kind() == "call_expression" { let value = text(node, content)?.to_ascii_lowercase(); if value.starts_with("expression(") { @@ -272,7 +344,11 @@ pub(super) fn css(content: &str, file: &str) -> Result { )); } } - if node.kind() == "import_statement" && text(node, content)?.contains("http://") { + if node.kind() == "import_statement" + && text(node, content)? + .to_ascii_lowercase() + .contains("http://") + { result.findings.push(finding( file, node, diff --git a/tests/build_input_coverage_tests.rs b/tests/build_input_coverage_tests.rs index f2cbb8a..7c8d2e1 100644 --- a/tests/build_input_coverage_tests.rs +++ b/tests/build_input_coverage_tests.rs @@ -55,7 +55,7 @@ fn digest_pinned_images_are_not_floating_and_exec_interpreters_are_analyzed() { fn invalid_or_unhandled_build_dialects_remain_gaps() { for source in [ "RUN echo no-base\n", - "FROM scratch\nRUN [\"bash\",]\n", + "FROM scratch\nRUN [\"bash\",7]\n", "FROM scratch\nRUN echo incomplete \\\n", "FROM scratch\nRUN < +//! Concrete review counterexamples for the declared input-adapter contract. +use panic_attack::{ + assail, + types::{AssailReport, WeakPointCategory}, +}; + +fn scan(name: &str, source: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, source).unwrap(); + assail::analyze(&path).unwrap() +} + +fn category(report: &AssailReport, expected: WeakPointCategory) -> bool { + report + .weak_points + .iter() + .any(|f| f.category == expected && !f.suppressed) +} + +#[test] +fn clustered_shell_flags_reach_the_actual_inline_source() { + for argv in [ + r#"["bash","-lc","eval \"$INPUT\""]"#, + r#"["bash","-euo","pipefail","-lc","eval \"$INPUT\""]"#, + ] { + let report = scan("Containerfile", &format!("FROM scratch\nRUN {argv}\n")); + assert_eq!(report.coverage.status, "complete", "{argv}"); + assert!( + category(&report, WeakPointCategory::CommandInjection), + "{argv}" + ); + } + let report = scan( + "Containerfile", + "FROM scratch\nRUN [\"bash\",\"script.sh\",\"-c\",\"eval \\\"$INPUT\\\"\"]\n", + ); + assert!( + !category(&report, WeakPointCategory::CommandInjection), + "script arguments are not inline source" + ); +} + +#[test] +fn remote_script_integrity_requires_a_usable_supported_digest_token() { + for integrity in [ + "".to_owned(), + "bogus".into(), + "sha256-short".into(), + format!("sha256-{}=", "B".repeat(43)), + ] { + let report = scan( + "index.html", + &format!(""), + ); + assert_eq!(report.coverage.status, "complete"); + assert!( + category(&report, WeakPointCategory::SupplyChain), + "{integrity}" + ); + } + for integrity in [ + format!("sha256-{}=", "A".repeat(43)), + format!("sha384-{}", "A".repeat(64)), + format!("sha512-{}==", "A".repeat(86)), + ] { + let report = scan( + "index.html", + &format!(""), + ); + assert!( + !category(&report, WeakPointCategory::SupplyChain), + "valid syntax, not a fetched-resource digest proof" + ); + } +} + +#[test] +fn encoded_inline_css_remains_an_explicit_gap() { + let report = scan( + "index.html", + "

x

", + ); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + assert!(report.coverage.incomplete()); +} + +#[test] +fn plaintext_schemes_are_case_insensitive() { + for prefix in ["http", "HTTP", "hTtP"] { + let report = scan( + "config.json", + &format!(r#"{{"endpoint":"{prefix}://remote.invalid"}}"#), + ); + assert!(category(&report, WeakPointCategory::InsecureProtocol)); + let report = scan( + "config.json", + &format!(r#"{{"endpoint":"{prefix}://localhost:9099"}}"#), + ); + assert!(!category(&report, WeakPointCategory::InsecureProtocol)); + } +} + +#[test] +fn copy_sources_distinguish_stages_pins_and_unresolved_images() { + let report = scan( + "Containerfile", + "FROM scratch\nCOPY --from=nginx:latest /source /dest\n", + ); + assert!(category(&report, WeakPointCategory::SupplyChain)); + for source in [ + "builder".to_owned(), + "0".into(), + format!("nginx@sha256:{}", "a".repeat(64)), + ] { + let report = scan( + "Containerfile", + &format!("FROM scratch AS builder\nCOPY --from={source} /source /dest\n"), + ); + assert_eq!(report.coverage.status, "complete", "{source}"); + assert!( + !category(&report, WeakPointCategory::SupplyChain), + "{source}" + ); + } + let report = scan( + "Containerfile", + "FROM scratch\nCOPY --from=external-context /source /dest\n", + ); + assert_eq!( + report.coverage.inputs[0].status, "unsupported", + "external named context needs resolution" + ); +} + +#[cfg(unix)] +#[test] +fn non_utf8_filenames_keep_registered_ascii_extensions_in_scope() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + for (suffix, content) in [ + ("htm", "

text

"), + ("fth", ": go 1 ;"), + ("forth", ": go 1 ;"), + ("4th", ": go 1 ;"), + ("just", "check:\n echo safe\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let mut bytes = b"input-\xff.".to_vec(); + bytes.extend_from_slice(suffix.as_bytes()); + let path = dir.path().join(OsString::from_vec(bytes)); + std::fs::write(&path, content).unwrap(); + let report = assail::analyze(&path).unwrap(); + assert_eq!(report.coverage.status, "complete", "{suffix}"); + assert_eq!(report.coverage.candidate_files, 1); + assert_eq!(report.coverage.inputs[0].path, path); + } +} + +#[test] +fn container_variant_dispatch_matches_the_inventory_adapter() { + for name in ["Dockerfile.d", "Containerfile.sh", "Dockerfile.pl"] { + let report = scan(name, "FROM scratch\nRUN eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(report.coverage.inputs[0] + .analysis + .as_ref() + .unwrap() + .starts_with("Containerfile")); + assert!( + category(&report, WeakPointCategory::CommandInjection), + "{name}" + ); + } +} + +#[test] +fn non_configuration_literal_credential_fields_are_checked() { + for (name, content) in [ + ( + "Containerfile", + "FROM scratch\nENV API_KEY=fixture-literal-value\n", + ), + ( + "Dockerfile", + "FROM scratch\nARG ACCESS_TOKEN=fixture-literal-value\n", + ), + ( + "Containerfile.example", + "FROM scratch\nLABEL api-key=fixture-literal-value\n", + ), + ( + "index.html", + "

text

", + ), + ("style.css", ":root { --api-key: 'fixture-literal-value'; }"), + ( + "Justfile", + "api_key := 'fixture-literal-value'\ncheck:\n echo safe\n", + ), + ( + "example.forth", + "s\" fixture-literal-value\" 2constant api_key\n", + ), + ] { + let report = scan(name, content); + assert_eq!( + report.coverage.status, "complete", + "{name}: {:?}", + report.coverage.inputs + ); + assert!( + category(&report, WeakPointCategory::HardcodedSecret), + "{name}" + ); + assert!(report + .weak_points + .iter() + .all(|f| !f.description.contains("fixture-literal-value"))); + } + for (name,content) in [("Containerfile","FROM scratch\n# ENV API_KEY=fixture-literal-value\n"),("index.html","

text

"),("style.css","/* --api-key: 'fixture-literal-value' */ p { color: blue; }"),("Justfile","# api_key := 'fixture-literal-value'\ncheck:\n echo safe\n"),("example.forth","\\ s\" fixture-literal-value\" 2constant api_key\n")] { + let report=scan(name,content); + assert!(!category(&report,WeakPointCategory::HardcodedSecret),"{name}"); + assert!(!category(&report,WeakPointCategory::InsecureProtocol),"{name}"); + } +} + +#[test] +fn bracket_prefixed_shell_commands_are_not_misparsed_as_exec_json() { + let report = scan( + "Containerfile", + "FROM scratch\nRUN [ -f /etc/config ] && eval \"$INPUT\"\n", + ); + assert_eq!(report.coverage.status, "complete"); + assert!(category(&report, WeakPointCategory::CommandInjection)); +} + +#[test] +fn just_shell_settings_do_not_depend_on_canonical_spacing() { + for line in [ + r#"set shell:=["pwsh","-c"]"#, + r#"set shell := ["pwsh","-c"]"#, + "set\tshell := [\"pwsh\",\"-c\"]", + ] { + let report = scan("Justfile", &format!("{line}\ncheck:\n echo safe\n")); + assert_eq!(report.coverage.inputs[0].status, "unsupported", "{line}"); + } +} + +#[test] +fn cargo_git_revisions_have_one_structured_finding_per_dependency() { + let report = scan( + "Cargo.toml", + "[dependencies.example]\ngit = \"https://example.org/repo\"\n", + ); + let findings: Vec<_> = report + .weak_points + .iter() + .filter(|f| f.category == WeakPointCategory::SupplyChain) + .collect(); + assert_eq!(findings.len(), 1, "{findings:?}"); +} + +#[test] +fn plain_forth_strings_do_not_escape_their_closing_quote() { + let report = scan( + "example.forth", + r#": run s" C:\dir\" system s" trailing" ;"#, + ); + assert_eq!(report.coverage.status, "complete"); + assert!(category(&report, WeakPointCategory::CommandInjection)); + let report = scan( + "example.forth", + r#": run s\" escaped \" system still string" ;"#, + ); + assert_eq!(report.coverage.status, "complete"); + assert!(!category(&report, WeakPointCategory::CommandInjection)); +} From 566adfbb06488987e9c86fabfdc3bef011a274d6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:48:07 +0000 Subject: [PATCH 04/14] fix(assail): qualify adapter detection and canonical sources --- .github/workflows/mirror.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner-vendored.yml | 10 +- docs/input-coverage.adoc | 9 +- src/assail/analyzer.rs | 11 +- src/assail/build_inputs.rs | 28 +++- src/assail/forth.rs | 47 ++++++- src/assail/input_adapters.rs | 6 +- src/assail/templates.rs | 10 +- tests/input_adapter_review_tests.rs | 125 ++++++++++++++++++ 10 files changed, 229 insertions(+), 21 deletions(-) diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 5ec8af8..952687b 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -15,7 +15,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 secrets: GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 398c75d..b7eeb0c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,7 +15,7 @@ permissions: jobs: scorecard: - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 permissions: actions: read # required by scorecard-reusable.yml to read workflow metadata contents: read diff --git a/.github/workflows/secret-scanner-vendored.yml b/.github/workflows/secret-scanner-vendored.yml index 9df24e2..bce8a0c 100644 --- a/.github/workflows/secret-scanner-vendored.yml +++ b/.github/workflows/secret-scanner-vendored.yml @@ -25,8 +25,8 @@ # WHAT THIS IS NOT # ---------------- # This is not a weakened or stubbed check: it is the canon file at commit -# 333b06a01db1874ffedb2de1bfabb195640927c7, byte-for-byte, with only the -# `name:` line changed. Same pinned, checksum-verified gitleaks binary, same +# 333b06a01db1874ffedb2de1bfabb195640927c7, adapted for local reuse with a +# local name and explicit baseline source pins. Same checksum-verified binary, same # three jobs (gitleaks working-tree + git-history + AsciiDoc mirror, Rust # hardcoded-secret scan, shell-secret scan), same estate baseline, same gate. # Nothing is suppressed that the reusable did not already suppress, and the @@ -42,6 +42,8 @@ # `docs/ci/PROMPT-actions-policy.md` is the prerequisite for removing it. # # VENDORED-FROM: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 +# Runtime baseline fetches use the canonical metadatastician/standards coordinate +# at that same immutable commit; the historical source record above is retained. name: Secret Scanner (reusable, vendored) @@ -156,7 +158,7 @@ jobs: if: hashFiles('.gitleaks.toml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: hyperpolymath/standards + repository: metadatastician/standards ref: 333b06a01db1874ffedb2de1bfabb195640927c7 # canon SHA this file was copied from; job.workflow_sha would name THIS repo's commit when the reusable is local path: .standards-gitleaks sparse-checkout: | @@ -233,7 +235,7 @@ jobs: if: steps.estate_baseline.outputs.referenced == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: hyperpolymath/standards + repository: metadatastician/standards ref: 333b06a01db1874ffedb2de1bfabb195640927c7 # canon SHA this file was copied from; job.workflow_sha would name THIS repo's commit when the reusable is local path: .estate-baseline-checkout sparse-checkout: config/gitleaks diff --git a/docs/input-coverage.adoc b/docs/input-coverage.adoc index e01016a..0f302cb 100644 --- a/docs/input-coverage.adoc +++ b/docs/input-coverage.adoc @@ -88,8 +88,9 @@ they are inside the declared traversal. |Forth .fs, .fth, .forth, .4th |Word-level evaluate/system/raw-memory hazards with comments and quoted data -masked. Ambiguous .fs requires a definition witness; unfinished lexical forms -remain gaps. +masked. Ambiguous .fs conservatively requires a leading named colon definition +with matched definition boundaries; independent type/list punctuation, a +different prelude, and unfinished lexical forms remain gaps. |No Forth grammar, dictionary/stack interpretation or execution proof. A lexical witness distinguishes this adapter's bounded scope; it is not a type check. |=== @@ -110,6 +111,10 @@ filename is non-UTF-8. Discovery and execution use that same choice. The shared literal-credential check also covers named HTML attributes, CSS declarations, Just literals and Forth string constants; it never scans comment text as those fields. A field match is a review signal, not proof that a credential is live. +Build-input environment references are distinguished from literal values only +in their format context; escaped or single-quoted dollar signs remain literal. +Adapter inputs remain in coverage but do not vote for a repository's source +language or disable its language-specific metrics. Container exec forms recognise clustered shell flags; script arguments remain arguments. Malformed JSON command text follows BuildKit's shell fallback, while diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index ada784f..efdcaab 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -343,7 +343,11 @@ impl Analyzer { } let language = if target.is_file() { - Language::detect_path(target) + if super::input_adapters::Adapter::detect(target).is_some() { + Language::Unknown + } else { + Language::detect_path(target) + } } else { Self::detect_directory_language(target)? }; @@ -925,6 +929,11 @@ impl Analyzer { let (files, _) = super::coverage::discover(dir)?; let mut counts = std::collections::HashMap::new(); for path in files { + // Adapter inputs are covered, but do not vote for the source + // language or disable language-specific report metrics. + if super::input_adapters::Adapter::detect(&path).is_some() { + continue; + } let language = Language::detect_path(&path); *counts.entry(language).or_insert(0usize) += 1; } diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs index 689e210..9b9ead2 100644 --- a/src/assail/build_inputs.rs +++ b/src/assail/build_inputs.rs @@ -181,7 +181,9 @@ fn image_pinned(image: &str) -> bool { } /// Declarative instruction words, with quotes/backslashes removed but no -/// environment expansion or command execution. Comments were removed earlier. +/// environment expansion or command execution. Keep a backslash marker on a +/// literal dollar sign so reference recognition cannot erase escaped values. +/// Comments were removed earlier. fn field_words(text: &str) -> Result> { let mut words = Vec::new(); let mut word = String::new(); @@ -189,6 +191,9 @@ fn field_words(text: &str) -> Result> { let mut escaped = false; for ch in text.chars() { if escaped { + if ch == '$' { + word.push('\\'); + } word.push(ch); escaped = false; continue; @@ -201,6 +206,9 @@ fn field_words(text: &str) -> Result> { if ch == delimiter { quote = None; } else { + if delimiter == '\'' && ch == '$' { + word.push('\\'); + } word.push(ch); } } else if ch == '\'' || ch == '"' { @@ -223,6 +231,16 @@ fn field_words(text: &str) -> Result> { Ok(words) } +fn simple_environment_reference(value: &str) -> bool { + value.strip_prefix('$').is_some_and(|name| { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) +} + fn literal_fields( args: &str, allow_unassigned: bool, @@ -252,6 +270,11 @@ fn literal_fields( fields }; for (name, value) in fields { + // This syntax is expanded by the build-input format, unlike a plain + // JSON string. Do not broaden the shared literal predicate for it. + if simple_environment_reference(&value) { + continue; + } if let Some(finding) = super::configuration::literal_credential( file, &format!("build field at line {line}"), @@ -419,7 +442,8 @@ pub(super) fn container(content: &str, file: &str) -> Result { } } "USER" => { - if matches!(args.split(':').next(), Some("root" | "0")) { + let user = args.split(':').next().unwrap_or(""); + if user == "root" || user.parse::().ok() == Some(0) { result.findings.push(finding( file, line, diff --git a/src/assail/forth.rs b/src/assail/forth.rs index 79a5555..0da85aa 100644 --- a/src/assail/forth.rs +++ b/src/assail/forth.rs @@ -86,6 +86,46 @@ fn words(content: &str) -> Result> { Ok(result) } +/// A conservative witness for the ambiguous .fs suffix, not a Forth grammar. +/// Begin with a named colon definition and require matched definition +/// boundaries; independent F# annotation/list tokens are not a witness. +fn has_colon_definition(words: &[(usize, String, bool)]) -> bool { + if !matches!(words.first(), Some((_, word, false)) if word == ":") { + return false; + } + let mut open = false; + let mut needs_name = false; + let mut complete = false; + for (_, word, literal) in words { + if *literal { + if needs_name { + return false; + } + continue; + } + if needs_name { + if matches!(word.as_str(), ":" | ";") { + return false; + } + needs_name = false; + continue; + } + match word.as_str() { + ":" if !open => { + open = true; + needs_name = true; + } + ";" if open => { + open = false; + complete = true; + } + ":" | ";" => return false, + _ => {} + } + } + complete && !open && !needs_name +} + pub(super) fn analyze(content: &str, file: &str) -> Result { let words = words(content)?; // .fs is shared with F#. Never infer Forth merely from that suffix. @@ -93,12 +133,7 @@ pub(super) fn analyze(content: &str, file: &str) -> Result { .extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| extension.eq_ignore_ascii_case("fs")) - && !(words - .iter() - .any(|(_, word, literal)| !literal && word == ":") - && words - .iter() - .any(|(_, word, literal)| !literal && word == ";")) + && !has_colon_definition(&words) { return Err(UnsupportedInput( "ambiguous .fs input has no recognised Forth colon definition; F# is unsupported" diff --git a/src/assail/input_adapters.rs b/src/assail/input_adapters.rs index 4dbc438..59a344d 100644 --- a/src/assail/input_adapters.rs +++ b/src/assail/input_adapters.rs @@ -43,9 +43,6 @@ pub(super) enum Adapter { impl Adapter { pub(super) fn detect(path: &Path) -> Option { - if let Some(format) = configuration::Format::detect(path) { - return Some(Self::Configuration(format)); - } let name = path.file_name()?; let bytes = name.as_encoded_bytes(); if bytes == b"Containerfile" @@ -58,6 +55,9 @@ impl Adapter { if matches!(name.to_str(), Some("Justfile" | "justfile")) { return Some(Self::Just); } + if let Some(format) = configuration::Format::detect(path) { + return Some(Self::Configuration(format)); + } match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { "html" | "htm" => Some(Self::Html), "css" => Some(Self::Css), diff --git a/src/assail/templates.rs b/src/assail/templates.rs index 2fe9c08..0aa893b 100644 --- a/src/assail/templates.rs +++ b/src/assail/templates.rs @@ -104,7 +104,15 @@ pub(super) fn html(content: &str, file: &str) -> Result { "src" | "href" | "action" | "formaction" | "poster" ) { ensure_attribute_decoded(&value)?; - let lower = value.trim().to_ascii_lowercase(); + // WHATWG URL parsing trims outer C0 controls/spaces + // and removes ASCII tab/newline characters before + // scheme parsing. Keep the same value for SRI below. + value = value + .trim_matches(|ch: char| ch <= '\u{20}') + .chars() + .filter(|ch| !matches!(ch, '\t' | '\n' | '\r')) + .collect(); + let lower = value.to_ascii_lowercase(); if lower.starts_with("javascript:") || lower.starts_with("vbscript:") { result.findings.push(finding( file, diff --git a/tests/input_adapter_review_tests.rs b/tests/input_adapter_review_tests.rs index 44dd6d5..e06be5b 100644 --- a/tests/input_adapter_review_tests.rs +++ b/tests/input_adapter_review_tests.rs @@ -164,6 +164,7 @@ fn container_variant_dispatch_matches_the_inventory_adapter() { for name in ["Dockerfile.d", "Containerfile.sh", "Dockerfile.pl"] { let report = scan(name, "FROM scratch\nRUN eval \"$INPUT\"\n"); assert_eq!(report.coverage.status, "complete"); + assert_eq!(report.language, panic_attack::types::Language::Unknown); assert!(report.coverage.inputs[0] .analysis .as_ref() @@ -278,3 +279,127 @@ fn plain_forth_strings_do_not_escape_their_closing_quote() { assert_eq!(report.coverage.status, "complete"); assert!(!category(&report, WeakPointCategory::CommandInjection)); } + +#[test] +fn fs_type_annotations_do_not_supply_a_forth_definition() { + for content in [ + "let f (x : int) = [x ; 1]\n", + "; : unfinished\n", + ": ;\n", + ": outer : nested ; ;\n", + ] { + let report = scan("input.fs", content); + assert!(report.coverage.incomplete(), "{content}"); + } + let report = scan("input.fs", "\\ comment with : and ;\n: run system ;\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(category(&report, WeakPointCategory::CommandInjection)); +} + +#[test] +fn adapter_inputs_do_not_vote_for_the_repository_source_language() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Main.res"), "let answer = 42\n").unwrap(); + for (name, source) in [ + ("config.json", "{}"), + ("config.yaml", "enabled: true"), + ("config.toml", "enabled = true"), + ("index.html", "

text

"), + ("style.css", "p { color: blue; }"), + ("Dockerfile.d", "FROM scratch\n"), + ] { + std::fs::write(dir.path().join(name), source).unwrap(); + } + let report = assail::analyze(dir.path()).unwrap(); + assert_eq!(report.language, panic_attack::types::Language::ReScript); + assert!(report.migration_metrics.is_some()); + assert_eq!(report.coverage.candidate_files, 7); + assert_eq!(report.coverage.analyzed_files, 7); +} + +#[test] +fn remote_script_urls_are_normalized_before_integrity_checks() { + for src in [ + " https://cdn.invalid/app.js", + "\tHTTPS://cdn.invalid/app.js\n", + "\r//cdn.invalid/app.js ", + "ht\ntps://cdn.invalid/app.js", + ] { + let report = scan("index.html", &format!("")); + assert!(category(&report, WeakPointCategory::SupplyChain), "{src:?}"); + } + let report = scan("index.html", ""); + assert!(!category(&report, WeakPointCategory::SupplyChain)); +} + +#[test] +fn container_environment_references_are_not_literal_credentials() { + for value in ["$SERVICE_TOKEN", "${SERVICE_TOKEN}", "\"$SERVICE_TOKEN\""] { + let report = scan( + "Containerfile", + &format!("FROM scratch\nENV API_KEY={value}\n"), + ); + assert_eq!(report.coverage.status, "complete"); + assert!( + !category(&report, WeakPointCategory::HardcodedSecret), + "{value}" + ); + } + for value in [ + "$9not-a-variable", + "$SERVICE_TOKEN-suffix", + "literal-$SERVICE_TOKEN", + "\\$SERVICE_TOKEN", + "'$SERVICE_TOKEN'", + "'${SERVICE_TOKEN}'", + ] { + let report = scan( + "Containerfile", + &format!("FROM scratch\nENV API_KEY={value}\n"), + ); + assert!( + category(&report, WeakPointCategory::HardcodedSecret), + "{value}" + ); + } + // JSON has no built-in environment expansion; a leading dollar sign alone + // must not waive a literal configuration credential. + let report = scan("config.json", r#"{"api_key":"$SERVICE_TOKEN"}"#); + assert!(category(&report, WeakPointCategory::HardcodedSecret)); +} + +#[test] +fn reserved_build_names_take_precedence_over_configuration_suffixes() { + for name in ["Dockerfile.json", "Containerfile.yaml", "Dockerfile.toml"] { + let report = scan(name, "FROM scratch\nRUN eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert!( + category(&report, WeakPointCategory::CommandInjection), + "{name}" + ); + assert!(report.coverage.inputs[0] + .analysis + .as_ref() + .unwrap() + .starts_with("Containerfile")); + } +} + +#[test] +fn numeric_root_user_spellings_are_not_missed() { + for user in ["0", "00", "000:1000", "root:root"] { + let report = scan("Containerfile", &format!("FROM scratch\nUSER {user}\n")); + assert_eq!(report.coverage.status, "complete"); + assert!( + category(&report, WeakPointCategory::ExcessivePermissions), + "{user}" + ); + } + for user in ["1000", "001000", "00service"] { + let report = scan("Containerfile", &format!("FROM scratch\nUSER {user}\n")); + assert!( + !category(&report, WeakPointCategory::ExcessivePermissions), + "{user}" + ); + } +} From 5dfc5c70a74190706312d87343959615ac77484a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:04:46 +0000 Subject: [PATCH 05/14] docs: document assail input adapters, script extraction, and error handling --- src/assail/analyzer.rs | 23 +++++++++++++++++------ src/assail/build_inputs.rs | 27 +++++++++++++++++++++++++++ src/assail/configuration.rs | 16 +++++++++++++++- src/assail/configuration_scripts.rs | 8 ++++++++ src/assail/forth.rs | 7 +++++++ src/assail/input_adapters.rs | 9 +++++++++ src/assail/templates.rs | 13 +++++++++++++ 7 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index efdcaab..fb0af3a 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -337,6 +337,12 @@ impl Analyzer { Self::build(target, true, true) } + /// Construct an analyser for a target, selecting its source language. + /// Registered input adapters take precedence over source language detection. + /// + /// # Errors + /// Returns an error if the target does not exist, or if directory language + /// discovery fails or rejects a symlink root. fn build(target: &Path, verbose: bool, browser_extension: bool) -> Result { if !target.exists() { anyhow::bail!("Target does not exist: {}", target.display()); @@ -377,15 +383,17 @@ impl Analyzer { self.analyze_inner(None) } - /// Analyse discovered source files and return findings, statistics and coverage. + /// Analyse discovered inputs and return findings, statistics and coverage. /// UTF-8 decoding falls back to Windows-1252. Files larger than 16 MiB, read - /// or decoding failures, and PHP parser failures are skipped and remain - /// coverage gaps in a successful report. + /// or decoding failures, and adapter parse or unsupported-input failures are + /// skipped and remain coverage gaps in a successful report. Extracted scripts + /// receive their interpreter and cross-language checks separately. /// If supplied, `accumulator` records candidate parent directories and files - /// that were read and decoded, including PHP files that subsequently fail parsing. + /// that were read and decoded, including inputs that subsequently fail parsing. /// /// # Errors /// Propagates root metadata errors and symlink-root rejection from discovery. + /// Also returns an error for an extracted script with an unimplemented language. fn analyze_inner( &self, mut accumulator: Option<&mut crate::attestation::EvidenceAccumulator>, @@ -920,7 +928,8 @@ impl Analyzer { } /// Choose the most common language among discovered source files, or `Unknown` - /// if none are found. Ties favour the lexicographically greatest debug name. + /// if none are found. Registered input adapters do not vote. Ties favour the + /// lexicographically greatest debug name. /// /// # Errors /// Propagates root metadata errors and symlink-root rejection from discovery; @@ -5192,7 +5201,9 @@ impl Analyzer { /// Operates on the project root (self.target or its parent) rather than on /// individual source files, because the relevant artefacts (Cargo.toml, /// Cargo.lock, Julia Manifest.toml, deno.json) are not source files in the - /// language-detection sense. + /// language-detection sense. Cargo git revisions are checked separately by + /// the per-file configuration adapter. Manifest reads are capped at 4 MiB; + /// failed reads are skipped. This check always returns `Ok`. fn analyze_supply_chain_manifests(&self, weak_points: &mut Vec) -> Result<()> { let project_root = if self.target.is_dir() { self.target.clone() diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs index 9b9ead2..d6c9814 100644 --- a/src/assail/build_inputs.rs +++ b/src/assail/build_inputs.rs @@ -38,6 +38,13 @@ fn interpreter(command: &str) -> Result { } } +/// Extract inline shell, Python or JavaScript source from an interpreter argv. +/// Returns `None` for invocations without inline source; later arguments are not +/// treated as source. +/// +/// # Errors +/// Returns an error for unsupported interpreters or options and for options +/// requiring a missing argument or source. fn inline_source(name: &str, argv: &[String]) -> Result> { let language = interpreter(name)?; let mut index = 1; @@ -132,6 +139,14 @@ fn inline_source(name: &str, argv: &[String]) -> Result> { Ok(None) } +/// Append executable source from a build command, using `shell` for shell form. +/// `line` is the one-based start of the instruction. Malformed JSON and JSON +/// that is not an array fall back to shell form; direct exec arguments are not +/// treated as source unless they invoke a recognised inline interpreter. +/// +/// # Errors +/// Returns an error for empty or non-string exec arrays and propagates inline +/// interpreter option errors. fn command_input(text: &str, line: usize, shell: Language, result: &mut Analysis) -> Result<()> { // BuildKit's parseMaybeJSON falls back to shell form for malformed JSON // (including the POSIX `[` command), but rejects non-string array members. @@ -287,6 +302,12 @@ fn literal_fields( Ok(()) } +/// Check supported Containerfile instructions and extract executable source. +/// Returns findings and scripts for later interpreter checks; no build is run. +/// +/// # Errors +/// Returns errors for malformed instructions or command vectors, and unsupported +/// instructions, interpreters or constructs such as heredocs and `ONBUILD`. pub(super) fn container(content: &str, file: &str) -> Result { let mut instructions = Vec::new(); let mut current = String::new(); @@ -516,6 +537,12 @@ fn recipe_script( Ok(()) } +/// Check recognised Just declarations and extract recipe bodies for later checks. +/// Imports and interpolation are not resolved and recipes are not run. +/// +/// # Errors +/// Returns errors for malformed recipe indentation or shell settings, and for +/// unsupported declarations or interpreters. pub(super) fn just(content: &str, file: &str) -> Result { let mut result = Analysis::default(); let mut default = Language::Shell; diff --git a/src/assail/configuration.rs b/src/assail/configuration.rs index 70d2596..4d19a63 100644 --- a/src/assail/configuration.rs +++ b/src/assail/configuration.rs @@ -40,10 +40,22 @@ impl Format { } } + /// Return configuration findings, discarding any extracted scripts. + /// + /// # Errors + /// Propagates parsing, conversion and script-extraction errors from + /// [`Self::analyze_input`]. pub(super) fn analyze(self, content: &str, file: &str) -> Result> { Ok(self.analyze_input(content, file)?.findings) } + /// Parse configuration, check each document and extract recognised scripts. + /// `file` identifies findings and selects package.json script extraction. + /// Duplicate mapping keys are rejected; YAML may contain multiple documents. + /// + /// # Errors + /// Returns parsing or value-conversion errors, including duplicate keys and + /// non-finite numbers, and propagates script-extraction errors. pub(super) fn analyze_input(self, content: &str, file: &str) -> Result { let documents: Vec = match self { Self::Json => vec![ @@ -231,8 +243,10 @@ fn is_reference(value: &str) -> bool { || (value.starts_with("{{") && value.ends_with("}}")) } +/// Flag named credential fields with values of at least eight bytes, except +/// values enclosed in `${...}` or `{{...}}` after trimming. Key matching ignores +/// case, underscores and hyphens. The value is absent from the diagnostic. /// Reused only for actual parsed/declarative literal fields, not comment text. -/// The value is deliberately absent from the returned diagnostic. pub(super) fn literal_credential( file: &str, field: &str, diff --git a/src/assail/configuration_scripts.rs b/src/assail/configuration_scripts.rs index c8e5642..9168f3b 100644 --- a/src/assail/configuration_scripts.rs +++ b/src/assail/configuration_scripts.rs @@ -119,6 +119,14 @@ fn steps( Ok(()) } +/// Append recognised Actions run steps or package.json scripts for later checks. +/// `field` identifies the document within the input; npm scripts use POSIX-style +/// heuristics without resolving the runtime shell. Actions shell selection uses +/// step, job and workflow defaults before considering the runner. +/// +/// # Errors +/// Returns errors for malformed recognised structures and unsupported or +/// unresolved interpreters. Scripts already appended are retained on error. pub(super) fn extract( format: Format, document: &Value, diff --git a/src/assail/forth.rs b/src/assail/forth.rs index 0da85aa..a7abb8c 100644 --- a/src/assail/forth.rs +++ b/src/assail/forth.rs @@ -126,6 +126,13 @@ fn has_colon_definition(words: &[(usize, String, bool)]) -> bool { complete && !open && !needs_name } +/// Check Forth words for execution and raw-memory hazards and named string +/// credentials. Comments, string contents and definition names are not treated +/// as executable words. This is a lexical check, not grammar or stack validation. +/// +/// # Errors +/// Returns an error for unterminated comments or strings, or for an ambiguous +/// `.fs` input without a recognised colon definition. pub(super) fn analyze(content: &str, file: &str) -> Result { let words = words(content)?; // .fs is shared with F#. Never infer Forth merely from that suffix. diff --git a/src/assail/input_adapters.rs b/src/assail/input_adapters.rs index 59a344d..ea6254b 100644 --- a/src/assail/input_adapters.rs +++ b/src/assail/input_adapters.rs @@ -42,6 +42,9 @@ pub(super) enum Adapter { } impl Adapter { + /// Select an adapter from the file name or extension, without reading the file. + /// Containerfile and Dockerfile names, including dotted variants, take + /// precedence over extensions; extension matching is case-insensitive. pub(super) fn detect(path: &Path) -> Option { let name = path.file_name()?; let bytes = name.as_encoded_bytes(); @@ -78,6 +81,12 @@ impl Adapter { } } + /// Run the selected adapter and return findings and scripts for later analysis. + /// `file` supplies the location used in findings. + /// + /// # Errors + /// Propagates malformed-input and unsupported-dialect errors from the adapter. + /// The scan caller records these as coverage gaps. pub(super) fn analyze(self, content: &str, file: &str) -> Result { match self { Self::Configuration(format) => format.analyze_input(content, file), diff --git a/src/assail/templates.rs b/src/assail/templates.rs index 0aa893b..4f23f0c 100644 --- a/src/assail/templates.rs +++ b/src/assail/templates.rs @@ -47,6 +47,13 @@ fn finding( } } +/// Check parsed HTML attributes and embedded CSS or JSON, and extract JavaScript +/// for later analysis. Remote-script integrity checks validate metadata syntax +/// only; they do not fetch the resource or verify its digest. +/// +/// # Errors +/// Propagates parser and embedded CSS/JSON errors, and rejects duplicate +/// attributes, unsupported embedded types and encoded sensitive attributes. pub(super) fn html(content: &str, file: &str) -> Result { let tree = parsed(content, tree_sitter_html::LANGUAGE.into())?; let mut result = Analysis::default(); @@ -307,6 +314,12 @@ fn element_type(element: Node<'_>, content: &str) -> Result { Ok(String::new()) } +/// Check parsed CSS declarations, functions and imports for selected hazards. +/// Findings use locations relative to `content`; callers embedding CSS adjust them. +/// +/// # Errors +/// Propagates parser errors, including recovered syntax errors, and text decoding +/// errors from tree-sitter nodes. pub(super) fn css(content: &str, file: &str) -> Result { let tree = parsed(content, tree_sitter_css::LANGUAGE.into())?; let mut result = Analysis::default(); From 2e1cdeb0ff6132515fbea3a4184ac186e60e4ebe Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:10:44 +0000 Subject: [PATCH 06/14] fix(ci): delegate native lock integrity to published canonical tooling Retire the handwritten native actions.lock generator and its automatic Justfile path. Verify the official v0.2.0 executable digest, use the unchanged published Standards 087f6bff helper and lock-sync boundary, and retain raw native status/output with immutable source identity. A temporary exact-argv transport withholds reports from reconciliation when the process exits outside 0/1; it does not copy upstream finding logic. Retire it after Standards #1394 is signed, hosted-qualified and adopted. Parse executable YAML refs and match complete reusable paths and exact SHAs. Add only the five existing canonical Standards permissions. Preserve unrelated policy, required job context, triggers and permissions. Correct startup triage so zero jobs are not treated as proof of a live Actions policy failure. Add isolated protocol, malformed-native-lock and exact-coordinate controls. Six final shell sources pass bash -n; seven direct jq schema controls pass. Full YAML/native regression execution is pending hosted CI: the isolated binaries are unavailable locally, and automatic approval review rejected the attempted temporary protocol subset before process creation because its normal rm-style cleanup was not permitted. No runtime/native success is claimed. This is an unreferenced review candidate. actions.lock is unchanged and must come from the existing canonical recovery workflow at the published immutable source. Existing scan coverage and critical fixture findings are untouched. --- .githooks/validate-actions-lock.sh | 307 +++++------------------ .github/actions-allowlist.json | 5 + .github/workflows/ci-integrity.yml | 123 +++++----- Justfile | 23 +- docs/CI-CD.adoc | 227 +++++++++-------- scripts/ci/actions-lock-transport.sh | 37 +++ scripts/ci/check-allowed-actions.sh | 157 +++++++----- scripts/ci/pin-workflow-uses.py | 355 --------------------------- scripts/ci/resolve-action-shas.sh | 3 +- scripts/ci/test-ci-integrity.sh | 184 ++++++++++++++ scripts/ci/triage-startup-failure.sh | 67 ++--- 11 files changed, 607 insertions(+), 881 deletions(-) create mode 100755 scripts/ci/actions-lock-transport.sh delete mode 100755 scripts/ci/pin-workflow-uses.py create mode 100755 scripts/ci/test-ci-integrity.sh diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index d93ec8e..321d9de 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -1,255 +1,66 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/ -# AND in .github/actions/*/action.yml must have a matching entry in -# .github/workflows/actions.lock. -# -# WHY THIS EXISTS -# -# GitHub validates a reusable workflow against the CALLEE repo's own -# actions.lock. A lockfile that has drifted from its workflows therefore -# does not break this repo -- it breaks every repo that calls into it, with -# `failure` (not `startup_failure`), 0 jobs, and no reason in either the -# REST or the GraphQL payload; only the run page says why. actionlint -# passes either way. Dependabot bumps a `uses:` SHA without regenerating -# the lock, and Dependabot never runs pre-commit -- so CI is the only place -# this is caught before it reaches the callers. -# -# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6: -# -# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the -# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the -# workflow at startup -- while running in a mode whose own help text -# calls it read-only and "ideal for pre-commit hooks". -# --no-migrate-local-actions suppresses that, but then the tool stops -# descending into local composite actions and misreports their -# dependencies as stale. Safety and accuracy are in conflict there. -# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's -# two refs to the same action leaves the old lock key still referenced by -# the other: nothing reported stale, nothing reported missing, check -# green, workflow broken. That mutant survives it and dies here. -# -# SCOPE -- stated, not implied: -# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a -# different question, gated by validate-sha-pins.sh. -# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are -# skipped: the lockfile models actions and keys no entry for them. -# * Local refs (./...) are skipped; they carry no SHA. -# * Sub-path actions normalise to their repo -- codeql-action/init@X is -# keyed once as codeql-action@X, never once per sub-path. -# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while -# the lockfile stores swatinem/rust-cache. -# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too. -# Their deps are keyed in the lock under the workflow that uses them, and -# Dependabot bumps them like any other ref; scanning only the workflow -# directory leaves that drift invisible. -# * Membership is GLOBAL, not per-workflow-section. A ref present in some -# other workflow's lock section satisfies this check. A workflow with -# SHA-pinned refs but no lock section of its own is therefore NOT -# detected here -- see the PR notes. - +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Native coverage plus the published canonical reusable/local-action lock-sync +# boundary. No native YAML is generated, patched, or inferred here. set -euo pipefail -REPO_ROOT="${INPUT_PATH:-.}" -LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock" -WORKFLOW_DIR="$REPO_ROOT/.github/workflows" -ACTIONS_DIR="$REPO_ROOT/.github/actions" - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' - -# Refs deliberately absent from the lockfile. Each entry is an exact -# normalised owner/repo@sha plus the reason it is absent. This is an explicit -# list, never a pattern: anything not named here fails, so the list cannot -# quietly widen to cover an accident. An entry that stops being used is -# reported as stale, so it cannot rot either. -EXPECTED_ABSENT=( - # Empty: the denoland/setup-deno exception was removed 2026-09-22 after - # the last consumer went away (the validator itself reported it stale). - # Estate doctrine is still bun-only; if a deno ref ever returns, the - # cure is to remove the consumer, not to re-add an exception. -) - -if [ ! -f "$LOCKFILE" ]; then - echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2 - exit 1 -fi - -# An unreadable lockfile must abort, never silently pass. -if [ ! -r "$LOCKFILE" ]; then - echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2 - exit 1 -fi - -# Parse the lockfile's keys into an explicit set ONCE, rather than substring -# -matching against the whole file. A substring test over a blob fails open in -# ways that are hard to see: if the blob is empty or truncated, every ref is -# reported "missing" and the operator is told to regenerate a lockfile that -# was actually fine. An explicit set can be counted, and is counted below. -mapfile -t LOCK_KEYS < <( - grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \ - | tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u -) - -# Positive control: the lockfile is known to key at least one action. If the -# parse yields nothing, the FILE FORMAT changed or the read failed -- say so, -# rather than reporting every ref in the repo as missing. -if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then - echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2 - echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2 - echo " This is a parser/format failure, NOT a coverage failure." >&2 - exit 1 -fi - -lock_has() { - local needle="$1" k - for k in "${LOCK_KEYS[@]}"; do - [ "$k" = "$needle" ] && return 0 - done - return 1 -} - -# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex -# SHA pin fail GitHub runner lockfile verification during `Set up job` -# ("Unable to resolve action ...: invalid lockfile pin", standards#1271). -mapfile -t SELF_SHA_KEYS < <( - awk ' - /^dependencies:/ { in_dep = 1; next } - /^[a-z_]+:/ { in_dep = 0; cur = ""; next } - !in_dep { next } - /^ \x27[^\x27]+\x27:$/ { - cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) - next - } - cur != "" && /^ ref: \x27[^\x27]+\x27$/ { - r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) - n = index(cur, "@") - pin = (n > 0) ? substr(cur, n + 1) : "" - if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { - print tolower(cur) - } - cur = "" - } - ' "$LOCKFILE" | sort -u -) - -# Check a normalized owner/repo@sha key against the parsed self-SHA entries. -# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent. -lock_has_self_sha() { - local needle="$1" k - for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do - [ "$k" = "$needle" ] && return 0 - done - return 1 -} - -ERRORS=0 -CHECKED=0 -declare -a SEEN_ABSENT=() - -# Collect every SHA-pinned uses: ref across all workflow files. -# -# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only -# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it -# captured the quote and comma into the ref and reported a pinned, locked -# action as missing. The parser returns the scalar VALUE whatever the style, -# and never matches a `uses:` that is text inside a `run:` body. -# Measured 2026-10-01: same 29-ref set as the old grep on the block tree. -if ! command -v yq >/dev/null 2>&1; then - echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2 - exit 1 -fi -declare -a UNPARSED=() -mapfile -t RAW < <( - for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \ - "$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do - [ -f "$f" ] || continue - yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \ - || printf '\001UNPARSED\001%s\n' "$f" - done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u -) -# A file yq cannot parse is a file whose refs went unchecked: fail closed. -for ref in "${RAW[@]}"; do - case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STANDARDS_REF=087f6bff5135ce3da6f287c83752256ce4a8efc1 +NATIVE_SHA256=79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6 +: "${STANDARDS_ROOT:?checkout the pinned metadatastician/standards source; see docs/CI-CD.adoc}" +: "${GH_ACTIONS_LOCK_BIN:?path to the official gh-actions-lock v0.2.0 linux-amd64 binary}" +for tool in git jq yq sha256sum diff; do + command -v "$tool" >/dev/null || { echo "validate-actions-lock: missing $tool" >&2; exit 2; } done -if [ "${#UNPARSED[@]}" -gt 0 ]; then - echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2 - exit 1 -fi - -# A zero-input pass is the classic fake green: if ref extraction ever breaks, -# this script would report success having checked nothing. If the lockfile -# already names workflows, finding no refs is a contradiction, not an empty -# repo -- fail instead of passing vacuously. The success line below also -# prints the number checked, so a silent collapse to near-zero is visible. -if [ "${#RAW[@]}" -eq 0 ]; then - if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then - echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2 - exit 1 +[[ "$GH_ACTIONS_LOCK_BIN" = /* && -f "$GH_ACTIONS_LOCK_BIN" && -x "$GH_ACTIONS_LOCK_BIN" ]] || + { echo 'validate-actions-lock: official native binary is unavailable' >&2; exit 2; } +printf '%s %s\n' "$NATIVE_SHA256" "$GH_ACTIONS_LOCK_BIN" | sha256sum --check --status || + { echo 'validate-actions-lock: native binary digest mismatch' >&2; exit 2; } +STANDARDS_ROOT="$(cd "$STANDARDS_ROOT" && pwd -P)" +[[ "$(git -C "$STANDARDS_ROOT" rev-parse --show-toplevel)" = "$STANDARDS_ROOT" ]] || + { echo 'validate-actions-lock: Standards path must be its repository root' >&2; exit 2; } +[[ "$(git -C "$STANDARDS_ROOT" rev-parse HEAD)" = "$STANDARDS_REF" ]] || + { echo 'validate-actions-lock: wrong Standards source revision' >&2; exit 2; } +[[ -z "$(git -C "$STANDARDS_ROOT" status --porcelain --untracked-files=all)" ]] || + { echo 'validate-actions-lock: Standards checkout is not clean' >&2; exit 2; } + +cd "$ROOT" +[[ -z "$(git status --porcelain --untracked-files=all)" ]] || + { echo 'validate-actions-lock: commit the exact input tree before qualification' >&2; exit 2; } +[[ -f .github/workflows/actions.lock ]] || + { echo 'validate-actions-lock: missing native lock; use canonical recovery' >&2; exit 1; } +snapshot="$(mktemp -d)" +cp -a .github/workflows "$snapshot/workflows" +cleanup() { + local status=$? + if ! diff -qr "$snapshot/workflows" .github/workflows; then + echo 'validate-actions-lock: verification changed workflow/lock bytes; refusing success' >&2 + status=1 fi - echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}" - exit 0 -fi - -for ref in "${RAW[@]}"; do - case "$ref" in - ./*|.\\*) continue ;; # local action, carries no SHA - */.github/workflows/*) continue ;; # reusable workflow, not keyed - esac - case "$ref" in - *.yml@*|*.yaml@*) continue ;; # reusable workflow, any path - esac - - sha="${ref##*@}" - path="${ref%@*}" - owner="${path%%/*}" - rest="${path#*/}" - repo="${rest%%/*}" - [ -n "$owner" ] && [ -n "$repo" ] || continue - norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')" - - skip=0 - for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do - ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" - if [ "$norm" = "$ex_lc" ]; then - SEEN_ABSENT+=("$ex_lc") - skip=1 - break - fi - done - [ "$skip" -eq 1 ] && continue - - CHECKED=$((CHECKED + 1)) - if ! lock_has "$norm"; then - echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 - echo " normalised to: $norm" >&2 - ERRORS=$((ERRORS + 1)) - elif lock_has_self_sha "$norm"; then - echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2 - echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2 - ERRORS=$((ERRORS + 1)) + rm -rf "$snapshot" + if [[ "$status" -eq 0 ]]; then + echo 'validate-actions-lock: canonical native verification and lock sync passed; live GitHub admission is not established' fi -done - -# A doctrine exception that is no longer used must be removed, or the list -# becomes a place where real coverage gaps can hide. -for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do - ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" - found=0 - for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do - [ "$s" = "$ex_lc" ] && found=1 && break - done - if [ "$found" -eq 0 ]; then - echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2 - fi -done - -if [ "$ERRORS" -gt 0 ]; then - echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2 - echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2 - echo " gh actions-lock --no-migrate-local-actions --no-narrow" >&2 - echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2 - exit 1 -fi - -echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}" -exit 0 + exit "$status" +} +trap cleanup EXIT +export GH_ACTIONS_LOCK_BIN +export ACTIONS_LOCK_EVIDENCE_DIR="${ACTIONS_LOCK_EVIDENCE_DIR:-$snapshot/evidence}" +mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR" +printf 'not_run\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" +jq -n \ + --arg subject "$(git rev-parse HEAD)" \ + --arg tree "$(git rev-parse HEAD^{tree})" \ + --arg authority "$STANDARDS_REF" \ + --arg native_sha256 "$NATIVE_SHA256" \ + --arg run_id "${GITHUB_RUN_ID:-}" \ + --arg run_attempt "${GITHUB_RUN_ATTEMPT:-}" \ + '{subject_commit:$subject,subject_tree:$tree,standards_commit:$authority, + native_version:"v0.2.0",native_sha256:$native_sha256, + run_id:$run_id,run_attempt:$run_attempt}' \ + > "$ACTIONS_LOCK_EVIDENCE_DIR/source.json" + +GH_BIN="$ROOT/scripts/ci/actions-lock-transport.sh" \ + bash "$STANDARDS_ROOT/scripts/update-actions-lock.sh" --verify-local +bash "$STANDARDS_ROOT/scripts/check-lock-sync.sh" .github/workflows diff --git a/.github/actions-allowlist.json b/.github/actions-allowlist.json index e2f08d0..4e8035e 100644 --- a/.github/actions-allowlist.json +++ b/.github/actions-allowlist.json @@ -65,6 +65,11 @@ "leanprover/lean-action@*", "ludeeus/action-shellcheck@*", "lycheeverse/lychee-action@*", + "metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12", + "metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12", + "metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7", + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12", + "metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7", "mlugg/setup-zig@*", "ocaml/setup-ocaml@*", "orhun/git-cliff-action@*", diff --git a/.github/workflows/ci-integrity.yml b/.github/workflows/ci-integrity.yml index f2228c4..bfb4715 100644 --- a/.github/workflows/ci-integrity.yml +++ b/.github/workflows/ci-integrity.yml @@ -1,35 +1,9 @@ # SPDX-License-Identifier: MPL-2.0 # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # -# ci-integrity.yml — gates on the CI *system* itself, not on the code. -# -# WHY THIS WORKFLOW EXISTS -# ------------------------ -# On 2026-10-25 this repository's CI went dark for four hours and the red was -# unreadable: 12 of 12 pull_request workflows concluded `startup_failure` with -# zero jobs and no log. The cause was not code. It was two policy mismatches -# between this repo and the estate's own settings canon: -# -# 1. `uses: metadatastician/standards/...` — the canonical allowlist -# (rhodium-standard-repositories/actions-allowlist/allowed-actions.json) -# permits `hyperpolymath/*` and does NOT permit `metadatastician/*`. -# 2. tag-pinned `uses:` (actions/checkout@v7.0.1) while the estate posture is -# `sha_pinning_required = true`. -# -# A run that never starts cannot be diagnosed from its log, cannot be -# satisfyingly re-run, and does not report a check at all — so a required -# context silently stops arriving and the branch looks healthier than it is. -# These four gates make each cause of that failure legible *before* it happens: -# -# sha-pins every third-party `uses:` names a 40-hex commit -# lock-coverage every SHA-pinned action is keyed in .github/workflows/actions.lock -# allowlist every `uses:` is covered by the estate allowlist -# yaml-parse every workflow actually parses, and has a jobs: map -# -# NO `paths:` FILTER, deliberately: this check is meant to be REQUIRED, and a -# required check that filters itself off never reports (see standards -# required-contexts.yml). It is a checkout plus a few seconds of bash. - +# Source integrity checks cannot establish GitHub's effective live Actions +# policy or explain a zero-job startup failure without its actual diagnostic. +# Native lock generation belongs to the published canonical recovery chain. name: CI Integrity on: @@ -37,7 +11,7 @@ on: push: branches: [main, master] schedule: - - cron: '17 5 * * 1' # Mondays, off the hour + - cron: '17 5 * * 1' workflow_dispatch: permissions: @@ -48,8 +22,10 @@ concurrency: cancel-in-progress: true env: - # Pinned: this is the parser the canon validators require (YAML-POLICY Y-1). YQ_VERSION: v4.54.1 + STANDARDS_REF: 087f6bff5135ce3da6f287c83752256ce4a8efc1 + STANDARDS_ROOT: ${{ runner.temp }}/panic-ci-integrity-standards + ACTIONS_LOCK_EVIDENCE_DIR: ${{ runner.temp }}/panic-ci-integrity-evidence jobs: integrity: @@ -62,36 +38,53 @@ jobs: with: persist-credentials: false - - name: Install yq + - name: Install checksum-qualified YAML and native lock tools run: | set -euo pipefail - url="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" - curl -fsSL "$url" -o /usr/local/bin/yq - chmod +x /usr/local/bin/yq - yq --version + mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR" + printf 'not_run\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" + tool_dir="$RUNNER_TEMP/panic-ci-integrity-bin" + mkdir -p "$tool_dir" + curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --max-time 60 \ + "https://github.com/mikefarah/yq/releases/download/$YQ_VERSION/yq_linux_amd64" -o "$tool_dir/yq" + printf '%s %s\n' '8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f' "$tool_dir/yq" | sha256sum --check + curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --max-time 60 \ + https://github.com/github/gh-actions-lock/releases/download/v0.2.0/linux-amd64 -o "$tool_dir/gh-actions-lock" + printf '%s %s\n' '79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6' "$tool_dir/gh-actions-lock" | sha256sum --check + chmod u+x "$tool_dir/yq" "$tool_dir/gh-actions-lock" + "$tool_dir/yq" --version + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + printf 'GH_ACTIONS_LOCK_BIN=%s\n' "$tool_dir/gh-actions-lock" >> "$GITHUB_ENV" + + - name: Fetch unchanged published canonical helpers + run: | + set -euo pipefail + git init --quiet "$STANDARDS_ROOT" + timeout 120 git -C "$STANDARDS_ROOT" fetch --quiet --no-tags --depth=1 \ + https://github.com/metadatastician/standards.git "$STANDARDS_REF" + git -C "$STANDARDS_ROOT" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$STANDARDS_ROOT" rev-parse HEAD)" = "$STANDARDS_REF" + + - name: CI integrity regression controls + run: bash scripts/ci/test-ci-integrity.sh - name: SHA pins (canon rule 10) run: bash .githooks/validate-sha-pins.sh - - name: Lockfile is derived from the workflows - run: python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check + - name: Canonical executable workflow coordinates + run: bash "$STANDARDS_ROOT/scripts/check-workflow-coordinates.sh" .github/workflows - - name: Lockfile covers every SHA-pinned action + - name: Native lock coverage and canonical lock sync run: bash .githooks/validate-actions-lock.sh - - name: Actions allowlist (would this run start?) + - name: Exact tree allowlist (live Settings unverified) run: bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows - # The cause no file scan can see: a `uses:` that is well-formed, correctly - # pinned, and still refused at startup (`referenced_workflows: []`, zero - # jobs, no log) because the repository's allowed-actions policy does not - # cover it — or because the referenced repository was renamed and nothing - # covers the new owner. Both are named here, with the evidence. - - name: Startup-failure triage + - name: Startup-failure source triage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ github.event_name }}" = workflow_dispatch ]; then bash scripts/ci/triage-startup-failure.sh else bash scripts/ci/triage-startup-failure.sh --report @@ -103,32 +96,42 @@ jobs: fail=0 for f in .github/workflows/*.yml .github/workflows/*.yaml; do [ -e "$f" ] || continue - case "$f" in *.lock) continue ;; esac if ! yq -e '.' "$f" >/dev/null 2>&1; then echo "::error file=$f::does not parse as YAML" fail=1 continue fi - # A file that parses to a scalar (unclosed quote swallowing the - # document) has no jobs at all and dies at startup. Refuse it. - if [ "$(yq -r '.jobs | tag' "$f" 2>/dev/null)" != '!!map' ]; then + if [ "$(yq -r '.jobs | tag' "$f")" != '!!map' ]; then echo "::error file=$f::parses but declares no jobs: map" fail=1 fi done exit "$fail" + - name: Retain raw native result and immutable source identity + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ci-integrity-native-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/panic-ci-integrity-evidence + if-no-files-found: error + retention-days: 14 + - name: Summary if: always() run: | { - echo "## CI integrity" - echo "" - echo "| Gate | What it prevents |" - echo "|------|------------------|" - echo "| SHA pins | tag-pinned \`uses:\` refused at startup under \`sha_pinning_required\` |" - echo "| Lockfile | a reusable caller dying with \`failure\`, 0 jobs, no reason |" - echo "| Allowlist | \`startup_failure\` from an action outside the estate allowlist |" - echo "| YAML parse | a workflow that parses to nothing and never runs |" - echo "| Startup triage | \`startup_failure\` with zero jobs: unpinned, renamed, missing pin, or refused by the repo's actions policy |" + echo '## CI integrity' + echo '' + echo 'Step conclusions record the result; this table describes each gate.' + echo '' + echo '| Gate | Scope |' + echo '|------|-------|' + echo '| SHA pins and coordinates | Immutable refs and canonical executable repository names |' + echo '| Native lock | Official v0.2.0 result, unchanged published reconciliation and bidirectional lock sync |' + echo '| Tree allowlist | Complete action/workflow path and exact SHA, without a live Settings claim |' + echo '| YAML parse | Readable workflow documents with a jobs map |' + echo '| Startup triage | Source/API observations; no inferred platform root cause from zero jobs alone |' + echo '' + echo 'The raw artifact records not_run if consumer verification never executed, or running if no native exit was recorded.' } >> "$GITHUB_STEP_SUMMARY" diff --git a/Justfile b/Justfile index f3a5ef4..365c6d9 100644 --- a/Justfile +++ b/Justfile @@ -273,26 +273,27 @@ fmt-check: test-local: cargo test --locked -# CI integrity: SHA pins, lockfile coverage, allowlist coverage, YAML parse. -# Needs `yq` (mikefarah) and `python3`; see scripts/ci/. +# CI integrity: requires the pinned clean Standards checkout and official binary. +# See docs/CI-CD.adoc for STANDARDS_ROOT and GH_ACTIONS_LOCK_BIN. integrity: #!/usr/bin/env bash set -euo pipefail command -v yq >/dev/null || { echo "integrity: yq not found (mikefarah/yq)"; exit 2; } bash .githooks/validate-sha-pins.sh - python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check bash .githooks/validate-actions-lock.sh + bash "$STANDARDS_ROOT/scripts/check-workflow-coordinates.sh" .github/workflows bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows - for f in .github/workflows/*.yml; do - case "$f" in *.lock) continue ;; esac - [ "$(yq -r '.jobs | tag' "$f")" = '!!map' ] || { echo "integrity: $f has no jobs map"; exit 1; } - done - echo "integrity: OK" + echo "integrity: source gates passed; live GitHub admission remains unverified" -# After changing any `uses:`: re-resolve tag -> commit and rewrite the lockfile. +# Native generation is an explicitly requested canonical recovery operation. +# Printing the subject is not generation or authorization to dispatch. pins: - bash scripts/ci/resolve-action-shas.sh - python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json + #!/usr/bin/env bash + set -euo pipefail + echo "Native lock generation moved to hyperpolymath/proven-cicd Actions Lock Recovery." + printf 'Immutable source candidate: %s\n' "$(git rev-parse HEAD)" + echo "Follow docs/CI-CD.adoc; no workflow or lock bytes were changed." >&2 + exit 2 # ── Evidence-graded testing (proven-tests-and-benches) ─────────────────────── diff --git a/docs/CI-CD.adoc b/docs/CI-CD.adoc index 03076c9..fbf9595 100644 --- a/docs/CI-CD.adoc +++ b/docs/CI-CD.adoc @@ -5,60 +5,31 @@ :toclevels: 3 :sectnums: -== The failure this replaces - -On 2026-10-25 this repository's CI went dark for four hours. Twelve -`pull_request` workflows concluded `startup_failure`: **zero jobs, no log, no -annotation, no check run**. Nothing in the GitHub UI said why, and the only -diagnosis available was to read the estate's settings canon and compare it with -our own workflow files. - -Four causes, all policy-level, none visible from the code: - -tag-pinned `uses:`, e.g. `actions/checkout@v7.0.1`:: The estate posture is -`sha_pinning_required = true`. Under it a tag-pinned `uses:` is refused **at -startup** — the same opaque death. The control is worth having, so the fix is to -pin properly: every `uses:` in this repository now names a 40-hex commit with a -`# ` comment, and `ci-integrity.yml` refuses a regression. - -this repository's allowed-actions policy:: The remaining six — `rust-ci`, -`governance`, `hypatia-scan`, `secret-scanner`, `scorecard`, `mirror` — call the -estate's reusable workflows, and every run of them concludes `startup_failure` -with zero jobs. The estate allowlist already covers `hyperpolymath/*`, and -sibling repositories call the *same* reusable at the *same* pin successfully, so -the refusal is scoped to this repository: `allowed_actions: selected` with -`verified_allowed` passes GitHub-owned and marketplace-verified actions and -refuses a non-verified cross-repository `uses:`. That is the exact pattern here — -checkout, cache and `dtolnay/rust-toolchain` work; every estate call dies. -`secret-scanner.yml` is the serious one: `main` requires the context -**`scan / gitleaks`**, which can therefore never arrive, and no pull request can -merge until it does. This needs repository admin (`allowed_actions: all`, the -canon default, or `hyperpolymath/*` in `patterns_allowed`); the policy endpoints -are 403 for an ordinary token. See `docs/ci/PROMPT-actions-policy.md`. - -a `uses:` pinned to a commit that no longer exists upstream:: The caller dies -before its first job, exactly like the other two. Cheap to check, so it is. - -The signature that distinguishes all three from an ordinary build failure is in -the runs API: `jobs: []` and `referenced_workflows: []` on a run whose workflow -has a job at all. `scripts/ci/triage-startup-failure.sh` (run by -`ci-integrity.yml`, strict on `workflow_dispatch`) resolves every referenced -repository's canonical name and every reusable pin, so the next occurrence is one -command away from a sentence: - -[source,sh] ----- -scripts/ci/triage-startup-failure.sh -# hyperpolymath/standards is now metadatastician/standards — allowlist patterns -# match the canonical name, so a `uses:` written with the old owner is refused at -# startup (signature: startup_failure with referenced_workflows: []). ----- - -The lesson generalises and is the reason this document exists: *a run that never -starts is invisible to every gate that watches runs*. It produces no check run, -so a required context silently stops arriving and the branch looks **healthier** -than a broken one. Two of the three causes are now gated before they can bite -(see <>); the third is named, tracked, and evidenced. +== What the observed startup failures establish + +At source `5dfc5c70a74190706312d87343959615ac77484a`, the fourteen +pull-request runs created on 2026-11-04 conclude `startup_failure` and have +empty job lists. For example, +https://github.com/hyperpolymath/panic-attack/actions/runs/41912719217[CI Integrity] +has no job log to inspect. These observations do not identify the platform's +rejection reason. Effective GitHub Actions Settings and the actual startup +diagnostic have not been verified. + +There are independently observable source defects. The five Standards callers +now use canonical `metadatastician/standards` coordinates, but their committed +lock entries still name `hyperpolymath/standards@333b06a...`. Three callers +also changed their source pin to `a797d308...`. The tree allowlist omits the +canonical calls, and its former checker discarded both subpaths and refs. +The removed Python generator manufactured native dependency records by +combining old identity fields with newly inferred pins. Those facts require +repair; they do not by themselves prove the cause of all fourteen startup +failures. + +The source gates below distinguish immutable references, native coverage, +canonical coordinate identity and offline policy matching. A successful source +check does not certify live admission, billing, or a particular GitHub startup +diagnostic. Missing required contexts remain missing until real runs publish +them; source repairs are not substitutes for those runs. == The shape of the pipeline @@ -72,8 +43,8 @@ than a broken one. Two of the three causes are now gated before they can bite | every PR, every push | `ci-integrity.yml` -| Can the CI itself start? SHA pins, lockfile coverage, allowlist coverage, - YAML parseability. +| Are immutable source refs, native coverage, exact tree policy and YAML + consistent? Live GitHub admission remains a separate observation. | every PR, every push, Mondays | `proven-tests.yml` @@ -114,52 +85,110 @@ another repository can remove this repository's entire build signal. `verify` depends on nothing but actions this repository pins itself, so the "does it compile and pass" answer survives any policy change elsewhere. -<> === CI integrity - -`ci-integrity.yml` runs four gates, each of which corresponds to a way a -workflow dies before its first job: - -SHA pins:: `.githooks/validate-sha-pins.sh` (canon). Every third-party `uses:` -names a 40-hex commit plus a `# ` comment. -Lockfile coverage:: `.githooks/validate-actions-lock.sh` (canon), plus -`scripts/ci/pin-workflow-uses.py --check`, which derives the lockfile from the -workflows. A caller pinned to a commit whose lockfile has drifted dies with -`failure`, 0 jobs and *no reason in the API* - which is why this is a gate and -not a courtesy. -Allowlist coverage:: `scripts/ci/check-allowed-actions.sh` (canon) against the -vendored `.github/actions-allowlist.json`. No API, no admin: it lists exactly -which `uses:` the estate policy does not cover. -YAML parseability:: every workflow parses *and* declares a `jobs:` map. A file -whose quoting swallowed the document parses to a scalar and never runs. -Startup-failure triage:: `scripts/ci/triage-startup-failure.sh` checks every -`uses:`: 40-hex pin, canonical repository name after any rename, and that each -reusable pin still exists. It also states the one cause no file can show — a -repository-scoped allowed-actions policy refusing a cross-repository reference — -and can print sibling repositories' run conclusions as the discriminator. -Report-only on a pull request (the finding is an owner action, not a code -change), strict on `workflow_dispatch`. - -Maintenance: after changing any `uses:`, run - -[source,sh] +[[integrity]] +=== CI integrity + +The required job context remains `CI integrity (pins, lock, allowlist, YAML)`, +with the same triggers and read-only repository permission. + +SHA pins:: `.githooks/validate-sha-pins.sh` checks external action and +reusable refs. The offline allowlist also requires a complete 40-hex SHA before +matching an external coordinate. + +Canonical names:: The unchanged published Standards +`scripts/check-workflow-coordinates.sh` rejects executable former-owner +`hyperpolymath/standards` coordinates, including runtime checkouts. +Historical comments are not executable references. + +Native lock coverage:: `.githooks/validate-actions-lock.sh` invokes the +official `gh-actions-lock v0.2.0` binary through the unchanged published +`scripts/update-actions-lock.sh --verify-local`, then +`scripts/check-lock-sync.sh`. The latter checks reusable and local composite +references in both directions. No consumer code emits native lock records. +The gate checks the native executable digest, the exact clean Standards +checkout, a clean consumer commit, and unchanged workflow/lock bytes. + +Tree allowlist:: `scripts/ci/check-allowed-actions.sh` parses YAML, resolves +aliases, and reads job/step `uses` values. Owner/repository spelling is +case-insensitive; the complete subpath and ref must match. The five canonical +Standards permissions name only the existing reviewed paths and SHAs. +Existing unrelated patterns are retained. The GitHub-owned flag is respected; +Marketplace verification and effective service Settings are not inferred. + +YAML and triage:: Unparseable workflows, invalid ref structures and an empty +input population fail. Printed/commented examples are not executable refs. +The source/API triage reports observations rather than claiming that zero jobs +prove an allowlist failure. + +The helper source is +https://github.com/metadatastician/standards/tree/087f6bff5135ce3da6f287c83752256ce4a8efc1[Standards `087f6bff...`]. +CI fetches that immutable public commit outside the consumer checkout. +The official Linux amd64 executable is release asset 634768078, with SHA-256 +`79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6`. +The YAML reader is mikefarah yq v4.54.1, release asset 597222595, SHA-256 +`8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f`. + +For local verification, supply absolute paths to the same clean helper +checkout and checksum-matching binary, with yq, jq, Bash and Git available: + +[source,shell] ---- -bash scripts/ci/resolve-action-shas.sh # network: resolves tag -> commit -python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json +export STANDARDS_ROOT=/absolute/path/to/standards +export GH_ACTIONS_LOCK_BIN=/absolute/path/to/gh-actions-lock +just integrity ---- -`action-pins.json` is committed on purpose: it records which tag a pin stood for -on the day it was taken, so a reviewer can tell an intentional bump from a -silent re-point. - -One hard-won rule about `actions.lock`: it is a **superset** of what `grep -uses:` finds. `gh actions-lock` also records actions that composite actions -resolve internally — `actions/upload-pages-artifact` runs -`actions/upload-artifact@bbbca2dd…` itself — and GitHub validates the lockfile -at *job setup*. A regenerator that rebuilds the file from the workflows deletes -those entries, and the only symptom is `lockfile missing pin for …` in a job -that never runs a step. `scripts/ci/pin-workflow-uses.py` therefore MERGES its -`dependencies:` section instead of rebuilding it, and `--check` (which CI runs) -is idempotent over the merged form. +==== Temporary process-status adapter + +The published helper can promote a valid-looking report even when the native +process exits 2 or higher. The separate upstream repair is tracked in +https://github.com/metadatastician/standards/pull/1394[Standards #1394]. +Its unsigned, unqualified candidate is not copied into this consumer. + +`scripts/ci/actions-lock-transport.sh` accepts only +`actions-lock --verify-local` and the same command with +`--json=valid,findings`. It invokes the official binary without ambient +authentication or alternate workflow-directory settings. Native exit 0/1 and +their unmodified stdout reach the published helper. For every other exit, +stdout is retained as raw failure evidence but is not offered to that helper +as a reconcilable report. The native status remains nonzero. This adapter +neither parses findings nor changes the published reconciliation rules. + +The artifact retains actual stdout, stderr, exit status, consumer commit/tree, +helper revision and executable digest. `not_run` means consumer verification +did not execute; `running` means no native exit was recorded after invocation. A native exit 1 can pass only through the existing canonical +source reconciliation and its actual findings. No report is replaced with an +empty success. This status guard does not claim to repair every upstream +report-shape defect. Remove it after #1394's signed, hosted-qualified fix is +adopted at a reviewed canonical pin. + +`scripts/ci/test-ci-integrity.sh` includes exact path/SHA negatives, +quoted/flow YAML controls, deliberately injected valid-JSON/exit-2 and +missing-tool/timeout statuses, malformed reports and unsupported argv. +Those mocked protocol cases are distinguished from the real official +malformed-lock control and from actual consumer verification. Their presence +in source is not an execution receipt; hosted results must record execution. + +==== Updating the native lock + +Commit the reviewed source change first. Use the existing +https://github.com/hyperpolymath/proven-cicd/actions/workflows/actions-lock-recovery.yml[canonical Actions Lock Recovery workflow] +for `hyperpolymath/panic-attack` at that exact 40-character commit. +Dispatch is a separate authorized operation; `just pins` prints the +candidate SHA and exits nonzero without generating or dispatching anything. + +The recovery workflow runs the official binary and the published canonical +`regen_lock` chain in its isolated subject checkout. It restores workflow +bytes and produces a candidate artifact. Before applying that artifact, +verify its exact source identity, actual execution result, canonical lock-sync +result, preserved workflow bytes and native dependency identities. Commit only +the reviewed generated lock after those checks. Reconcile a changed subject +before generating again. + +The legacy `action-pins.json` and resolver record historical tag references; +they are not native identity evidence and no longer drive generation. +Do not reconstruct native owner/repository IDs, transitive edges or ref fields, +and do not remove workflow coverage to obtain a passing report. == Testing: evidence-graded, not declared diff --git a/scripts/ci/actions-lock-transport.sh b/scripts/ci/actions-lock-transport.sh new file mode 100755 index 0000000..dcae1ed --- /dev/null +++ b/scripts/ci/actions-lock-transport.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Temporary process-status boundary for the unchanged published Standards +# verifier. Remove after standards#1394 is signed, hosted-qualified and adopted. +# This adapter does not interpret findings or implement lock reconciliation. +set -euo pipefail + +if ! { [[ $# -eq 2 && $1 = actions-lock && $2 = --verify-local ]] || + [[ $# -eq 3 && $1 = actions-lock && $2 = --verify-local && + $3 = --json=valid,findings ]]; }; then + echo 'actions-lock transport: only the two read-only verifier argv forms are permitted' >&2 + exit 2 +fi +shift +: "${GH_ACTIONS_LOCK_BIN:?official binary path is required}" +: "${ACTIONS_LOCK_EVIDENCE_DIR:?raw evidence directory is required}" +mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR" +printf 'running\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" +set +e +env -u GH_ACTIONS_LOCK_WORKFLOWS_DIR -u GH_HOST -u GH_REPO \ + -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN -u GITHUB_ENTERPRISE_TOKEN \ + "$GH_ACTIONS_LOCK_BIN" "$@" \ + > "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" \ + 2> "$ACTIONS_LOCK_EVIDENCE_DIR/native.stderr" +status=$? +set -e +printf '%s\n' "$status" > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" +cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.stderr" >&2 +case "$status" in + 0|1) cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" ;; + *) + cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" >&2 + echo "actions-lock transport: tool failed (exit $status); report not offered for reconciliation" >&2 + ;; +esac +exit "$status" diff --git a/scripts/ci/check-allowed-actions.sh b/scripts/ci/check-allowed-actions.sh index ed09274..34b6d6d 100755 --- a/scripts/ci/check-allowed-actions.sh +++ b/scripts/ci/check-allowed-actions.sh @@ -1,68 +1,103 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# check-allowed-actions.sh — make the un-squabble-able startup_failure legible. -# -# When a repo sets `allowed_actions: selected` (RSR default) and a workflow -# `uses:` an action or reusable-workflow the allowlist does not permit, the run -# dies at STARTUP with no job and no log — an opaque red you cannot "squabble" -# with (RSR-PHILOSOPHY: reach green by satisfying the gate, never admin-override). -# -# This check turns that into a concrete, satisfiable gate: it lists exactly which -# `uses:` are not covered by the canonical allowlist, so the fix is obvious -# (add the pattern, or run set-allowed-actions.sh). Needs NO API and NO admin — -# safe to run in CI, in rhodibot, or locally. -# -# Usage: check-allowed-actions.sh [ALLOWED_ACTIONS_JSON] [WORKFLOWS_DIR] -# Exit: 0 = every `uses:` is covered; 1 = one or more gaps (printed). +# Offline tree policy: match the whole action/reusable coordinate and ref. +# This cannot read effective GitHub Settings or verify Marketplace publishers. set -euo pipefail -CANON="${1:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" +POLICY="${1:-.github/actions-allowlist.json}" WF_DIR="${2:-.github/workflows}" - -if [ ! -f "$CANON" ]; then - # fall back to the published canonical list in standards - CANON_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/rhodium-standard-repositories/actions-allowlist/allowed-actions.json" - TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT - curl -fsSL "$CANON_URL" -o "$TMP" && CANON="$TMP" || { echo "!! no allowlist ($CANON)"; exit 2; } +YQ_BIN="${YQ_BIN:-yq}" +[[ -f "$POLICY" && -d "$WF_DIR" ]] || + { echo 'check-allowed-actions: policy file and workflow directory are required' >&2; exit 2; } +command -v "$YQ_BIN" >/dev/null && command -v jq >/dev/null || + { echo 'check-allowed-actions: mikefarah yq v4 and jq are required' >&2; exit 2; } +if ! jq -e -s 'length == 1 and (.[0] | type == "object" and + (.github_owned_allowed | type == "boolean") and + (.patterns_allowed | type == "array" and all(.[]; type == "string" and length > 0 and + (test("\\s") | not) and (contains("\u0000") | not)))) +' "$POLICY" >/dev/null; then + echo 'check-allowed-actions: malformed tree policy' >&2 + exit 2 fi +owned="$(jq -r '.github_owned_allowed' "$POLICY")" +mapfile -t patterns < <(jq -r '.patterns_allowed[]' "$POLICY") +probe="$(printf '%s\n' '{jobs: {probe: {steps: [{uses: "Fixture/Action@0123456789012345678901234567890123456789"}]}}}' | + "$YQ_BIN" -o=json 'explode(.)' - | jq -r '.jobs.probe.steps[0].uses')" || + { echo 'check-allowed-actions: YAML parser probe failed' >&2; exit 2; } +[[ "$probe" = 'Fixture/Action@0123456789012345678901234567890123456789' ]] || + { echo 'check-allowed-actions: YAML parser did not read the known ref' >&2; exit 2; } -python3 - "$CANON" "$WF_DIR" <<'PY' -import json, sys, os, re, glob, fnmatch -canon = json.load(open(sys.argv[1])) -patterns = canon.get("patterns_allowed", []) -gh_owned = {"actions", "github"} # github_owned_allowed - -def owner_repo(u): - base = u.split('@', 1)[0] - # GitHub owner/repository names are case-insensitive. - parts = base.lower().split('/') - return '/'.join(parts[:2]), parts[0] - -def covered(u): - # $/ is gh actions-lock's inherently-pinned same-repo form (like ./) - if u.startswith('./') or u.startswith('$/') or u.startswith('docker://'): - return True - orp, owner = owner_repo(u) - if owner in gh_owned: # github-owned (actions/*, github/*) - return True - for p in patterns: - base = p.split('@', 1)[0].lower() - if base == f"{owner}/*": # owner-wide (covers reusable workflows) - return True - if base == orp or fnmatch.fnmatch(orp, base): - return True - return False - -uses = set() -for f in sorted(glob.glob(os.path.join(sys.argv[2], '*.yml')) + - glob.glob(os.path.join(sys.argv[2], '*.yaml'))): - for line in open(f, encoding='utf-8'): - m = re.search(r'^\s*(?:-\s*)?uses:\s*([^\s#]+)', line) - if m: - uses.add(m.group(1).strip().strip('"\'')) +covered() { + local ref=$1 action sha owner tail repo suffix pattern base allowed_ref p_owner p_tail p_repo p_suffix + case "$ref" in + ./*|docker://*) return 0 ;; + '$/'*) return 1 ;; + esac + [[ "$ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*@[0-9a-fA-F]{40}$ ]] || return 1 + action="${ref%@*}" + sha="${ref##*@}" + sha="${sha,,}" + case "$action" in */../*|*/./*|*/..|*/.) return 1 ;; esac + owner="${action%%/*}"; tail="${action#*/}"; repo="${tail%%/*}" + suffix="${tail#"$repo"}" + action="${owner,,}/${repo,,}$suffix" + if [[ "$owned" = true && ( "${owner,,}" = actions || "${owner,,}" = github ) ]]; then + return 0 + fi + for pattern in "${patterns[@]}"; do + base="${pattern%%@*}" + if [[ "$pattern" = *@* ]]; then + allowed_ref="${pattern#*@}" + if [[ "$allowed_ref" =~ ^[0-9a-fA-F]{40}$ ]]; then + allowed_ref="${allowed_ref,,}" + fi + [[ "$sha" = $allowed_ref ]] || continue + fi + [[ "$base" = */* ]] || continue + p_owner="${base%%/*}"; p_tail="${base#*/}"; p_repo="${p_tail%%/*}" + p_suffix="${p_tail#"$p_repo"}" + base="${p_owner,,}/${p_repo,,}$p_suffix" + [[ "$action" = $base ]] && return 0 + done + return 1 +} -gaps = sorted(u for u in uses if not covered(u)) -for u in gaps: - print(f"GAP {u} (add its owner/* or owner/repo@* pattern, or run set-allowed-actions.sh)") -print(f"checked {len(uses)} `uses:` refs across {sys.argv[2]} — {len(gaps)} not covered by the allowlist") -sys.exit(1 if gaps else 0) -PY +scratch="$(mktemp)" +trap 'rm -f "$scratch"' EXIT +shopt -s nullglob +files=("$WF_DIR"/*.yml "$WF_DIR"/*.yaml) +[[ ${#files[@]} -gt 0 ]] || + { echo 'check-allowed-actions: no workflow files examined' >&2; exit 2; } +checked=0 +gaps=0 +for file in "${files[@]}"; do + if ! "$YQ_BIN" -o=json 'explode(.)' "$file" > "$scratch"; then + echo "check-allowed-actions: unparseable workflow: $file" >&2 + exit 2 + fi + # A scalar, invalid jobs/steps node, null/non-string uses, or whitespace in + # a coordinate is unexamined input, never an empty passing ref population. + if ! jq -e -s ' + def nodes: .jobs[] | ., ((.steps // [])[]); + length == 1 and (.[0] | + type == "object" and (.jobs | type == "object" and length > 0) and + all(.jobs[]; type == "object" and + ((has("steps") | not) or (.steps | type == "array" and all(.[]; type == "object")))) and + all(nodes | select(has("uses")); .uses | type == "string" and length > 0 and + (test("\\s") | not) and (contains("\u0000") | not))) + ' "$scratch" >/dev/null; then + echo "check-allowed-actions: invalid workflow/ref structure: $file" >&2 + exit 2 + fi + refs="$(jq -r '.jobs[] | ., ((.steps // [])[]) | select(has("uses")) | .uses' "$scratch")" + while IFS= read -r ref; do + [[ -n "$ref" ]] || continue + checked=$((checked + 1)) + if ! covered "$ref"; then + printf 'GAP %s: %s (requires an immutable ref and an exact tree-policy match)\n' "$file" "$ref" >&2 + gaps=$((gaps + 1)) + fi + done <<< "$refs" +done +printf 'checked %s executable uses refs in %s workflow files; %s tree-policy gaps (live policy unverified)\n' \ + "$checked" "${#files[@]}" "$gaps" +[[ "$gaps" -eq 0 ]] diff --git a/scripts/ci/pin-workflow-uses.py b/scripts/ci/pin-workflow-uses.py deleted file mode 100755 index b3e4778..0000000 --- a/scripts/ci/pin-workflow-uses.py +++ /dev/null @@ -1,355 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -"""Pin every `uses:` ref in .github/workflows to a full commit SHA, and -regenerate .github/workflows/actions.lock in the format `gh actions-lock` -emits. - -Why this exists as a checked-in script rather than a one-off edit: - - * the estate runs `actions_permissions.sha_pinning_required = true` - (standards config/settings/repo.json). Under that posture a tag-pinned - `uses:` is refused at workflow STARTUP: zero jobs, no log, an opaque - "workflow file issue". That is the same class of outage as an empty - allowlist, and it is invisible in a green-looking PR. - * `gh actions-lock` will not touch a workflow whose refs it did not write - itself, and it rewrites local composite refs into invalid `$/` syntax in - some modes. Pinning is therefore done here, deterministically, and the - lockfile is derived from the workflows rather than trusted. - -Usage: - scripts/ci/pin-workflow-uses.py --map pins.json [--check] - -`pins.json` maps `owner/repo@ref` -> {"sha": ..., "tag": ...}. It is produced -by scripts/ci/resolve-action-shas.sh so that this script needs no network. - ---check exits 1 if any `uses:` is not a 40-hex SHA or if the lockfile would -change; CI runs that mode so drift cannot land. -""" -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - -WORKFLOW_DIR = Path(".github/workflows") -LOCKFILE = WORKFLOW_DIR / "actions.lock" - -# `- uses: owner/repo@ref # comment` / `uses: owner/repo/path@ref` -USES_RE = re.compile( - r"^(?P\s*(?:-\s+)?)uses:\s*(?P[^\s#]+)(?P\s*#\s*(?P.*))?$" -) -SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") -SHA_COMMENT_RE = re.compile(r"^\s*[#]?\s*(v?\d[\w.\-+]*)\s*$") - - -def normalise(ref: str) -> tuple[str, str, str]: - """Split `owner/repo[/sub/path...]@ref` into (owner/repo, path, ref).""" - path, _, gitref = ref.partition("@") - parts = path.split("/") - slug = "/".join(parts[:2]) - return slug, path, gitref - - -def workflow_files() -> list[Path]: - files = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted(WORKFLOW_DIR.glob("*.yaml")) - return [f for f in files if f.is_file()] - - -def rewrite(workflow: Path, pins: dict[str, dict[str, str]], check: bool) -> tuple[str, bool]: - text = workflow.read_text(encoding="utf-8") - changed = False - out_lines: list[str] = [] - - for line in text.splitlines(keepends=True): - stripped = line.lstrip() - if stripped.startswith("#") or "uses:" not in line: - out_lines.append(line) - continue - m = USES_RE.match(line.rstrip("\n")) - if not m: - out_lines.append(line) - continue - ref = m.group("ref") - if ref.startswith("./") or ref.startswith("docker://") or "/" not in ref: - out_lines.append(line) - continue - - slug, _, gitref = normalise(ref) - comment = (m.group("comment") or "").strip() - - if SHA_RE.match(gitref): - # Already pinned. Ensure a version comment names the tag we know. - tag = None - for key, info in pins.items(): - if normalise(key)[0].lower() == slug.lower() and info.get("sha", "").lower() == gitref.lower(): - tag = info.get("tag") - break - if tag and (not comment or SHA_RE.match(comment)): - new_line = f"{m.group('lead')}uses: {ref} # {tag}\n" - if new_line != line: - changed = True - out_lines.append(new_line) - continue - out_lines.append(line) - continue - - key = f"{slug}@{gitref}" - info = pins.get(key) or next( - (v for k, v in pins.items() if k.lower() == key.lower()), None - ) - if not info or not SHA_RE.match(info.get("sha", "")): - print(f"::error file={workflow}::no SHA known for {key}", file=sys.stderr) - out_lines.append(line) - continue - - pin = info["sha"].lower() - tag = info.get("tag") or gitref - tail = f" # {tag}" - head = f"{m.group('lead')}uses: {ref.replace('@' + gitref, '@' + pin, 1)}" - new_line = head + tail + "\n" - if new_line != line: - changed = True - out_lines.append(new_line) - - return "".join(out_lines), changed - - -def refs_in(workflow: Path) -> list[str]: - refs = [] - for line in workflow.read_text(encoding="utf-8").splitlines(): - stripped = line.lstrip() - if stripped.startswith("#") or "uses:" not in line: - continue - m = USES_RE.match(line) - if not m: - continue - ref = m.group("ref") - if ref.startswith("./") or ref.startswith("docker://") or "/" not in ref: - continue - refs.append(ref) - return sorted(set(refs)) - - -def parse_lock(text: str) -> tuple[dict[str, list[str]], list[str]]: - """Split an existing lockfile into its dependency BLOCKS. - - The blocks are kept as raw lines because `gh actions-lock` writes things - this script does not compute — `owner_id`/`repo_id`, tag-keyed aliases, and - crucially a `uses:` list for composite actions that resolve OTHER actions at - run time (`actions/upload-pages-artifact` pulls - `actions/upload-artifact@bbbca2dd…`). Discarding those is not cosmetic: - GitHub validates the lockfile at job setup and refuses the workflow with - "lockfile missing pin for …", which is how a regenerated lockfile took the - Pages deploy down. - """ - blocks: dict[str, list[str]] = {} - current: str | None = None - in_deps = False - for line in text.splitlines(): - if line.startswith("dependencies:"): - in_deps = True - current = None - continue - if not in_deps: - continue - m = re.match(r"^ '([^']+)':$", line) - if m: - current = m.group(1) - blocks[current] = [line] - continue - if current is not None: - blocks[current].append(line) - # Trim trailing blank lines accidentally captured. - for key, lines in blocks.items(): - while lines and not lines[-1].strip(): - lines.pop() - return blocks - - -def block_fields(lines: list[str]) -> dict[str, str]: - out: dict[str, str] = {} - for line in lines[1:]: - m = re.match(r"^ ([a-z_]+): '?([^'\n]*)'?$", line) - if m: - out[m.group(1)] = m.group(2) - return out - - -def build_lock(pins: dict[str, dict[str, str]], existing: str) -> str: - """Emit actions.lock: `workflows:` regenerated from the files on disk; the - `dependencies:` section MERGED into what is already committed. - - Merge, not rebuild, on purpose: the lockfile is a superset of what the - workflows name — `gh actions-lock` also records the actions that composite - actions resolve internally, and it records tag-keyed aliases. A generator - that reconstructs the file from `grep uses:` silently deletes both, and the - deletion is only visible as a startup failure inside the one workflow that - relied on it. - """ - preserved = parse_lock(existing) - - # Identity knowledge: owner/repo ids, and sha -> tag, taken from the - # preserved entries so a new pin can inherit them instead of guessing. - id_by_key: dict[str, tuple[str, str]] = {} - id_by_tag: dict[tuple[str, str], tuple[str, str]] = {} - by_sha: dict[tuple[str, str], str] = {} - for key, lines in preserved.items(): - fields = block_fields(lines) - slug, _, ref = normalise(key) - if fields.get("owner_id"): - id_by_key[key.lower()] = (fields["owner_id"], fields.get("repo_id", "")) - if fields.get("ref"): - id_by_tag[(slug.lower(), fields["ref"])] = ( - fields["owner_id"], - fields.get("repo_id", ""), - ) - if SHA_RE.match(ref) and not SHA_RE.match(fields["ref"]): - by_sha[(slug.lower(), ref.lower())] = fields["ref"] - for key, info in pins.items(): - slug, _, tag = normalise(key) - sha = info.get("sha", "") - if SHA_RE.match(sha) and tag and not SHA_RE.match(tag): - by_sha[(slug.lower(), sha.lower())] = tag - - comment_tag: dict[tuple[str, str], str] = {} - for wf in workflow_files(): - for line in wf.read_text(encoding="utf-8").splitlines(): - m = USES_RE.match(line) - if not m: - continue - slug, path, gitref = normalise(m.group("ref")) - comment = (m.group("comment") or "").strip() - if not SHA_RE.match(gitref) or not comment or ".github/workflows/" in path: - continue - if re.match(r"^v?\d[\w.\-+]*$", comment): - comment_tag[(slug.lower(), gitref.lower())] = comment - - per_file: dict[str, list[str]] = {} - new_blocks: dict[str, list[str]] = {} - missing: list[str] = [] - for wf in workflow_files(): - entries = [] - for ref in refs_in(wf): - slug, path, gitref = normalise(ref) - entries.append(f"{slug.lower()}@{gitref}") - if ".github/workflows/" in path or not SHA_RE.match(gitref): - continue - key = f"{slug.lower()}@{gitref.lower()}" - if key in preserved: - continue # keep the committed block verbatim - tag = ( - comment_tag.get((slug.lower(), gitref.lower())) - or by_sha.get((slug.lower(), gitref.lower())) - or pins.get(f"{slug}@{gitref}", {}).get("tag", "") - ) - owner_id, repo_id = id_by_key.get(key, ("", "")) - if not (owner_id and repo_id): - owner_id, repo_id = id_by_tag.get((slug.lower(), tag or ""), ("", "")) - if not (tag and not SHA_RE.match(tag) and owner_id and repo_id): - missing.append(f"{slug}@{gitref}") - continue - new_blocks[key] = [ - f" '{key}':", - f" ref: '{tag}'", - f" commit: 'sha1-{gitref.lower()}'", - f" owner_id: {owner_id}", - f" repo_id: {repo_id}", - ] - per_file[f".github/workflows/{wf.name}"] = sorted(set(entries)) - - if missing: - raise SystemExit( - "pin-workflow-uses: cannot key these refs in actions.lock " - "(need a tag and owner/repo ids; see scripts/ci/resolve-action-shas.sh):\n " - + "\n ".join(sorted(set(missing))) - ) - - out = [ - "# This file is machine-generated by `gh actions-lock`.", - "# Do not edit by hand; run `gh actions-lock` to update.", - "# Docs: https://gh.io/actions-lockfile", - "version: 'v0.0.3'", - "workflows:", - ] - for path in sorted(per_file): - if not per_file[path]: - out.append(f" '{path}': []") - continue - out.append(f" '{path}':") - for e in per_file[path]: - out.append(f" - '{e}'") - out.append("dependencies:") - merged = dict(preserved) - merged.update(new_blocks) - - # A composite action's own dependencies are recorded on whichever entry - # `gh actions-lock` happened to write them against — in practice the - # TAG-keyed one. GitHub resolves them from the entry the workflow actually - # names, which after a re-pin is the SHA-keyed one. Carry the `uses:` list - # across to every entry describing the same commit, or a re-pinned workflow - # dies in Set up job with "lockfile missing pin for ". - deps_by_commit: dict[str, list[str]] = {} - for lines in merged.values(): - fields = block_fields(lines) - if "uses" in fields or any(l.lstrip().startswith("uses:") for l in lines): - pass - try: - idx = next(i for i, l in enumerate(lines) if l.strip() == "uses:") - except StopIteration: - continue - commit = fields.get("commit", "") - if commit: - deps_by_commit.setdefault(commit, lines[idx:]) - - for key, lines in merged.items(): - if any(l.strip() == "uses:" for l in lines): - continue - commit = block_fields(lines).get("commit", "") - extra = deps_by_commit.get(commit) - if extra: - merged[key] = lines + list(extra) - - for key in sorted(merged): - out.extend(merged[key]) - return "\n".join(out) + "\n" - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--map", required=True, help="pins JSON (see module docstring)") - ap.add_argument("--check", action="store_true", help="verify only; never write") - args = ap.parse_args() - - pins = json.loads(Path(args.map).read_text(encoding="utf-8")) - failures = 0 - - for wf in workflow_files(): - new, changed = rewrite(wf, pins, args.check) - if changed: - failures += 1 - if args.check: - print(f"::error file={wf}::unpinned or un-commented `uses:` (run scripts/ci/pin-workflow-uses.py)", file=sys.stderr) - else: - wf.write_text(new, encoding="utf-8") - print(f"pinned {wf}") - - existing = LOCKFILE.read_text(encoding="utf-8") if LOCKFILE.exists() else "" - lock = build_lock(pins, existing) - if lock != existing: - failures += 1 - if args.check: - print("::error file=.github/workflows/actions.lock::lockfile is stale (regenerate)", file=sys.stderr) - else: - LOCKFILE.write_text(lock, encoding="utf-8") - print("regenerated actions.lock") - - if args.check and failures: - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/resolve-action-shas.sh b/scripts/ci/resolve-action-shas.sh index a3aed73..5ad3aee 100755 --- a/scripts/ci/resolve-action-shas.sh +++ b/scripts/ci/resolve-action-shas.sh @@ -3,7 +3,8 @@ # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # # Resolve every tag/branch `uses:` ref in .github/workflows to a full commit -# SHA and write the mapping to a JSON file for scripts/ci/pin-workflow-uses.py. +# SHA and write a legacy tag-reference index. This is not a native lock +# generator; native updates use the canonical recovery route in docs/CI-CD.adoc. # # Needs `gh` (authenticated) and `jq`. One API call per distinct ref. # diff --git a/scripts/ci/test-ci-integrity.sh b/scripts/ci/test-ci-integrity.sh new file mode 100755 index 0000000..72bfcd5 --- /dev/null +++ b/scripts/ci/test-ci-integrity.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Isolated tree-policy controls and an explicitly injected process-status fault. +# Mock reports qualify adapter behaviour, not actual native lock coverage. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +: "${STANDARDS_ROOT:?pinned canonical checkout required}" +: "${GH_ACTIONS_LOCK_BIN:?official binary required for the malformed-lock control}" +official_native=$GH_ACTIONS_LOCK_BIN +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/workflows" "$work/empty" "$work/repo/.github/workflows" +git -C "$work/repo" init --quiet +checker="$ROOT/scripts/ci/check-allowed-actions.sh" +transport="$ROOT/scripts/ci/actions-lock-transport.sh" +policy="$ROOT/.github/actions-allowlist.json" +checks=0 +expect() { + local expected=$1 label=$2 status=0 + shift 2 + "$@" > "$work/stdout" 2> "$work/stderr" || status=$? + if [[ "$status" -ne "$expected" ]]; then + cat "$work/stdout" "$work/stderr" >&2 + printf 'FAIL %s: expected %s, got %s\n' "$label" "$expected" "$status" >&2 + exit 1 + fi + checks=$((checks + 1)) + printf 'PASS %s\n' "$label" +} +workflow() { + jq -n --arg ref "$1" \ + '{on:{push:{}},jobs:{gate:{uses:$ref}}}' > "$work/workflows/test.yml" +} +check() { bash "$checker" "$policy" "$work/workflows"; } +canonical() { bash "$STANDARDS_ROOT/scripts/check-workflow-coordinates.sh" "$work/workflows"; } +old_pin=333b06a01db1874ffedb2de1bfabb195640927c7 +current_pin=a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 +for name in governance hypatia-scan rust-ci; do + workflow "metadatastician/standards/.github/workflows/$name-reusable.yml@$current_pin" + expect 0 "exact canonical $name coordinate" check +done +for name in mirror scorecard; do + workflow "metadatastician/standards/.github/workflows/$name-reusable.yml@$old_pin" + expect 0 "exact canonical $name coordinate" check +done +workflow "METADATastician/STANDARDS/.github/workflows/rust-ci-reusable.yml@$current_pin" +expect 0 'owner and repository case is immaterial' check +for ref in \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@$old_pin" \ + "metadatastician/standards/.github/workflows/not-approved.yml@$current_pin" \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml.extra@$current_pin" \ + "metadatastician/standards/.github/workflows/Rust-ci-reusable.yml@$current_pin" \ + "metadatastician-other/standards/.github/workflows/rust-ci-reusable.yml@$current_pin" \ + "metadatastician/standards-other/.github/workflows/rust-ci-reusable.yml@$current_pin" \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@main" \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@${current_pin}0" \ + "actions/checkout@v7.0.1" \ + '$/.github/actions/example'; do + workflow "$ref" + expect 1 "reject coordinate: $ref" check +done +workflow "hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@$current_pin" +expect 1 'published coordinate gate rejects the former owner despite the inherited owner-wide policy' canonical +workflow "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" +jq '.github_owned_allowed = false' "$policy" > "$work/no-owned.json" +expect 1 'GitHub-owned flag is respected' bash "$checker" "$work/no-owned.json" "$work/workflows" + +cat > "$work/workflows/test.yml" < "$work/workflows/test.yml" < "$work/workflows/test.yml" <<'YAML' +on: {push: {}} +jobs: + gate: + runs-on: ubuntu-latest + steps: + # uses: unapproved/comment@main + - run: | + echo 'uses: unapproved/example@main' +YAML +expect 0 'quoted command and comment examples are not executable uses' check +for ref in null 42 '""' '"actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n./hidden"'; do + printf '{"jobs":{"gate":{"uses":%s}}}\n' "$ref" > "$work/workflows/test.yml" + expect 2 "reject malformed uses value: $ref" check +done +printf 'jobs: [broken\n' > "$work/workflows/test.yml" +expect 2 'malformed YAML is unexamined' check +printf 'ordinary scalar\n' > "$work/workflows/test.yml" +expect 2 'scalar workflow is unexamined' check +expect 2 'empty input directory is not a pass' bash "$checker" "$policy" "$work/empty" +expect 2 'missing tree policy is not downloaded or assumed' bash "$checker" "$work/absent.json" "$work/workflows" +printf '%s\n%s\n' '{"github_owned_allowed":true,"patterns_allowed":[]}' '{"github_owned_allowed":true,"patterns_allowed":[]}' > "$work/stream-policy.json" +expect 2 'multiple JSON policies cannot be combined implicitly' bash "$checker" "$work/stream-policy.json" "$work/workflows" +printf '%s\n' '{"github_owned_allowed":false,"patterns_allowed":["fixture/action@*\nevil/*"]}' > "$work/newline-policy.json" +expect 2 'a newline cannot inject a second allowlist pattern' bash "$checker" "$work/newline-policy.json" "$work/workflows" + +cat > "$work/mock-native" <<'SH' +#!/usr/bin/env bash +set -eu +printf '%s\n' "$@" > "$MOCK_ARGS" +printf '%s|%s\n' "${GH_ACTIONS_LOCK_WORKFLOWS_DIR-unset}" "${GH_TOKEN-unset}" > "$MOCK_ENV" +cat "$MOCK_REPORT" +exit "$MOCK_STATUS" +SH +chmod +x "$work/mock-native" +export MOCK_ARGS="$work/mock-args" MOCK_ENV="$work/mock-env" MOCK_REPORT="$work/mock-report" +export GH_ACTIONS_LOCK_BIN="$work/mock-native" ACTIONS_LOCK_EVIDENCE_DIR="$work/evidence" +export GH_ACTIONS_LOCK_WORKFLOWS_DIR="$work/wrong-input" GH_TOKEN=fixture-only +printf '{"valid":true,"findings":[]}\n' > "$MOCK_REPORT" +export MOCK_STATUS=0 +expect 0 'transport preserves actual success JSON' "$transport" actions-lock --verify-local --json=valid,findings +cmp "$MOCK_REPORT" "$work/stdout" +[[ "$(cat "$MOCK_ENV")" = 'unset|unset' ]] || + { echo 'native child retained ambient input/auth overrides' >&2; exit 1; } + +cat > "$work/repo/.github/workflows/test.yml" <<'YAML' +on: {push: {}} +jobs: + gate: + runs-on: ubuntu-latest + steps: + - run: echo fixture +YAML +# Opaque sentinel: mocked process controls do not assert this is a native lock. +printf 'mock-only lock sentinel\n' > "$work/repo/.github/workflows/actions.lock" +before="$(sha256sum "$work/repo/.github/workflows/"*)" +helper() ( + cd "$work/repo" + GH_BIN="$transport" bash "$STANDARDS_ROOT/scripts/update-actions-lock.sh" --verify-local +) +expect 0 'unchanged published helper accepts a successful mock protocol result' helper +for status in 2 124 127; do + export MOCK_STATUS=$status + expect "$status" "valid-looking JSON cannot hide native exit $status" helper + [[ "$(cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit")" = "$status" ]] + cmp "$MOCK_REPORT" "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" + grep -F '"valid":true' "$work/stderr" >/dev/null +done +export MOCK_STATUS=0 +printf 'malformed report\n' > "$MOCK_REPORT" +expect 1 'unchanged canonical parser rejects malformed success output' helper +printf '' > "$MOCK_REPORT" +expect 1 'empty success output is not accepted' helper +printf '{"valid":false,"findings":[]}\n' > "$MOCK_REPORT" +expect 1 'false validity without findings is not accepted' helper +printf '{"valid":true,"findings":[{"category":"sha-as-ref","severity":"warning","workflow":".github/workflows/test.yml","dependency":"fixture/action@0123456789012345678901234567890123456789"}]}\n' > "$MOCK_REPORT" +export MOCK_STATUS=1 +expect 0 'existing canonical advisory reconciliation is preserved, not reimplemented' helper +[[ "$(sha256sum "$work/repo/.github/workflows/"*)" = "$before" ]] || + { echo 'mocked verification changed workflow or lock bytes' >&2; exit 1; } + +rm -f "$MOCK_ARGS" +expect 2 'generator argv is forbidden in the verification transport' "$transport" actions-lock +expect 2 'unknown native option is forbidden' "$transport" actions-lock --verify-local --no-fix +expect 2 'alternate command is forbidden' "$transport" api repos/fixture +[[ ! -e "$MOCK_ARGS" ]] || { echo 'unsupported argv reached the native child' >&2; exit 1; } + +export GH_ACTIONS_LOCK_BIN="$work/missing-native-binary" +expect 127 'actual missing native executable remains an infrastructure failure' helper +export GH_ACTIONS_LOCK_BIN="$official_native" +printf 'version: [malformed native lock\n' > "$work/repo/.github/workflows/actions.lock" +before="$(sha256sum "$work/repo/.github/workflows/"*)" +expect 2 'real official v0.2.0 rejects malformed native lock through the unchanged helper' helper +grep -F 'opening lockfile:' "$ACTIONS_LOCK_EVIDENCE_DIR/native.stderr" >/dev/null +[[ "$(sha256sum "$work/repo/.github/workflows/"*)" = "$before" ]] || + { echo 'native malformed-lock control changed input bytes' >&2; exit 1; } +printf 'CI integrity controls: %s passed; real consumer coverage is a separate gate\n' "$checks" diff --git a/scripts/ci/triage-startup-failure.sh b/scripts/ci/triage-startup-failure.sh index e5da0e1..c9c6823 100755 --- a/scripts/ci/triage-startup-failure.sh +++ b/scripts/ci/triage-startup-failure.sh @@ -9,34 +9,14 @@ # `referenced_workflows: []` even when the workflow's only job is `uses:` a # reusable workflow from another repository. Nothing in the API says why. # -# There are five causes, and they are distinguishable with the files plus a -# read-only API call: +# This command reports source/API observations: pin shape, repository +# canonical name and whether a reusable commit can be read. Zero jobs and +# referenced_workflows: [] do not identify a cause. API reachability is not +# GitHub admission, and sibling runs are comparative evidence, not a read of +# this repository's effective Actions Settings or startup diagnostic. # -# 1. a `uses:` that is not a 40-hex commit, under `sha_pinning_required`; -# 2. a `uses:` whose repository is NOT the one the workflow names — i.e. the -# repo was renamed (or transferred) and the allowlist patterns match the -# *canonical* owner, not the spelling in the file; -# 3. a reusable workflow pinned to a commit that no longer exists upstream; -# 4. a workflow file that does not parse as a workflow (GitHub's parser is -# stricter than YAML: duplicate job keys, `on:` shapes, unknown keys); -# 5. THIS REPOSITORY's allowed-actions policy refusing a non-verified -# cross-repository `uses:`. Under `allowed_actions: selected` with -# `verified_allowed`, GitHub-owned and marketplace-verified actions pass and -# a `uses: some-org/some-repo/...` does not — so checkout, cache and -# dtolnay/rust-toolchain work while every call into the estate's own -# reusable workflows dies at startup. The signature is exactly the one above -# (zero jobs, `referenced_workflows: []`), and the discriminator is that -# sibling repositories calling the SAME reusable at the SAME pin succeed. -# Only a repository admin can change it (GET /actions/permissions is 403 for -# an ordinary token), which is why this script reports rather than fixes. - -# Set TRIAGE_SIBLINGS to a space-separated list of repositories that use the same -# reusable workflows, to have the script state the discriminator explicitly: -# TRIAGE_SIBLINGS="hyperpolymath/hypatia hyperpolymath/verisimdb" -# -# This script checks 1–3 against the live GitHub API (read-only, no admin -# permission needed: public repository metadata is enough) and leaves 4 to -# `ci-integrity.yml`, which parses every workflow with PyYAML. +# TRIAGE_SIBLINGS optionally lists space-separated repositories whose recent +# run conclusions will be printed without inferring a policy failure. # # Usage: # scripts/ci/triage-startup-failure.sh # exit 1 on any finding @@ -144,43 +124,38 @@ for ref in "${refs[@]}"; do repo="$owner/$second" if ! printf '%s' "$at" | grep -qE '^[0-9a-f]{40}$'; then - emit error "$ref: not pinned to a 40-hex commit — refused at startup under sha_pinning_required" + emit error "$ref: not pinned to a 40-hex commit — source violates the immutable-ref policy" continue fi canon="$(canonical_name "$repo")" if [ -z "$canon" ]; then - emit error "$repo: repository not found (or API unreachable) for \`$ref\` — the run cannot start" + emit error "$repo: repository not found or API unreachable for \`$ref\` — source identity could not be verified" continue fi if [ "$canon" != "$repo" ]; then - # A rename is reported, not condemned: what matters is whether the - # reference is permitted, and a rename only bites when the allowlist - # pattern matches the old owner and not the new one. - emit warning "$repo now resolves to $canon — a \`uses:\` keeps working across a rename as long as an allowlist pattern matches the canonical name; check \`patterns_allowed\` before assuming this is the cause" + # A metadata redirect does not qualify executable coordinates or the + # identity records in the consumer's generated native lock. + emit warning "$repo now resolves to $canon — review the canonical executable coordinate and generated lock identity; this metadata redirect does not prove startup admission" repo="$canon" fi case "$subpath" in .github/workflows/*) if ! ref_exists "$repo" "$at"; then - emit error "$ref: reusable workflow pinned to $at, which no longer exists in $repo — the caller dies before its first job" + emit error "$ref: could not verify reusable commit $at in $repo — absence and API failure are not distinguished" continue fi - # The pin exists. If runs of the calling workflow still die at - # startup, the reference is being refused by this repository's - # allowed-actions policy — the one cause no file can show. echo " cross-repo reusable: $ref" - echo " if the calling workflow reports startup_failure with referenced_workflows: [], this" - echo " repository's allowed-actions policy is refusing it. Sibling repos calling the same" - echo " reusable at the same pin are the discriminator; the fix needs repository admin" - echo " (allowed_actions=all, or add \`$owner/*\` to patterns_allowed)." + echo " commit metadata is readable; this does not verify the reusable file," + echo " native lock closure, effective Actions Settings or startup admission." + echo " use the actual platform diagnostic before attributing a zero-job failure." ;; esac done if [ -n "${TRIAGE_SIBLINGS:-}" ]; then - echo "== discriminator: do siblings actually run this? ==" + echo "== comparative sibling run observations ==" for sib in $TRIAGE_SIBLINGS; do summary="$(api "repos/$sib/actions/runs?per_page=20")" printf '%s\n' "$summary" | python3 -c ' @@ -193,15 +168,15 @@ counts = collections.Counter((r.get("conclusion") or r.get("status")) for r in d print(" " + ", ".join(f"{k}={v}" for k, v in counts.most_common()) or " (no runs)") ' | sed "s|^| $sib: |" done - echo " A sibling whose reusable-workflow runs SUCCEED, at the same pin, means the" - echo " estate allowlist is fine and the refusal is scoped to this repository." + echo " Sibling conclusions are comparative observations only; they do not establish" + echo " this repository's effective Actions policy or the cause of its startup failure." fi echo "== summary ==" if [ "$findings" -eq 0 ]; then - echo "no startup-failure causes found in $WORKFLOWS_DIR" + echo "No source/API findings in $WORKFLOWS_DIR; live admission and startup cause remain unverified." else - echo "$findings finding(s) — see above. Each one produces a run with zero jobs and no log." + echo "$findings source/API finding(s) — see above; platform causation is not established." fi if [ "$REPORT_ONLY" -eq 1 ]; then From fca4977a0f0a696b070df11611aaa6ee79e9f541 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:17:09 +0000 Subject: [PATCH 07/14] fix(ci): reject hidden source state before native qualification Normal git status can conceal a modified consumed helper or workflow behind assume-unchanged/skip-worktree flags. Refuse these flags and tracked, untracked or ignored changes in both exact repository roots. Require a completed native 0/1 receipt before accepting the unchanged canonical helper. Add actual Git fixture controls for both hidden flags in both checkouts and an ignored action.yml shadowing a tracked action.yaml. The new shell syntax passes bash -n; execution of the expanded suite is pending hosted CI with the official native binary and YAML parser. Do not transfer earlier runtime receipts to these source bytes. No native lock or refs are changed. --- .githooks/validate-actions-lock.sh | 35 +++++++++++++++++--- docs/CI-CD.adoc | 4 ++- scripts/ci/test-ci-integrity.sh | 53 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index 321d9de..3c37057 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -5,7 +5,25 @@ # boundary. No native YAML is generated, patched, or inferred here. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +assert_clean_checkout() { + local checkout=$1 git_state + # Git's normal status can hide modified consumed files behind these flags. + # NUL records keep unusual filenames from changing the flag interpretation. + git -C "$checkout" ls-files -v -z | + while IFS= read -r -d '' entry; do + case "${entry:0:1}" in + [a-z]|S) + echo "validate-actions-lock: unsafe index flags in $checkout; source bytes are unqualified" >&2 + exit 2 + ;; + esac + done + git_state="$(git -C "$checkout" status --porcelain --untracked-files=all --ignored=matching)" || + { echo "validate-actions-lock: cannot inspect source state in $checkout" >&2; return 2; } + [[ -z "$git_state" ]] || + { echo "validate-actions-lock: tracked, untracked or ignored changes in $checkout; use an isolated clean checkout" >&2; return 2; } +} STANDARDS_REF=087f6bff5135ce3da6f287c83752256ce4a8efc1 NATIVE_SHA256=79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6 : "${STANDARDS_ROOT:?checkout the pinned metadatastician/standards source; see docs/CI-CD.adoc}" @@ -22,12 +40,12 @@ STANDARDS_ROOT="$(cd "$STANDARDS_ROOT" && pwd -P)" { echo 'validate-actions-lock: Standards path must be its repository root' >&2; exit 2; } [[ "$(git -C "$STANDARDS_ROOT" rev-parse HEAD)" = "$STANDARDS_REF" ]] || { echo 'validate-actions-lock: wrong Standards source revision' >&2; exit 2; } -[[ -z "$(git -C "$STANDARDS_ROOT" status --porcelain --untracked-files=all)" ]] || - { echo 'validate-actions-lock: Standards checkout is not clean' >&2; exit 2; } +assert_clean_checkout "$STANDARDS_ROOT" cd "$ROOT" -[[ -z "$(git status --porcelain --untracked-files=all)" ]] || - { echo 'validate-actions-lock: commit the exact input tree before qualification' >&2; exit 2; } +[[ "$(git rev-parse --show-toplevel)" = "$ROOT" ]] || + { echo 'validate-actions-lock: consumer path must be its repository root' >&2; exit 2; } +assert_clean_checkout "$ROOT" [[ -f .github/workflows/actions.lock ]] || { echo 'validate-actions-lock: missing native lock; use canonical recovery' >&2; exit 1; } snapshot="$(mktemp -d)" @@ -63,4 +81,11 @@ jq -n \ GH_BIN="$ROOT/scripts/ci/actions-lock-transport.sh" \ bash "$STANDARDS_ROOT/scripts/update-actions-lock.sh" --verify-local +case "$(cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit")" in + 0|1) ;; + *) + echo 'validate-actions-lock: helper returned without a completed native verifier result' >&2 + exit 1 + ;; +esac bash "$STANDARDS_ROOT/scripts/check-lock-sync.sh" .github/workflows diff --git a/docs/CI-CD.adoc b/docs/CI-CD.adoc index fbf9595..ddae0c9 100644 --- a/docs/CI-CD.adoc +++ b/docs/CI-CD.adoc @@ -106,7 +106,9 @@ official `gh-actions-lock v0.2.0` binary through the unchanged published `scripts/check-lock-sync.sh`. The latter checks reusable and local composite references in both directions. No consumer code emits native lock records. The gate checks the native executable digest, the exact clean Standards -checkout, a clean consumer commit, and unchanged workflow/lock bytes. +checkout, a clean consumer commit, and unchanged workflow/lock bytes. Both +checkouts reject assume-unchanged/skip-worktree index flags and ignored inputs; +Git status alone cannot bind hidden working bytes to the recorded commit. Tree allowlist:: `scripts/ci/check-allowed-actions.sh` parses YAML, resolves aliases, and reads job/step `uses` values. Owner/repository spelling is diff --git a/scripts/ci/test-ci-integrity.sh b/scripts/ci/test-ci-integrity.sh index 72bfcd5..ad3e423 100755 --- a/scripts/ci/test-ci-integrity.sh +++ b/scripts/ci/test-ci-integrity.sh @@ -111,6 +111,59 @@ expect 2 'multiple JSON policies cannot be combined implicitly' bash "$checker" printf '%s\n' '{"github_owned_allowed":false,"patterns_allowed":["fixture/action@*\nevil/*"]}' > "$work/newline-policy.json" expect 2 'a newline cannot inject a second allowlist pattern' bash "$checker" "$work/newline-policy.json" "$work/workflows" +# Run the actual consumer entrypoint against disposable Git checkouts. +# Hidden index flags must fail before any helper or native program executes. +source_fixture="$work/source-fixture" +authority_fixture="$work/standards-fixture" +mkdir -p "$source_fixture/.githooks" "$source_fixture/scripts/ci" \ + "$source_fixture/.github/workflows" "$source_fixture/.github/actions/local" +cp "$ROOT/.githooks/validate-actions-lock.sh" "$source_fixture/.githooks/" +cp "$transport" "$source_fixture/scripts/ci/" +printf 'mock-only lock sentinel\n' > "$source_fixture/.github/workflows/actions.lock" +printf 'jobs: {gate: {runs-on: ubuntu-latest, steps: [{run: echo fixture}]}}\n' \ + > "$source_fixture/.github/workflows/test.yml" +printf 'name: fixture\nruns: {using: composite, steps: []}\n' \ + > "$source_fixture/.github/actions/local/action.yaml" +printf '.github/actions/local/action.yml\n' > "$source_fixture/.gitignore" +git -C "$source_fixture" init --quiet +git -C "$source_fixture" add --all +git -C "$source_fixture" -c user.name=Fixture -c user.email=fixture@example.invalid \ + -c commit.gpgsign=false commit --quiet -m 'disposable source-qualification fixture' +git clone --quiet --no-hardlinks "$STANDARDS_ROOT" "$authority_fixture" +source_gate() { + STANDARDS_ROOT="$authority_fixture" GH_ACTIONS_LOCK_BIN="$official_native" \ + ACTIONS_LOCK_EVIDENCE_DIR="$work/source-evidence" \ + bash "$source_fixture/.githooks/validate-actions-lock.sh" +} +for flag in --assume-unchanged --skip-worktree; do + for fixture in "$authority_fixture" "$source_fixture"; do + if [[ "$fixture" = "$authority_fixture" ]]; then + path=scripts/update-actions-lock.sh + else + path=.github/workflows/test.yml + fi + git -C "$fixture" update-index "$flag" "$path" + printf 'tampered consumed source\n' > "$fixture/$path" + # This is the actual premise that fooled the previous source guard. + [[ -z "$(git -C "$fixture" status --porcelain --untracked-files=all)" ]] + expect 2 "hidden source flag $flag in $fixture is rejected" source_gate + grep -F 'unsafe index flags' "$work/stderr" >/dev/null + [[ ! -e "$work/source-evidence/native.exit" ]] || + { echo 'an unqualified source reached native verification' >&2; exit 1; } + git -C "$fixture" update-index --no-assume-unchanged "$path" + git -C "$fixture" update-index --no-skip-worktree "$path" + git -C "$fixture" checkout -- "$path" + done +done +# A clean status must not hide action.yml shadowing a tracked action.yaml. +printf 'name: ignored shadow\nruns: {using: composite, steps: []}\n' \ + > "$source_fixture/.github/actions/local/action.yml" +[[ -z "$(git -C "$source_fixture" status --porcelain --untracked-files=all)" ]] +expect 2 'ignored action input is not part of the qualified commit' source_gate +grep -F 'tracked, untracked or ignored changes' "$work/stderr" >/dev/null +[[ ! -e "$work/source-evidence/native.exit" ]] || + { echo 'ignored source reached native verification' >&2; exit 1; } + cat > "$work/mock-native" <<'SH' #!/usr/bin/env bash set -eu From b93461ad2c282a528f139bf4f611e1c012751893 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:24:57 +0000 Subject: [PATCH 08/14] fix(ci): apply the official canonical lock recovery candidate Generated for fca4977a0f0a696b070df11611aaa6ee79e9f541 by proven-cicd run 41924139410, artifact 13368187636. Official gh-actions-lock v0.2.0 binary and published Standards regeneration chain preserved all workflow bytes and passed canonical lock sync. Generated blob 784b7d643b81bc2ce0c59a99689ca61b435820fc is applied unchanged. Target CI admission and consumer verification still require their own results. --- .github/workflows/actions.lock | 129 ++++++++++----------------------- 1 file changed, 39 insertions(+), 90 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 761942e..784b7d6 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -27,6 +27,7 @@ workflows: - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' '.github/workflows/ci-integrity.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' '.github/workflows/codeql.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'github/codeql-action@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' @@ -46,15 +47,15 @@ workflows: - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/governance.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/hypatia-scan.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/pages.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' @@ -82,13 +83,13 @@ workflows: - 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/rust-ci.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/scan-and-report.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/scorecard.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/secret-scanner-vendored.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/secret-scanner.yml': [] @@ -107,61 +108,36 @@ dependencies: commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 - 'actions/cache@v6.1.0': - ref: 'v6.1.0' - commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - owner_id: 44036562 - repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 - 'actions/checkout@v7.0.1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d': ref: 'v6.0.0' commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' owner_id: 44036562 repo_id: 513659658 - 'actions/configure-pages@v6.0.0': - ref: 'v6.0.0' - commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' - owner_id: 44036562 - repo_id: 513659658 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294': ref: 'v5.0.0' commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' owner_id: 44036562 repo_id: 476372928 - 'actions/dependency-review-action@v5.0.0': - ref: 'v5.0.0' - commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' - owner_id: 44036562 - repo_id: 476372928 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346': ref: 'v5.0.1' commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 - 'actions/deploy-pages@v5.0.1': - ref: 'v5.0.1' - commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' - owner_id: 44036562 - repo_id: 438112499 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333': ref: 'v8.0.2' commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' owner_id: 44036562 repo_id: 192626254 - 'actions/download-artifact@v8.0.2': - ref: 'v8.0.2' - commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' + 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08': + ref: 'v4.6.0' + commit: 'sha1-65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' owner_id: 44036562 - repo_id: 192626254 + repo_id: 192625955 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' @@ -172,11 +148,6 @@ dependencies: commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 - 'actions/upload-artifact@v7.0.2': - ref: 'v7.0.2' - commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' - owner_id: 44036562 - repo_id: 192625955 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9': ref: 'v5.0.0' commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' @@ -184,13 +155,6 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' - 'actions/upload-pages-artifact@v5.0.0': - ref: 'v5.0.0' - commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' - owner_id: 44036562 - repo_id: 496012378 - uses: - - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': ref: 'v2.0.5' commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' @@ -201,11 +165,6 @@ dependencies: commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' owner_id: 1940490 repo_id: 260749683 - 'dtolnay/rust-toolchain@v1': - ref: 'v1' - commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' - owner_id: 1940490 - repo_id: 260749683 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': ref: 'v3.0.0' commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' @@ -226,11 +185,6 @@ dependencies: commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 - 'github/codeql-action@v4.39.0': - ref: 'v4.39.0' - commit: 'sha1-6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' - owner_id: 9919 - repo_id: 259445878 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': ref: 'v2.2.1' commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' @@ -241,29 +195,32 @@ dependencies: commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 - 'haskell-actions/setup@v2.12.1': - ref: 'v2.12.1' - commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - owner_id: 75048950 - repo_id: 623796603 'hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef': ref: 'v0.5.0' commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 - 'hyperpolymath/smtp-notify-action@v0.5.0': - ref: 'v0.5.0' - commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' - owner_id: 6759885 - repo_id: 1352485172 - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7': + 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7': ref: '333b06a01db1874ffedb2de1bfabb195640927c7' commit: 'sha1-333b06a01db1874ffedb2de1bfabb195640927c7' - owner_id: 6759885 + owner_id: 297579217 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' + - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12': + ref: 'a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + commit: 'sha1-a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + owner_id: 297579217 repo_id: 1116521501 uses: - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' @@ -271,10 +228,8 @@ dependencies: - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' - - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' - - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e': ref: 'v2.5.0' commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' @@ -290,41 +245,35 @@ dependencies: commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' owner_id: 18365890 repo_id: 220359305 - 'peter-evans/repository-dispatch@v4.0.1': - ref: 'v4.0.1' - commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' - owner_id: 18365890 - repo_id: 220359305 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a': + ref: 'f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + owner_id: 80431187 + repo_id: 475074978 + uses: + - 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' + - 'slsa-framework/slsa-github-generator@v2.1.0' + - 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' + 'slsa-framework/slsa-github-generator@v2.1.0': ref: 'v2.1.0' commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' owner_id: 80431187 repo_id: 475074978 - 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64': - ref: 'v3.0.3' - commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' + 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda': + ref: 'v2.2.1' + commit: 'sha1-c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' owner_id: 2242 repo_id: 204253808 - 'softprops/action-gh-release@v3.0.3': + 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64': ref: 'v3.0.3' commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': - ref: 'v2.9.2' - commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - owner_id: 580492 - repo_id: 298565987 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': ref: 'v2.9.3' commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' owner_id: 580492 repo_id: 298565987 - 'swatinem/rust-cache@v2.9.3': - ref: 'v2.9.3' - commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' - owner_id: 580492 - repo_id: 298565987 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': ref: 'v0.10.0' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' From dbc3cb4c467b242a98d917344a59dd29ae7a3bb5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:30:44 +0000 Subject: [PATCH 09/14] fix(ci): initialize runner-local integrity paths inside the job The runner context is unavailable in workflow-level env. Resolve paths from RUNNER_TEMP in the first shell step and export them through GITHUB_ENV for later steps. Record native.exit=not_run before checkout or tool provisioning, preserving raw evidence when subsequent setup fails. Action references, permissions, native lock bytes, and validation steps are unchanged. Source candidate only; exact-source hosted admission and verification remain required. --- .github/workflows/ci-integrity.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-integrity.yml b/.github/workflows/ci-integrity.yml index bfb4715..20e1e8a 100644 --- a/.github/workflows/ci-integrity.yml +++ b/.github/workflows/ci-integrity.yml @@ -24,8 +24,6 @@ concurrency: env: YQ_VERSION: v4.54.1 STANDARDS_REF: 087f6bff5135ce3da6f287c83752256ce4a8efc1 - STANDARDS_ROOT: ${{ runner.temp }}/panic-ci-integrity-standards - ACTIONS_LOCK_EVIDENCE_DIR: ${{ runner.temp }}/panic-ci-integrity-evidence jobs: integrity: @@ -33,6 +31,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Initialize runner-local paths and evidence + shell: bash + run: | + set -euo pipefail + standards_root="$RUNNER_TEMP/panic-ci-integrity-standards" + evidence_dir="$RUNNER_TEMP/panic-ci-integrity-evidence" + mkdir -p "$evidence_dir" + printf 'not_run\n' > "$evidence_dir/native.exit" + { + printf 'STANDARDS_ROOT=%s\n' "$standards_root" + printf 'ACTIONS_LOCK_EVIDENCE_DIR=%s\n' "$evidence_dir" + } >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -41,8 +52,6 @@ jobs: - name: Install checksum-qualified YAML and native lock tools run: | set -euo pipefail - mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR" - printf 'not_run\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" tool_dir="$RUNNER_TEMP/panic-ci-integrity-bin" mkdir -p "$tool_dir" curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --max-time 60 \ From e711e35819450d78784de4787e25c8597ea878e3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:38:07 +0000 Subject: [PATCH 10/14] fix(ci): admit the existing pinned SLSA reusable workflow to tree policy --- .github/actions-allowlist.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions-allowlist.json b/.github/actions-allowlist.json index 4e8035e..121d072 100644 --- a/.github/actions-allowlist.json +++ b/.github/actions-allowlist.json @@ -88,6 +88,7 @@ "rustsec/audit-check@*", "shivammathur/setup-php@*", "slsa-framework/slsa-github-generator@*", + "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a", "softprops/action-gh-release@*", "SonarSource/sonarcloud-github-action@*", "SonarSource/sonarqube-scan-action@*", From 41cc818f455c63b5d85d75c6237e2184effc4941 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:49:27 +0000 Subject: [PATCH 11/14] Extract bounded input checks and clarify CI shell cases Split the long HTML, Forth, interpreter-argument, Containerfile and Just readers into documented helpers while retaining check order, findings, source locations and unsupported-input errors. Replace the two redundant OsStr closures and make the existing shell case fallthroughs and test argument explicit. The existing regression populations, coverage decisions, workflow references and native lock are unchanged. Compilation, formatting, regression execution and Sonar qualification await hosted CI; no local pass is claimed. --- .githooks/validate-actions-lock.sh | 1 + scripts/ci/check-allowed-actions.sh | 6 +- scripts/ci/test-ci-integrity.sh | 3 +- src/assail/build_inputs.rs | 527 ++++++++++++++++------------ src/assail/configuration_scripts.rs | 2 +- src/assail/forth.rs | 106 +++--- src/assail/templates.rs | 331 +++++++++-------- 7 files changed, 554 insertions(+), 422 deletions(-) diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index 3c37057..74650e8 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -17,6 +17,7 @@ assert_clean_checkout() { echo "validate-actions-lock: unsafe index flags in $checkout; source bytes are unqualified" >&2 exit 2 ;; + *) : ;; # Other index flags continue through the full cleanliness check. esac done git_state="$(git -C "$checkout" status --porcelain --untracked-files=all --ignored=matching)" || diff --git a/scripts/ci/check-allowed-actions.sh b/scripts/ci/check-allowed-actions.sh index 34b6d6d..09cda2a 100755 --- a/scripts/ci/check-allowed-actions.sh +++ b/scripts/ci/check-allowed-actions.sh @@ -31,12 +31,16 @@ covered() { case "$ref" in ./*|docker://*) return 0 ;; '$/'*) return 1 ;; + *) : ;; # Remote coordinates must pass the immutable-ref checks below. esac [[ "$ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*@[0-9a-fA-F]{40}$ ]] || return 1 action="${ref%@*}" sha="${ref##*@}" sha="${sha,,}" - case "$action" in */../*|*/./*|*/..|*/.) return 1 ;; esac + case "$action" in + */../*|*/./*|*/..|*/.) return 1 ;; + *) : ;; # Ordinary coordinates still require a full policy match. + esac owner="${action%%/*}"; tail="${action#*/}"; repo="${tail%%/*}" suffix="${tail#"$repo"}" action="${owner,,}/${repo,,}$suffix" diff --git a/scripts/ci/test-ci-integrity.sh b/scripts/ci/test-ci-integrity.sh index ad3e423..c6335c3 100755 --- a/scripts/ci/test-ci-integrity.sh +++ b/scripts/ci/test-ci-integrity.sh @@ -29,7 +29,8 @@ expect() { printf 'PASS %s\n' "$label" } workflow() { - jq -n --arg ref "$1" \ + local ref=$1 + jq -n --arg ref "$ref" \ '{on:{push:{}},jobs:{gate:{uses:$ref}}}' > "$work/workflows/test.yml" } check() { bash "$checker" "$policy" "$work/workflows"; } diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs index d6c9814..5c4b5f2 100644 --- a/src/assail/build_inputs.rs +++ b/src/assail/build_inputs.rs @@ -38,6 +38,91 @@ fn interpreter(command: &str) -> Result { } } +/// Consume shell options without changing when the caller selects inline source. +fn shell_option( + arg: &str, + argv: &[String], + index: &mut usize, + shell_command: &mut bool, +) -> Result<()> { + match arg { + "--noprofile" | "--norc" | "--posix" | "--restricted" | "--verbose" | "--login" => {} + "--rcfile" | "--init-file" => { + *index += 1; + ensure!(argv.get(*index).is_some(), "shell option lacks its argument"); + } + _ if !arg.starts_with("--") => { + let flags = &arg[1..]; + for (offset, flag) in flags.char_indices() { + match flag { + 'c' if arg.starts_with('-') => *shell_command = true, + 'o' | 'O' => { + if offset + 1 != flags.len() { + return Err(unsupported( + "attached named shell option is not extracted", + )); + } + *index += 1; + ensure!( + argv.get(*index).is_some(), + "named shell option lacks its argument" + ); + } + 'a' | 'b' | 'e' | 'f' | 'h' | 'k' | 'm' | 'n' | 'p' | 't' | 'u' + | 'v' | 'x' | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' | 'i' | 'l' | 'r' + | 's' | 'D' => {} + _ => return Err(unsupported("unrecognised shell invocation option")), + } + } + } + _ => return Err(unsupported("unrecognised shell invocation option")), + } + Ok(()) +} + +/// A non-shell interpreter option either identifies its source, terminates +/// extraction, or remains unsupported; later argv words are never scanned. +fn interpreter_option( + language: Language, + arg: &str, + argv: &[String], + index: usize, +) -> Result> { + let option = if language == Language::Python { + "-c" + } else { + "-e" + }; + if arg == option + || language == Language::JavaScript + && matches!(arg, "--eval" | "--print" | "-p") + { + return Ok(Some( + argv.get(index + 1) + .ok_or_else(|| anyhow!("inline interpreter option has no source"))? + .clone(), + )); + } + if let Some(source) = arg.strip_prefix(option).filter(|s| !s.is_empty()) { + return Ok(Some(source.into())); + } + if language == Language::JavaScript { + if let Some(source) = arg + .strip_prefix("--eval=") + .or_else(|| arg.strip_prefix("--print=")) + .or_else(|| arg.strip_prefix("-p").filter(|s| !s.is_empty())) + { + return Ok(Some(source.into())); + } + } + if matches!(arg, "-m" | "--version" | "--help") { + return Ok(None); + } + Err(unsupported( + "inline interpreter invocation options require an adapter", + )) +} + /// Extract inline shell, Python or JavaScript source from an interpreter argv. /// Returns `None` for invocations without inline source; later arguments are not /// treated as source. @@ -65,73 +150,9 @@ fn inline_source(name: &str, argv: &[String]) -> Result> { return Ok(shell_command.then(|| arg.clone())); } if language == Language::Shell { - match arg.as_str() { - "--noprofile" | "--norc" | "--posix" | "--restricted" | "--verbose" | "--login" => { - } - "--rcfile" | "--init-file" => { - index += 1; - ensure!(argv.get(index).is_some(), "shell option lacks its argument"); - } - _ if !arg.starts_with("--") => { - let flags = &arg[1..]; - for (offset, flag) in flags.char_indices() { - match flag { - 'c' if arg.starts_with('-') => shell_command = true, - 'o' | 'O' => { - if offset + 1 != flags.len() { - return Err(unsupported( - "attached named shell option is not extracted", - )); - } - index += 1; - ensure!( - argv.get(index).is_some(), - "named shell option lacks its argument" - ); - } - 'a' | 'b' | 'e' | 'f' | 'h' | 'k' | 'm' | 'n' | 'p' | 't' | 'u' - | 'v' | 'x' | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' | 'i' | 'l' | 'r' - | 's' | 'D' => {} - _ => return Err(unsupported("unrecognised shell invocation option")), - } - } - } - _ => return Err(unsupported("unrecognised shell invocation option")), - } + shell_option(arg, argv, &mut index, &mut shell_command)?; } else { - let option = if language == Language::Python { - "-c" - } else { - "-e" - }; - if arg == option - || language == Language::JavaScript - && matches!(arg.as_str(), "--eval" | "--print" | "-p") - { - return Ok(Some( - argv.get(index + 1) - .ok_or_else(|| anyhow!("inline interpreter option has no source"))? - .clone(), - )); - } - if let Some(source) = arg.strip_prefix(option).filter(|s| !s.is_empty()) { - return Ok(Some(source.into())); - } - if language == Language::JavaScript { - if let Some(source) = arg - .strip_prefix("--eval=") - .or_else(|| arg.strip_prefix("--print=")) - .or_else(|| arg.strip_prefix("-p").filter(|s| !s.is_empty())) - { - return Ok(Some(source.into())); - } - } - if matches!(arg.as_str(), "-m" | "--version" | "--help") { - return Ok(None); - } - return Err(unsupported( - "inline interpreter invocation options require an adapter", - )); + return interpreter_option(language, arg, argv, index); } index += 1; } @@ -302,13 +323,9 @@ fn literal_fields( Ok(()) } -/// Check supported Containerfile instructions and extract executable source. -/// Returns findings and scripts for later interpreter checks; no build is run. -/// -/// # Errors -/// Returns errors for malformed instructions or command vectors, and unsupported -/// instructions, interpreters or constructs such as heredocs and `ONBUILD`. -pub(super) fn container(content: &str, file: &str) -> Result { +/// Join physical lines in their original order before interpreting instructions. +/// Unsupported escapes and unfinished continuations remain errors. +fn container_instructions(content: &str) -> Result> { let mut instructions = Vec::new(); let mut current = String::new(); let mut first_line = 0; @@ -332,6 +349,135 @@ pub(super) fn container(content: &str, file: &str) -> Result { } } ensure!(current.is_empty(), "unfinished Containerfile continuation"); + Ok(instructions) +} + +/// Validate and record one FROM stage before the caller resets its shell. +fn container_from( + args: &str, + file: &str, + line: usize, + stages: &mut BTreeSet, + result: &mut Analysis, +) -> Result<()> { + let fields: Vec<_> = args + .split_whitespace() + .filter(|field| !field.starts_with("--platform=")) + .collect(); + ensure!( + fields.len() == 1 || fields.len() == 3 && fields[1].eq_ignore_ascii_case("as"), + "invalid FROM instruction at line {line}" + ); + let image = fields[0]; + let pinned = image_pinned(image); + if image != "scratch" && !stages.contains(&image.to_ascii_lowercase()) && !pinned { + result.findings.push(finding(file, line, WeakPointCategory::SupplyChain, "Container base image lacks a complete SHA256 digest; local stage aliases are accepted")); + } + if fields.len() == 3 { + stages.insert(fields[2].to_ascii_lowercase()); + } + Ok(()) +} + +/// Remove instruction options before checking heredocs and executable source. +fn container_command( + args: &str, + line: usize, + shell: Language, + result: &mut Analysis, +) -> Result<()> { + let mut command = args; + while command.starts_with("--") { + command = command + .split_once(char::is_whitespace) + .ok_or_else(|| anyhow!("build command has only options"))? + .1 + .trim_start(); + } + if command.contains("<<") { + return Err(unsupported( + "Containerfile heredoc extraction is not implemented", + )); + } + command_input(command, line, shell, result) +} + +/// Check remote ADD pinning without fetching or interpreting the resource. +fn container_add(args: &str, file: &str, line: usize, result: &mut Analysis) { + let checksum = args + .split_whitespace() + .find_map(|arg| arg.strip_prefix("--checksum=sha256:")); + let pinned = checksum.is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if (args.contains("https://") || args.contains("http://")) && !pinned { + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "Remote ADD has no SHA256 checksum", + )); + } +} + +/// Resolve only already-declared stages or literal external image coordinates. +/// Duplicate, missing and unresolved COPY sources keep their existing errors. +fn container_copy( + args: &str, + file: &str, + line: usize, + stages: &BTreeSet, + stage_count: usize, + result: &mut Analysis, +) -> Result<()> { + let words = field_words(args)?; + let mut source = None; + let mut fields = words.iter(); + while let Some(field) = fields.next().filter(|field| field.starts_with("--")) { + let value = if field == "--from" { + Some( + fields + .next() + .ok_or_else(|| anyhow!("COPY --from has no value"))? + .as_str(), + ) + } else { + field.strip_prefix("--from=") + }; + if let Some(value) = value { + ensure!( + source.is_none() && !value.is_empty(), + "duplicate or empty COPY --from" + ); + source = Some(value); + } + } + if let Some(source) = source { + if let Ok(index) = source.parse::() { + ensure!(index < stage_count, "COPY stage index does not exist"); + } else if !stages.contains(&source.to_ascii_lowercase()) && !image_pinned(source) { + if source.contains('$') || !source.contains([':', '/', '@']) { + return Err(unsupported("COPY source is an unresolved external image or named build context")); + } + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "COPY external image lacks a complete SHA256 digest", + )); + } + } + Ok(()) +} + +/// Check supported Containerfile instructions and extract executable source. +/// Returns findings and scripts for later interpreter checks; no build is run. +/// +/// # Errors +/// Returns errors for malformed instructions or command vectors, and unsupported +/// instructions, interpreters or constructs such as heredocs and `ONBUILD`. +pub(super) fn container(content: &str, file: &str) -> Result { + let instructions = container_instructions(content)?; let mut result = Analysis::default(); let mut stages = BTreeSet::new(); let mut shell = Language::Shell; @@ -354,22 +500,7 @@ pub(super) fn container(content: &str, file: &str) -> Result { } match name.as_str() { "FROM" => { - let fields: Vec<_> = args - .split_whitespace() - .filter(|field| !field.starts_with("--platform=")) - .collect(); - ensure!( - fields.len() == 1 || fields.len() == 3 && fields[1].eq_ignore_ascii_case("as"), - "invalid FROM instruction at line {line}" - ); - let image = fields[0]; - let pinned = image_pinned(image); - if image != "scratch" && !stages.contains(&image.to_ascii_lowercase()) && !pinned { - result.findings.push(finding(file, line, WeakPointCategory::SupplyChain, "Container base image lacks a complete SHA256 digest; local stage aliases are accepted")); - } - if fields.len() == 3 { - stages.insert(fields[2].to_ascii_lowercase()); - } + container_from(args, file, line, &mut stages, &mut result)?; seen_from = true; stage_count += 1; shell = Language::Shell; @@ -382,20 +513,7 @@ pub(super) fn container(content: &str, file: &str) -> Result { )?; } "RUN" | "CMD" | "ENTRYPOINT" => { - let mut command = args; - while command.starts_with("--") { - command = command - .split_once(char::is_whitespace) - .ok_or_else(|| anyhow!("build command has only options"))? - .1 - .trim_start(); - } - if command.contains("<<") { - return Err(unsupported( - "Containerfile heredoc extraction is not implemented", - )); - } - command_input(command, line, shell, &mut result)?; + container_command(args, line, shell, &mut result)?; } "HEALTHCHECK" => { if !args.eq_ignore_ascii_case("NONE") { @@ -405,62 +523,9 @@ pub(super) fn container(content: &str, file: &str) -> Result { command_input(command.trim(), line, shell, &mut result)?; } } - "ADD" => { - let checksum = args - .split_whitespace() - .find_map(|arg| arg.strip_prefix("--checksum=sha256:")); - let pinned = checksum.is_some_and(|digest| { - digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) - }); - if (args.contains("https://") || args.contains("http://")) && !pinned { - result.findings.push(finding( - file, - line, - WeakPointCategory::SupplyChain, - "Remote ADD has no SHA256 checksum", - )); - } - } + "ADD" => container_add(args, file, line, &mut result), "COPY" => { - let words = field_words(args)?; - let mut source = None; - let mut fields = words.iter(); - while let Some(field) = fields.next().filter(|field| field.starts_with("--")) { - let value = if field == "--from" { - Some( - fields - .next() - .ok_or_else(|| anyhow!("COPY --from has no value"))? - .as_str(), - ) - } else { - field.strip_prefix("--from=") - }; - if let Some(value) = value { - ensure!( - source.is_none() && !value.is_empty(), - "duplicate or empty COPY --from" - ); - source = Some(value); - } - } - if let Some(source) = source { - if let Ok(index) = source.parse::() { - ensure!(index < stage_count, "COPY stage index does not exist"); - } else if !stages.contains(&source.to_ascii_lowercase()) - && !image_pinned(source) - { - if source.contains('$') || !source.contains([':', '/', '@']) { - return Err(unsupported("COPY source is an unresolved external image or named build context")); - } - result.findings.push(finding( - file, - line, - WeakPointCategory::SupplyChain, - "COPY external image lacks a complete SHA256 digest", - )); - } - } + container_copy(args, file, line, &stages, stage_count, &mut result)?; } "USER" => { let user = args.split(':').next().unwrap_or(""); @@ -537,6 +602,87 @@ fn recipe_script( Ok(()) } +/// Check one literal Just assignment without evaluating backticks or shell(). +fn just_assignment(text: &str, file: &str, line: usize, result: &mut Analysis) -> Result<()> { + if text.contains('`') || text.contains("shell(") { + return Err(unsupported( + "executable Just variable expression is not extracted", + )); + } + let (name, value) = text.split_once(":=").expect("assignment matched"); + let value = value.trim(); + if value.len() >= 2 + && (value.starts_with('"') && value.ends_with('"') + || value.starts_with('\'') && value.ends_with('\'')) + { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("Just declaration at line {}", line), + name.trim().trim_start_matches("export ").trim(), + &value[1..value.len() - 1], + ) { + result.findings.push(finding); + } + } + Ok(()) +} + +/// Process a top-level declaration after the preceding recipe was flushed. +/// Only a recipe header returns true; unsupported declarations remain errors. +fn just_declaration( + text: &str, + file: &str, + line: usize, + default: &mut Language, + result: &mut Analysis, +) -> Result { + let setting = text + .strip_prefix("set") + .filter(|rest| rest.starts_with(char::is_whitespace)) + .map(str::trim_start); + let shell_setting = setting + .and_then(|rest| rest.strip_prefix("shell")) + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix(":=")); + if let Some(value) = shell_setting { + let argv: Vec = serde_json::from_str(value.trim())?; + *default = interpreter(argv.first().ok_or_else(|| anyhow!("empty Just shell"))?)?; + } else if let Some(setting) = setting { + if setting.starts_with("shell") + || setting.contains("interpreter") + || setting.contains("windows-shell") + { + return Err(unsupported( + "additional Just interpreter setting requires an adapter", + )); + } + } else if text.starts_with("import ") + || text.starts_with("import? ") + || text.starts_with("mod ") + || text.starts_with("mod? ") + || text.starts_with("alias ") + { + // Inventory covers local *.just files independently. Imports and + // variable interpolation are never executed or presented as checked. + } else if text.starts_with('[') && text.ends_with(']') { + if text.contains("script") { + return Err(unsupported( + "Just script attributes require interpreter extraction", + )); + } + } else if text.contains(":=") { + just_assignment(text, file, line, result)?; + } else if text.contains(':') { + return Ok(true); + } else { + return Err(unsupported(&format!( + "unrecognised Just declaration at line {}", + line + ))); + } + Ok(false) +} + /// Check recognised Just declarations and extract recipe bodies for later checks. /// Imports and interpolation are not resolved and recipes are not run. /// @@ -576,70 +722,7 @@ pub(super) fn just(content: &str, file: &str) -> Result { recipe_script(&body, default, &mut result)?; body.clear(); indentation = None; - in_recipe = false; - let setting = text - .strip_prefix("set") - .filter(|rest| rest.starts_with(char::is_whitespace)) - .map(str::trim_start); - let shell_setting = setting - .and_then(|rest| rest.strip_prefix("shell")) - .map(str::trim_start) - .and_then(|rest| rest.strip_prefix(":=")); - if let Some(value) = shell_setting { - let argv: Vec = serde_json::from_str(value.trim())?; - default = interpreter(argv.first().ok_or_else(|| anyhow!("empty Just shell"))?)?; - } else if let Some(setting) = setting { - if setting.starts_with("shell") - || setting.contains("interpreter") - || setting.contains("windows-shell") - { - return Err(unsupported( - "additional Just interpreter setting requires an adapter", - )); - } - } else if text.starts_with("import ") - || text.starts_with("import? ") - || text.starts_with("mod ") - || text.starts_with("mod? ") - || text.starts_with("alias ") - { - // Inventory covers local *.just files independently. Imports and - // variable interpolation are never executed or presented as checked. - } else if text.starts_with('[') && text.ends_with(']') { - if text.contains("script") { - return Err(unsupported( - "Just script attributes require interpreter extraction", - )); - } - } else if text.contains(":=") { - if text.contains('`') || text.contains("shell(") { - return Err(unsupported( - "executable Just variable expression is not extracted", - )); - } - let (name, value) = text.split_once(":=").expect("assignment matched"); - let value = value.trim(); - if value.len() >= 2 - && (value.starts_with('"') && value.ends_with('"') - || value.starts_with('\'') && value.ends_with('\'')) - { - if let Some(finding) = super::configuration::literal_credential( - file, - &format!("Just declaration at line {}", offset + 1), - name.trim().trim_start_matches("export ").trim(), - &value[1..value.len() - 1], - ) { - result.findings.push(finding); - } - } - } else if text.contains(':') { - in_recipe = true; - } else { - return Err(unsupported(&format!( - "unrecognised Just declaration at line {}", - offset + 1 - ))); - } + in_recipe = just_declaration(text, file, offset + 1, &mut default, &mut result)?; } recipe_script(&body, default, &mut result)?; Ok(result) diff --git a/src/assail/configuration_scripts.rs b/src/assail/configuration_scripts.rs index 9168f3b..6fbec85 100644 --- a/src/assail/configuration_scripts.rs +++ b/src/assail/configuration_scripts.rs @@ -167,7 +167,7 @@ pub(super) fn extract( } } if format == Format::Json - && Path::new(file).file_name().and_then(|s| s.to_str()) == Some("package.json") + && Path::new(file).file_name().and_then(std::ffi::OsStr::to_str) == Some("package.json") { if let Some(values) = document.get("scripts") { let values = values diff --git a/src/assail/forth.rs b/src/assail/forth.rs index a7abb8c..7a99343 100644 --- a/src/assail/forth.rs +++ b/src/assail/forth.rs @@ -6,6 +6,64 @@ use super::input_adapters::{Analysis, UnsupportedInput}; use crate::types::{Severity, WeakPoint, WeakPointCategory}; use anyhow::{ensure, Result}; +/// Advance through one parenthesised comment, retaining source line positions. +fn parenthesised_comment(bytes: &[u8], offset: &mut usize, line: &mut usize) -> Result<()> { + while *offset < bytes.len() && bytes[*offset] != b')' { + if bytes[*offset] == b'\n' { + *line += 1; + } + *offset += 1; + } + ensure!( + *offset < bytes.len(), + "unterminated Forth parenthesised comment" + ); + if *offset < bytes.len() { + *offset += 1; + } + Ok(()) +} + +/// Read one literal without interpreting its words; escaped newlines retain +/// the same line accounting as ordinary string bytes. +fn string_literal( + content: &str, + offset: &mut usize, + line: &mut usize, + escapes: bool, +) -> Result<(usize, String, bool)> { + let bytes = content.as_bytes(); + let string_start = *offset; + let first_line = *line; + let mut string_end = *offset; + let mut closed = false; + while *offset < bytes.len() { + if bytes[*offset] == b'\n' { + *line += 1; + } + if escapes && bytes[*offset] == b'\\' && *offset + 1 < bytes.len() { + if bytes[*offset + 1] == b'\n' { + *line += 1; + } + *offset += 2; + continue; + } + if bytes[*offset] == b'"' { + string_end = *offset; + *offset += 1; + closed = true; + break; + } + *offset += 1; + } + ensure!(closed, "unterminated Forth string"); + Ok(( + first_line, + content[string_start..string_end].trim().into(), + true, + )) +} + /// The final flag distinguishes literal data from executable words. Keeping the /// data lets named string constants receive credential checks without executing /// or interpreting words embedded in those strings. @@ -33,52 +91,10 @@ fn words(content: &str) -> Result> { offset += 1; } } - "(" => { - while offset < bytes.len() && bytes[offset] != b')' { - if bytes[offset] == b'\n' { - line += 1; - } - offset += 1; - } - ensure!( - offset < bytes.len(), - "unterminated Forth parenthesised comment" - ); - if offset < bytes.len() { - offset += 1; - } - } + "(" => parenthesised_comment(bytes, &mut offset, &mut line)?, ".\"" | "s\"" | "c\"" | "s\\\"" | ".\\\"" | "abort\"" => { let escapes = matches!(word.as_str(), "s\\\"" | ".\\\""); - let string_start = offset; - let first_line = line; - let mut string_end = offset; - let mut closed = false; - while offset < bytes.len() { - if bytes[offset] == b'\n' { - line += 1; - } - if escapes && bytes[offset] == b'\\' && offset + 1 < bytes.len() { - if bytes[offset + 1] == b'\n' { - line += 1; - } - offset += 2; - continue; - } - if bytes[offset] == b'"' { - string_end = offset; - offset += 1; - closed = true; - break; - } - offset += 1; - } - ensure!(closed, "unterminated Forth string"); - result.push(( - first_line, - content[string_start..string_end].trim().into(), - true, - )); + result.push(string_literal(content, &mut offset, &mut line, escapes)?); } _ => result.push((line, word, false)), } @@ -138,7 +154,7 @@ pub(super) fn analyze(content: &str, file: &str) -> Result { // .fs is shared with F#. Never infer Forth merely from that suffix. if std::path::Path::new(file) .extension() - .and_then(|extension| extension.to_str()) + .and_then(std::ffi::OsStr::to_str) .is_some_and(|extension| extension.eq_ignore_ascii_case("fs")) && !has_colon_definition(&words) { diff --git a/src/assail/templates.rs b/src/assail/templates.rs index 4f23f0c..43c71a2 100644 --- a/src/assail/templates.rs +++ b/src/assail/templates.rs @@ -47,6 +47,183 @@ fn finding( } } +/// Read raw attribute spelling without decoding character references. +fn html_attribute(child: Node<'_>, content: &str) -> Result<(String, String)> { + let mut name = String::new(); + let mut value = String::new(); + for part in child.named_children(&mut child.walk()) { + match part.kind() { + "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), + "attribute_value" | "quoted_attribute_value" => { + value = text(part, content)?.trim_matches(['\'', '"']).to_owned() + } + _ => {} + } + } + Ok((name, value)) +} + +/// Preserve attribute check order: embedded code, URLs, then literal credentials. +/// URL normalization also supplies the value used by the tag's SRI check. +fn check_html_attribute( + file: &str, + child: Node<'_>, + name: &str, + value: &mut String, + result: &mut Analysis, +) -> Result<()> { + if name.starts_with("on") && name.len() > 2 { + ensure_attribute_decoded(value)?; + result.scripts.push(EmbeddedScript { + content: value.clone(), + language: Language::JavaScript, + origin: format!( + "source block beginning at line {}", + child.start_position().row + 1 + ), + }); + } + if name == "style" && !value.is_empty() { + ensure_attribute_decoded(value)?; + let inline = css(&format!("element {{{value}}}"), file)?; + for mut finding in inline.findings { + finding.line = Some(child.start_position().row as u32 + 1); + finding.location = Some(format!("{file}:{}", child.start_position().row + 1)); + result.findings.push(finding); + } + } + if matches!(name, "src" | "href" | "action" | "formaction" | "poster") { + ensure_attribute_decoded(value)?; + // WHATWG URL parsing trims outer C0 controls/spaces + // and removes ASCII tab/newline characters before + // scheme parsing. Keep the same value for SRI below. + *value = value + .trim_matches(|ch: char| ch <= '\u{20}') + .chars() + .filter(|ch| !matches!(ch, '\t' | '\n' | '\r')) + .collect(); + let lower = value.to_ascii_lowercase(); + if lower.starts_with("javascript:") || lower.starts_with("vbscript:") { + result.findings.push(finding( + file, + child, + WeakPointCategory::DynamicCodeExecution, + Severity::High, + "HTML attribute contains an executable URL scheme", + )); + } + if lower.starts_with("http://") { + result.findings.push(finding( + file, + child, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "HTML resource or navigation uses plaintext HTTP", + )); + } + } + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("HTML attribute at line {}", child.start_position().row + 1), + name.strip_prefix("data-").unwrap_or(name), + value, + ) { + result.findings.push(finding); + } + Ok(()) +} + +/// Reject duplicates before examining each attribute and check remote-script +/// integrity only after the normalized attribute map is complete. +fn html_start_tag(node: Node<'_>, content: &str, file: &str, result: &mut Analysis) -> Result<()> { + let mut tag = String::new(); + let mut attributes = BTreeMap::new(); + for child in node.named_children(&mut node.walk()) { + if child.kind() == "tag_name" { + tag = text(child, content)?.to_ascii_lowercase(); + } + if child.kind() != "attribute" { + continue; + } + let (name, mut value) = html_attribute(child, content)?; + ensure!( + !attributes.contains_key(&name), + "duplicate HTML attribute {name}; browser interpretation is ambiguous" + ); + check_html_attribute(file, child, &name, &mut value, result)?; + attributes.insert(name, value); + } + if tag == "script" + && attributes.get("src").is_some_and(|src| { + src.to_ascii_lowercase().starts_with("https://") + || src.to_ascii_lowercase().starts_with("http://") + || src.starts_with("//") + }) + && !attributes + .get("integrity") + .is_some_and(|value| usable_integrity(value)) + { + result.findings.push(finding( + file, + node, + WeakPointCategory::SupplyChain, + Severity::Medium, + "Remote script has no syntactically usable supported integrity digest", + )); + } + Ok(()) +} + +/// Preserve the source type and relative line mapping of embedded HTML blocks. +fn html_raw_text(node: Node<'_>, content: &str, file: &str, result: &mut Analysis) -> Result<()> { + match node.parent().map(|parent| parent.kind()) { + Some("script_element") => { + let kind = element_type(node.parent().expect("parent matched"), content)?; + match kind.as_str() { + "" | "module" | "text/javascript" | "application/javascript" => { + result.scripts.push(EmbeddedScript { + content: text(node, content)?.into(), + language: Language::JavaScript, + origin: format!( + "source block beginning at line {}", + node.start_position().row + 1 + ), + }) + } + "application/json" | "application/ld+json" | "importmap" => { + result.findings.extend( + super::configuration::Format::Json.analyze(text(node, content)?, file)?, + ) + } + _ => { + return Err(UnsupportedInput(format!( + "embedded script type {kind:?} has no selected adapter" + )) + .into()) + } + } + } + Some("style_element") => { + let kind = element_type(node.parent().expect("parent matched"), content)?; + if !kind.is_empty() && kind != "text/css" { + return Err(UnsupportedInput(format!( + "embedded style type {kind:?} is not CSS" + )) + .into()); + } + let nested = css(text(node, content)?, file)?; + for mut finding in nested.findings { + let line = finding.line.unwrap_or(1) + node.start_position().row as u32; + finding.line = Some(line); + finding.location = Some(format!("{file}:{line}")); + result.findings.push(finding); + } + } + _ => {} + } + Ok(()) +} + /// Check parsed HTML attributes and embedded CSS or JSON, and extract JavaScript /// for later analysis. Remote-script integrity checks validate metadata syntax /// only; they do not fetch the resource or verify its digest. @@ -61,159 +238,9 @@ pub(super) fn html(content: &str, file: &str) -> Result { while let Some(node) = pending.pop() { match node.kind() { "start_tag" | "self_closing_tag" => { - let mut tag = String::new(); - let mut attributes = BTreeMap::new(); - for child in node.named_children(&mut node.walk()) { - if child.kind() == "tag_name" { - tag = text(child, content)?.to_ascii_lowercase(); - } - if child.kind() != "attribute" { - continue; - } - let mut name = String::new(); - let mut value = String::new(); - for part in child.named_children(&mut child.walk()) { - match part.kind() { - "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), - "attribute_value" | "quoted_attribute_value" => { - value = text(part, content)?.trim_matches(['\'', '"']).to_owned() - } - _ => {} - } - } - ensure!( - !attributes.contains_key(&name), - "duplicate HTML attribute {name}; browser interpretation is ambiguous" - ); - if name.starts_with("on") && name.len() > 2 { - ensure_attribute_decoded(&value)?; - result.scripts.push(EmbeddedScript { - content: value.clone(), - language: Language::JavaScript, - origin: format!( - "source block beginning at line {}", - child.start_position().row + 1 - ), - }); - } - if name == "style" && !value.is_empty() { - ensure_attribute_decoded(&value)?; - let inline = css(&format!("element {{{value}}}"), file)?; - for mut finding in inline.findings { - finding.line = Some(child.start_position().row as u32 + 1); - finding.location = - Some(format!("{file}:{}", child.start_position().row + 1)); - result.findings.push(finding); - } - } - if matches!( - name.as_str(), - "src" | "href" | "action" | "formaction" | "poster" - ) { - ensure_attribute_decoded(&value)?; - // WHATWG URL parsing trims outer C0 controls/spaces - // and removes ASCII tab/newline characters before - // scheme parsing. Keep the same value for SRI below. - value = value - .trim_matches(|ch: char| ch <= '\u{20}') - .chars() - .filter(|ch| !matches!(ch, '\t' | '\n' | '\r')) - .collect(); - let lower = value.to_ascii_lowercase(); - if lower.starts_with("javascript:") || lower.starts_with("vbscript:") { - result.findings.push(finding( - file, - child, - WeakPointCategory::DynamicCodeExecution, - Severity::High, - "HTML attribute contains an executable URL scheme", - )); - } - if lower.starts_with("http://") { - result.findings.push(finding( - file, - child, - WeakPointCategory::InsecureProtocol, - Severity::Medium, - "HTML resource or navigation uses plaintext HTTP", - )); - } - } - if let Some(finding) = super::configuration::literal_credential( - file, - &format!("HTML attribute at line {}", child.start_position().row + 1), - name.strip_prefix("data-").unwrap_or(&name), - &value, - ) { - result.findings.push(finding); - } - attributes.insert(name, value); - } - if tag == "script" - && attributes.get("src").is_some_and(|src| { - src.to_ascii_lowercase().starts_with("https://") - || src.to_ascii_lowercase().starts_with("http://") - || src.starts_with("//") - }) - && !attributes - .get("integrity") - .is_some_and(|value| usable_integrity(value)) - { - result.findings.push(finding( - file, - node, - WeakPointCategory::SupplyChain, - Severity::Medium, - "Remote script has no syntactically usable supported integrity digest", - )); - } + html_start_tag(node, content, file, &mut result)?; } - "raw_text" => match node.parent().map(|parent| parent.kind()) { - Some("script_element") => { - let kind = element_type(node.parent().expect("parent matched"), content)?; - match kind.as_str() { - "" | "module" | "text/javascript" | "application/javascript" => { - result.scripts.push(EmbeddedScript { - content: text(node, content)?.into(), - language: Language::JavaScript, - origin: format!( - "source block beginning at line {}", - node.start_position().row + 1 - ), - }) - } - "application/json" | "application/ld+json" | "importmap" => { - result.findings.extend( - super::configuration::Format::Json - .analyze(text(node, content)?, file)?, - ) - } - _ => { - return Err(UnsupportedInput(format!( - "embedded script type {kind:?} has no selected adapter" - )) - .into()) - } - } - } - Some("style_element") => { - let kind = element_type(node.parent().expect("parent matched"), content)?; - if !kind.is_empty() && kind != "text/css" { - return Err(UnsupportedInput(format!( - "embedded style type {kind:?} is not CSS" - )) - .into()); - } - let nested = css(text(node, content)?, file)?; - for mut finding in nested.findings { - let line = finding.line.unwrap_or(1) + node.start_position().row as u32; - finding.line = Some(line); - finding.location = Some(format!("{file}:{line}")); - result.findings.push(finding); - } - } - _ => {} - }, + "raw_text" => html_raw_text(node, content, file, &mut result)?, _ => {} } pending.extend(node.named_children(&mut node.walk())); From 00638da12663d5ee1421340ba9ff0a8504bc4cc0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:59:15 +0000 Subject: [PATCH 12/14] Apply hosted rustfmt output for extracted input checks Apply exactly eight formatter hunks reported by job126922752314 at the 41cc source. No condition, finding, test, workflow or native-lock behavior changes. The formatter rerun remains pending hosted CI. --- src/assail/build_inputs.rs | 24 ++++++++++++++++-------- src/assail/configuration_scripts.rs | 5 ++++- src/assail/templates.rs | 15 ++++++--------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs index 5c4b5f2..1106b8e 100644 --- a/src/assail/build_inputs.rs +++ b/src/assail/build_inputs.rs @@ -49,7 +49,10 @@ fn shell_option( "--noprofile" | "--norc" | "--posix" | "--restricted" | "--verbose" | "--login" => {} "--rcfile" | "--init-file" => { *index += 1; - ensure!(argv.get(*index).is_some(), "shell option lacks its argument"); + ensure!( + argv.get(*index).is_some(), + "shell option lacks its argument" + ); } _ if !arg.starts_with("--") => { let flags = &arg[1..]; @@ -68,9 +71,8 @@ fn shell_option( "named shell option lacks its argument" ); } - 'a' | 'b' | 'e' | 'f' | 'h' | 'k' | 'm' | 'n' | 'p' | 't' | 'u' - | 'v' | 'x' | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' | 'i' | 'l' | 'r' - | 's' | 'D' => {} + 'a' | 'b' | 'e' | 'f' | 'h' | 'k' | 'm' | 'n' | 'p' | 't' | 'u' | 'v' | 'x' + | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' | 'i' | 'l' | 'r' | 's' | 'D' => {} _ => return Err(unsupported("unrecognised shell invocation option")), } } @@ -94,8 +96,7 @@ fn interpreter_option( "-e" }; if arg == option - || language == Language::JavaScript - && matches!(arg, "--eval" | "--print" | "-p") + || language == Language::JavaScript && matches!(arg, "--eval" | "--print" | "-p") { return Ok(Some( argv.get(index + 1) @@ -371,7 +372,12 @@ fn container_from( let image = fields[0]; let pinned = image_pinned(image); if image != "scratch" && !stages.contains(&image.to_ascii_lowercase()) && !pinned { - result.findings.push(finding(file, line, WeakPointCategory::SupplyChain, "Container base image lacks a complete SHA256 digest; local stage aliases are accepted")); + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "Container base image lacks a complete SHA256 digest; local stage aliases are accepted", + )); } if fields.len() == 3 { stages.insert(fields[2].to_ascii_lowercase()); @@ -457,7 +463,9 @@ fn container_copy( ensure!(index < stage_count, "COPY stage index does not exist"); } else if !stages.contains(&source.to_ascii_lowercase()) && !image_pinned(source) { if source.contains('$') || !source.contains([':', '/', '@']) { - return Err(unsupported("COPY source is an unresolved external image or named build context")); + return Err(unsupported( + "COPY source is an unresolved external image or named build context", + )); } result.findings.push(finding( file, diff --git a/src/assail/configuration_scripts.rs b/src/assail/configuration_scripts.rs index 6fbec85..54b10a6 100644 --- a/src/assail/configuration_scripts.rs +++ b/src/assail/configuration_scripts.rs @@ -167,7 +167,10 @@ pub(super) fn extract( } } if format == Format::Json - && Path::new(file).file_name().and_then(std::ffi::OsStr::to_str) == Some("package.json") + && Path::new(file) + .file_name() + .and_then(std::ffi::OsStr::to_str) + == Some("package.json") { if let Some(values) = document.get("scripts") { let values = values diff --git a/src/assail/templates.rs b/src/assail/templates.rs index 43c71a2..3baab5b 100644 --- a/src/assail/templates.rs +++ b/src/assail/templates.rs @@ -190,11 +190,9 @@ fn html_raw_text(node: Node<'_>, content: &str, file: &str, result: &mut Analysi ), }) } - "application/json" | "application/ld+json" | "importmap" => { - result.findings.extend( - super::configuration::Format::Json.analyze(text(node, content)?, file)?, - ) - } + "application/json" | "application/ld+json" | "importmap" => result.findings.extend( + super::configuration::Format::Json.analyze(text(node, content)?, file)?, + ), _ => { return Err(UnsupportedInput(format!( "embedded script type {kind:?} has no selected adapter" @@ -206,10 +204,9 @@ fn html_raw_text(node: Node<'_>, content: &str, file: &str, result: &mut Analysi Some("style_element") => { let kind = element_type(node.parent().expect("parent matched"), content)?; if !kind.is_empty() && kind != "text/css" { - return Err(UnsupportedInput(format!( - "embedded style type {kind:?} is not CSS" - )) - .into()); + return Err( + UnsupportedInput(format!("embedded style type {kind:?} is not CSS")).into(), + ); } let nested = css(text(node, content)?, file)?; for mut finding in nested.findings { From b99b9ea7e8ca5ded93c7a69056dcc45886bff76f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:20:47 +0000 Subject: [PATCH 13/14] fix: require Docker action digests and check HTTP hosts after userinfo --- scripts/ci/check-allowed-actions.sh | 3 ++- scripts/ci/test-ci-integrity.sh | 17 +++++++++++++++ src/assail/configuration.rs | 6 +++++- tests/configuration_coverage_tests.rs | 31 +++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/scripts/ci/check-allowed-actions.sh b/scripts/ci/check-allowed-actions.sh index 09cda2a..6a32743 100755 --- a/scripts/ci/check-allowed-actions.sh +++ b/scripts/ci/check-allowed-actions.sh @@ -29,7 +29,8 @@ probe="$(printf '%s\n' '{jobs: {probe: {steps: [{uses: "Fixture/Action@012345678 covered() { local ref=$1 action sha owner tail repo suffix pattern base allowed_ref p_owner p_tail p_repo p_suffix case "$ref" in - ./*|docker://*) return 0 ;; + ./*) return 0 ;; + docker://*) [[ "$ref" =~ ^docker://[^@]+@sha256:[0-9a-f]{64}$ ]]; return $? ;; '$/'*) return 1 ;; *) : ;; # Remote coordinates must pass the immutable-ref checks below. esac diff --git a/scripts/ci/test-ci-integrity.sh b/scripts/ci/test-ci-integrity.sh index c6335c3..429bd39 100755 --- a/scripts/ci/test-ci-integrity.sh +++ b/scripts/ci/test-ci-integrity.sh @@ -45,6 +45,23 @@ for name in mirror scorecard; do workflow "metadatastician/standards/.github/workflows/$name-reusable.yml@$old_pin" expect 0 "exact canonical $name coordinate" check done +digest=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +for ref in './local/action' './local/action@main' "docker://registry.example/image:tag@sha256:$digest"; do + workflow "$ref" + expect 0 "accept local or digest-pinned coordinate: $ref" check +done +for ref in \ + 'docker://registry.example/image' \ + 'docker://registry.example/image:latest' \ + "docker://registry.example/image@sha256:${digest:0:63}" \ + "docker://registry.example/image@sha256:${digest}0" \ + "docker://registry.example/image@sha256:${digest:0:63}g" \ + "docker://registry.example/image@sha512:$digest" \ + "docker://@sha256:$digest" \ + "docker://registry.example/image@sha256:$digest:tag"; do + workflow "$ref" + expect 1 "reject unpinned or malformed Docker coordinate: $ref" check +done workflow "METADATastician/STANDARDS/.github/workflows/rust-ci-reusable.yml@$current_pin" expect 0 'owner and repository case is immaterial' check for ref in \ diff --git a/src/assail/configuration.rs b/src/assail/configuration.rs index 4d19a63..72e68a1 100644 --- a/src/assail/configuration.rs +++ b/src/assail/configuration.rs @@ -360,7 +360,11 @@ fn inspect_value(value: &Value, field: &str, file: &str, findings: &mut Vec Date: Wed, 4 Nov 2026 23:43:43 +0000 Subject: [PATCH 14/14] fix: preserve inner quotes when parsing HTML attributes and CSS values --- src/assail/templates.rs | 46 ++++++++++++++++++++++++++++---- tests/template_coverage_tests.rs | 28 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/assail/templates.rs b/src/assail/templates.rs index 3baab5b..f9fa066 100644 --- a/src/assail/templates.rs +++ b/src/assail/templates.rs @@ -26,6 +26,18 @@ fn text<'a>(node: Node<'_>, source: &'a str) -> Result<&'a str> { Ok(node.utf8_text(source.as_bytes())?) } +fn unquote(value: &str) -> &str { + for quote in ['\'', '"'] { + if let Some(inner) = value + .strip_prefix(quote) + .and_then(|v| v.strip_suffix(quote)) + { + return inner; + } + } + value +} + fn finding( file: &str, node: Node<'_>, @@ -55,7 +67,7 @@ fn html_attribute(child: Node<'_>, content: &str) -> Result<(String, String)> { match part.kind() { "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), "attribute_value" | "quoted_attribute_value" => { - value = text(part, content)?.trim_matches(['\'', '"']).to_owned() + value = unquote(text(part, content)?).to_owned() } _ => {} } @@ -323,9 +335,7 @@ fn element_type(element: Node<'_>, content: &str) -> Result { match part.kind() { "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), "attribute_value" | "quoted_attribute_value" => { - value = text(part, content)? - .trim_matches(['\'', '"']) - .to_ascii_lowercase() + value = unquote(text(part, content)?).to_ascii_lowercase() } _ => {} } @@ -359,7 +369,7 @@ pub(super) fn css(content: &str, file: &str) -> Result { file, &format!("CSS declaration at line {}", node.start_position().row + 1), &name, - text(child, content)?.trim_matches(['\'', '"']), + unquote(text(child, content)?), ) { result.findings.push(finding); } @@ -406,3 +416,29 @@ pub(super) fn css(content: &str, file: &str) -> Result { } Ok(result) } + +#[cfg(test)] +mod tests { + use super::{html, unquote}; + + #[test] + fn unquote_removes_only_a_matching_outer_pair() { + for (input, expected) in [ + ("\"'value'\"", "'value'"), + ("'\"value\"'", "\"value\""), + ("\"\"", ""), + ("plain", "plain"), + ("\"mismatched'", "\"mismatched'"), + ("'", "'"), + ("", ""), + ] { + assert_eq!(unquote(input), expected); + } + } + + #[test] + fn html_event_handler_keeps_quotes_inside_attribute() { + let result = html(r#""#, "index.html").unwrap(); + assert_eq!(result.scripts[0].content, "'value'"); + } +} diff --git a/tests/template_coverage_tests.rs b/tests/template_coverage_tests.rs index 5f5a2c4..68bdcd6 100644 --- a/tests/template_coverage_tests.rs +++ b/tests/template_coverage_tests.rs @@ -102,3 +102,31 @@ fn script_types_and_encoded_attributes_cannot_silently_bypass_an_adapter() { assert_eq!(report.coverage.inputs[0].status, "unsupported"); } } + +#[test] +fn nested_quotes_remain_part_of_attribute_and_css_values() { + for (name, source) in [ + ("secret.html", r#"
"#), + ("secret.css", r#"p { password: "'1234567'"; }"#), + ] { + let report = scan(name, source); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert!( + report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::HardcodedSecret), + "{name}: {:?}", + report.weak_points + ); + } + + let report = scan( + "type.html", + r#""#, + ); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + + let report = scan("type.html", ""); + assert_eq!(report.coverage.status, "complete"); +}