Built alone from an empty CARGO_HOME, its tests passed, and flowrsc checked, ran and exported a document from the conformance corpus; the export built alone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
854 lines
30 KiB
Rust
854 lines
30 KiB
Rust
//! Credentials as references, never values (`graph::secret`): a canary credential put in the
|
|
//! store and referred to by a Particle node must occur nowhere a value leaves a run or a
|
|
//! document: the steps a run returns, its log, its console, the files it wrote, the
|
|
//! `.flowrs` and `.fdsl` texts, and the generated program. Each test follows one channel a
|
|
//! value can take from the node that holds the credential, as `tests/taint.rs` does for
|
|
//! neural data.
|
|
|
|
use flowrs_core::graph::runtime::Runtime;
|
|
use flowrs_core::graph::secret::{self, MARKER, Secret, SecretTaint};
|
|
use flowrs_core::graph::widget::{ExecContext, WidgetRegistry, builtin_registry};
|
|
use flowrs_core::graph::{Graph, GraphExecutor, NodeId};
|
|
use flowrs_core::{Value, WireMode};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
/// The canary. Built at run time so the source carries no credential-shaped literal.
|
|
fn canary() -> String {
|
|
format!("{}-{}-{}", "flowrs", "canary", "7f3a9c41d2e8b6aa")
|
|
}
|
|
|
|
/// The name the canary is stored under.
|
|
const NAME: &str = "PARTICLE_CANARY";
|
|
|
|
fn runtime() -> Arc<Runtime> {
|
|
let rt = Runtime::new();
|
|
rt.put_secret(NAME, Secret::new(canary())).unwrap();
|
|
rt
|
|
}
|
|
|
|
struct G {
|
|
g: Graph,
|
|
}
|
|
|
|
impl G {
|
|
fn new() -> Self {
|
|
G { g: Graph::new() }
|
|
}
|
|
fn node(&mut self, ty: &str, props: &[(&str, &str)]) -> NodeId {
|
|
let n = self.g.add_node(ty, 0.0, 0.0);
|
|
for (k, v) in props {
|
|
self.g
|
|
.nodes
|
|
.get_mut(&n)
|
|
.unwrap()
|
|
.properties
|
|
.insert((*k).into(), (*v).into());
|
|
}
|
|
n
|
|
}
|
|
fn wire(&mut self, a: NodeId, ap: &str, b: NodeId, bp: &str) {
|
|
self.g.add_wire(a, ap, b, bp, WireMode::Clone).unwrap();
|
|
}
|
|
/// A Particle function call holding the canary by reference.
|
|
fn source(&mut self) -> NodeId {
|
|
let reference = secret::make_reference(NAME);
|
|
self.node(
|
|
"cloud.particle.function",
|
|
&[
|
|
("token", reference.as_str()),
|
|
("device_id", "dev"),
|
|
("function", "led"),
|
|
],
|
|
)
|
|
}
|
|
}
|
|
|
|
struct Run {
|
|
outputs: HashMap<(NodeId, String), Value>,
|
|
taint: SecretTaint,
|
|
console: String,
|
|
log: String,
|
|
written: String,
|
|
unexecuted: String,
|
|
}
|
|
|
|
fn run(g: &Graph, rt: &Arc<Runtime>, withhold: bool) -> Run {
|
|
let mut ex =
|
|
GraphExecutor::with_runtime(WidgetRegistry::with_builtins(), rt.clone()).withhold(withhold);
|
|
let before = rt.log().events.len();
|
|
let steps = ex.run(g).expect("runs");
|
|
let mut outputs = HashMap::new();
|
|
let mut inputs = String::new();
|
|
for s in steps {
|
|
inputs.push_str(&format!("{:?}", s.inputs));
|
|
for (p, v) in s.outputs {
|
|
outputs.insert((s.node_id, p), v);
|
|
}
|
|
}
|
|
let log = format!("{:?}{inputs}", &rt.log().events[before..]);
|
|
Run {
|
|
outputs,
|
|
taint: ex.secret_taint().clone(),
|
|
console: ex.console().to_string(),
|
|
log,
|
|
// As text: a `Vec<u8>`'s `Debug` would hide a credential from the check.
|
|
written: ex
|
|
.written()
|
|
.iter()
|
|
.map(|(k, v)| format!("{k}={}", String::from_utf8_lossy(v)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
unexecuted: format!("{:?}", ex.unexecuted()),
|
|
}
|
|
}
|
|
|
|
/// No form of the canary (itself, JSON-escaped, percent-encoded, base64) in `text`.
|
|
fn assert_clean(text: &str, what: &str) {
|
|
assert!(
|
|
!secret::holds(text, &[canary()]),
|
|
"{what} carries the credential: {text}"
|
|
);
|
|
}
|
|
|
|
fn assert_run_clean(r: &Run) {
|
|
assert_clean(&format!("{:?}", r.outputs), "the steps");
|
|
assert_clean(&r.log, "the log");
|
|
assert_clean(&r.console, "the console");
|
|
assert_clean(&r.written, "the files written");
|
|
assert_clean(&r.unexecuted, "the reasons a node did not run");
|
|
}
|
|
|
|
/// Whether `v` is the marker.
|
|
fn is_marker(v: &Value) -> bool {
|
|
matches!(v, Value::String(s) if s == MARKER)
|
|
}
|
|
|
|
#[test]
|
|
fn a_reference_resolves_from_the_store_at_t0_and_never_in_an_agents_run() {
|
|
let rt = runtime();
|
|
let params = HashMap::from([("token".to_string(), secret::make_reference(NAME))]);
|
|
let ctx = ExecContext::new(params.clone()).with_runtime(rt.clone());
|
|
assert_eq!(ctx.secret("token"), canary());
|
|
let mut agent = ExecContext::new(params).with_runtime(rt.clone());
|
|
agent.secrets_withheld = true;
|
|
assert_eq!(agent.secret("token"), "", "an agent's run resolves nothing");
|
|
let missing = ExecContext::new(HashMap::from([(
|
|
"token".to_string(),
|
|
"secret:NOT_SET".to_string(),
|
|
)]))
|
|
.with_runtime(rt.clone());
|
|
assert_eq!(
|
|
missing.secret("token"),
|
|
"",
|
|
"a name not in the store is empty"
|
|
);
|
|
let typed = ExecContext::new(HashMap::from([("token".to_string(), "typed".to_string())]));
|
|
assert_eq!(
|
|
typed.secret("token"),
|
|
"typed",
|
|
"an old document's value still runs"
|
|
);
|
|
assert!(rt.put_secret("not a name", Secret::new("x")).is_err());
|
|
assert_eq!(rt.secret_names(), [NAME]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_secret_is_withheld_from_the_replay_log() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let p = run(&g.g, &rt, false);
|
|
assert!(p.taint.sources.contains(&src));
|
|
assert!(p.taint.port(src, "auth"), "auth carries the token");
|
|
assert!(!p.taint.port(src, "url"), "the url does not");
|
|
assert!(is_marker(&p.outputs[&(src, "auth".into())]));
|
|
assert!(
|
|
matches!(&p.outputs[&(src, "url".into())], Value::String(u) if u.contains("/v1/devices/dev/led"))
|
|
);
|
|
assert!(
|
|
p.log.contains(MARKER),
|
|
"the log records the marker where the value was"
|
|
);
|
|
assert!(
|
|
p.log.contains(&secret::make_reference(NAME)),
|
|
"the log keeps the reference, which is a name"
|
|
);
|
|
assert_run_clean(&p);
|
|
// The timeline (probes over the log) reads the same marker.
|
|
let tl = flowrs_core::graph::timeline::Timeline::new(&rt.log());
|
|
assert_clean(&format!("{tl:?}"), "the timeline");
|
|
// The whole log, setting the credential included, holds no value.
|
|
assert_clean(&format!("{:?}", rt.log()), "the log from the start");
|
|
// And it replays: the log names which credentials were set, never their values, and
|
|
// every value derived from one is a marker in both runs.
|
|
flowrs_core::graph::runtime::replay(&rt.log(), &g.g, WidgetRegistry::with_builtins())
|
|
.expect("a log with a credential replays");
|
|
}
|
|
|
|
#[test]
|
|
fn taint_follows_a_secret_through_io_format() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let fmt = g.node("io.format", &[("format", "header: {}")]);
|
|
g.wire(src, "auth", fmt, "value");
|
|
let upper = g.node("text.case", &[]);
|
|
g.wire(fmt, "string", upper, "text");
|
|
let len = g.node("text.length", &[]);
|
|
g.wire(upper, "out", len, "text");
|
|
let print = g.node("io.print", &[]);
|
|
g.wire(fmt, "string", print, "value");
|
|
let print2 = g.node("PrintLn", &[]);
|
|
g.wire(upper, "out", print2, "input");
|
|
// A neighbour free of the credential is untouched.
|
|
let hello = g.node("StringLiteral", &[("value", "hello")]);
|
|
let print3 = g.node("PrintLn", &[]);
|
|
g.wire(hello, "value", print3, "input");
|
|
let p = run(&g.g, &rt, false);
|
|
for (n, port) in [(fmt, "string"), (upper, "out"), (len, "length")] {
|
|
assert!(p.taint.port(n, port), "node {} {port}", n.0);
|
|
if let Some(v) = p.outputs.get(&(n, port.to_string())) {
|
|
assert!(is_marker(v), "node {} {port} shows the marker", n.0);
|
|
}
|
|
}
|
|
assert!(p.taint.node(print) && p.taint.node(print2));
|
|
assert!(!p.taint.node(print3) && !p.taint.node(hello));
|
|
assert!(p.console.contains("hello"), "{}", p.console);
|
|
assert!(
|
|
p.console.contains(secret::CONSOLE_LINE.trim()),
|
|
"{}",
|
|
p.console
|
|
);
|
|
assert_run_clean(&p);
|
|
}
|
|
|
|
#[test]
|
|
fn a_secret_never_appears_in_generated_code() {
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let fmt = g.node("io.format", &[]);
|
|
g.wire(src, "auth", fmt, "value");
|
|
let detect = g.node(
|
|
"cloud.roboflow.detect",
|
|
&[("api_key", "secret:ROBOFLOW_KEY"), ("model", "cars")],
|
|
);
|
|
// A credential typed into the document: compiled as a read of a derived name.
|
|
let typed = format!("typed-{}", canary());
|
|
let old = g.node("cloud.particle.variable", &[("token", typed.as_str())]);
|
|
let program = g.g.to_rust().expect("generates");
|
|
assert_clean(&program, "the program");
|
|
assert!(
|
|
!program.contains(&typed),
|
|
"the typed value is not compiled in"
|
|
);
|
|
assert!(program.contains("std::env::var(\"FLOWRS_SECRET_PARTICLE_CANARY\")"));
|
|
assert!(program.contains("std::env::var(\"FLOWRS_SECRET_ROBOFLOW_KEY\")"));
|
|
let derived = secret::env_var(&secret::derived_name(old, "token"));
|
|
assert!(
|
|
program.contains(&format!("std::env::var({derived:?})")),
|
|
"{derived} read: {program}"
|
|
);
|
|
// The other assembler, the profile-driven one, the same way.
|
|
let profiled = flowrs_core::codegen::rust::RustCodegen::generate(
|
|
&g.g,
|
|
&WidgetRegistry::with_builtins(),
|
|
&flowrs_core::graph::profile::Profile::learning(),
|
|
)
|
|
.expect("generates");
|
|
assert!(
|
|
!profiled.contains(&typed),
|
|
"the typed value is not compiled in"
|
|
);
|
|
assert!(
|
|
profiled.contains(&format!("std::env::var({derived:?})")),
|
|
"{derived} read: {profiled}"
|
|
);
|
|
let _ = detect;
|
|
}
|
|
|
|
#[test]
|
|
fn export_writes_a_reference_only() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let fmt = g.node("io.format", &[]);
|
|
g.wire(src, "auth", fmt, "value");
|
|
// Run it, so the store's value has been used, then write every text a graph leaves as.
|
|
let p = run(&g.g, &rt, false);
|
|
assert_run_clean(&p);
|
|
let flowrs = flowrs_core::graph::document::save(&g.g);
|
|
let fdsl = flowrs_core::graph::dsl::print::print(&g.g, builtin_registry());
|
|
let program = g.g.to_rust().unwrap();
|
|
for (what, text) in [
|
|
(".flowrs", &flowrs),
|
|
(".fdsl", &fdsl),
|
|
("the program", &program),
|
|
] {
|
|
assert_clean(text, what);
|
|
}
|
|
assert!(flowrs.contains("secret:PARTICLE_CANARY"), "{flowrs}");
|
|
assert!(fdsl.contains("secret:PARTICLE_CANARY"), "{fdsl}");
|
|
// Both read back to the same reference.
|
|
let back = flowrs_core::graph::document::load(&flowrs).unwrap();
|
|
assert_eq!(
|
|
back.nodes[&src].properties["token"],
|
|
secret::make_reference(NAME)
|
|
);
|
|
let parsed = flowrs_core::graph::dsl::parse_graph(&fdsl).unwrap().graph;
|
|
assert!(
|
|
parsed
|
|
.nodes
|
|
.values()
|
|
.any(|n| n.properties.get("token") == Some(&secret::make_reference(NAME)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_old_document_with_a_literal_token_loads_and_gets_a_finding() {
|
|
let typed = format!("old-{}", canary());
|
|
let mut g = G::new();
|
|
let src = g.node(
|
|
"cloud.particle.function",
|
|
&[("token", typed.as_str()), ("device_id", "dev")],
|
|
);
|
|
let fmt = g.node("io.format", &[]);
|
|
g.wire(src, "auth", fmt, "value");
|
|
let text = flowrs_core::graph::document::save(&g.g);
|
|
// It loads, and saves back byte for byte: the document is the person's.
|
|
let loaded = flowrs_core::graph::document::load(&text).expect("an old document loads");
|
|
assert_eq!(flowrs_core::graph::document::save(&loaded), text);
|
|
// The diagnostics say so on the node, without repeating the value.
|
|
let diags = flowrs_core::graph::diagnostics::check(&loaded);
|
|
let finding = diags
|
|
.iter()
|
|
.find(|d| d.node == src && d.message.contains("credential's value"))
|
|
.expect("a finding on the node");
|
|
assert_eq!(
|
|
finding.severity,
|
|
flowrs_core::graph::diagnostics::Severity::Warning,
|
|
"a warning: the node still runs"
|
|
);
|
|
assert!(!finding.message.contains(&typed));
|
|
assert!(finding.message.contains("secret:NAME"));
|
|
// It still runs on the value, and no run shows it.
|
|
let rt = Runtime::new();
|
|
let p = run(&loaded, &rt, false);
|
|
assert!(p.taint.sources.contains(&src));
|
|
assert!(!format!("{:?}{}{}", p.outputs, p.log, p.console).contains(&typed));
|
|
assert!(
|
|
p.log.contains(MARKER),
|
|
"the parameter is logged as the marker"
|
|
);
|
|
// An agent sees it withheld, and the program never compiles it in.
|
|
let shown = secret::redacted(&loaded, builtin_registry());
|
|
assert!(!flowrs_core::graph::document::save(&shown).contains(&typed));
|
|
assert!(!loaded.to_rust().unwrap().contains(&typed));
|
|
assert_eq!(secret::literal_values(&loaded, builtin_registry()), [typed]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_held_wire_carries_a_secret_to_the_next_step() {
|
|
// The token's length, summed on a held wire: what the next step reads came from it.
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let len = g.node("text.length", &[]);
|
|
g.wire(src, "auth", len, "text");
|
|
let add = g.node("Add", &[]);
|
|
g.wire(len, "length", add, "a");
|
|
let fb =
|
|
g.g.add_wire(add, "result", add, "b", WireMode::Copy)
|
|
.unwrap();
|
|
g.g.wires.get_mut(&fb).unwrap().feedback = true;
|
|
let mut ex = GraphExecutor::with_runtime(WidgetRegistry::with_builtins(), rt.clone());
|
|
let before = rt.log().events.len();
|
|
let first = ex.step(&g.g).unwrap();
|
|
let key = flowrs_core::graph::held::key(&g.g.wires[&fb]);
|
|
// The real value is held, as the program holds it: the run computed on the credential.
|
|
let want = ("Bearer ".len() + canary().len()) as i64;
|
|
assert!(
|
|
matches!(rt.held.get(&key), Some(Value::Int(n)) if n == want),
|
|
"the held value is computed on the real token: {:?}",
|
|
rt.held.get(&key)
|
|
);
|
|
let second = ex.step(&g.g).unwrap();
|
|
assert!(
|
|
ex.secret_taint().port(add, "result"),
|
|
"the reader is tainted"
|
|
);
|
|
assert!(ex.secret_taint().input(add, "b"), "through the held wire");
|
|
let shown = format!("{:?}{:?}", step_outputs(&first), step_outputs(&second));
|
|
assert_clean(&shown, "the steps");
|
|
assert!(
|
|
!shown.contains(&format!("Int({want})")),
|
|
"the steps show no sum: {shown}"
|
|
);
|
|
let log = format!("{:?}", &rt.log().events[before..]);
|
|
assert!(
|
|
!log.contains(&format!("Int({want})")),
|
|
"the log holds no sum: {log}"
|
|
);
|
|
rt.clear("held");
|
|
}
|
|
|
|
fn step_outputs(steps: &[flowrs_core::graph::executor::ExecutionStep]) -> Vec<String> {
|
|
steps
|
|
.iter()
|
|
.map(|s| format!("{:?} {:?}", s.inputs, s.outputs))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_loop_steered_by_a_secret_taints_its_body() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let split = g.node("text.split", &[("separator", " ")]);
|
|
g.wire(src, "auth", split, "text");
|
|
let lp = g.node("ForLoop", &[]);
|
|
g.wire(split, "parts", lp, "iterator");
|
|
let v = g.node("VecNew", &[]);
|
|
let push = g.node("VecPush", &[]);
|
|
g.wire(v, "vec", push, "vec");
|
|
let one = g.node("IntLiteral", &[("value", "1")]);
|
|
g.wire(one, "value", push, "item");
|
|
g.wire(push, "vec", lp, "body");
|
|
let p = run(&g.g, &rt, false);
|
|
assert!(p.taint.node(lp), "the loop ran on the credential");
|
|
assert!(p.taint.port(push, "vec"), "its body did");
|
|
assert_run_clean(&p);
|
|
}
|
|
|
|
#[test]
|
|
fn the_program_scope_and_a_file_the_run_wrote() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let block = g.node("CodeBlock", &[("src", "format!(\"t,{}\\n1,2\\n\", input)")]);
|
|
g.wire(src, "auth", block, "inputs");
|
|
let name = g.node("StringLiteral", &[("value", "leak.csv")]);
|
|
let write = g.node("WriteFile", &[]);
|
|
g.wire(name, "value", write, "path");
|
|
g.wire(block, "outputs", write, "contents");
|
|
// A code block wired to nothing that carries the credential reads the scope.
|
|
let later = g.node("CodeBlockNode", &[("src", "println!(\"{}\", input);")]);
|
|
// A widget that loads the file the run wrote: no wire ties it to the credential.
|
|
let load = g.node("data.csv.load", &[("path", "leak.csv")]);
|
|
let name2 = g.node("StringLiteral", &[("value", "leak.csv")]);
|
|
let read = g.node("ReadFile", &[]);
|
|
g.wire(name2, "value", read, "path");
|
|
let p = run(&g.g, &rt, false);
|
|
assert!(p.taint.node(block) && p.taint.node(write));
|
|
assert!(p.taint.node(later), "the scope carried it");
|
|
assert!(p.taint.node(read), "the file carried it to ReadFile");
|
|
assert!(
|
|
p.outputs.contains_key(&(load, "data".to_string())),
|
|
"the loader ran after the write: {:?}",
|
|
p.unexecuted
|
|
);
|
|
assert!(
|
|
p.taint.port(load, "data"),
|
|
"the file carried it to the loader"
|
|
);
|
|
assert!(
|
|
p.written.contains(MARKER),
|
|
"the written file is withheld: {}",
|
|
p.written
|
|
);
|
|
assert_run_clean(&p);
|
|
}
|
|
|
|
#[test]
|
|
fn an_in_place_write_back_reaches_the_bindings_other_readers() {
|
|
// `v.push(auth)` then `v.len()` through the wire `v` came on.
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let v = g.node("VecNew", &[]);
|
|
let push = g.node("VecPush", &[]);
|
|
g.wire(v, "vec", push, "vec");
|
|
g.wire(src, "auth", push, "item");
|
|
let len = g.node("VecLen", &[]);
|
|
g.wire(v, "vec", len, "vec");
|
|
let p = run(&g.g, &rt, false);
|
|
assert!(p.taint.port(len, "length"), "the binding's other reader");
|
|
assert_run_clean(&p);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_run_resolves_no_credential() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let src = g.source();
|
|
let len = g.node("text.length", &[]);
|
|
g.wire(src, "auth", len, "text");
|
|
let a = run(&g.g, &rt, true);
|
|
assert!(a.taint.port(len, "length"), "still tainted");
|
|
assert!(is_marker(&a.outputs[&(src, "auth".into())]));
|
|
assert_run_clean(&a);
|
|
// What an agent's step holds was computed on nothing: the token's length, summed on a
|
|
// held wire, is the length of the scheme alone. The person's step holds the real one.
|
|
let mut h = G::new();
|
|
let src = h.source();
|
|
let len = h.node("text.length", &[]);
|
|
h.wire(src, "auth", len, "text");
|
|
let add = h.node("Add", &[]);
|
|
h.wire(len, "length", add, "a");
|
|
let fb =
|
|
h.g.add_wire(add, "result", add, "b", WireMode::Copy)
|
|
.unwrap();
|
|
h.g.wires.get_mut(&fb).unwrap().feedback = true;
|
|
let key = flowrs_core::graph::held::key(&h.g.wires[&fb]);
|
|
for (withhold, want) in [
|
|
(true, "Bearer ".len() as i64),
|
|
(false, ("Bearer ".len() + canary().len()) as i64),
|
|
] {
|
|
rt.clear("held");
|
|
let mut ex = GraphExecutor::with_runtime(WidgetRegistry::with_builtins(), rt.clone())
|
|
.withhold(withhold);
|
|
ex.step(&h.g).unwrap();
|
|
assert!(
|
|
matches!(rt.held.get(&key), Some(Value::Int(n)) if n == want),
|
|
"withhold={withhold}: {:?}",
|
|
rt.held.get(&key)
|
|
);
|
|
}
|
|
rt.clear("held");
|
|
}
|
|
|
|
#[test]
|
|
fn a_graph_without_credentials_is_untouched() {
|
|
let rt = runtime();
|
|
let mut g = G::new();
|
|
let hello = g.node("StringLiteral", &[("value", "hello")]);
|
|
let print = g.node("PrintLn", &[]);
|
|
g.wire(hello, "value", print, "input");
|
|
// A Particle node with no token set brings nothing in.
|
|
let unset = g.node("cloud.particle.devices", &[]);
|
|
let p = run(&g.g, &rt, false);
|
|
assert!(!p.taint.any());
|
|
assert!(matches!(&p.outputs[&(unset, "auth".into())], Value::String(a) if a == "Bearer "));
|
|
assert!(!p.log.contains(MARKER));
|
|
assert_eq!(p.console, "hello\n");
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_may_not_write_a_credentials_value() {
|
|
let reg = WidgetRegistry::with_builtins();
|
|
let mut g = Graph::new();
|
|
let n = g.add_node("cloud.particle.function", 0.0, 0.0);
|
|
let typed = format!("agent-{}", canary());
|
|
let err = flowrs_core::graph::agent::set_declared_properties(
|
|
&mut g,
|
|
®,
|
|
n,
|
|
&[("token".into(), typed.clone())],
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("secret:NAME"), "{err}");
|
|
assert!(
|
|
!err.contains(&typed),
|
|
"the refusal does not repeat the value"
|
|
);
|
|
assert!(g.nodes[&n].properties.is_empty(), "unchanged");
|
|
flowrs_core::graph::agent::set_declared_properties(
|
|
&mut g,
|
|
®,
|
|
n,
|
|
&[("token".into(), "secret:PARTICLE".into())],
|
|
)
|
|
.expect("a reference is written");
|
|
}
|
|
|
|
/// Every parameter whose name says it holds a credential is declared one, with an empty
|
|
/// default; every declared one is a real parameter.
|
|
#[test]
|
|
fn every_credential_parameter_is_declared() {
|
|
let shaped = regex_lite::Regex::new(
|
|
r"(?i)(^|_)(token|api_?key|apikey|password|passwd|secret|credential|bearer|private_?key|access_?key|client_?secret)($|_)",
|
|
)
|
|
.unwrap();
|
|
let mut declared = 0usize;
|
|
for w in builtin_registry().all() {
|
|
let id = w.id();
|
|
let params = w.parameters();
|
|
for p in ¶ms {
|
|
// A tokenizer is not a token.
|
|
if shaped.is_match(&p.name) {
|
|
assert!(
|
|
w.secret_parameters().contains(&p.name.as_str()),
|
|
"{id}.{} reads like a credential and is not declared one",
|
|
p.name
|
|
);
|
|
}
|
|
}
|
|
for s in w.secret_parameters() {
|
|
declared += 1;
|
|
let p = params
|
|
.iter()
|
|
.find(|p| p.name == *s)
|
|
.unwrap_or_else(|| panic!("{id} declares credential {s} but no such parameter"));
|
|
assert!(p.default.is_empty(), "{id}.{s} has a default credential");
|
|
assert_eq!(p.ty, "String", "{id}.{s}");
|
|
}
|
|
if let Some(outs) = w.secret_outputs() {
|
|
assert!(!w.secret_parameters().is_empty(), "{id}");
|
|
let names: Vec<String> = w.outputs().into_iter().map(|p| p.name).collect();
|
|
for o in outs {
|
|
assert!(names.iter().any(|n| n == o), "{id} names no output {o}");
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(declared, 6, "five Particle tokens and Roboflow's key");
|
|
}
|
|
|
|
/// Every widget with a credential parameter, run with the canary set: every output it does
|
|
/// not name as carrying the credential is free of it in every encoding, and the ones it
|
|
/// names do carry it (or there is no point naming them).
|
|
#[test]
|
|
fn every_credential_bearing_widget_keeps_it_to_the_outputs_it_names() {
|
|
let rt = runtime();
|
|
let mut checked = 0usize;
|
|
for w in builtin_registry().all() {
|
|
if w.secret_parameters().is_empty() {
|
|
continue;
|
|
}
|
|
let id = w.id();
|
|
let mut props: HashMap<String, String> = w
|
|
.parameters()
|
|
.into_iter()
|
|
.map(|p| (p.name, p.default))
|
|
.collect();
|
|
for s in w.secret_parameters() {
|
|
props.insert((*s).to_string(), secret::make_reference(NAME));
|
|
}
|
|
let ctx = ExecContext::for_widget(w, &props).with_runtime(rt.clone());
|
|
// Every text input wired, so a widget that echoes an input cannot hide behind an
|
|
// empty one.
|
|
let inputs: HashMap<String, Value> = w
|
|
.inputs()
|
|
.into_iter()
|
|
.map(|p| (p.name, Value::String("x".into())))
|
|
.collect();
|
|
let out = w.execute(&ctx, inputs);
|
|
let named = w.secret_outputs().unwrap_or(&[]);
|
|
let mut carried = false;
|
|
for (port, v) in &out {
|
|
let text = format!("{v:?}");
|
|
if named.contains(&port.as_str()) || w.secret_outputs().is_none() {
|
|
carried |= secret::holds(&text, &[canary()]);
|
|
continue;
|
|
}
|
|
assert!(
|
|
!secret::holds(&text, &[canary()]),
|
|
"{id}.{port} carries the credential and is not named in secret_outputs"
|
|
);
|
|
}
|
|
assert!(
|
|
carried,
|
|
"{id}: the credential reached none of its named outputs"
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert_eq!(checked, 6);
|
|
}
|
|
|
|
/// The edit checker refuses an agent's edit that writes a credential's value, and lets one
|
|
/// through that writes a reference; a value the person's document already held is theirs,
|
|
/// said by the diagnostics, and not the edit's to fix.
|
|
#[test]
|
|
fn an_agents_edit_may_not_write_a_credentials_value() {
|
|
use flowrs_core::graph::verify::{self, Expect, InBrowser, Stage, Status};
|
|
let mut g = Graph::new();
|
|
let n = g.add_node("cloud.particle.function", 0.0, 0.0);
|
|
g.nodes
|
|
.get_mut(&n)
|
|
.unwrap()
|
|
.properties
|
|
.insert("token".into(), "secret:PARTICLE".into());
|
|
let typed = format!("edit-{}", canary());
|
|
let refused = verify::check_patch(
|
|
&g,
|
|
&format!("set n{} {{ token: {typed:?} }};", n.0),
|
|
builtin_registry(),
|
|
&InBrowser,
|
|
&Expect::default(),
|
|
);
|
|
let v = &refused.verification;
|
|
// Refused where the patch is applied (`set_declared_properties`).
|
|
assert!(
|
|
matches!(v.status(Stage::Apply), Some(Status::Failed(_))),
|
|
"{}",
|
|
v.feedback()
|
|
);
|
|
let f = v
|
|
.errors()
|
|
.find(|f| f.message.contains("credential parameter"))
|
|
.expect("the refusal names the parameter");
|
|
assert!(!f.message.contains(&typed));
|
|
// Nothing the checker reports (its findings, its evidence) repeats the value.
|
|
let reported = serde_json::to_string(&v.to_json()).unwrap();
|
|
assert!(!reported.contains(&typed), "{reported}");
|
|
let fine = verify::check_patch(
|
|
&g,
|
|
&format!("set n{} {{ token: \"secret:OTHER\" }};", n.0),
|
|
builtin_registry(),
|
|
&InBrowser,
|
|
&Expect::default(),
|
|
);
|
|
assert!(
|
|
fine.verification
|
|
.errors()
|
|
.all(|f| !f.message.contains("credential parameter")),
|
|
"{}",
|
|
fine.verification.summary()
|
|
);
|
|
// A value the document already held: the edit touches something else and passes it by.
|
|
let mut old = g.clone();
|
|
old.nodes
|
|
.get_mut(&n)
|
|
.unwrap()
|
|
.properties
|
|
.insert("token".into(), typed.clone());
|
|
let other = verify::check_patch(
|
|
&old,
|
|
&format!("set n{} {{ device_id: \"lab\" }};", n.0),
|
|
builtin_registry(),
|
|
&InBrowser,
|
|
&Expect::default(),
|
|
);
|
|
assert!(
|
|
other
|
|
.verification
|
|
.errors()
|
|
.all(|f| !f.message.contains("credential parameter")),
|
|
"{}",
|
|
other.verification.summary()
|
|
);
|
|
let reported = serde_json::to_string(&other.verification.to_json()).unwrap();
|
|
assert!(
|
|
!reported.contains(&typed),
|
|
"the old value is not echoed: {reported}"
|
|
);
|
|
// A whole graph an agent hands back is held to it at the structure stage: a value it
|
|
// wrote fails, one the base already held does not.
|
|
let origin = flowrs_core::graph::dsl::Origin::default();
|
|
let whole = verify::check_graph(
|
|
Some(&g),
|
|
&old,
|
|
&origin,
|
|
builtin_registry(),
|
|
&InBrowser,
|
|
&Expect::default(),
|
|
);
|
|
assert!(
|
|
matches!(whole.status(Stage::Structure), Some(Status::Failed(_))),
|
|
"{}",
|
|
whole.feedback()
|
|
);
|
|
assert!(
|
|
whole
|
|
.errors()
|
|
.any(|f| f.message.contains("credential parameter"))
|
|
);
|
|
let kept = verify::check_graph(
|
|
Some(&old),
|
|
&old,
|
|
&origin,
|
|
builtin_registry(),
|
|
&InBrowser,
|
|
&Expect::default(),
|
|
);
|
|
assert!(
|
|
kept.errors()
|
|
.all(|f| !f.message.contains("credential parameter")),
|
|
"{}",
|
|
kept.feedback()
|
|
);
|
|
}
|
|
|
|
/// A password typed into a front-panel control is saved as the literal's value: the
|
|
/// diagnostics say so, and no run returns it, whatever it flows into.
|
|
#[test]
|
|
fn a_password_control_is_said_and_removed_by_value() {
|
|
let typed = format!("pw-{}", canary());
|
|
let mut g = G::new();
|
|
let quoted = format!("{typed:?}");
|
|
let ctl = g.node(
|
|
"StringLiteral",
|
|
&[("value", quoted.as_str()), ("widget", "password")],
|
|
);
|
|
let fmt = g.node("io.format", &[("format", "login {}")]);
|
|
g.wire(ctl, "value", fmt, "value");
|
|
let print = g.node("PrintLn", &[]);
|
|
g.wire(fmt, "string", print, "input");
|
|
assert_eq!(secret::password_controls(&g.g), [(ctl, typed.clone())]);
|
|
let d = flowrs_core::graph::diagnostics::check(&g.g);
|
|
assert!(
|
|
d.iter()
|
|
.any(|d| d.node == ctl && d.message.contains("password control"))
|
|
);
|
|
let p = run(&g.g, &Runtime::new(), false);
|
|
let all = format!("{:?}{}{}", p.outputs, p.log, p.console);
|
|
assert!(!all.contains(&typed), "{all}");
|
|
assert!(
|
|
p.console.contains("login"),
|
|
"the run still ran: {}",
|
|
p.console
|
|
);
|
|
}
|
|
|
|
/// An evidence bundle names a credential parameter by its reference, never a value: one an
|
|
/// edit wrote is refused before evidence is gathered, and this holds the bundle to it even if
|
|
/// one were gathered.
|
|
#[test]
|
|
fn an_evidence_bundle_carries_no_credential() {
|
|
use flowrs_core::graph::evidence::{self, Bench, Budget, Context, Edit};
|
|
use flowrs_core::graph::verify::{self, Expect, InBrowser};
|
|
let typed = format!("ev-{}", canary());
|
|
let mut base = Graph::new();
|
|
let n = base.add_node("cloud.particle.function", 0.0, 0.0);
|
|
let mut edited = base.clone();
|
|
for (g, token) in [
|
|
(&mut base, "secret:PARTICLE"),
|
|
(&mut edited, typed.as_str()),
|
|
] {
|
|
g.nodes
|
|
.get_mut(&n)
|
|
.unwrap()
|
|
.properties
|
|
.insert("token".into(), token.to_string());
|
|
}
|
|
let origin = flowrs_core::graph::dsl::Origin::default();
|
|
let v = verify::check_graph(
|
|
Some(&base),
|
|
&edited,
|
|
&origin,
|
|
builtin_registry(),
|
|
&InBrowser,
|
|
&Expect::default(),
|
|
);
|
|
let changes = flowrs_core::graph::diff::diff(&base, &edited);
|
|
let bundle = evidence::gather(
|
|
&Edit {
|
|
base: &base,
|
|
graph: &edited,
|
|
changes: &changes,
|
|
origin: &origin,
|
|
registry: builtin_registry(),
|
|
verification: &v,
|
|
},
|
|
&InBrowser,
|
|
&Context {
|
|
bench: Bench::default(),
|
|
budget: Budget::default(),
|
|
},
|
|
);
|
|
let text = serde_json::to_string(&bundle).unwrap();
|
|
assert!(
|
|
text.contains("token"),
|
|
"the bundle speaks of the parameter: {text}"
|
|
);
|
|
assert!(!text.contains(&typed), "{text}");
|
|
}
|