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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ vt-module = [
"dep:psl",
]

# The `vsix` module parses Visual Studio Code Extension files.
vsix-module = [
"dep:sha2",
"dep:zip",
]

# Enables all the default modules.
default-modules = [
"console-module",
Expand All @@ -238,6 +244,7 @@ default-modules = [
"lnk-module",
"test_proto2-module",
"test_proto3-module",
"vsix-module",
"vt-module",
]

Expand Down
2 changes: 2 additions & 0 deletions lib/src/modules/add_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ add_module!(modules, "test_proto3", test_proto3, "test_proto3.TestProto3", Some(
add_module!(modules, "text", text, "text.Text", Some("text"), Some(text::__main__ as MainFn));
#[cfg(feature = "time-module")]
add_module!(modules, "time", time, "time.Time", Some("time"), Some(time::__main__ as MainFn));
#[cfg(feature = "vsix-module")]
add_module!(modules, "vsix", vsix, "vsix.Vsix", Some("vsix"), Some(vsix::__main__ as MainFn));
#[cfg(feature = "vt-module")]
add_module!(modules, "vt", titan, "vt.titan.LiveHuntData", Some("vt"), Some(vt::__main__ as MainFn));
}
10 changes: 10 additions & 0 deletions lib/src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ pub mod mods {
/// Data structure returned by the `pe` module.
pub use super::protos::pe::PE;

/// Data structures defined by the `vsix` module.
///
/// The main structure produced by the module is [`vsix::Vsix`]. The rest
/// of them are used by one or more fields in the main structure.
///
pub use super::protos::vsix;
/// Data structure returned by the `vsix` module.
pub use super::protos::vsix::Vsix;

/// A data structure containing the data returned by all modules.
pub use super::protos::mods::Modules;

Expand Down Expand Up @@ -313,6 +322,7 @@ pub mod mods {
info.lnk = protobuf::MessageField(invoke::<Lnk>(data));
info.crx = protobuf::MessageField(invoke::<Crx>(data));
info.dex = protobuf::MessageField(invoke::<Dex>(data));
info.vsix = protobuf::MessageField(invoke::<Vsix>(data));
info
}

Expand Down
2 changes: 2 additions & 0 deletions lib/src/modules/modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,7 @@ mod test_proto3;
mod text;
#[cfg(feature = "time-module")]
mod time;
#[cfg(feature = "vsix-module")]
mod vsix;
#[cfg(feature = "vt-module")]
mod vt;
2 changes: 2 additions & 0 deletions lib/src/modules/protos/mods.proto
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import "elf.proto";
import "pe.proto";
import "lnk.proto";
import "macho.proto";
import "vsix.proto";

package mods;

Expand All @@ -20,4 +21,5 @@ message Modules {
optional lnk.Lnk lnk = 5;
optional crx.Crx crx = 6;
optional dex.Dex dex = 7;
optional vsix.Vsix vsix = 8;
}
42 changes: 42 additions & 0 deletions lib/src/modules/protos/vsix.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
syntax = "proto2";
import "yara.proto";

package vsix;

option (yara.module_options) = {
name : "vsix"
root_message: "vsix.Vsix"
rust_module: "vsix"
cargo_feature: "vsix-module"
};

message Vsix {
// True if the file is a valid VSIX extension.
optional bool is_vsix = 1;

// Extension identity
optional string name = 2;
optional string display_name = 3;
optional string publisher = 4;
optional string version = 5;
optional string id = 6; // publisher.name
optional string description = 7;

// Entry points (security-critical)
optional string main = 8;
optional string browser = 9;

// Activation events (security-critical)
repeated string activation_events = 10;

// Metadata
optional string vscode_version = 11; // engines.vscode
optional string repository = 12;
optional string homepage = 13;
optional string license = 14;
repeated string categories = 15;
repeated string keywords = 16;

// Files in archive
repeated string files = 17;
}
1 change: 1 addition & 0 deletions lib/src/modules/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ fn test_invoke_modules() {
assert!(modules.lnk.is_lnk.is_some_and(|value| !value));
assert!(modules.crx.is_crx.is_some_and(|value| !value));
assert!(modules.dex.is_dex.is_some_and(|value| !value));
assert!(modules.vsix.is_vsix.is_some_and(|value| !value));
}

#[cfg(feature = "test_proto2-module")]
Expand Down
101 changes: 101 additions & 0 deletions lib/src/modules/vsix/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*! YARA module that parses Visual Studio Code Extension (VSIX) files.

This allows creating YARA rules based on metadata extracted from those files.
*/

mod parser;

use sha2::{Digest, Sha256};
use std::cell::RefCell;

use crate::modules::prelude::*;
use crate::modules::protos::vsix::*;

#[cfg(test)]
mod tests;

thread_local!(
static ACTIVATIONHASH_CACHE: RefCell<Option<String>> =
const { RefCell::new(None) };
);

#[module_main]
fn main(data: &[u8], _meta: Option<&[u8]>) -> Result<Vsix, ModuleError> {
ACTIVATIONHASH_CACHE.with(|cache| *cache.borrow_mut() = None);
match parser::Vsix::parse(data) {
Ok(vsix) => Ok(vsix.into()),
Err(_) => {
let mut vsix = Vsix::new();
vsix.set_is_vsix(false);
Ok(vsix)
}
}
}

/// Returns the SHA-256 hash of the sorted activation events.
///
/// Events are sorted alphabetically before hashing to produce an
/// order-independent fingerprint. A null byte separator is added
/// between events to prevent hash collisions from concatenation
/// (e.g., distinguishing ["ab", "c"] from ["a", "bc"]).
///
/// This differs from the CRX module's `permhash()` which does not
/// sort permissions before hashing.
#[module_export]
fn activationhash(ctx: &ScanContext) -> Option<Lowercase<FixedLenString<64>>> {
let cached = ACTIVATIONHASH_CACHE.with(
|cache| -> Option<Lowercase<FixedLenString<64>>> {
cache.borrow().as_deref().map(|s| {
Lowercase::<FixedLenString<64>>::from_slice(ctx, s.as_bytes())
})
},
);

if cached.is_some() {
return cached;
}

let vsix = ctx.module_output::<Vsix>()?;

if !vsix.is_vsix() {
return None;
}

let mut events: Vec<&str> =
vsix.activation_events.iter().map(String::as_str).collect();
events.sort();

let mut sha256_hash = Sha256::new();
for event in events {
sha256_hash.update(event.as_bytes());
sha256_hash.update(b"\x00"); // Null byte separator
}

let digest = format!("{:x}", sha256_hash.finalize());

ACTIVATIONHASH_CACHE.with(|cache| {
*cache.borrow_mut() = Some(digest.clone());
});

Some(Lowercase::<FixedLenString<64>>::new(digest))
}

/// Returns true if the extension has the specified activation event.
///
/// # Arguments
///
/// * `event` - The activation event to check for (e.g., "*", "onCommand:test.run")
#[module_export]
fn has_activation_event(
ctx: &ScanContext,
event: RuntimeString,
) -> Option<bool> {
let vsix = ctx.module_output::<Vsix>()?;

if !vsix.is_vsix() {
return None;
}

let event_str = event.as_bstr(ctx);
Some(vsix.activation_events.iter().any(|e| e.as_bytes() == event_str.as_bytes()))
}
Loading
Loading