Governance & Audit

Where Will the Attacker Strike? Assumptions, limits and checks

All money is in $M. Losses are expected loss per targeted ransomware campaign, and the model assumes one campaign per year. All values are illustrative.

How a board should use this

Use this analysis as a challenge to the budget, not as an allocation engine. The numbers are synthetic. What carries over to your own firm is the logic: attackers respond to what we defend, and the best plan is the one that still works when our read of the attacker is wrong.

A board can ask three things. Does our plan state who we think the attacker is? Has anyone tested the plan against a different attacker? What would we change if our threat intelligence were wrong by a third?

Assumptions register

Code
reg |>
  gt() |>
  cols_label(id = "ID", assumption = "Assumption", why_it_matters = "Why it matters",
             how_it_could_fail = "How it could fail", where_tested = "Where tested") |>
  gt_se() |>
  cols_align(align = "left")
Table 1: Assumptions behind the analysis, why each matters, how it could fail, and where it is tested.
ID Assumption Why it matters How it could fail Where tested
A1 One ransomware campaign per year Loss figures are per campaign; the budget is annual Several campaigns or repeat attacks would multiply loss and change the best trade-offs Not tested (out of scope: repeated play)
A2 Single adversary type The crew's payoffs drive every strategy Different attackers (insiders; nation-states) want different assets Not tested (out of scope: attacker mixtures)
A3 The crew observes coverage before choosing a target Equilibrium logic relies on the attacker responding to what it sees An attacker who cannot see defenses attacks closer to its prior Partly tested through the noisy-crew sweep (Robustness page)
A4 Coverage cost is linear Linear cost makes the allocation a clean budget split Real controls show diminishing returns and fixed costs Not tested
A5 Coverage acts as the probability an attack is stopped Turns spending into loss reduction Controls fail in correlated ways; coverage may not map to a probability Not tested
A6 A stopped attack still costs 10% of the full loss Sets the value of coverage for the defender A higher share weakens the gain from coverage; a lower share strengthens it Sensitivity table: 5% and 20% (robust still best)
A7 Every asset keeps at least 25% hygiene coverage Fixes a minimum on every asset including low-value ones A different floor changes how much budget is free to allocate Sensitivity table: 15% and 35% (robust still best)
A8 Synthetic calibration All asset values are illustrative Real values could reorder the strategies Misestimation Monte Carlo (Robustness page) stresses the crew's payoffs only
A9 Robust rationality parameter lambda = 2 Controls how noisy the robust strategy assumes the crew is A very different crew would make a different lambda best Chosen on a separate simulation seed; lambda sweep (Robustness page)
A10 Crew payoff error is lognormal with sigma 0.35 Defines how badly the firm may misjudge the crew Larger or biased errors would hurt every strategy Sensitivity table: sigma 0.20 and 0.50; seed-stability check (diagnostics)
A11 Assets fail independently (no lateral movement) Each attack's loss depends only on the asset hit Ransomware crews pivot through identity and corporate IT to reach other systems Sensitivity table: higher identity and corporate IT losses (robust still best)

How to read the table. Each row is something we assumed to build the model. The columns say why the assumption matters, how it could turn out wrong, and where this site tests it, if anywhere.

What the table tells you. Several assumptions are not tested here. These are the first places to look when applying the approach to real data, because a wrong assumption can reorder the strategies.

Sensitivity

We changed one assumption at a time and reran the full comparison. The robust strategy was best in 8 of 8 cases, including a lateral-movement case where an identity or corporate IT breach spreads and costs more.

Code
sens |>
  transmute(assumption, setting,
            best = STRAT_LABELS[best],
            robust_mean,
            next_best = paste0(STRAT_LABELS[next_best], " (", fmt_m(next_mean), ")"),
            robust_p95, textbook_p95) |>
  gt() |>
  cols_label(assumption = "Assumption", setting = "Setting", best = "Best strategy",
             robust_mean = "Robust", next_best = "Next best",
             robust_p95 = "Robust 1-in-20", textbook_p95 = "Textbook 1-in-20") |>
  fmt_currency(columns = c(robust_mean, robust_p95, textbook_p95),
               decimals = 1, pattern = "{x}M") |>
  gt_se() |>
  cols_align(align = "left", columns = c(assumption, setting, best, next_best))
Table 2: Expected loss if we misjudge the crew, with one assumption changed at a time.
Assumption Setting Best strategy Robust Next best Robust 1-in-20 Textbook 1-in-20
Base case As published Robust equilibrium $9.0M Proportional ($10.7M) $14.9M $46.5M
A6 covered-attack share 5% Robust equilibrium $8.7M Proportional ($10.3M) $14.1M $45.8M
A6 covered-attack share 20% Robust equilibrium $9.6M Proportional ($11.5M) $16.5M $48.0M
A7 coverage floor 15% Robust equilibrium $9.0M Proportional ($10.5M) $14.9M $51.9M
A7 coverage floor 35% Robust equilibrium $9.3M Proportional ($11.5M) $19.8M $23.8M
A10 misjudgment size (sigma) 0.20 Robust equilibrium $7.7M Proportional ($9.7M) $8.1M $23.2M
A10 misjudgment size (sigma) 0.50 Robust equilibrium $10.6M Proportional ($11.5M) $19.4M $46.5M
A11 lateral movement Identity loss 30, corporate IT loss 22 Robust equilibrium $14.5M Proportional ($16.2M) $19.8M $46.5M

