vangrail 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +473 -0
- data/lib/vangrail/actions.rb +61 -0
- data/lib/vangrail/chat.rb +88 -0
- data/lib/vangrail/client/completion.rb +122 -0
- data/lib/vangrail/client.rb +219 -0
- data/lib/vangrail/colang/ast.rb +53 -0
- data/lib/vangrail/colang/interpreter.rb +131 -0
- data/lib/vangrail/colang/library.rb +53 -0
- data/lib/vangrail/colang/parser.rb +222 -0
- data/lib/vangrail/config.rb +270 -0
- data/lib/vangrail/confusables.rb +67 -0
- data/lib/vangrail/confusables_data.rb +1673 -0
- data/lib/vangrail/conversation.rb +105 -0
- data/lib/vangrail/engine.rb +240 -0
- data/lib/vangrail/errors.rb +48 -0
- data/lib/vangrail/http.rb +109 -0
- data/lib/vangrail/parsers.rb +181 -0
- data/lib/vangrail/policies.rb +202 -0
- data/lib/vangrail/prompt.rb +88 -0
- data/lib/vangrail/provider.rb +191 -0
- data/lib/vangrail/providers/gateway.rb +131 -0
- data/lib/vangrail/providers/llmlite.rb +71 -0
- data/lib/vangrail/providers.rb +72 -0
- data/lib/vangrail/rail.rb +93 -0
- data/lib/vangrail/rails/budget.rb +63 -0
- data/lib/vangrail/rails/canary.rb +76 -0
- data/lib/vangrail/rails/colang_flow.rb +40 -0
- data/lib/vangrail/rails/escalation.rb +178 -0
- data/lib/vangrail/rails/exfiltration.rb +167 -0
- data/lib/vangrail/rails/grounding.rb +64 -0
- data/lib/vangrail/rails/guard_model.rb +96 -0
- data/lib/vangrail/rails/hidden.rb +105 -0
- data/lib/vangrail/rails/injected_instructions.rb +86 -0
- data/lib/vangrail/rails/jailbreak.rb +114 -0
- data/lib/vangrail/rails/known_answer.rb +118 -0
- data/lib/vangrail/rails/many_shot.rb +80 -0
- data/lib/vangrail/rails/markup.rb +77 -0
- data/lib/vangrail/rails/missing.rb +38 -0
- data/lib/vangrail/rails/obfuscation.rb +186 -0
- data/lib/vangrail/rails/pattern.rb +57 -0
- data/lib/vangrail/rails/personal_data.rb +152 -0
- data/lib/vangrail/rails/remote.rb +40 -0
- data/lib/vangrail/rails/secrets.rb +77 -0
- data/lib/vangrail/rails/self_check.rb +81 -0
- data/lib/vangrail/rails/trajectory.rb +101 -0
- data/lib/vangrail/result.rb +114 -0
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/spotlight.rb +157 -0
- data/lib/vangrail/stream_guard.rb +163 -0
- data/lib/vangrail/version.rb +5 -0
- data/lib/vangrail.rb +354 -0
- metadata +120 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rail'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Rails
|
|
7
|
+
# Removes markup that does something when the answer is rendered.
|
|
8
|
+
#
|
|
9
|
+
# An answer is text until a client renders it, and most clients render
|
|
10
|
+
# markdown by pulling in a library that passes raw HTML straight through.
|
|
11
|
+
# At that point a script tag in the answer is a script tag in the page, and
|
|
12
|
+
# the model wrote it because a retrieved document told it to. That is a
|
|
13
|
+
# cross-site scripting bug with a language model in the middle of it, and
|
|
14
|
+
# the fact that the model is the delivery mechanism does not make it a
|
|
15
|
+
# different class of bug.
|
|
16
|
+
#
|
|
17
|
+
# The application's own sanitiser is the real defence and this does not
|
|
18
|
+
# replace it. What this covers is the case where there is no sanitiser,
|
|
19
|
+
# which is most of them, and where the answer is passed to a renderer that
|
|
20
|
+
# was chosen for how its tables look.
|
|
21
|
+
#
|
|
22
|
+
# Removes rather than blocks. An answer with a script tag in it is an answer
|
|
23
|
+
# with one bad span, the same as an answer with a credential in it.
|
|
24
|
+
#
|
|
25
|
+
# Not on by default. A desk whose client renders markdown as text, or which
|
|
26
|
+
# escapes before rendering, does not need it, and a rail that strips markup
|
|
27
|
+
# nobody was going to execute is noise in the result.
|
|
28
|
+
class Markup < Rail
|
|
29
|
+
PATTERNS = {
|
|
30
|
+
# Executes on load.
|
|
31
|
+
'script' => /<script\b[^>]*>.*?<\/script>|<script\b[^>]*\/?>/mi,
|
|
32
|
+
# Loads and executes something else.
|
|
33
|
+
'frame' => /<(?:iframe|frame|embed|object|applet)\b[^>]*>(?:.*?<\/(?:iframe|frame|embed|object|applet)>)?/mi,
|
|
34
|
+
# Runs on an event, which is how a lone img tag becomes an exploit.
|
|
35
|
+
'event_handler' => /\son[a-z]{3,20}\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i,
|
|
36
|
+
# A scheme that executes rather than fetches.
|
|
37
|
+
'active_scheme' => /(?:javascript|vbscript|data)\s*:\s*[^\s"'<>)]+/i,
|
|
38
|
+
# Rewrites where a form or a link goes, or what the page loads next.
|
|
39
|
+
'meta_refresh' => /<meta\b[^>]*http-equiv\s*=\s*["']?refresh["']?[^>]*>/i,
|
|
40
|
+
'base_tag' => /<base\b[^>]*>/i,
|
|
41
|
+
'form' => /<form\b[^>]*>.*?<\/form>|<form\b[^>]*>/mi,
|
|
42
|
+
# Styling can position an invisible overlay over the page.
|
|
43
|
+
'style_block' => /<style\b[^>]*>.*?<\/style>/mi
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :patterns
|
|
47
|
+
|
|
48
|
+
def initialize(patterns: PATTERNS, name: 'markup', sides: [:output])
|
|
49
|
+
super(name: name, sides: sides)
|
|
50
|
+
@patterns = patterns
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def offline?
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cache_key(text, _context)
|
|
58
|
+
text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call(text, _context)
|
|
62
|
+
body = text.to_s
|
|
63
|
+
found = []
|
|
64
|
+
cleaned = patterns.reduce(body) do |acc, (label, pattern)|
|
|
65
|
+
acc.gsub(pattern) do
|
|
66
|
+
found << label
|
|
67
|
+
''
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
return pass if found.empty?
|
|
71
|
+
|
|
72
|
+
modify(cleaned, categories: found.uniq,
|
|
73
|
+
reason: "removed #{found.uniq.join(', ')} from the answer")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rail'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Rails
|
|
7
|
+
# Stands in for a rail that was asked for and could not be built.
|
|
8
|
+
#
|
|
9
|
+
# Without this, a configuration asking for a model-backed rail against an
|
|
10
|
+
# endpoint that is down produces an engine holding only its offline rails.
|
|
11
|
+
# Those rails pass, the engine reports passed and certain, and the answer is
|
|
12
|
+
# a lie: the check the operator configured never ran.
|
|
13
|
+
#
|
|
14
|
+
# A placeholder that always returns unchecked keeps the arithmetic right.
|
|
15
|
+
# The turn is allowed, the engine's result is uncertain, and the reason
|
|
16
|
+
# names what is missing and why.
|
|
17
|
+
class Missing < Rail
|
|
18
|
+
attr_reader :reason
|
|
19
|
+
|
|
20
|
+
def initialize(reason:, name: 'missing', sides: Rail::SIDES)
|
|
21
|
+
super(name: name, sides: sides)
|
|
22
|
+
@reason = reason
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def offline?
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def placeholder?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(_text, _context)
|
|
34
|
+
unchecked(reason)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../confusables'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# Runs other rails again over the text an attacker actually meant.
|
|
9
|
+
#
|
|
10
|
+
# Every pattern rail reads what is written. An attacker who knows that
|
|
11
|
+
# writes it differently: base64 the paragraph and ask the model to decode
|
|
12
|
+
# it, rot13 it, spell it with Cyrillic letters that look like Latin ones,
|
|
13
|
+
# put zero-width joiners between the letters of "ignore", or set a
|
|
14
|
+
# right-to-left override so the rendered page and the byte sequence say
|
|
15
|
+
# different things. The model reads through all of it, because that is what
|
|
16
|
+
# models do, and the regexps see nothing.
|
|
17
|
+
#
|
|
18
|
+
# The answer is not more patterns. It is to undo the encoding and run the
|
|
19
|
+
# rails that already exist over the result, which is why this takes a rail
|
|
20
|
+
# list rather than defining checks of its own:
|
|
21
|
+
#
|
|
22
|
+
# Rails::Obfuscation.new(rails: [Rails::InjectedInstructions.new,
|
|
23
|
+
# Rails::Jailbreak.new])
|
|
24
|
+
#
|
|
25
|
+
# Each transform is applied on its own, and a variant identical to the
|
|
26
|
+
# original is dropped, so ordinary text costs one comparison per transform
|
|
27
|
+
# and nothing else. A hit names both the rail and the encoding it was
|
|
28
|
+
# hiding under, because "blocked" without that is unactionable for whoever
|
|
29
|
+
# has to look at the page.
|
|
30
|
+
#
|
|
31
|
+
# Invisible characters are handled here directly rather than by a delegate:
|
|
32
|
+
# they are stripped, and the strip is reported as a rewrite. A zero-width
|
|
33
|
+
# joiner inside a word has no honest use in a handbook, and removing it
|
|
34
|
+
# costs a reader nothing while denying the cheapest bypass there is.
|
|
35
|
+
#
|
|
36
|
+
# What this does not do is guess. There is no scoring, no entropy
|
|
37
|
+
# threshold, no "this looks encoded" heuristic that would fire on the base64
|
|
38
|
+
# blobs and hashes a cluster handbook is full of. A blob either decodes to
|
|
39
|
+
# text a rail objects to, or it does not.
|
|
40
|
+
class Obfuscation < Rail
|
|
41
|
+
# Zero-width and bidi control characters. The first four are the invisible
|
|
42
|
+
# separators; the bidi set is the trojan-source family, where the rendered
|
|
43
|
+
# order and the stored order disagree.
|
|
44
|
+
INVISIBLE = /[---]/
|
|
45
|
+
|
|
46
|
+
# A base64 run long enough to hold a sentence. Below this the decode is
|
|
47
|
+
# noise, and a handbook is full of short tokens that happen to be in the
|
|
48
|
+
# alphabet.
|
|
49
|
+
#
|
|
50
|
+
# Bounded by lookaround rather than \b, because + and / are not word
|
|
51
|
+
# characters: a blob ending in one had its last character trimmed off the
|
|
52
|
+
# match, and a base64 string one character short decodes to a sentence
|
|
53
|
+
# with its tail missing. That cost the corpus a case, and the case it
|
|
54
|
+
# cost was an HTML comment, whose pattern needs the closing marker.
|
|
55
|
+
BASE64 = /(?<![A-Za-z0-9+\/=])[A-Za-z0-9+\/]{24,}={0,2}(?![A-Za-z0-9+\/=])/
|
|
56
|
+
|
|
57
|
+
attr_reader :rails, :transforms
|
|
58
|
+
|
|
59
|
+
def initialize(rails:, transforms: %i[invisible confusables confusables_all rot13 base64 nfkc],
|
|
60
|
+
name: 'obfuscation', sides: %i[input context])
|
|
61
|
+
super(name: name, sides: sides)
|
|
62
|
+
@rails = Array(rails)
|
|
63
|
+
@transforms = Array(transforms).map(&:to_sym)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only if everything it delegates to is. A wrapper around a model rail
|
|
67
|
+
# inherits the model rail's posture.
|
|
68
|
+
def offline?
|
|
69
|
+
rails.all?(&:offline?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cache_key(text, _context)
|
|
73
|
+
text if offline?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call(text, context)
|
|
77
|
+
body = text.to_s
|
|
78
|
+
stripped = body.gsub(INVISIBLE, '')
|
|
79
|
+
|
|
80
|
+
hit = first_objection(body, stripped, context)
|
|
81
|
+
return hit if hit
|
|
82
|
+
|
|
83
|
+
return pass if stripped == body
|
|
84
|
+
|
|
85
|
+
modify(stripped, categories: ['invisible_characters'],
|
|
86
|
+
reason: 'removed zero-width or bidi control characters')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The decoded forms of a text, labelled. Public because an application
|
|
90
|
+
# that logs a blocked page wants to show what it decoded to.
|
|
91
|
+
def variants(text)
|
|
92
|
+
body = text.to_s
|
|
93
|
+
transforms.filter_map do |name|
|
|
94
|
+
decoded = apply(name, body)
|
|
95
|
+
next if decoded.nil? || decoded == body || decoded.strip.empty?
|
|
96
|
+
|
|
97
|
+
[name, decoded]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Stops at the first rail that objects to any variant. Order is the
|
|
104
|
+
# caller's rail order, then transform order, so the reported reason is
|
|
105
|
+
# stable rather than whichever regexp happened to be quickest.
|
|
106
|
+
def first_objection(body, stripped, context)
|
|
107
|
+
candidates = []
|
|
108
|
+
# The stripped text is a variant in its own right when anything was
|
|
109
|
+
# removed: "i<zwj>gnore previous instructions" is the whole attack, and
|
|
110
|
+
# every other transform runs after the removal rather than instead of it.
|
|
111
|
+
candidates << [:invisible, stripped] unless stripped == body
|
|
112
|
+
candidates.concat(variants(stripped))
|
|
113
|
+
|
|
114
|
+
candidates.each do |name, decoded|
|
|
115
|
+
rails.each do |rail|
|
|
116
|
+
result = rail.call(decoded, context)
|
|
117
|
+
next unless result.blocked?
|
|
118
|
+
|
|
119
|
+
return block(categories: (result.categories || []) + ["encoded:#{name}"],
|
|
120
|
+
reason: "#{result.reason} (hidden with #{name})")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def apply(name, body)
|
|
127
|
+
case name
|
|
128
|
+
when :invisible then body.gsub(INVISIBLE, '')
|
|
129
|
+
when :confusables then defold(body)
|
|
130
|
+
when :confusables_all then Confusables.fold_all(body)
|
|
131
|
+
when :rot13 then body.tr('A-Za-z', 'N-ZA-Mn-za-m')
|
|
132
|
+
when :base64 then decode_base64(body)
|
|
133
|
+
when :nfkc then normalise(body)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Two readings of the same text, because the policies fail in opposite
|
|
138
|
+
# directions and a variant costs one comparison.
|
|
139
|
+
#
|
|
140
|
+
# :confusables folds only words that mix ASCII with imitators, which is
|
|
141
|
+
# what an imitation attack looks like and what leaves a page of Russian
|
|
142
|
+
# alone. It misses a word converted character for character, where
|
|
143
|
+
# nothing ASCII is left to mix with.
|
|
144
|
+
#
|
|
145
|
+
# :confusables_all folds everything and catches that, at the price of
|
|
146
|
+
# turning genuine Cyrillic into noise. The noise is never shown to
|
|
147
|
+
# anybody: a variant exists to be read by a pattern, and the corpus
|
|
148
|
+
# asserts that ordinary non-Latin prose does not match one.
|
|
149
|
+
def defold(body)
|
|
150
|
+
Confusables.fold(body)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Every long base64 run in the text, decoded and joined. A run that is not
|
|
154
|
+
# valid base64, or that decodes to bytes rather than text, contributes
|
|
155
|
+
# nothing rather than failing the whole pass: a handbook page carrying one
|
|
156
|
+
# hash and one payload should still have the payload read.
|
|
157
|
+
def decode_base64(body)
|
|
158
|
+
pieces = body.scan(BASE64).filter_map { |run| readable(run) }
|
|
159
|
+
pieces.empty? ? nil : pieces.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def readable(run)
|
|
163
|
+
decoded = run.unpack1('m')
|
|
164
|
+
return nil if decoded.nil? || decoded.empty?
|
|
165
|
+
|
|
166
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
167
|
+
return nil unless decoded.valid_encoding?
|
|
168
|
+
# Printable enough to be a sentence rather than a compressed blob.
|
|
169
|
+
return nil if decoded.count("^ -~\n\t").positive?
|
|
170
|
+
|
|
171
|
+
decoded
|
|
172
|
+
rescue ArgumentError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Compatibility normalisation folds the fullwidth and mathematical
|
|
177
|
+
# alphabets, which is the other cheap way to write a word a regexp will
|
|
178
|
+
# not recognise.
|
|
179
|
+
def normalise(body)
|
|
180
|
+
body.unicode_normalize(:nfkc)
|
|
181
|
+
rescue ArgumentError, Encoding::CompatibilityError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rail'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Rails
|
|
7
|
+
# Blocks text matching any of a list of patterns.
|
|
8
|
+
#
|
|
9
|
+
# A guardrail stack that is only a language model has no floor: when the
|
|
10
|
+
# endpoint is slow, cold, or down, every check degrades at once. A pattern
|
|
11
|
+
# rail decides in microseconds, offline, and identically every time, so the
|
|
12
|
+
# cases you can state exactly stay covered no matter what the network does.
|
|
13
|
+
#
|
|
14
|
+
# Pattern.new(patterns: { 'instruction_override' => /ignore (all )?previous instructions/i })
|
|
15
|
+
class Pattern < Rail
|
|
16
|
+
attr_reader :patterns
|
|
17
|
+
|
|
18
|
+
def initialize(patterns:, name: 'pattern', sides: Rail::SIDES, reason: nil)
|
|
19
|
+
super(name: name, sides: sides)
|
|
20
|
+
@patterns = normalize(patterns)
|
|
21
|
+
@reason = reason
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def offline?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cache_key(text, _context)
|
|
29
|
+
text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(text, _context)
|
|
33
|
+
hit = patterns.find { |_label, pattern| pattern.match?(text.to_s) }
|
|
34
|
+
return pass unless hit
|
|
35
|
+
|
|
36
|
+
label = hit.first
|
|
37
|
+
block(categories: [label], reason: @reason || "matched #{label}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalize(patterns)
|
|
43
|
+
case patterns
|
|
44
|
+
when Hash then patterns.to_h { |k, v| [k.to_s, to_regexp(v)] }
|
|
45
|
+
when Array then patterns.each_with_index.to_h { |v, i| ["pattern_#{i + 1}", to_regexp(v)] }
|
|
46
|
+
else { 'pattern_1' => to_regexp(patterns) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_regexp(value)
|
|
51
|
+
return value if value.is_a?(Regexp)
|
|
52
|
+
|
|
53
|
+
Regexp.new(Regexp.escape(value.to_s), Regexp::IGNORECASE)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rail'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Rails
|
|
7
|
+
# Redacts a reader's own details before the question leaves the building.
|
|
8
|
+
#
|
|
9
|
+
# This is a privacy rail rather than a security one, and it exists because
|
|
10
|
+
# of where the text goes next. A question typed into a documentation desk
|
|
11
|
+
# is about to be sent to a model endpoint, which may be a third party, may
|
|
12
|
+
# log, and may sit in another jurisdiction. A reader pasting a support
|
|
13
|
+
# thread into it has not thought about any of that, and nothing in the
|
|
14
|
+
# answer needs their phone number.
|
|
15
|
+
#
|
|
16
|
+
# Redacts rather than blocks, for the same reason the secrets rail does: the
|
|
17
|
+
# question is answerable, and one span in it should not have been sent.
|
|
18
|
+
#
|
|
19
|
+
# The hard part on a cluster desk is not detection. It is that
|
|
20
|
+
# `ssh rgoswami@snellius.example.org` is an email address by every
|
|
21
|
+
# syntactic measure, and redacting it destroys the answer to the most
|
|
22
|
+
# commonly asked question there is. So an address is left alone when it is
|
|
23
|
+
# inside backticks or a fence, when its line carries a command that takes a
|
|
24
|
+
# user@host argument or an ssh config keyword, or when a remote path
|
|
25
|
+
# follows it. All three are in the corpus, because a rail that eats login
|
|
26
|
+
# examples is worse for a handbook than no rail at all.
|
|
27
|
+
#
|
|
28
|
+
# Deliberately not included: national identity numbers. The Dutch BSN is
|
|
29
|
+
# nine digits with a checksum, a Slurm job id is six to eight digits, and
|
|
30
|
+
# one in eleven job ids passes the checksum by accident. A rail that
|
|
31
|
+
# redacts job ids from a cluster support question is unusable, and the
|
|
32
|
+
# trade is not close.
|
|
33
|
+
class PersonalData < Rail
|
|
34
|
+
PLACEHOLDER = '[redacted]'
|
|
35
|
+
|
|
36
|
+
# Commands whose argument is a login target rather than a mailbox. Read
|
|
37
|
+
# over the line rather than the character before the match: scp puts a
|
|
38
|
+
# source path in between, and an ssh config line has no command on it at
|
|
39
|
+
# all, only the User keyword.
|
|
40
|
+
HOST_COMMANDS = /\b(?:ssh|scp|sftp|rsync|mosh|ssh-copy-id|ssh:\/\/|sftp:\/\/|User)\b/i
|
|
41
|
+
|
|
42
|
+
# The other half of scp and rsync syntax: an address followed by a remote
|
|
43
|
+
# path is a target, not a mailbox.
|
|
44
|
+
REMOTE_PATH = /\A:[~\/\w.]/
|
|
45
|
+
|
|
46
|
+
# Local parts that are documentation rather than a person.
|
|
47
|
+
PLACEHOLDER_USERS = /\A(?:user|username|your[._-]?name|login|account|me|example|
|
|
48
|
+
firstname|lastname|name|admin|root)\z/xi
|
|
49
|
+
|
|
50
|
+
PATTERNS = {
|
|
51
|
+
'email' => /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
|
|
52
|
+
# International or national, with a separator, long enough to be a
|
|
53
|
+
# phone number and not a job id: a leading + or 00, or a leading zero
|
|
54
|
+
# with grouping.
|
|
55
|
+
'phone' => /(?:\+|\b00)[1-9]\d{0,2}[\s.-]?(?:\(?\d{1,4}\)?[\s.-]?){2,5}\d{2,4}\b
|
|
56
|
+
|\b0\d{1,3}[\s.-]\d{3}[\s.-]?\d{3,4}\b/x,
|
|
57
|
+
'iban' => /\b[A-Z]{2}\d{2}\s?(?:[A-Z0-9]{4}\s?){2,7}[A-Z0-9]{1,4}\b/,
|
|
58
|
+
'card' => /\b(?:\d[ -]?){13,19}\b/
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
attr_reader :patterns, :placeholder
|
|
62
|
+
|
|
63
|
+
def initialize(patterns: PATTERNS, placeholder: PLACEHOLDER,
|
|
64
|
+
name: 'personal_data', sides: [:input])
|
|
65
|
+
super(name: name, sides: sides)
|
|
66
|
+
@patterns = patterns
|
|
67
|
+
@placeholder = placeholder
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def offline?
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cache_key(text, _context)
|
|
75
|
+
text
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(text, _context)
|
|
79
|
+
body = text.to_s
|
|
80
|
+
found = []
|
|
81
|
+
redacted = patterns.reduce(body) do |acc, (label, pattern)|
|
|
82
|
+
replace(acc, label, pattern, found)
|
|
83
|
+
end
|
|
84
|
+
return pass if found.empty?
|
|
85
|
+
|
|
86
|
+
modify(redacted, categories: found.uniq,
|
|
87
|
+
reason: "redacted #{found.uniq.join(', ')} before sending")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def replace(body, label, pattern, found)
|
|
93
|
+
body.gsub(pattern) do |match|
|
|
94
|
+
m = Regexp.last_match
|
|
95
|
+
next match unless redact?(label, match, m.pre_match, m.post_match)
|
|
96
|
+
|
|
97
|
+
found << label
|
|
98
|
+
placeholder
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def redact?(label, match, before, after)
|
|
103
|
+
return false if in_code?(before)
|
|
104
|
+
|
|
105
|
+
case label
|
|
106
|
+
when 'email' then mailbox?(match, before, after)
|
|
107
|
+
when 'card' then card?(match)
|
|
108
|
+
else true
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# An address is a mailbox unless it is a login target or a placeholder in
|
|
113
|
+
# documentation.
|
|
114
|
+
def mailbox?(match, before, after)
|
|
115
|
+
return false if after.match?(REMOTE_PATH)
|
|
116
|
+
return false if before[/[^\n]*\z/].match?(HOST_COMMANDS)
|
|
117
|
+
|
|
118
|
+
!match.split('@').first.match?(PLACEHOLDER_USERS)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Digits alone are not a card number. A cluster question is full of long
|
|
122
|
+
# numbers, so the check has to be the one the issuers use.
|
|
123
|
+
def card?(match)
|
|
124
|
+
digits = match.gsub(/\D/, '')
|
|
125
|
+
return false unless digits.length.between?(13, 19)
|
|
126
|
+
|
|
127
|
+
luhn?(digits)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def luhn?(digits)
|
|
131
|
+
sum = digits.reverse.chars.each_with_index.sum do |c, i|
|
|
132
|
+
n = c.to_i
|
|
133
|
+
next n if i.even?
|
|
134
|
+
|
|
135
|
+
n * 2 > 9 ? (n * 2) - 9 : n * 2
|
|
136
|
+
end
|
|
137
|
+
(sum % 10).zero?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Inside backticks or a fence, the text is a command rather than a
|
|
141
|
+
# person's details, and rewriting a command is how a guardrail becomes
|
|
142
|
+
# the thing the reader has to work around.
|
|
143
|
+
def in_code?(before)
|
|
144
|
+
before.count('`').odd? || fenced?(before)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def fenced?(before)
|
|
148
|
+
before.scan(/^```/).size.odd?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../client'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# A NeMo Guardrails server as one rail among the local ones.
|
|
9
|
+
#
|
|
10
|
+
# Wrapping the client in the rail protocol is what keeps the two paths from
|
|
11
|
+
# becoming two designs. A team migrating off the Python service can put this
|
|
12
|
+
# rail and a local one in the same ordered list, compare them on live
|
|
13
|
+
# traffic, and drop the remote one when the local rails cover it.
|
|
14
|
+
class Remote < Rail
|
|
15
|
+
attr_reader :client
|
|
16
|
+
|
|
17
|
+
def initialize(client: nil, base_url: nil, config_id: nil, api_key: nil,
|
|
18
|
+
name: 'remote', sides: Rail::SIDES)
|
|
19
|
+
super(name: name, sides: sides)
|
|
20
|
+
@client = client || Client.new(base_url: base_url, config_id: config_id, api_key: api_key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cache_key(text, context)
|
|
24
|
+
return text if context[:side] == :input
|
|
25
|
+
|
|
26
|
+
"#{context[:user_input]} #{text}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(text, context)
|
|
30
|
+
result =
|
|
31
|
+
if context[:side] == :output
|
|
32
|
+
client.check_output(text, user_input: context[:user_input])
|
|
33
|
+
else
|
|
34
|
+
client.check_input(text)
|
|
35
|
+
end
|
|
36
|
+
result.with_rail(name)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rail'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Rails
|
|
7
|
+
# Redacts credentials, returning :modified rather than blocking.
|
|
8
|
+
#
|
|
9
|
+
# This is the rail that justifies having three statuses. An answer that
|
|
10
|
+
# quotes a config file with a live token is a useful answer with one bad
|
|
11
|
+
# span in it: blocking it throws away the help, and passing it leaks the
|
|
12
|
+
# token. Replacing the span keeps both halves honest, and the caller can see
|
|
13
|
+
# from the status that what it is about to show has been edited.
|
|
14
|
+
#
|
|
15
|
+
# Patterns cover shapes that are unambiguous on sight. Anything needing
|
|
16
|
+
# judgement belongs in a policy rail, not here: a false positive silently
|
|
17
|
+
# corrupts an answer, which is worse than a missed match a later rail can
|
|
18
|
+
# still catch.
|
|
19
|
+
class Secrets < Rail
|
|
20
|
+
PLACEHOLDER = '[redacted]'
|
|
21
|
+
|
|
22
|
+
DEFAULT_PATTERNS = {
|
|
23
|
+
'private_key' => /-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----/m,
|
|
24
|
+
'openai_key' => /\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
25
|
+
'anthropic_key' => /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
|
|
26
|
+
'github_token' => /\bgh[pousr]_[A-Za-z0-9]{30,}\b/,
|
|
27
|
+
'slack_token' => /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/,
|
|
28
|
+
'aws_access_key' => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
|
|
29
|
+
'jwt' => /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
30
|
+
'bearer_header' => /\b(?i:authorization)\s*:\s*(?i:bearer)\s+\S{12,}/,
|
|
31
|
+
'inline_password' => /\b(?i:password|passwd|api[_-]?key|secret)\s*[=:]\s*(?!\[redacted\])\S{6,}/
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :patterns, :placeholder
|
|
35
|
+
|
|
36
|
+
def initialize(patterns: DEFAULT_PATTERNS, placeholder: PLACEHOLDER, name: 'secrets',
|
|
37
|
+
sides: [:output])
|
|
38
|
+
super(name: name, sides: sides)
|
|
39
|
+
@patterns = patterns
|
|
40
|
+
@placeholder = placeholder
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def offline?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cache_key(text, _context)
|
|
48
|
+
text
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(text, _context)
|
|
52
|
+
body = text.to_s
|
|
53
|
+
found = []
|
|
54
|
+
redacted = patterns.reduce(body) do |acc, (label, pattern)|
|
|
55
|
+
acc.gsub(pattern) do |match|
|
|
56
|
+
found << label
|
|
57
|
+
replacement(label, match)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
return pass if found.empty?
|
|
61
|
+
|
|
62
|
+
modify(redacted, categories: found.uniq, reason: "redacted #{found.uniq.join(', ')}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Keeps the key name visible so the reader still learns which setting the
|
|
68
|
+
# answer was talking about, and loses only the value.
|
|
69
|
+
def replacement(label, match)
|
|
70
|
+
return "#{match[/\A[^=:]*[=:]\s*/]}#{placeholder}" if label == 'inline_password'
|
|
71
|
+
return "#{match[/\A\S+\s*:\s*\S+\s/]}#{placeholder}" if label == 'bearer_header' && match =~ /\s/
|
|
72
|
+
|
|
73
|
+
placeholder
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|