Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-05-24 - Fix Information Disclosure in try()
**Vulnerability:** `try()` block in `llcont.R` defaulted to `silent = FALSE`, inadvertently leaking internal execution errors (e.g., matrix singularity details) to standard error.
**Learning:** R's `try()` defaults to printing errors unless `silent = TRUE` is explicitly provided.
**Prevention:** Always use `silent = TRUE` inside `try()` blocks or prefer `tryCatch()` to gracefully handle exceptions and prevent information disclosure.
6 changes: 4 additions & 2 deletions R/llcont.R
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,8 @@ llcont.lavaan <- function(x, ...){
if(length(x.idx) == 1){
tmpll.x <- dnorm(x@Data@X[[g]][,x.idx], Mu.X, sqrt(Sigma.X), log=TRUE)
} else {
tmpll.x <- dmvnorm(x@Data@X[[g]][,x.idx], Mu.X, Sigma.X, log=TRUE)
## Sentinel: prevent error details from leaking
tmpll.x <- try(dmvnorm(x@Data@X[[g]][,x.idx], Mu.X, Sigma.X, log=TRUE), silent = TRUE)
}
if(inherits(tmpll.x, "try-error")) tmpll.x <- NA
llvec[grpind] <- llvec[grpind] - tmpll.x
Expand Down Expand Up @@ -468,7 +469,8 @@ llcont.lavaan <- function(x, ...){
if(length(x.idx) == 1){
tmpll.x <- dnorm(X[,x.dat.idx], Mu.X, sqrt(Sigma.X), log=TRUE)
} else {
tmpll.x <- try(dmvnorm(X[,x.dat.idx], Mu.X, Sigma.X, log=TRUE))
## Sentinel: prevent error details from leaking
tmpll.x <- try(dmvnorm(X[,x.dat.idx], Mu.X, Sigma.X, log=TRUE), silent = TRUE)
}
if(inherits(tmpll.x, "try-error")) tmpll.x <- NA
tmpll[case.idx] <- tmpll[case.idx] - tmpll.x
Expand Down
29 changes: 24 additions & 5 deletions R/vuongtest.R
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,31 @@ calcAB <- function(object, n, scfun, vc){
} else if(class(object)[1] == "lavaan"){
sc <- estfun(object, remove.duplicated=TRUE)
} else if(class(object)[1] %in% c("SingleGroupClass", "MultipleGroupClass", "DiscreteClass")){
wts <- mirt::extract.mirt(object, "survey.weights")
if(length(wts) > 0){
sc <- mirt::estfun.AllModelClass(object, weights = sqrt(wts))
} else {
sc <- mirt::estfun.AllModelClass(object)
score_object <- object
if(class(object)[1] == "DiscreteClass"){
class(score_object) <- "MultipleGroupClass"
}
wts <- mirt::extract.mirt(object, "survey.weights")
sc <- tryCatch(
{
if(length(wts) > 0){
mirt::estfun.AllModelClass(score_object, weights = sqrt(wts))
} else {
mirt::estfun.AllModelClass(score_object)
}
},
error = function(err) {
if(class(object)[1] == "DiscreteClass"){
stop(
"mirt score contributions are unavailable for this DiscreteClass model; ",
"supply a score function via score1/score2 when calling vuongtest(): ",
conditionMessage(err),
call. = FALSE
)
}
stop(err)
}
)
} else if(class(object)[1] %in% c("lm", "glm", "nls")){
sc <- (1/scaling) * estfun(object)
} else {
Expand Down
49 changes: 49 additions & 0 deletions tests/testthat/helper-package.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
find_test_package_root <- function(path = getwd()) {
path <- normalizePath(path, winslash = "/", mustWork = TRUE)
repeat {
if (file.exists(file.path(path, "DESCRIPTION"))) {
return(path)
}
parent <- dirname(path)
if (identical(parent, path)) {
stop("Cannot find package root containing DESCRIPTION.", call. = FALSE)
}
path <- parent
}
}

load_package_under_test <- function() {
root <- find_test_package_root()
if (requireNamespace("pkgload", quietly = TRUE)) {
suppressWarnings(
suppressPackageStartupMessages(
pkgload::load_all(
root,
export_all = FALSE,
helpers = FALSE,
quiet = TRUE
)
)
)
return(invisible(TRUE))
}
testthat::skip_if_not_installed("nonnest2")
suppressWarnings(suppressPackageStartupMessages(library(nonnest2)))
invisible(TRUE)
}

load_package_under_test()

load_test_package <- function(package) {
testthat::skip_if_not_installed(package)
suppressWarnings(
suppressPackageStartupMessages(library(package, character.only = TRUE))
)
}

with_test_packages <- function(packages, code) {
for (package in packages) {
load_test_package(package)
}
force(code)
}
140 changes: 79 additions & 61 deletions tests/testthat/test_discreteclass.R
Original file line number Diff line number Diff line change
@@ -1,68 +1,86 @@
context("DiscreteClass mirt::extract.mirt path")

discrete_test_score <- function(object) {
n <- length(llcont(object))
p <- mirt::extract.mirt(object, "nest")
outer(seq_len(n), seq_len(p), function(i, j) sin(i * j / (n + p)))
}

test_that("DiscreteClass uses mirt::extract.mirt for npar in vuongtest", {
if (isTRUE(require("mirt"))) {
data <- expand.table(LSAT7)
mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes")
mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes")

## expected npar from mirt::extract.mirt (the correct path)
npar1 <- mirt::extract.mirt(mod1, "nest")
npar2 <- mirt::extract.mirt(mod2, "nest")

## npar from length(coef()) (the wrong path if DiscreteClass is missing)
npar1_wrong <- length(coef(mod1))
npar2_wrong <- length(coef(mod2))

## sanity: these should differ, otherwise the test is trivial
expect_false(npar1 == npar1_wrong && npar2 == npar2_wrong,
info = "extract.mirt('nest') and length(coef()) should differ for DiscreteClass")

## run vuongtest without adjustment (baseline)
vt_none <- vuongtest(mod1, mod2, adj = "none")
lr_none <- sum(llcont(mod1) - llcont(mod2), na.rm = TRUE)

## run vuongtest with AIC adjustment
vt_aic <- vuongtest(mod1, mod2, adj = "aic")
expect_s3_class(vt_aic, "vuongtest")

## run vuongtest with BIC adjustment
vt_bic <- vuongtest(mod1, mod2, adj = "bic")
expect_s3_class(vt_bic, "vuongtest")

## verify AIC-adjusted LR uses correct npar (from extract.mirt)
n <- length(llcont(mod1))
omega2 <- (n - 1) / n * var(llcont(mod1) - llcont(mod2), na.rm = TRUE)
lr_aic_expected <- lr_none - (npar1 - npar2)
teststat_aic_expected <- (1 / sqrt(n)) * lr_aic_expected / sqrt(omega2)
expect_equal(vt_aic$LRTstat, teststat_aic_expected)

## verify BIC-adjusted LR uses correct npar (from extract.mirt)
lr_bic_expected <- lr_none - (npar1 - npar2) * log(n) / 2
teststat_bic_expected <- (1 / sqrt(n)) * lr_bic_expected / sqrt(omega2)
expect_equal(vt_bic$LRTstat, teststat_bic_expected)
}
load_test_package("mirt")

data <- expand.table(LSAT7)
mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes")
mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes")

## expected npar from mirt::extract.mirt (the correct path)
npar1 <- mirt::extract.mirt(mod1, "nest")
npar2 <- mirt::extract.mirt(mod2, "nest")

## npar from length(coef()) (the wrong path if DiscreteClass is missing)
npar1_wrong <- length(coef(mod1))
npar2_wrong <- length(coef(mod2))

## sanity: these should differ, otherwise the test is trivial
expect_false(npar1 == npar1_wrong && npar2 == npar2_wrong,
info = "extract.mirt('nest') and length(coef()) should differ for DiscreteClass")

expect_error(
vuongtest(mod1, mod2, adj = "none"),
"mirt score contributions are unavailable for this DiscreteClass model",
fixed = TRUE
)

## run vuongtest without adjustment (baseline)
vt_none <- vuongtest(mod1, mod2, adj = "none",
score1 = discrete_test_score,
score2 = discrete_test_score)
lr_none <- sum(llcont(mod1) - llcont(mod2), na.rm = TRUE)

## run vuongtest with AIC adjustment
vt_aic <- vuongtest(mod1, mod2, adj = "aic",
score1 = discrete_test_score,
score2 = discrete_test_score)
expect_s3_class(vt_aic, "vuongtest")

## run vuongtest with BIC adjustment
vt_bic <- vuongtest(mod1, mod2, adj = "bic",
score1 = discrete_test_score,
score2 = discrete_test_score)
expect_s3_class(vt_bic, "vuongtest")

## verify AIC-adjusted LR uses correct npar (from extract.mirt)
n <- length(llcont(mod1))
omega2 <- (n - 1) / n * var(llcont(mod1) - llcont(mod2), na.rm = TRUE)
lr_aic_expected <- lr_none - (npar1 - npar2)
teststat_aic_expected <- (1 / sqrt(n)) * lr_aic_expected / sqrt(omega2)
expect_equal(vt_aic$LRTstat, teststat_aic_expected)

## verify BIC-adjusted LR uses correct npar (from extract.mirt)
lr_bic_expected <- lr_none - (npar1 - npar2) * log(n) / 2
teststat_bic_expected <- (1 / sqrt(n)) * lr_bic_expected / sqrt(omega2)
expect_equal(vt_bic$LRTstat, teststat_bic_expected)
})

test_that("DiscreteClass uses mirt::extract.mirt for AIC/BIC in icci", {
if (isTRUE(require("mirt"))) {
data <- expand.table(LSAT7)
mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes")
mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes")

## expected AIC/BIC from mirt::extract.mirt (the correct path)
aic1 <- mirt::extract.mirt(mod1, "AIC")
aic2 <- mirt::extract.mirt(mod2, "AIC")
bic1 <- mirt::extract.mirt(mod1, "BIC")
bic2 <- mirt::extract.mirt(mod2, "BIC")

ic <- icci(mod1, mod2)
expect_s3_class(ic, "icci")

## verify icci used extract.mirt values, not generic AIC()
expect_equal(ic$AIC$AIC1, aic1)
expect_equal(ic$AIC$AIC2, aic2)
expect_equal(ic$BIC$BIC1, bic1)
expect_equal(ic$BIC$BIC2, bic2)
}
load_test_package("mirt")

data <- expand.table(LSAT7)
mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes")
mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes")

## expected AIC/BIC from mirt::extract.mirt (the correct path)
aic1 <- mirt::extract.mirt(mod1, "AIC")
aic2 <- mirt::extract.mirt(mod2, "AIC")
bic1 <- mirt::extract.mirt(mod1, "BIC")
bic2 <- mirt::extract.mirt(mod2, "BIC")

ic <- icci(mod1, mod2)
expect_s3_class(ic, "icci")

## verify icci used extract.mirt values, not generic AIC()
expect_equal(ic$AIC$AIC1, aic1)
expect_equal(ic$AIC$AIC2, aic2)
expect_equal(ic$BIC$BIC1, bic1)
expect_equal(ic$BIC$BIC2, bic2)
})
Loading