augure 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +136 -0
  3. data/LICENSE +21 -0
  4. data/README.md +217 -0
  5. data/exe/augure +181 -0
  6. data/exe/augure-benchmark +67 -0
  7. data/home/runner/work/augure/augure/docs/agent-snippet.md +31 -0
  8. data/home/runner/work/augure/augure/docs/auditability.md +160 -0
  9. data/home/runner/work/augure/augure/docs/build-format.md +119 -0
  10. data/home/runner/work/augure/augure/docs/explanation-architecture.md +74 -0
  11. data/home/runner/work/augure/augure/docs/how-to-swap-solver.md +73 -0
  12. data/home/runner/work/augure/augure/docs/how-to-write-rules.md +71 -0
  13. data/home/runner/work/augure/augure/docs/pack-format.md +137 -0
  14. data/home/runner/work/augure/augure/docs/reference.md +293 -0
  15. data/home/runner/work/augure/augure/docs/tutorial.md +108 -0
  16. data/home/runner/work/augure/augure/examples/ret2win.facts +11 -0
  17. data/home/runner/work/augure/augure/examples/split.facts +12 -0
  18. data/home/runner/work/augure/augure/exe/augure +181 -0
  19. data/home/runner/work/augure/augure/exe/augure-benchmark +67 -0
  20. data/home/runner/work/augure/augure/lib/augure/bandit.rb +118 -0
  21. data/home/runner/work/augure/augure/lib/augure/coverage.rb +56 -0
  22. data/home/runner/work/augure/augure/lib/augure/ctf_corpus.json +773 -0
  23. data/home/runner/work/augure/augure/lib/augure/engine.rb +128 -0
  24. data/home/runner/work/augure/augure/lib/augure/facts.rb +194 -0
  25. data/home/runner/work/augure/augure/lib/augure/kb_corpus.json +345 -0
  26. data/home/runner/work/augure/augure/lib/augure/knowledge_base.rb +109 -0
  27. data/home/runner/work/augure/augure/lib/augure/mcts.rb +178 -0
  28. data/home/runner/work/augure/augure/lib/augure/pack.rb +244 -0
  29. data/home/runner/work/augure/augure/lib/augure/pack_loader.rb +93 -0
  30. data/home/runner/work/augure/augure/lib/augure/parity.rb +69 -0
  31. data/home/runner/work/augure/augure/lib/augure/pipeline.rb +107 -0
  32. data/home/runner/work/augure/augure/lib/augure/priors.rb +32 -0
  33. data/home/runner/work/augure/augure/lib/augure/rules.rb +300 -0
  34. data/home/runner/work/augure/augure/lib/augure/smt_process.rb +48 -0
  35. data/home/runner/work/augure/augure/lib/augure/verifier.rb +40 -0
  36. data/home/runner/work/augure/augure/lib/augure/version.rb +5 -0
  37. data/home/runner/work/augure/augure/lib/augure.rb +25 -0
  38. data/home/runner/work/augure/augure/packs/banner_leak.yml +46 -0
  39. data/home/runner/work/augure/augure/packs/brop.yml +46 -0
  40. data/home/runner/work/augure/augure/packs/fastbin_hook.yml +45 -0
  41. data/home/runner/work/augure/augure/packs/got_partial_overwrite.yml +45 -0
  42. data/home/runner/work/augure/augure/packs/got_partial_overwrite_deref.yml +39 -0
  43. data/home/runner/work/augure/augure/packs/house_botcake.yml +38 -0
  44. data/home/runner/work/augure/augure/packs/house_force.yml +37 -0
  45. data/home/runner/work/augure/augure/packs/house_of_apple2.yml +45 -0
  46. data/home/runner/work/augure/augure/packs/house_orange.yml +38 -0
  47. data/home/runner/work/augure/augure/packs/jop.yml +39 -0
  48. data/home/runner/work/augure/augure/packs/large_bin_attack.yml +39 -0
  49. data/home/runner/work/augure/augure/packs/one_gadget.yml +38 -0
  50. data/home/runner/work/augure/augure/packs/orw.yml +55 -0
  51. data/home/runner/work/augure/augure/packs/ret2csu_v2.yml +55 -0
  52. data/home/runner/work/augure/augure/packs/ret2dlresolve_x86.yml +39 -0
  53. data/home/runner/work/augure/augure/packs/ret2partial_overwrite.yml +38 -0
  54. data/home/runner/work/augure/augure/packs/ret2vdso.yml +39 -0
  55. data/home/runner/work/augure/augure/packs/setcontext_srop.yml +46 -0
  56. data/home/runner/work/augure/augure/packs/uaf_tcache.yml +45 -0
  57. data/home/runner/work/augure/augure/packs/unsorted_bin_attack.yml +38 -0
  58. metadata +103 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Augure
