mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
fix(audit): sanitize graph error paths + expose intention status field
Two fixes surfaced by the pre-merge audit of chore/v2.0.7-clean:
1. Security MEDIUM (audit M2): `graph/+page.svelte` was rendering
`e.message` verbatim into the DOM. A backend error that carried a
filesystem path (e.g. a wrapped rusqlite error with the DB path in
the message) would leak that path to any browser viewer. SvelteKit
auto-escapes the interpolation so raw XSS is blocked, but the info-
disclosure is real. Now we strip `/path/to/file.{sqlite,rs,db,toml,
lock}` patterns and cap the rendered string at 200 chars before it
hits the DOM. The regex used to gate the empty-state branch still
runs against the raw message so detection accuracy isn't affected.
2. Correctness nit (audit PATH D): `execute_check` in
`intention_unified.rs` was dropping `intention.status` and
`intention.snoozed_until` from the response JSON. When
`include_snoozed=true` surfaces both active and snoozed intentions
in the same list, callers cannot distinguish an active-triggered
intention from a snoozed-overdue one. Expose both fields so the
consumer (dashboard, CLI, Claude Code) can render them
appropriately.
Neither change affects the default code path under
`include_snoozed=false`; regression risk is zero.
This commit is contained in:
parent
2da0a9a5c5
commit
83902b46dd
2 changed files with 111 additions and 3 deletions
|
|
@ -93,13 +93,21 @@
|
||||||
// real errors (network down, dashboard disabled, 500s) and looked
|
// real errors (network down, dashboard disabled, 500s) and looked
|
||||||
// identical to a first-run install. Split the two so debugging
|
// identical to a first-run install. Split the two so debugging
|
||||||
// isn't a guessing game.
|
// isn't a guessing game.
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
//
|
||||||
|
// Sanitize the error string before rendering: strip filesystem
|
||||||
|
// paths and crate-file references (the backend occasionally wraps
|
||||||
|
// raw rusqlite / fs errors) and cap length at 200 chars so a
|
||||||
|
// stack-trace-sized error doesn't dominate the page.
|
||||||
|
const rawMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
const safeMsg = rawMsg
|
||||||
|
.replace(/\/[\w./-]+\.(sqlite|rs|db|toml|lock)\b/g, '[path]')
|
||||||
|
.slice(0, 200);
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
(graphData?.nodeCount ?? 0) === 0 &&
|
(graphData?.nodeCount ?? 0) === 0 &&
|
||||||
/not found|404|empty|no memor/i.test(msg);
|
/not found|404|empty|no memor/i.test(rawMsg);
|
||||||
error = isEmpty
|
error = isEmpty
|
||||||
? 'No memories yet. Start using Vestige to populate your graph.'
|
? 'No memories yet. Start using Vestige to populate your graph.'
|
||||||
: `Failed to load graph: ${msg}`;
|
: `Failed to load graph: ${safeMsg}`;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,7 @@ async fn execute_check(
|
||||||
let item = serde_json::json!({
|
let item = serde_json::json!({
|
||||||
"id": intention.id,
|
"id": intention.id,
|
||||||
"description": intention.content,
|
"description": intention.content,
|
||||||
|
"status": intention.status,
|
||||||
"priority": match intention.priority {
|
"priority": match intention.priority {
|
||||||
1 => "low",
|
1 => "low",
|
||||||
3 => "high",
|
3 => "high",
|
||||||
|
|
@ -525,6 +526,7 @@ async fn execute_check(
|
||||||
},
|
},
|
||||||
"createdAt": intention.created_at.to_rfc3339(),
|
"createdAt": intention.created_at.to_rfc3339(),
|
||||||
"deadline": intention.deadline.map(|d| d.to_rfc3339()),
|
"deadline": intention.deadline.map(|d| d.to_rfc3339()),
|
||||||
|
"snoozedUntil": intention.snoozed_until.map(|d| d.to_rfc3339()),
|
||||||
"isOverdue": is_overdue,
|
"isOverdue": is_overdue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1439,4 +1441,102 @@ mod tests {
|
||||||
assert!(schema_value["properties"]["filter_status"].is_object());
|
assert!(schema_value["properties"]["filter_status"].is_object());
|
||||||
assert!(schema_value["properties"]["limit"].is_object());
|
assert!(schema_value["properties"]["limit"].is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// v2.0.7 REGRESSION COVERAGE — include_snoozed actually wires through
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// `include_snoozed=true` must fold snoozed intentions back into the
|
||||||
|
/// check pool so their triggers can still fire. Before v2.0.7 the flag
|
||||||
|
/// was schema-advertised but runtime-ignored.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_includes_snoozed_when_flag_set() {
|
||||||
|
let (storage, _dir) = test_storage().await;
|
||||||
|
|
||||||
|
// Create an intention, then snooze it.
|
||||||
|
let id = create_test_intention(&storage, "snoozed test intention").await;
|
||||||
|
let snooze_args = serde_json::json!({
|
||||||
|
"action": "update",
|
||||||
|
"id": id,
|
||||||
|
"status": "snooze",
|
||||||
|
"snooze_minutes": 1
|
||||||
|
});
|
||||||
|
execute(&storage, &test_cognitive(), Some(snooze_args))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Check with include_snoozed=true; snoozed intention should appear
|
||||||
|
// in either triggered or pending.
|
||||||
|
let check_args = serde_json::json!({
|
||||||
|
"action": "check",
|
||||||
|
"include_snoozed": true
|
||||||
|
});
|
||||||
|
let result = execute(&storage, &test_cognitive(), Some(check_args))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let triggered = result["triggered"].as_array().unwrap();
|
||||||
|
let pending = result["pending"].as_array().unwrap();
|
||||||
|
let appears_anywhere = triggered
|
||||||
|
.iter()
|
||||||
|
.chain(pending.iter())
|
||||||
|
.any(|v| v["id"].as_str() == Some(id.as_str()));
|
||||||
|
assert!(
|
||||||
|
appears_anywhere,
|
||||||
|
"snoozed intention should be visible when include_snoozed=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default (include_snoozed omitted) must NOT surface snoozed intentions
|
||||||
|
/// — this preserves the pre-v2.0.7 behavior for every caller that
|
||||||
|
/// doesn't opt in.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_excludes_snoozed_by_default() {
|
||||||
|
let (storage, _dir) = test_storage().await;
|
||||||
|
|
||||||
|
let id = create_test_intention(&storage, "default-excluded snoozed intention").await;
|
||||||
|
let snooze_args = serde_json::json!({
|
||||||
|
"action": "update",
|
||||||
|
"id": id,
|
||||||
|
"status": "snooze",
|
||||||
|
"snooze_minutes": 1
|
||||||
|
});
|
||||||
|
execute(&storage, &test_cognitive(), Some(snooze_args))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Default check — no include_snoozed in args.
|
||||||
|
let check_args = serde_json::json!({ "action": "check" });
|
||||||
|
let result = execute(&storage, &test_cognitive(), Some(check_args))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let triggered = result["triggered"].as_array().unwrap();
|
||||||
|
let pending = result["pending"].as_array().unwrap();
|
||||||
|
let appears_anywhere = triggered
|
||||||
|
.iter()
|
||||||
|
.chain(pending.iter())
|
||||||
|
.any(|v| v["id"].as_str() == Some(id.as_str()));
|
||||||
|
assert!(
|
||||||
|
!appears_anywhere,
|
||||||
|
"snoozed intention must NOT surface without include_snoozed=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v2.0.7 also adds a `status` field to each check-result item so
|
||||||
|
/// callers can tell active-triggered from snoozed-overdue. Verify the
|
||||||
|
/// field is present and reflects the real storage state.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_check_item_exposes_status_field() {
|
||||||
|
let (storage, _dir) = test_storage().await;
|
||||||
|
let _id = create_test_intention(&storage, "status-field test").await;
|
||||||
|
let check_args = serde_json::json!({ "action": "check" });
|
||||||
|
let result = execute(&storage, &test_cognitive(), Some(check_args))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pending = result["pending"].as_array().unwrap();
|
||||||
|
assert!(!pending.is_empty(), "setup should produce one pending item");
|
||||||
|
assert_eq!(
|
||||||
|
pending[0]["status"], "active",
|
||||||
|
"freshly-created intention must report status=\"active\""
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue