Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
29c0dce
test(evidence): require offline capture replay verification
seonghobae Aug 26, 2026
0974ff4
test(evidence): canonicalize offline replay regression
seonghobae Aug 26, 2026
b7ae185
feat(evidence): verify capture packages offline
seonghobae Aug 26, 2026
6b4fa02
feat(evidence): expose offline replay verifier
seonghobae Aug 26, 2026
f34cdbc
test(evidence): cover typed offline replay diagnostics
seonghobae Aug 26, 2026
ff1aded
test(evidence): reject offline replay with missing WARC evidence
seonghobae Aug 26, 2026
2e31297
chore(evidence): realign offline replay with manifest parent
seonghobae Aug 27, 2026
cdd86e7
chore(evidence): realign offline replay with manifest docs
seonghobae Aug 28, 2026
e45cd6c
chore(evidence): realign offline replay with provenance-bound manifests
seonghobae Aug 28, 2026
0572dfd
test(evidence): cover query-bearing provenance URLs
seonghobae Aug 28, 2026
5474b19
test(evidence): retire blanket query rejection
seonghobae Aug 28, 2026
34936a7
fix(evidence): preserve safe query-bearing provenance URLs
seonghobae Aug 28, 2026
5e66715
fix(evidence): satisfy strict query URL lint
seonghobae Aug 28, 2026
2edf09a
test(evidence): close query parser branch coverage
seonghobae Aug 28, 2026
926529c
test(evidence): reject credential-bearing provenance queries
seonghobae Aug 28, 2026
356e063
fix(evidence): reject credential query names
seonghobae Aug 28, 2026
6a0e6ab
test(evidence): cover bare query fields
seonghobae Aug 28, 2026
d83748a
fix(evidence): reject nested credential query values
seonghobae Aug 28, 2026
83459c4
test(evidence): reject double-encoded nested credential names
seonghobae Aug 28, 2026
c8a9376
fix(evidence): reject recursively encoded nested credential names
seonghobae Aug 28, 2026
61243c9
docs(evidence): separate RFC 9700 from credential policy
seonghobae Aug 28, 2026
0341079
fix(evidence): reject encoded query controls
seonghobae Aug 28, 2026
6ecb3b0
test(evidence): reject recursively encoded credential names
seonghobae Aug 28, 2026
9f0117c
fix(evidence): reject recursively encoded credential names
seonghobae Aug 28, 2026
bea6564
style(evidence): preserve rustfmt newline
seonghobae Aug 28, 2026
6b8a3fd
merge: sync latest WARC resource-record base
seonghobae Aug 28, 2026
7a08857
Merge d514d5eb1593b12c204061455b12a433906a321f into 6b8a3fdeae52ad94b…
seonghobae Aug 28, 2026
9d8e28f
test(evidence): reject truncated structured-value source
seonghobae Aug 28, 2026
74c971b
test(evidence): format partial-source regression
seonghobae Aug 28, 2026
b829400
fix(evidence): reject truncated structured-value sources
seonghobae Aug 28, 2026
303da3b
test(evidence): pin truncated-source error contract
seonghobae Aug 28, 2026
299a2bb
style(evidence): apply canonical rustfmt
seonghobae Aug 28, 2026
9056f92
merge(evidence): reconcile offline replay with live capture manifest
seonghobae Aug 29, 2026
50079d9
test(evidence): require persisted WARC byte verification offline
seonghobae Aug 30, 2026
e0e56c9
feat(evidence): verify persisted WARC and PROV bytes offline
seonghobae Aug 30, 2026
f7eccaa
fix(evidence): bind offline replay to persisted WARC and PROV bytes
seonghobae Aug 30, 2026
f028cfb
test(evidence): cover exact persisted offline replay artifacts
seonghobae Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Security

- Provenance source URLs now reject case-insensitive and percent-encoded credential query-field names, including recursively encoded nested query-like values, and percent-encoded controls before query-bearing URLs can be retained or serialized into WARC target metadata.
- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority.
- Raw page content cannot become a trusted instruction.
- Raw secrets are rejected and secret-capable actions require an opaque broker handle.
Expand Down
27 changes: 17 additions & 10 deletions crates/originweave-evidence/src/capture_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use sha2::{Digest, Sha256};

use crate::{
ExtractionCardinality, ExtractionNormalizationRule, ExtractionSchema, ExtractionSourceChannel,
ExtractionValueType, MAX_EXTRACTION_IDENTIFIER_BYTES, WarcProvBundle,
ExtractionValueType, MAX_EXTRACTION_IDENTIFIER_BYTES, WarcPayloadCompleteness, WarcProvBundle,
WarcProvBundleVerificationError, WarcResourceRecord,
};

Expand Down Expand Up @@ -45,6 +45,8 @@ pub enum CaptureManifestError {
UnknownValueField,
/// A structured value referenced a WARC record absent from the manifest.
ValueSourceRecordMissing,
/// A structured value referenced a WARC record whose retained payload was truncated.
ValueSourceRecordTruncated,
/// A structured value used WARC evidence for a field that did not admit network-response evidence.
ValueSourceChannelMismatch,
/// The same field, value digest, and source WARC record were supplied more than once.
Expand Down Expand Up @@ -83,6 +85,8 @@ impl fmt::Display for CaptureManifestError {
.write_str("capture manifest structured-value field is absent from the schema"),
Self::ValueSourceRecordMissing => formatter
.write_str("capture manifest structured value references an absent WARC record"),
Self::ValueSourceRecordTruncated => formatter
.write_str("capture manifest structured value references a truncated WARC record"),
Self::ValueSourceChannelMismatch => formatter.write_str(
"capture manifest structured value is not admitted by the field source channels",
),
Expand Down Expand Up @@ -112,6 +116,7 @@ impl std::error::Error for CaptureManifestError {
| Self::InvalidValueDigest
| Self::UnknownValueField
| Self::ValueSourceRecordMissing
| Self::ValueSourceRecordTruncated
| Self::ValueSourceChannelMismatch
| Self::DuplicateValue
| Self::ValueCardinalityExceeded
Expand Down Expand Up @@ -301,11 +306,11 @@ impl CaptureManifest {
/// Construct a schema-conforming manifest with WARC-backed structured-value identities.
///
/// Every value must name a declared schema field that admits network-response evidence and an
/// exact WARC record present in this manifest. Required fields must be present; `One` and
/// `ZeroOrOne` fields admit at most one value. Duplicate bindings and over-limit collections
/// fail closed. Values are canonicalized independently of caller order. No raw extracted value
/// is retained and no browser, network, persistence, secret, model, or authorization operation
/// is performed.
/// exact complete WARC record present in this manifest. Required fields must be present; `One`
/// and `ZeroOrOne` fields admit at most one value. Duplicate bindings, truncated source records,
/// and over-limit collections fail closed. Values are canonicalized independently of caller
/// order. No raw extracted value is retained and no browser, network, persistence, secret,
/// model, or authorization operation is performed.
pub fn new_with_warc_values(
schema: &ExtractionSchema,
records: &[(&WarcResourceRecord, &WarcProvBundle)],
Expand All @@ -322,12 +327,14 @@ impl CaptureManifest {
let Some(field) = schema.field(value.field_name()) else {
return Err(CaptureManifestError::UnknownValueField);
};
if !manifest
.records
let Some((source_record, _)) = records
.iter()
.any(|record| record.warc_record_id() == value.source_warc_record_id())
{
.find(|(record, _)| record.record_id() == value.source_warc_record_id())
else {
return Err(CaptureManifestError::ValueSourceRecordMissing);
};
if source_record.completeness() != WarcPayloadCompleteness::Complete {
return Err(CaptureManifestError::ValueSourceRecordTruncated);
}
if !field
.source_channels()
Expand Down
154 changes: 148 additions & 6 deletions crates/originweave-evidence/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

mod capture_manifest;
mod extraction_schema;
mod offline_replay;
mod sensitive_access;
mod sensitive_handle_lifecycle;
mod warc_prov_bundle;
Expand All @@ -24,6 +25,9 @@ pub use extraction_schema::{
ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType,
MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES,
};
pub use offline_replay::{
OfflineReplayVerification, OfflineReplayVerificationError, verify_offline_capture_package,
};
pub use sensitive_access::{
MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass,
SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome,
Expand Down Expand Up @@ -306,7 +310,7 @@ fn redact_all_values(values: BTreeMap<String, String>) -> BTreeMap<String, Strin
}

/// A provenance pointer from an extracted assertion to its exact evidence.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct ProvenanceRecord {
source_url: String,
source_locator: String,
Expand All @@ -315,6 +319,18 @@ pub struct ProvenanceRecord {
verification_result: VerificationResult,
}

impl std::fmt::Debug for ProvenanceRecord {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProvenanceRecord")
.field("source_url_byte_count", &self.source_url.len())
.field("source_locator_byte_count", &self.source_locator.len())
.field("source_kind", &self.source_kind)
.field("verification_result", &self.verification_result)
.finish()
}
}

impl ProvenanceRecord {
/// Validate and create one provenance record.
pub fn new(
Expand Down Expand Up @@ -383,21 +399,147 @@ fn valid_source_url(source_url: &str) -> bool {
|| source_url
.chars()
.any(|character| character.is_control() || character.is_whitespace())
|| source_url.contains(['?', '#', '\\'])
|| source_url.contains(['#', '\\'])
{
return false;
}
let Some((scheme, remainder)) = source_url.split_once("://") else {
return false;
};
let authority_end = remainder.find('/').unwrap_or(remainder.len());
let authority = &remainder[..authority_end];
let (hierarchical, query) = remainder
.split_once('?')
.map_or((remainder, None), |(hierarchical, query)| {
(hierarchical, Some(query))
});
let authority_end = hierarchical.find('/').unwrap_or(hierarchical.len());
let authority = &hierarchical[..authority_end];
let origin_text = format!("{scheme}://{authority}");
if Origin::parse(&origin_text).is_err() {
return false;
}
let path = &remainder[authority_end..];
path.is_empty() || validate_path(path).is_ok()
let path = &hierarchical[authority_end..];
if !path.is_empty() && validate_path(path).is_err() {
return false;
}
query.is_none_or(valid_query)
}

fn valid_query(query: &str) -> bool {
let bytes = query.as_bytes();
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if byte == b'%' {
let Some(high) = bytes
.get(index + 1)
.and_then(|byte| hexadecimal_value(*byte))
else {
return false;
};
let Some(low) = bytes
.get(index + 2)
.and_then(|byte| hexadecimal_value(*byte))
else {
return false;
};
if (high * 16 + low).is_ascii_control() {
return false;
}
index += 3;
continue;
}
if !is_rfc3986_pchar(byte) && !matches!(byte, b'/' | b'?') {
return false;
}
index += 1;
}
query.split('&').all(|field| {
let (name, value) = field
.split_once('=')
.map_or((field, ""), |(name, value)| (name, value));
!is_credential_query_name(name) && !nested_query_contains_credential(value)
})
}

fn nested_query_contains_credential(value: &str) -> bool {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = hexadecimal_value(bytes[index + 1]).unwrap_or(0);
let low = hexadecimal_value(bytes[index + 2]).unwrap_or(0);
decoded.push(high * 16 + low);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
decoded
.split(|byte| *byte == b'?')
.skip(1)
.any(|nested_query| {
nested_query.split(|byte| *byte == b'&').any(|field| {
let name_end = field
.iter()
.position(|byte| *byte == b'=')
.unwrap_or(field.len());
let name = &field[..name_end];
name.contains(&b'%') || is_credential_query_name_bytes(name)
})
})
}

fn is_credential_query_name(name: &str) -> bool {
let bytes = name.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = hexadecimal_value(bytes[index + 1]).unwrap_or(0);
let low = hexadecimal_value(bytes[index + 2]).unwrap_or(0);
decoded.push(high * 16 + low);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
decoded.contains(&b'%') || is_credential_query_name_bytes(&decoded)
}

fn is_credential_query_name_bytes(decoded: &[u8]) -> bool {
let mut decoded = decoded.to_owned();
decoded.make_ascii_lowercase();
decoded.iter_mut().for_each(|byte| {
if *byte == b'-' {
*byte = b'_';
}
});
matches!(
decoded.as_slice(),
b"access_token"
| b"api_key"
| b"auth"
| b"authorization"
| b"client_secret"
| b"credential"
| b"key"
| b"password"
| b"secret"
| b"secret_key"
| b"session"
| b"sig"
| b"signature"
| b"token"
| b"x_api_key"
| b"x_amz_credential"
| b"x_amz_security_token"
| b"x_amz_signature"
| b"x_goog_credential"
| b"x_goog_signature"
)
}

fn valid_sha256(source_hash: &str) -> bool {
Expand Down
Loading
Loading