How to read the table. Each row changes one assumption and reruns every strategy. Values are expected loss in $M when we misjudge the crew; the last two columns are the 1-in-20 bad case.

What the table tells you. The ranking does not depend on these settings. Larger misjudgment raises every loss but robust still leads, and the textbook plan’s bad case stays far above robust’s.

Limitations

The model has no repeated play: one campaign, one decision. It has no deception or honeypots, which can change what the crew sees. It has one attacker type, so insiders and state actors are out of scope. Assets fail independently: a breach of identity or corporate IT does not spread to other systems, although real ransomware crews often move laterally. The sensitivity table tests a simple version of this. The budget is static, so it does not respond to new intelligence during the year.

Natural follow-ons are repeated play, mixtures of attacker types, and the attack-graph Markov chain project.

Further reading

For a framework that tests any risk model, see Three Questions for Any Risk Model.

Diagnostics

All 7 automated checks ran when the results were built; 7 passed.

Code
diag |>
  gt() |>
  cols_label(check = "Check", result = "Result", detail = "Detail") |>
  gt_se() |>
  cols_align(align = "left")
Table 3: Automated checks run when the results were built.
Check Result Detail
SSE LPs solved without solver error pass 31 budgets on the grid, every LP optimal or infeasible
Best-response consistency pass 155 strategy-budget pairs rechecked
Budget constraint binding pass Baselines bind where B <= 16.4. Textbook leaves slack at 5 budgets (max 2.37); robust at 1 (max 0.13, no improving move)
Two-asset analytic match pass PLAT coverage 0.4483 vs analytic 0.4483
Gradient check pass max abs error 7.7e-09 at five points
Monte Carlo stability (seed + 1) pass max change in mean 1.0%; ranking unchanged
Robust minimizes the QRE objective at every budget pass Robust lowest or tied at all 31 budgets; closest call at $5.5M: robust 13.54 vs best other 13.92

How to read the table. Each row is a test of the calculations. “Pass” means the check met its standard. The detail column states what was checked.

What the table tells you. The checks cover the solver, the robust optimizer, the budget, and the Monte Carlo draws.

Code
tibble::tibble(
  Item = c("R", "Seed", names(si$packages)),
  Value = c(si$r_version, as.character(si$seed), unname(si$packages))
) |>
  gt() |>
  gt_se() |>
  cols_align(align = "left")
Table 4: Software versions and seed used to build the results.
Item Value
R R version 4.5.2 (2025-10-31)
Seed 20261103
highs 1.15.1.2
nloptr 2.2.1
dplyr 1.2.1
ggplot2 4.0.3
gt 1.3.0
testthat 3.3.2

How to read the table. The first row is the R version, the second the random seed, and the rest are the software packages that matter for the results.

What the table tells you. Anyone with these versions and seed can reproduce the numbers on this site exactly. A different seed gives slightly different Monte Carlo results.

Reproduce. Code and data: GitHub repository. From the project root run Rscript R/run_all.R, then quarto render.

Session Information

R version 4.5.2 (2025-10-31)
Platform: aarch64-apple-darwin20
Running under: macOS Tahoe 26.6.2

Matrix products: default
BLAS:   /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: America/New_York
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] gt_1.3.0      ggplot2_4.0.3 dplyr_1.2.1  

loaded via a namespace (and not attached):
 [1] bit_4.6.0          archive_1.1.12     gtable_0.3.6       jsonlite_2.0.0    
 [5] crayon_1.5.3       compiler_4.5.2     tidyselect_1.2.1   xml2_1.4.1        
 [9] parallel_4.5.2     scales_1.4.0       yaml_2.3.10        fastmap_1.2.0     
[13] readr_2.1.5        R6_2.6.1           generics_0.1.4     knitr_1.50        
[17] htmlwidgets_1.6.4  tibble_3.3.1       pillar_1.11.1      RColorBrewer_1.1-3
[21] tzdb_0.5.0         rlang_1.3.0        xfun_0.54          sass_0.4.10       
[25] fs_2.1.0           S7_0.2.2           bit64_4.8.2        cli_3.6.6         
[29] withr_3.0.3        magrittr_2.0.5     digest_0.6.39      grid_4.5.2        
[33] vroom_1.6.6        hms_1.1.4          lifecycle_1.0.5    vctrs_0.7.3       
[37] evaluate_1.0.5     glue_1.8.1         farver_2.1.2       rmarkdown_2.30    
[41] tools_4.5.2        pkgconfig_2.0.3    htmltools_0.5.9