6
+ # Retrieval-augmented knowledge base over a seed corpus of documented
7
+ # exploitation patterns (ROP Emporium / Protostar / pwnable.tw write-ups).
8
+ # Pure-Ruby TF-IDF; a consumer can swap in an embedding endpoint, the
9
+ # interface stays query -> ranked entries.
10
+ class KnowledgeBase
11
+ CORPUS_PATH = File.join(__dir__, "kb_corpus.json")
12
+ PSEUDO_COUNTS = 10
13
+ MIN_SCORE = 0.05
14
+
15
+ Entry = Struct.new(:doc, :score)
16
+
17
+ def self.corpus
18
+ @corpus ||= JSON.parse(File.read(CORPUS_PATH)).freeze
19
+ end
20
+
21
+ # The seed corpus extended with knowledge from loaded technique
22
+ # packs - entries carry the same shape, so retrieval, priors and
23
+ # explain see pack techniques like built-in ones.
24
+ def self.corpus_with(extra)
25
+ (corpus + extra.to_a).freeze
26
+ end
27
+
28
+ def self.priors
29
+ accum = Hash.new { |h, k| h[k] = [0.0, 0.0] }
30
+ corpus.each do |entry|
31
+ tech = entry["technique"]
32
+ rate = entry["success_rate"]
33
+ next if !tech || rate.nil?
34
+
35
+ accum[tech][0] += rate * PSEUDO_COUNTS
36
+ accum[tech][1] += (1 - rate) * PSEUDO_COUNTS
37
+ end
38
+ accum
39
+ end
40
+
41
+ def self.transitions
42
+ corpus.each_with_object(Hash.new { |h, k| h[k] = [] }) do |entry, acc|
43
+ unlocks = entry["unlocks"]
44
+ next if !entry["technique"] || !unlocks.is_a?(Array)
45
+
46
+ unlocks.each { |u| acc[entry["technique"]] << u unless acc[entry["technique"]].include?(u) }
47
+ end
48
+ end
49
+
50
+ attr_reader :size
51
+
52
+ def initialize(extra: [])
53
+ @corpus = extra.empty? ? self.class.corpus : self.class.corpus_with(extra)
54
+ @size = @corpus.size
55
+ build_index
56
+ end
57
+
58
+ def query(text, technique: nil, n_results: 3)
59
+ candidates = @corpus.each_with_index.select { |e, _| technique.nil? || e["technique"] == technique }
60
+ candidates = @corpus.each_with_index.to_a if candidates.empty?
61
+ return [] if candidates.empty?
62
+
63
+ q = encode(text)
64
+ scored = candidates.filter_map do |entry, i|
65
+ score = dot(@vectors[i], q)
66
+ Entry.new(entry, score) if score >= MIN_SCORE
67
+ end
68
+ scored.sort_by { |e| -e.score }.first(n_results)
69
+ end
70
+
71
+ private
72
+
73
+ def build_index
74
+ texts = @corpus.map { |e| e["description"] }
75
+ tokens = texts.map { |t| tokenize(t) }
76
+ @vocab = {}
77
+ tokens.each { |tk| tk.each { |w| @vocab[w] ||= @vocab.size } }
78
+ @df = Array.new(@vocab.size, 0)
79
+ tokens.each { |tk| tk.uniq.each { |w| @df[@vocab[w]] += 1 } }
80
+ @idf = @df.map { |df| Math.log((texts.size + 1).to_f / (df + 1)) }
81
+ @vectors = texts.each_with_index.map { |t, i| encode(t, tf_source: tokens[i]) }
82
+ end
83
+
84
+ def tokenize(text)
85
+ text.downcase.scan(/[a-z0-9_]+/)
86
+ end
87
+
88
+ # TF (length-normalized) * IDF, L2-normalized. Sparse Hash vector.
89
+ def encode(text, tf_source: nil)
90
+ tokens = tf_source || tokenize(text)
91
+ counts = Hash.new(0.0)
92
+ tokens.each { |w| counts[@vocab[w]] += 1 if @vocab.key?(w) }
93
+ total = counts.values.sum
94
+ return {} if total.zero?
95
+
96
+ vec = {}
97
+ counts.each do |term_idx, tf|
98
+ val = (tf / total) * @idf[term_idx]
99
+ vec[term_idx] = val
100
+ end
101
+ norm = Math.sqrt(vec.values.sum { |v| v * v }) + 1e-9
102
+ vec.transform_values { |v| v / norm }
103
+ end
104
+
105
+ def dot(a, b)
106
+ b.sum { |i, v| a.fetch(i, 0.0) * v }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Augure
4
+ # Monte Carlo Tree Search over the technique-transition graph (article 3:
5
+ # AG -> bandit -> MCTS). Nodes are capability sets, edges are verified
6
+ # techniques, rollouts score the discounted product of success rates. A
7
+ # flat bandit cannot value a leak by what it unlocks; this can.
8
+ class Mcts
9
+ # technique => [requires, provides, terminal, base_success]
10
+ STAGE_MODEL = {
11
+ "fmtstr_leak" => [[], %w[libc_base], false, 0.75],
12
+ "fmtstr_canary" => [[], %w[canary], false, 0.70],
13
+ "ret2plt_leak" => [[], %w[libc_base], false, 0.68],
14
+ "ret2libc" => [%w[libc_base], %w[shell], true, 0.85],
15
+ "rop" => [%w[libc_base], %w[shell], true, 0.75],
16
+ # ROP without a libc leak on a PIE target is unreliable (addresses are
17
+ # guessed). Low success reflects the real-world penalty.
18
+ "rop_nocontext" => [[], %w[shell], true, 0.25],
19
+ "ret2plt" => [[], %w[shell], true, 0.75],
20
+ "shellcode" => [[], %w[shell], true, 0.90],
21
+ "stack_pivot" => [[], %w[pivot], false, 0.60],
22
+ # Pivot-gated variants: the real chain only fits after a pivot,
23
+ # modeling the stack-space constraint.
24
+ "ret2plt_leak_big" => [%w[pivot], %w[libc_base], false, 0.68],
25
+ "ret2libc_big" => [%w[pivot libc_base], %w[shell], true, 0.85],
26
+ # Extended vocabulary: techniques documented by public suites.
27
+ "ret2csu" => [[], %w[libc_base], false, 0.70],
28
+ "dlresolve" => [[], %w[shell], true, 0.55],
29
+ "got_overwrite" => [[], %w[shell], true, 0.65],
30
+ # PinTheft (RDS zerocopy double-free privesc, V12 Security): a
31
+ # DOCUMENTED external chain collapsed to its load-bearing spine.
32
+ "io_uring_register" => [[], %w[iouring], false, 0.90],
33
+ "rds_pin_steal" => [%w[iouring], %w[pin_underflow], false, 0.70],
34
+ "page_free" => [%w[pin_underflow], %w[freed_page], false, 0.75],
35
+ "pagecache_reclaim" => [%w[freed_page], %w[pagecache_ctrl], false, 0.65],
36
+ "io_uring_write" => [%w[pagecache_ctrl iouring], %w[suid_overwrite], false, 0.70],
37
+ "suid_exec" => [%w[suid_overwrite], %w[shell], true, 0.90]
38
+ }.freeze
39
+
40
+ DISCOUNT = 0.97
41
+ MAX_DEPTH = 6
42
+ UCB_C = 1.41
43
+
44
+ class << self
45
+ def caps
46
+ Set.new.freeze
47
+ end
48
+
49
+ def available(caps, allowed, model: STAGE_MODEL)
50
+ model.filter_map do |tech, (req, _, _, _)|
51
+ tech if allowed.include?(tech) && req.all? { |r| caps.include?(r) }
52
+ end
53
+ end
54
+
55
+ def transition(caps, tech, model: STAGE_MODEL)
56
+ caps + model.fetch(tech)[1]
57
+ end
58
+
59
+ # terminal = any capability a terminal:true stage PROVIDES (the model
60
+ # owns the semantics - the orw pack terminates on flag_read, not
61
+ # on a shell that never comes). Built-in terminals all provide
62
+ # "shell", so built-in behaviour is unchanged.
63
+ def terminal?(caps, model: STAGE_MODEL)
64
+ model.each_value.any? do |(_, provides, term, _)|
65
+ term && provides.any? { |c| caps.include?(c) }
66
+ end
67
+ end
68
+
69
+ def stage(tech, model: STAGE_MODEL)
70
+ model.fetch(tech)
71
+ end
72
+
73
+ # Built-in stage model + technique-pack transitions (packs only add).
74
+ def merged_model(packs)
75
+ merged = STAGE_MODEL.dup
76
+ Array(packs).each do |pack|
77
+ m = pack.mcts
78
+ merged[pack.technique] = [m["requires"], m["provides"], m["terminal"], m["success"]]
79
+ end
80
+ merged.freeze
81
+ end
82
+
83
+ # Random rollout: discounted product of success rates down the path.
84
+ # Reaching shell is necessary but not sufficient: the terminal
85
+ # technique's own success scales the reward.
86
+ def rollout(caps, allowed, depth: 0, rng: Random.new, max_depth: MAX_DEPTH, model: STAGE_MODEL)
87
+ return 1.0 if terminal?(caps, model: model)
88
+ return 0.0 if depth >= max_depth
89
+
90
+ avail = available(caps, allowed, model: model)
91
+ return 0.0 if avail.empty?
92
+
93
+ tech = avail[rand_index(rng, avail.size)]
94
+ success = stage(tech, model: model)[3]
95
+ new_caps = transition(caps, tech, model: model)
96
+ success * (DISCOUNT**depth) * rollout(new_caps, allowed, depth: depth + 1, rng: rng, model: model)
97
+ end
98
+
99
+ # MCTS from an empty capability set: best first move + most-visited path.
100
+ def plan(allowed, iterations: 2000, seed: nil, model: STAGE_MODEL)
101
+ rng = seed ? Random.new(seed) : Random.new
102
+ root = Node.new(caps: caps)
103
+ root.untried = available(root.caps, allowed, model: model)
104
+
105
+ iterations.times do
106
+ node = root
107
+ # 1. Selection
108
+ node = node.children.max_by { |n| n.ucb1(node.visits) } while node.untried.empty? && !node.children.empty?
109
+ # 2. Expansion. A child whose capability set equals its
110
+ # parent's adds nothing (the degenerate-tree case: the walk
111
+ # grows ~iterations deep, selection+backprop O(iterations^2)).
112
+ # Such nodes are not expanded - the parent absorbs them.
113
+ unless node.untried.empty?
114
+ tech = node.untried.pop
115
+ new_caps = transition(node.caps, tech, model: model)
116
+ next if new_caps == node.caps && !terminal?(new_caps, model: model)
117
+
118
+ child = Node.new(caps: new_caps, technique_used: tech, parent: node)
119
+ child.untried = terminal?(new_caps) ? [] : available(new_caps, allowed, model: model)
120
+ node.children << child
121
+ node = child
122
+ end
123
+ # 3. Simulation. An already-terminal expansion node scores its own
124
+ # technique's success, not a flat 1.0.
125
+ reward = if terminal?(node.caps, model: model) && node.technique_used
126
+ stage(node.technique_used, model: model)[3]
127
+ else
128
+ rollout(node.caps, allowed, rng: rng, model: model)
129
+ end
130
+ # 4. Backpropagation
131
+ until node.nil?
132
+ node.visits += 1
133
+ node.value += reward
134
+ node = node.parent
135
+ end
136
+ end
137
+
138
+ path = []
139
+ node = root
140
+ until node.children.empty?
141
+ node = node.children.max_by(&:visits)
142
+ path << node.technique_used
143
+ break if terminal?(node.caps, model: model)
144
+ end
145
+ best_first = root.children.max_by(&:visits)&.technique_used
146
+ [best_first, path]
147
+ end
148
+
149
+ private
150
+
151
+ def rand_index(rng, n)
152
+ (rng.rand * n).to_i.clamp(0, n - 1)
153
+ end
154
+ end
155
+
156
+ class Node
157
+ attr_reader :caps, :children
158
+ attr_accessor :technique_used, :parent, :visits, :value, :untried
159
+
160
+ def initialize(caps:, technique_used: nil, parent: nil)
161
+ @caps = caps.freeze
162
+ @technique_used = technique_used
163
+ @parent = parent
164
+ @children = []
165
+ @visits = 0
166
+ @value = 0.0
167
+ @untried = []
168
+ end
169
+
170
+ def ucb1(parent_visits)
171
+ return Float::INFINITY if visits.zero?
172
+
173
+ (value / visits) +
174
+ UCB_C * Math.sqrt(Math.log(parent_visits) / visits)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Augure
6
+ # A technique pack: ONE new technique, complete, as DATA - rules, KB
7
+ # entries, MCTS transitions and priors. Written by an operator or an
8
+ # agent; validated here before it can influence anything.
9
+ #
10
+ # The armor (in load order):
11
+ # 1. constrained condition vocabulary (fact/not_fact/match/cmp)
12
+ # 2. conditions reference EXISTING predicates only (facts schema +
13
+ # built-in derived heads) - a pack cannot invent a fact source
14
+ # 3. stratification: negation on input facts only
15
+ # 4. new heads only: a pack never overrides a built-in technique
16
+ # 5. provenance is mandatory (author + source)
17
+ # 6. the corpus guard runs in PackLoader (belt and braces on 4)
18
+ class Pack
19
+ CONDITION_VOCABULARY = %w[fact not_fact match cmp].freeze
20
+
21
+ attr_reader :technique, :author, :source, :rules, :kb, :mcts, :priors, :build, :detection, :mitre
22
+
23
+ def self.load_file(path)
24
+ data = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
25
+ raise PackError, "#{path}: invalid YAML" unless data.is_a?(Hash)
26
+
27
+ load_hash(data)
28
+ end
29
+
30
+ def self.load_hash(data)
31
+ new(data)
32
+ end
33
+
34
+ def initialize(data)
35
+ @data = data
36
+ validate_shape
37
+ build
38
+ end
39
+
40
+ private
41
+
42
+ def reject(message)
43
+ raise PackError, "#{technique_name || "pack"}: #{message}"
44
+ end
45
+
46
+ def technique_name
47
+ @data["technique"]
48
+ end
49
+
50
+ def validate_shape
51
+ @technique = @data["technique"]
52
+ unless @technique.is_a?(String) && @technique.match?(/\A[a-z0-9_]+\z/)
53
+ reject "technique name must match [a-z0-9_]+"
54
+ end
55
+
56
+ @author = @data["author"]
57
+ reject "author is mandatory (provenance is not optional)" unless @author.is_a?(String) && !@author.empty?
58
+ @source = @data["source"]
59
+ unless @source.is_a?(String) && !@source.empty?
60
+ reject "source is mandatory (write-up URL, ticket, anything verifiable)"
61
+ end
62
+
63
+ validate_rules(@data["rules"])
64
+ validate_kb(@data["kb"])
65
+ validate_mcts(@data["mcts"])
66
+ validate_priors(@data["priors"])
67
+ validate_build(@data["build"])
68
+ validate_detection(@data["detection"])
69
+ validate_mitre(@data["mitre"])
70
+ build_rules
71
+ end
72
+
73
+ def known_predicates
74
+ @known_predicates ||= Facts::SCHEMA.keys + Rules.all.reject { |r| r.head[0] == Rules::APPLICABLE }.map { |r| r.head[0] }
75
+ end
76
+
77
+ def input_predicates
78
+ Facts::SCHEMA
79
+ end
80
+
81
+ def built_in_techniques
82
+ @built_in_techniques ||= Rules.applicable_rules.map { |r| r.head[1] }
83
+ end
84
+
85
+ def validate_rules(rules)
86
+ reject "rules are mandatory" unless rules.is_a?(Array) && !rules.empty?
87
+
88
+ rules.each_with_index do |rule, i|
89
+ reject "rule #{i}: id mandatory" unless rule["id"].is_a?(String)
90
+ head = rule["head"]
91
+ unless head.is_a?(Array) && head[0] == Rules::APPLICABLE && head[1] == @technique
92
+ reject "rule #{i}: head must be [applicable, <technique>]"
93
+ end
94
+ if built_in_techniques.include?(head[1])
95
+ reject "head overrides a built-in technique (#{head[1]}) - new techniques only"
96
+ end
97
+
98
+ conditions = rule["conditions"]
99
+ reject "rule #{i}: conditions mandatory" unless conditions.is_a?(Array) && !conditions.empty?
100
+ conditions.each_with_index do |cond, j|
101
+ validate_condition(cond, "rule #{i} condition #{j}")
102
+ end
103
+ end
104
+ end
105
+
106
+ def validate_condition(cond, label)
107
+ unless cond.is_a?(Array) && CONDITION_VOCABULARY.include?(cond[0])
108
+ reject "#{label}: #{cond.inspect} is outside the condition vocabulary #{CONDITION_VOCABULARY.inspect}"
109
+ end
110
+
111
+ kind = cond[0]
112
+ rel = cond[1]
113
+ if kind == "not_fact"
114
+ unless input_predicates.key?(rel)
115
+ reject "#{label}: not_fact on #{rel.inspect} breaks stratification (negation on input facts only)"
116
+ end
117
+ elsif kind == "cmp"
118
+ reject "#{label}: cmp needs [cmp, rel, op, n]" unless cond.size == 4
119
+ reject "#{label}: unknown predicate #{rel.inspect}" unless input_predicates.key?(rel)
120
+ unless Engine::OP.key?(cond[2].to_sym)
121
+ reject "#{label}: cmp operator #{cond[2].inspect} unknown (#{Engine::OP.keys.join(", ")})"
122
+ end
123
+ unless cond[3].is_a?(Integer)
124
+ reject "#{label}: cmp operand #{cond[3].inspect} must be an integer (the engine compares integer fact values)"
125
+ end
126
+ elsif kind == "match"
127
+ reject "#{label}: match pattern #{cond[3].inspect} must be a string" unless cond[3].is_a?(String)
128
+ begin
129
+ Regexp.new(cond[3])
130
+ rescue RegexpError => e
131
+ reject "#{label}: match pattern #{cond[3].inspect} is not a valid regex (#{e.message})"
132
+ end
133
+ else
134
+ reject "#{label}: unknown predicate #{rel.inspect}" unless known_predicates.include?(rel)
135
+ end
136
+ end
137
+
138
+ def validate_kb(entries)
139
+ reject "kb entries mandatory (the knowledge IS the technique)" unless entries.is_a?(Array) && !entries.empty?
140
+ entries.each_with_index do |e, i|
141
+ reject "kb #{i}: id mandatory" unless e["id"].is_a?(String)
142
+ rate = e["success_rate"]
143
+ reject "kb #{i}: success_rate must be a number in [0, 1]" unless rate.is_a?(Numeric) && rate.between?(0, 1)
144
+ reject "kb #{i}: description mandatory" unless e["description"].is_a?(String) && !e["description"].empty?
145
+ end
146
+ end
147
+
148
+ def validate_mcts(mcts)
149
+ reject "mcts transitions mandatory" unless mcts.is_a?(Hash)
150
+ %w[requires provides].each do |k|
151
+ reject "mcts.#{k} must be an array" unless mcts[k].is_a?(Array)
152
+ end
153
+ reject "mcts.terminal must be true or false" unless [true, false].include?(mcts["terminal"])
154
+ reject "mcts.success must be a number in [0, 1]" unless mcts["success"].is_a?(Numeric) && mcts["success"].between?(
155
+ 0, 1
156
+ )
157
+ end
158
+
159
+ def validate_priors(priors)
160
+ return if priors.is_a?(Array) && priors.size == 2 && priors.all?(Numeric)
161
+
162
+ reject "priors must be [alpha, beta] (two numbers)"
163
+ end
164
+
165
+ # armor check 8: the optional build: section (the pack teaching the
166
+ # BUILDER its assembly order) must name its own technique and use
167
+ # the slot vocabulary only. The consumer (lictor's PayloadBuilder)
168
+ # interprets the slots; here we gate the vocabulary.
169
+ BUILD_SLOTS = %w[padding qword dword shellcode chain].freeze
170
+
171
+ # armor check 9: the optional detection: section - the DEFENDER
172
+ # side of the technique (the corpus read backwards). Constrained
173
+ # channel vocabulary; every entry carries a signature and a note -
174
+ # a vague hint is not detection content.
175
+ DETECTION_CHANNELS = %w[wire syslog syscall crash file].freeze
176
+
177
+ def validate_detection(detection)
178
+ return if detection.nil?
179
+
180
+ unless detection.is_a?(Array) && !detection.empty?
181
+ reject "detection: must be a non-empty array"
182
+ end
183
+ detection.each_with_index do |e, i|
184
+ unless e.is_a?(Hash) && DETECTION_CHANNELS.include?(e["channel"])
185
+ reject "detection #{i}: channel must be from #{DETECTION_CHANNELS.inspect}"
186
+ end
187
+ %w[signature note].each do |field|
188
+ unless e[field].is_a?(String) && !e[field].empty?
189
+ reject "detection #{i}: #{field} is mandatory (a vague hint is not detection content)"
190
+ end
191
+ end
192
+ end
193
+ @detection = detection
194
+ end
195
+
196
+ # armor check 10: the optional mitre: mapping - ATT&CK technique
197
+ # codes (T####[.###]); the export layer (rules --mitre) consumes it.
198
+ MITRE_CODE = /\AT\d{4}(?:\.\d{3})?\z/
199
+
200
+ def validate_mitre(mitre)
201
+ return if mitre.nil?
202
+
203
+ unless mitre.is_a?(Array) && !mitre.empty? && mitre.all? { |c| c.is_a?(String) && c.match?(MITRE_CODE) }
204
+ reject "mitre: must be an array of ATT&CK technique codes (T1068, T1068.001)"
205
+ end
206
+ @mitre = mitre
207
+ end
208
+
209
+ def validate_build(build)
210
+ return if build.nil?
211
+
212
+ unless build.is_a?(Hash) && build["technique"] == @technique
213
+ reject "build: must name its own technique (#{build.is_a?(Hash) ? build["technique"].inspect : "missing"})"
214
+ end
215
+ layout = build["layout"]
216
+ unless layout.is_a?(Array) && !layout.empty? &&
217
+ layout.all? { |slot| slot.is_a?(Array) && BUILD_SLOTS.include?(slot[0]) }
218
+ reject "build: layout must be a non-empty array of slots from #{BUILD_SLOTS.inspect}"
219
+ end
220
+ @build = build
221
+ end
222
+
223
+ def build_rules
224
+ @rules = @data["rules"].map do |rule|
225
+ conditions = rule["conditions"].map do |cond|
226
+ normalized = cond.map do |v|
227
+ if v == true then "true"
228
+ elsif v == false then "false"
229
+ else v
230
+ end
231
+ end
232
+ [normalized[0].to_sym, *normalized[1..]]
233
+ end
234
+ Rules::Rule.new(
235
+ id: rule["id"].to_sym, head: rule["head"], conditions: conditions,
236
+ source: "#{@data["source"]} (pack #{@technique} by #{@author})", origin: @technique
237
+ )
238
+ end
239
+ @kb = @data["kb"]
240
+ @mcts = @data["mcts"]
241
+ @priors = @data["priors"].map(&:to_f)
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Augure
6
+ # Loads and screens technique packs. The corpus guard is the armor's
7
+ # last wall: with the packs loaded, every frozen corpus verdict must
8
+ # stay identical - a pack that moves an existing decision is refused
9
+ # at load time, not discovered in production.
10
+ module PackLoader
11
+ CORPUS_CACHE = {}
12
+ BASE_DECISIONS = {}
13
+
14
+ module_function
15
+
16
+ def load_file(path)
17
+ Pack.load_file(path)
18
+ end
19
+
20
+ def load_dir(dir)
21
+ Dir.glob(File.join(dir, "*.{yml,yaml}")).sort.map { |p| Pack.load_file(p) }
22
+ end
23
+
24
+ # Belt and braces: new-heads-only already makes flips structurally
25
+ # impossible; this proves it on every load.
26
+ def corpus_guard(packs, corpus_path: nil)
27
+ corpus_path ||= File.join(__dir__, "ctf_corpus.json")
28
+ corpus = CORPUS_CACHE[corpus_path] ||= JSON.parse(File.read(corpus_path))
29
+ # The "before" decisions depend only on the built-in rules and the
30
+ # base priors - identical across every guard run. Cached.
31
+ base_priors = Pipeline.default_priors
32
+ corpus["suites"].values.flatten.each do |entry|
33
+ before = BASE_DECISIONS[[corpus_path, entry["name"]]] ||=
34
+ Pipeline.analyze(facts: entry["facts"], priors: base_priors, plan: false)
35
+ after = Pipeline.analyze(facts: entry["facts"], priors: priors_with(packs, base_priors),
36
+ packs: packs, plan: false)
37
+ # A documented pack verdict is not a mover: the frozen entry may
38
+ # say the built-in table has no answer for this target AND the
39
+ # pack layer does (entry["pack_verdict"]) - the guard then
40
+ # checks the pack answer ARRIVES, not that nothing changed. The
41
+ # check holds only when that pack is among the loaded ones.
42
+ if entry["pack_verdict"] && packs.any? { |p| p.technique == entry["pack_verdict"] }
43
+ unless after[:ranking].first == entry["pack_verdict"]
44
+ raise PackError,
45
+ "corpus guard: pack(s) #{packs.map(&:technique).join(", ")} fail the documented " \
46
+ "pack verdict on #{entry["suite"]}/#{entry["name"]} " \
47
+ "(expected #{entry["pack_verdict"]}, got #{after[:ranking].first}) - refused"
48
+ end
49
+ next
50
+ end
51
+ # An empty frozen verdict has NOTHING to move: additive packs
52
+ # extending it are the pack layer working (the documented case
53
+ # was policed above by pack_verdict when its pack is loaded).
54
+ next if before[:applicable].empty?
55
+
56
+ # Additive packs may extend applicable/ranking with NEW techniques
57
+ # below the documented top - what they must never do: (1) move an
58
+ # EXISTING technique's order, (2) take the top-1 of a DOCUMENTED
59
+ # corpus target (that verdict belongs to the frozen truth; claiming
60
+ # better on it is a dev-time corpus update, not a runtime pack).
61
+ # selected is a seeded Thompson draw - stochastic by design, so the
62
+ # guard compares the deterministic prior-mean ranking only.
63
+ existing_order = after[:ranking].select { |t| before[:applicable].include?(t) }
64
+ next if existing_order == before[:ranking] && after[:ranking].first == before[:ranking].first
65
+
66
+ raise PackError,
67
+ "corpus guard: pack(s) #{packs.map(&:technique).join(", ")} move the verdict " \
68
+ "on #{entry["suite"]}/#{entry["name"]} " \
69
+ "(#{before[:ranking].first} -> #{after[:ranking].first}) - refused"
70
+ end
71
+ packs
72
+ end
73
+
74
+ # Pack priors fill the gaps: a taught technique starts on its
75
+ # authored prior, but a base table entry (outcome-adapted or
76
+ # consumer-provided) always wins - the loop stays able to learn.
77
+ def priors_with(packs, base)
78
+ return base if packs.nil? || packs.empty?
79
+
80
+ merged = base.transform_values(&:dup)
81
+ packs.each do |pack|
82
+ merged[pack.technique] = pack.priors.dup unless merged.key?(pack.technique)
83
+ end
84
+ merged
85
+ end
86
+
87
+ def mcts_model(packs)
88
+ return Mcts::STAGE_MODEL if packs.nil? || packs.empty?
89
+
90
+ Mcts.merged_model(packs.map(&:mcts))
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Augure
4
+ # Static-parity contract: what the frozen corpus claims about a
5
+ # documented target vs what the profiler observed on the REAL binary.
6
+ # The corpus stays the write-up ground truth; parity asserts only what
7
+ # static ELF analysis can derive, and reports honestly what it cannot:
8
+ # gadget types outside the profiler's pattern set and source-level
9
+ # hints (a gets() the binary never imports) are SKIPPED, not failed.
10
+ class ParityResult
11
+ attr_reader :target, :missing, :skipped, :checked
12
+
13
+ def initialize(target, missing, skipped, checked)
14
+ @target = target
15
+ @missing = missing
16
+ @skipped = skipped
17
+ @checked = checked
18
+ end
19
+
20
+ def ok? = missing.empty?
21
+ end
22
+
23
+ module Parity
24
+ EXACT_RELATIONS = %w[nx pie canary relro].freeze
25
+ SUBSET_RELATIONS = %w[plt target_function].freeze
26
+
27
+ module_function
28
+
29
+ # observed: a Facts object or a facts string (main binary + shipped
30
+ # libraries merged by the caller). entry: a corpus hash with "name"
31
+ # and "facts". supported_gadget_types: the byte-pattern vocabulary of
32
+ # the profiler at hand (passed in, so this core class stays
33
+ # profiler-agnostic).
34
+ def check(observed, entry, supported_gadget_types:)
35
+ observed = Facts.parse(observed) if observed.is_a?(String)
36
+ claimed = Facts.parse(entry.fetch("facts"))
37
+ missing = []
38
+ skipped = []
39
+ checked = 0
40
+
41
+ claimed.each do |rel, tuple|
42
+ case rel
43
+ when *EXACT_RELATIONS, *SUBSET_RELATIONS
44
+ checked += 1
45
+ missing << [rel, tuple] unless observed[rel]&.include?(tuple)
46
+ when "gadget"
47
+ if supported_gadget_types.include?(tuple[0])
48
+ checked += 1
49
+ missing << [rel, tuple] unless observed[rel]&.any? { |t| t[0] == tuple[0] }
50
+ else
51
+ skipped << [rel, tuple]
52
+ end
53
+ when "vuln_hint"
54
+ sym = tuple[0].delete_prefix("unsafe_func:")
55
+ if observed["plt"]&.any? { |t| t == [sym] }
56
+ checked += 1
57
+ missing << [rel, tuple] unless observed[rel]&.include?(tuple)
58
+ else
59
+ skipped << [rel, tuple]
60
+ end
61
+ else
62
+ skipped << [rel, tuple]
63
+ end
64
+ end
65
+
66
+ ParityResult.new(entry["name"], missing, skipped, checked)
67
+ end
68
+ end
69
+ end