vaultkit 1.1.6 → 1.2.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 +4 -4
- data/README.md +19 -0
- data/lib/vkit/cli/base_cli.rb +7 -1
- data/lib/vkit/cli/commands/agent_tokens_create_command.rb +7 -1
- data/lib/vkit/cli/commands/scan_command.rb +73 -36
- data/lib/vkit/policy/bundle_compiler.rb +27 -5
- data/lib/vkit/policy/match_check.rb +120 -0
- data/lib/vkit/policy/packs/ai_safety/metadata.yaml +2 -2
- data/lib/vkit/policy/packs/ai_safety/policies/01_require_approval_agents_sensitive_prod.yaml +17 -0
- data/lib/vkit/policy/packs/ai_safety/policies/02_require_approval_agents_high_sensitivity.yaml +16 -0
- data/lib/vkit/policy/packs/ai_safety/policies/03_require_approval_agents_unknown_prod.yaml +17 -0
- data/lib/vkit/policy/packs/financial_compliance/metadata.yaml +2 -2
- data/lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_financial_prod.yaml +16 -0
- data/lib/vkit/policy/packs/financial_compliance/policies/02_short_ttl_for_financial_grants.yaml +12 -0
- data/lib/vkit/policy/packs/starter/metadata.yaml +2 -2
- data/lib/vkit/policy/packs/starter/policies/01_deny_credentials.yaml +11 -0
- data/lib/vkit/policy/packs/starter/policies/02_mask_pii_by_default.yaml +4 -5
- data/lib/vkit/policy/packs/starter/policies/03_mask_high_sensitivity.yaml +15 -0
- data/lib/vkit/policy/packs/starter/policies/{03_require_approval_high_sensitivity.yaml → 04_require_approval_high_sensitivity.yaml} +4 -5
- data/lib/vkit/policy/packs/starter/policies/{06_default_ttl.yaml → 05_default_ttl.yaml} +3 -4
- data/lib/vkit/policy/packs/starter/policies/06_flag_unknown.yaml +12 -0
- data/lib/vkit/policy/schema/policy_bundle.schema.json +5 -0
- data/lib/vkit/policy/tenancy_check.rb +54 -0
- data/lib/vkit/version.rb +1 -1
- metadata +13 -13
- data/lib/vkit/policy/packs/ai_safety/policies/01_deny_agent_prod_without_clearance.yaml +0 -15
- data/lib/vkit/policy/packs/ai_safety/policies/02_require_approval_for_sensitive_in_prod.yaml +0 -16
- data/lib/vkit/policy/packs/ai_safety/policies/03_mask_sensitive_by_default_for_agents.yaml +0 -18
- data/lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_for_financial_prod.yaml +0 -16
- data/lib/vkit/policy/packs/financial_compliance/policies/02_mask_payment_tokens.yaml +0 -17
- data/lib/vkit/policy/packs/financial_compliance/policies/03_deny_non_admin_access_pci_in_prod.yaml +0 -15
- data/lib/vkit/policy/packs/financial_compliance/policies/04_short_ttl_for_financial_grants.yaml +0 -14
- data/lib/vkit/policy/packs/starter/policies/01_deny_sensitive_without_clearance.yaml +0 -15
- data/lib/vkit/policy/packs/starter/policies/04_block_cross_region.yaml +0 -14
- data/lib/vkit/policy/packs/starter/policies/05_limit_production_access.yaml +0 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8490da7d9c28171310faf52f3a8b754dd00798d75837ec76eee4b3f3b5ce6aab
|
|
4
|
+
data.tar.gz: 53ce9b61c202b464b66d9fe3f4d195597a6992c20d5f707247a33da93f196e89
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3f239d9f906313cac41a736d2e352b8be1b429e04fe0b08e8bb24190ee4e186510dd6141306dc1834da7750a2d710e39e246fcfe52b76c1915b2c5196731c1e
|
|
7
|
+
data.tar.gz: 822c8f6472e7bd4372b439bde33964329cbf93be371407873bbf2725750122633ba145d3d1635a84aebe0454a97d389f13a2014e1999cc2da96dfe60dc3b0a68
|
data/README.md
CHANGED
|
@@ -701,6 +701,25 @@ vkit scan production_pg
|
|
|
701
701
|
# Summary: 2 new datasets, 3 new fields, 1 modified field
|
|
702
702
|
```
|
|
703
703
|
|
|
704
|
+
**Classifier Suggestions:**
|
|
705
|
+
|
|
706
|
+
A rescan keeps the labels people reviewed (from a deployed bundle or a manual edit). When the classifier disagrees with one of them, the scan lists it as a suggestion under its dataset, marked `?`. Suggestions are not changes: they don't count toward the changed total, and applying the scan leaves them alone.
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
vkit scan production_pg
|
|
710
|
+
|
|
711
|
+
# Output:
|
|
712
|
+
# settings ✓
|
|
713
|
+
# ? key_value low/internal → high/credential (suggested)
|
|
714
|
+
#
|
|
715
|
+
# No changes detected
|
|
716
|
+
#
|
|
717
|
+
# ? = classifier suggestion, not applied. To accept one, edit
|
|
718
|
+
# datasets/registry.yaml and redeploy the bundle.
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
To accept a suggestion, change the field's sensitivity and tags in `datasets/registry.yaml`, then build and deploy the bundle. Control planes that predate suggestions don't send them, and the output is unchanged.
|
|
722
|
+
|
|
704
723
|
**Apply Changes:**
|
|
705
724
|
```bash
|
|
706
725
|
vkit scan production_pg --mode apply
|
data/lib/vkit/cli/base_cli.rb
CHANGED
|
@@ -331,11 +331,17 @@ module Vkit
|
|
|
331
331
|
option :name, required: true, desc: "Human-readable name (e.g. billing-bot)"
|
|
332
332
|
option :expires_in, type: :string, desc: "Token lifetime (e.g. 1h, 24h, 30d)"
|
|
333
333
|
option :role, type: :string, default: "agent", desc: "Role assigned to this token"
|
|
334
|
+
option :tenant, type: :string, desc: "Tenant this token acts for on tenant-scoped datasets"
|
|
335
|
+
option :clearance, type: :string, desc: "Clearance policies see: low, medium or high"
|
|
336
|
+
option :region, type: :string, desc: "Region policies see: EU, US, CA or APAC"
|
|
334
337
|
def create
|
|
335
338
|
Commands::AgentTokensCreateCommand.new.call(
|
|
336
339
|
name: options[:name],
|
|
337
340
|
expires_in: options[:expires_in],
|
|
338
|
-
role: options[:role]
|
|
341
|
+
role: options[:role],
|
|
342
|
+
tenant: options[:tenant],
|
|
343
|
+
clearance: options[:clearance],
|
|
344
|
+
region: options[:region]
|
|
339
345
|
)
|
|
340
346
|
end
|
|
341
347
|
|
|
@@ -26,6 +26,9 @@ module Vkit
|
|
|
26
26
|
|
|
27
27
|
puts "🧠 Name: #{token["name"]}"
|
|
28
28
|
puts "🎭 Role: #{token["role"]}"
|
|
29
|
+
puts "🏢 Tenant: #{token["tenant"]}" if token["tenant"]
|
|
30
|
+
puts "🛡 Clearance: #{token["clearance"]}" if token["clearance"]
|
|
31
|
+
puts "🌍 Region: #{token["region"]}" if token["region"]
|
|
29
32
|
puts "🆔 Token ID: #{token["id"]}"
|
|
30
33
|
puts "🏷 Prefix: #{token["prefix"]}"
|
|
31
34
|
puts "⏳ Expires At: #{token["expires_at"] || "never"}"
|
|
@@ -47,7 +50,10 @@ module Vkit
|
|
|
47
50
|
body = {
|
|
48
51
|
name: options.fetch(:name),
|
|
49
52
|
role: options[:role],
|
|
50
|
-
|
|
53
|
+
tenant: options[:tenant],
|
|
54
|
+
clearance: options[:clearance],
|
|
55
|
+
region: options[:region]
|
|
56
|
+
}.compact
|
|
51
57
|
|
|
52
58
|
if options[:expires_in]
|
|
53
59
|
body[:expires_at] = parse_expires_in(options[:expires_in]).iso8601
|
|
@@ -19,51 +19,88 @@ module Vkit
|
|
|
19
19
|
body: { datasource: datasource_name, mode: mode }
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
clean = datasets.reject { |d| d["changes"].values.any?(&:any?) }
|
|
22
|
+
print_diff(response)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
puts
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def print_diff(response)
|
|
29
|
+
diff = response["diff"] || {}
|
|
30
|
+
datasets = diff["datasets"] || []
|
|
31
|
+
changed = datasets.select { |d| d["changes"].values.any?(&:any?) }
|
|
32
|
+
clean = datasets.reject { |d| d["changes"].values.any?(&:any?) }
|
|
33
|
+
|
|
34
|
+
if changed.empty?
|
|
35
|
+
datasets.each { |d| print_clean(d) }
|
|
36
|
+
puts
|
|
37
|
+
puts " No changes detected"
|
|
38
|
+
else
|
|
39
|
+
changed.each do |dataset|
|
|
40
|
+
added = dataset.dig("changes", "added_fields") || []
|
|
41
|
+
removed = dataset.dig("changes", "removed_fields") || []
|
|
42
|
+
modified = dataset.dig("changes", "changed_fields") || []
|
|
43
|
+
|
|
44
|
+
puts " #{dataset["name"]}"
|
|
45
|
+
|
|
46
|
+
added.each do |f|
|
|
47
|
+
name = f.is_a?(Hash) ? f["name"] : f
|
|
48
|
+
type = f.is_a?(Hash) && f["type"] ? " (#{f["type"]})" : ""
|
|
49
|
+
puts " + #{name}#{type} ⚠️ unclassified"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
removed.each do |f|
|
|
53
|
+
name = f.is_a?(Hash) ? f["name"] : f
|
|
54
|
+
puts " - #{name}"
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
modified.each do |f|
|
|
58
|
+
name = f.is_a?(Hash) ? f["name"] : f
|
|
59
|
+
puts " ~ #{name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
print_suggestions(dataset)
|
|
59
63
|
|
|
60
64
|
puts
|
|
61
|
-
puts " #{changed.size} changed · run with --apply to update baseline"
|
|
62
65
|
end
|
|
63
66
|
|
|
67
|
+
clean.each { |d| print_clean(d) }
|
|
68
|
+
|
|
64
69
|
puts
|
|
65
|
-
puts "
|
|
70
|
+
puts " #{changed.size} changed · run with --apply to update baseline"
|
|
66
71
|
end
|
|
72
|
+
|
|
73
|
+
# Suggestions are reviewed labels the classifier now disagrees with.
|
|
74
|
+
# Older control planes don't send them; applying a scan never changes them.
|
|
75
|
+
if datasets.any? { |d| suggestions_for(d).any? }
|
|
76
|
+
puts
|
|
77
|
+
puts " ? = classifier suggestion, not applied. To accept one, edit"
|
|
78
|
+
puts " datasets/registry.yaml and redeploy the bundle."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
puts
|
|
82
|
+
puts " ✅ Applied" if response["applied"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def print_clean(dataset)
|
|
86
|
+
puts " #{dataset["name"].ljust(24)} ✓"
|
|
87
|
+
print_suggestions(dataset)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def print_suggestions(dataset)
|
|
91
|
+
suggestions_for(dataset).each do |s|
|
|
92
|
+
puts " ? #{s["name"]} #{label(s["current"])} → #{label(s["suggested"])} (suggested)"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def suggestions_for(dataset)
|
|
97
|
+
dataset["suggestions"] || []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def label(classification)
|
|
101
|
+
classification ||= {}
|
|
102
|
+
tags = Array(classification["tags"])
|
|
103
|
+
[classification["sensitivity"], *(tags.join(",") unless tags.empty?)].compact.join("/")
|
|
67
104
|
end
|
|
68
105
|
end
|
|
69
106
|
end
|
|
@@ -3,15 +3,21 @@ require "yaml"
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "time"
|
|
5
5
|
require_relative "policy_validator"
|
|
6
|
+
require_relative "match_check"
|
|
7
|
+
require_relative "tenancy_check"
|
|
8
|
+
require_relative "validation_error"
|
|
6
9
|
|
|
7
10
|
module Vkit
|
|
8
11
|
module Policy
|
|
9
12
|
class BundleCompiler
|
|
10
13
|
FORMAT_VERSION = "v1"
|
|
11
14
|
|
|
15
|
+
# Raises if a policy can never match (see MatchCheck).
|
|
12
16
|
def self.compile!(org_slug:, bundle_version:, policies_dir:, registry_dir:, source: {})
|
|
13
17
|
policies = load_policies(policies_dir)
|
|
14
|
-
|
|
18
|
+
raw_registry = load_raw_registry(registry_dir)
|
|
19
|
+
registry = normalize_registry(raw_registry)
|
|
20
|
+
check_matches!(policies, registry, raw_registry)
|
|
15
21
|
|
|
16
22
|
bundle = {
|
|
17
23
|
"bundle" => {
|
|
@@ -49,12 +55,25 @@ module Vkit
|
|
|
49
55
|
end
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
def self.
|
|
58
|
+
def self.check_matches!(policies, registry, raw_registry)
|
|
59
|
+
errors = MatchCheck.call(policies, registry).errors + TenancyCheck.call(raw_registry)
|
|
60
|
+
return if errors.empty?
|
|
61
|
+
|
|
62
|
+
raise ValidationError, <<~MSG
|
|
63
|
+
These policies or datasets would never protect anything as written:
|
|
64
|
+
|
|
65
|
+
#{errors.map { |e| " - #{e}" }.join("\n")}
|
|
66
|
+
|
|
67
|
+
Fix or remove them. If they came from a policy pack, `vkit policy pack upgrade` installs
|
|
68
|
+
versions that can match.
|
|
69
|
+
MSG
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.load_raw_registry(dir)
|
|
53
73
|
path = File.join(dir, "registry.yaml")
|
|
54
74
|
raise "Missing datasets/registry.yaml" unless File.exist?(path)
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
normalize_registry(raw)
|
|
76
|
+
YAML.load_file(path) || {}
|
|
58
77
|
end
|
|
59
78
|
|
|
60
79
|
# Normalization
|
|
@@ -138,8 +157,11 @@ module Vkit
|
|
|
138
157
|
{
|
|
139
158
|
"name" => name.to_s,
|
|
140
159
|
"datasource" => data["datasource"].to_s,
|
|
160
|
+
# A field name confines every query to the requester's tenant;
|
|
161
|
+
# false (a deliberately shared table) only matters to TenancyCheck.
|
|
162
|
+
"tenant_column" => (data["tenant_column"] if data["tenant_column"].is_a?(String)),
|
|
141
163
|
"fields" => normalize_fields(data["fields"] || {})
|
|
142
|
-
}
|
|
164
|
+
}.compact
|
|
143
165
|
end
|
|
144
166
|
|
|
145
167
|
datasources =
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vkit
|
|
6
|
+
module Policy
|
|
7
|
+
# Finds policies that can never match, by checking them against the
|
|
8
|
+
# registry being bundled and against what the control plane evaluates:
|
|
9
|
+
# a key the engine ignores, a tag or sensitivity no field can have, a
|
|
10
|
+
# dataset that isn't in the registry, or a context value VaultKit never
|
|
11
|
+
# sets. Requester clearance and region are set by admins on users and
|
|
12
|
+
# agent tokens (vaultkit-inc/vaultkit#76), so they're checked like any
|
|
13
|
+
# other value.
|
|
14
|
+
class MatchCheck
|
|
15
|
+
# Labels the scanner's classifier assigns. Always valid, so a pack can
|
|
16
|
+
# cover secrets before a database has any.
|
|
17
|
+
# unknown marks a column nobody has reviewed yet.
|
|
18
|
+
CLASSIFIER_TAGS = %w[pii financial credential internal unknown].freeze
|
|
19
|
+
SENSITIVITIES = %w[high medium low unknown].freeze
|
|
20
|
+
|
|
21
|
+
MATCH_KEYS = %w[dataset fields context].freeze
|
|
22
|
+
FIELD_KEYS = %w[any all contains sensitivity].freeze
|
|
23
|
+
CONTEXT_KEYS = %w[environment dataset_region requester_role requester_region requester_clearance time].freeze
|
|
24
|
+
ENVIRONMENTS = %w[production staging development sandbox].freeze
|
|
25
|
+
REGIONS = %w[EU US CA APAC].freeze
|
|
26
|
+
CLEARANCES = %w[low medium high].freeze
|
|
27
|
+
|
|
28
|
+
Result = Struct.new(:errors, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
def self.call(policies, registry)
|
|
31
|
+
new(registry).call(policies)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(registry)
|
|
35
|
+
fields = Array(registry["datasets"]).flat_map { |ds| Array(ds["fields"]) }
|
|
36
|
+
@datasets = Array(registry["datasets"]).map { |ds| ds["name"].to_s }.to_set
|
|
37
|
+
@tags = (CLASSIFIER_TAGS + fields.flat_map { |f| Array(f["tags"]).map(&:to_s) }).to_set
|
|
38
|
+
@sensitivities = (SENSITIVITIES + fields.map { |f| f["sensitivity"].to_s }).reject(&:empty?).to_set
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(policies)
|
|
42
|
+
errors = []
|
|
43
|
+
policies.each do |policy|
|
|
44
|
+
where = "#{policy['__file'] || policy['id']} (#{policy['id']})"
|
|
45
|
+
problems(policy).each { |p| errors << "#{where}: #{p}" }
|
|
46
|
+
end
|
|
47
|
+
Result.new(errors: errors)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def problems(policy)
|
|
53
|
+
match = policy["match"] || {}
|
|
54
|
+
return ["match must be a mapping"] unless match.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
unknown_keys(match.keys, MATCH_KEYS, "match") +
|
|
57
|
+
dataset_problems(match["dataset"]) +
|
|
58
|
+
field_problems(match["fields"]) +
|
|
59
|
+
context_problems(policy["context"], "context") +
|
|
60
|
+
context_problems(match["context"], "match.context") +
|
|
61
|
+
mask_problems(policy, match)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unknown_keys(keys, allowed, path)
|
|
65
|
+
(keys.map(&:to_s) - allowed).map do |key|
|
|
66
|
+
next "#{path}.category isn't supported: the engine ignores it. Use `contains: [<category>]` instead." if key == "category"
|
|
67
|
+
|
|
68
|
+
"#{path}.#{key} isn't supported, so the engine ignores it. Allowed: #{allowed.join(', ')}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def dataset_problems(dataset)
|
|
73
|
+
return [] if dataset.nil? || @datasets.include?(dataset.to_s)
|
|
74
|
+
|
|
75
|
+
["dataset #{dataset} isn't in datasets/registry.yaml. Scan the datasource, or fix the name."]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def field_problems(fields)
|
|
79
|
+
return [] if fields.nil?
|
|
80
|
+
return ["match.fields must be a mapping"] unless fields.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
problems = unknown_keys(fields.keys, FIELD_KEYS, "match.fields")
|
|
83
|
+
tags = Array(fields["contains"]).map(&:to_s)
|
|
84
|
+
unknown = tags.reject { |t| @tags.include?(t) }
|
|
85
|
+
problems << "no field has the tag #{unknown.join(', ')}. Known tags: #{@tags.to_a.sort.join(', ')}" if unknown.any?
|
|
86
|
+
if fields.key?("sensitivity") && !@sensitivities.include?(fields["sensitivity"].to_s)
|
|
87
|
+
problems << "sensitivity #{fields['sensitivity']} never occurs. Use one of: #{@sensitivities.to_a.join(', ')}"
|
|
88
|
+
end
|
|
89
|
+
problems
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def context_problems(context, path)
|
|
93
|
+
return [] if context.nil?
|
|
94
|
+
return ["#{path} must be a mapping"] unless context.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
unknown_keys(context.keys, CONTEXT_KEYS, path) + context.filter_map do |key, value|
|
|
97
|
+
case key.to_s
|
|
98
|
+
when "requester_clearance"
|
|
99
|
+
"#{path}.requester_clearance #{value} isn't one of: #{CLEARANCES.join(', ')}" unless CLEARANCES.include?(value.to_s)
|
|
100
|
+
when "requester_region"
|
|
101
|
+
"#{path}.requester_region #{value} isn't one of: #{REGIONS.join(', ')}" unless REGIONS.include?(value.to_s)
|
|
102
|
+
when "time"
|
|
103
|
+
"#{path}.time only works under match.context." if path == "context"
|
|
104
|
+
when "environment"
|
|
105
|
+
"#{path}.environment #{value} isn't one of: #{ENVIRONMENTS.join(', ')}" unless ENVIRONMENTS.include?(value.to_s)
|
|
106
|
+
when "dataset_region"
|
|
107
|
+
"#{path}.dataset_region #{value} isn't one of: #{REGIONS.join(', ')}" unless REGIONS.include?(value.to_s)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def mask_problems(policy, match)
|
|
113
|
+
return [] unless policy.dig("action", "mask") == true
|
|
114
|
+
return [] if match["fields"].is_a?(Hash) && (match["fields"].keys.map(&:to_s) & %w[any contains sensitivity]).any?
|
|
115
|
+
|
|
116
|
+
["a mask policy needs match.fields (any, contains or sensitivity) to say which fields to mask"]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
__pack_meta:
|
|
2
2
|
name: ai_safety
|
|
3
|
-
version: "
|
|
3
|
+
version: "2.0.0"
|
|
4
4
|
layer: 30
|
|
5
|
-
description: "
|
|
5
|
+
description: "Agents need approval for personal, financial and unreviewed data in production, and for high-sensitivity data anywhere."
|
|
6
6
|
dependencies:
|
|
7
7
|
- starter
|
|
8
8
|
priority_band:
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: ai_require_approval_agents_sensitive_prod
|
|
2
|
+
description: Agents need security approval for personal or financial data in production.
|
|
3
|
+
priority: 1200
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
contains: [pii, financial]
|
|
8
|
+
|
|
9
|
+
context:
|
|
10
|
+
environment: production
|
|
11
|
+
requester_role: agent
|
|
12
|
+
|
|
13
|
+
action:
|
|
14
|
+
require_approval: true
|
|
15
|
+
approver_role: security
|
|
16
|
+
reason: Agents reading personal or financial data in production need security review.
|
|
17
|
+
ttl: 1h
|
data/lib/vkit/policy/packs/ai_safety/policies/02_require_approval_agents_high_sensitivity.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
id: ai_require_approval_agents_high_sensitivity
|
|
2
|
+
description: Agents need security approval for high-sensitivity data in every environment.
|
|
3
|
+
priority: 1300
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
sensitivity: high
|
|
8
|
+
|
|
9
|
+
context:
|
|
10
|
+
requester_role: agent
|
|
11
|
+
|
|
12
|
+
action:
|
|
13
|
+
require_approval: true
|
|
14
|
+
approver_role: security
|
|
15
|
+
reason: Agents reading high-sensitivity data need security review, in any environment.
|
|
16
|
+
ttl: 1h
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: ai_require_approval_agents_unknown_prod
|
|
2
|
+
description: Agents need security approval in production for columns nobody has reviewed yet.
|
|
3
|
+
priority: 1400
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
sensitivity: unknown
|
|
8
|
+
|
|
9
|
+
context:
|
|
10
|
+
environment: production
|
|
11
|
+
requester_role: agent
|
|
12
|
+
|
|
13
|
+
action:
|
|
14
|
+
require_approval: true
|
|
15
|
+
approver_role: security
|
|
16
|
+
reason: This column's sensitivity is unknown, so an agent needs security review to read it in production.
|
|
17
|
+
ttl: 1h
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
__pack_meta:
|
|
2
2
|
name: financial_compliance
|
|
3
|
-
version: "
|
|
3
|
+
version: "2.0.0"
|
|
4
4
|
layer: 40
|
|
5
|
-
description: "
|
|
5
|
+
description: "Finance approval for financial data in production, and short-lived grants for financial fields."
|
|
6
6
|
dependencies:
|
|
7
7
|
- starter
|
|
8
8
|
priority_band:
|
data/lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_financial_prod.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
id: fin_require_approval_financial_prod
|
|
2
|
+
description: A finance manager approves requests for financial data in production.
|
|
3
|
+
priority: 2100
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
contains: [financial]
|
|
8
|
+
|
|
9
|
+
context:
|
|
10
|
+
environment: production
|
|
11
|
+
|
|
12
|
+
action:
|
|
13
|
+
require_approval: true
|
|
14
|
+
approver_role: finance_manager
|
|
15
|
+
reason: Financial data in production needs finance approval.
|
|
16
|
+
ttl: 30m
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
__pack_meta:
|
|
2
2
|
name: starter
|
|
3
|
-
version: "
|
|
3
|
+
version: "2.0.0"
|
|
4
4
|
layer: 10
|
|
5
|
-
description: "Secure-
|
|
5
|
+
description: "Secure defaults: secrets denied, PII and high-sensitivity fields masked, approval for high-sensitivity data in production, unreviewed columns flagged."
|
|
6
6
|
dependencies: []
|
|
7
7
|
priority_band:
|
|
8
8
|
min: 0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
id: deny_credentials
|
|
2
|
+
description: Never return passwords, tokens, API keys or other secrets.
|
|
3
|
+
priority: 95
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
contains: [credential]
|
|
8
|
+
|
|
9
|
+
action:
|
|
10
|
+
deny: true
|
|
11
|
+
reason: Credentials are never returned. Use the service that owns them instead.
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
id: mask_pii_by_default
|
|
2
|
-
description:
|
|
3
|
-
|
|
2
|
+
description: Mask personal data for every requester, including after approval.
|
|
4
3
|
priority: 80
|
|
5
4
|
|
|
6
5
|
match:
|
|
7
6
|
fields:
|
|
8
|
-
contains: [
|
|
7
|
+
contains: [pii]
|
|
9
8
|
|
|
10
9
|
action:
|
|
11
10
|
mask: true
|
|
12
|
-
reason:
|
|
11
|
+
reason: Personal data is masked by default.
|
|
13
12
|
ttl: 1h
|
|
14
13
|
|
|
15
14
|
masking:
|
|
16
|
-
default_method:
|
|
15
|
+
default_method: full
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
id: mask_high_sensitivity
|
|
2
|
+
description: Mask high-sensitivity fields, such as bank accounts, even when they aren't personal data.
|
|
3
|
+
priority: 82
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
sensitivity: high
|
|
8
|
+
|
|
9
|
+
action:
|
|
10
|
+
mask: true
|
|
11
|
+
reason: High-sensitivity data is masked by default.
|
|
12
|
+
ttl: 1h
|
|
13
|
+
|
|
14
|
+
masking:
|
|
15
|
+
default_method: full
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
id: require_approval_high_sensitivity
|
|
2
|
-
description:
|
|
2
|
+
description: A data owner approves requests for high-sensitivity data in production.
|
|
3
|
+
priority: 85
|
|
3
4
|
|
|
4
5
|
match:
|
|
5
6
|
fields:
|
|
@@ -8,10 +9,8 @@ match:
|
|
|
8
9
|
context:
|
|
9
10
|
environment: production
|
|
10
11
|
|
|
11
|
-
priority: 85
|
|
12
|
-
|
|
13
12
|
action:
|
|
14
13
|
require_approval: true
|
|
15
14
|
approver_role: data_owner
|
|
16
|
-
reason:
|
|
17
|
-
ttl:
|
|
15
|
+
reason: High-sensitivity data in production needs a data owner's approval.
|
|
16
|
+
ttl: 1h
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
id: flag_unknown_sensitivity
|
|
2
|
+
description: Allow columns nobody has reviewed yet, and record why, so they show up in the audit log.
|
|
3
|
+
priority: 20
|
|
4
|
+
|
|
5
|
+
match:
|
|
6
|
+
fields:
|
|
7
|
+
sensitivity: unknown
|
|
8
|
+
|
|
9
|
+
action:
|
|
10
|
+
allow: true
|
|
11
|
+
reason: This column's sensitivity is unknown. Review its label in datasets/registry.yaml.
|
|
12
|
+
ttl: 1h
|
|
@@ -102,6 +102,11 @@
|
|
|
102
102
|
"properties": {
|
|
103
103
|
"name": { "type": "string", "minLength": 1 },
|
|
104
104
|
"datasource": { "type": "string", "minLength": 1 },
|
|
105
|
+
"tenant_column": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"minLength": 1,
|
|
108
|
+
"description": "Column holding the tenant id. Every query on this dataset is confined to the requester's tenant."
|
|
109
|
+
},
|
|
105
110
|
"fields": {
|
|
106
111
|
"type": "array",
|
|
107
112
|
"minItems": 1,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vkit
|
|
4
|
+
module Policy
|
|
5
|
+
# Checks the tenant columns in datasets/registry.yaml. A dataset with
|
|
6
|
+
# `tenant_column: <field>` is confined to the requester's tenant on every
|
|
7
|
+
# query. The risk is a tenant table nobody marked, so a dataset that has
|
|
8
|
+
# another dataset's tenant column must either be marked too, or say
|
|
9
|
+
# `tenant_column: false` to declare every tenant may read all of it.
|
|
10
|
+
class TenancyCheck
|
|
11
|
+
def self.call(raw_registry)
|
|
12
|
+
new(raw_registry || {}).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(raw_registry)
|
|
16
|
+
@datasets = raw_registry.to_h { |name, data| [name.to_s, data || {}] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
@datasets.flat_map { |name, data| problems(name, data) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def problems(name, data)
|
|
26
|
+
column = data["tenant_column"]
|
|
27
|
+
fields = (data["fields"] || {}).keys.map(&:to_s)
|
|
28
|
+
|
|
29
|
+
case column
|
|
30
|
+
when false then []
|
|
31
|
+
when nil then unmarked(name, fields)
|
|
32
|
+
when String
|
|
33
|
+
return [] if fields.include?(column)
|
|
34
|
+
|
|
35
|
+
["#{name}: tenant_column #{column} isn't one of its fields, so no query on it could be confined to a tenant."]
|
|
36
|
+
else
|
|
37
|
+
["#{name}: tenant_column must be a field name, or false for a table every tenant may read."]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unmarked(name, fields)
|
|
42
|
+
(fields & tenant_columns).map do |column|
|
|
43
|
+
owners = @datasets.select { |_, d| d["tenant_column"] == column }.keys.join(", ")
|
|
44
|
+
"#{name} has #{column}, the tenant column of #{owners}, but no tenant_column. Add " \
|
|
45
|
+
"`tenant_column: #{column}` to confine it to one tenant, or `tenant_column: false` if every tenant may read all of it."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tenant_columns
|
|
50
|
+
@tenant_columns ||= @datasets.values.map { |d| d["tenant_column"] }.grep(String).uniq
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/vkit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vaultkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nnamdi Ogundu
|
|
@@ -95,24 +95,24 @@ files:
|
|
|
95
95
|
- lib/vkit/core/registry_diff_printer.rb
|
|
96
96
|
- lib/vkit/core/table_formatter.rb
|
|
97
97
|
- lib/vkit/policy/bundle_compiler.rb
|
|
98
|
+
- lib/vkit/policy/match_check.rb
|
|
98
99
|
- lib/vkit/policy/packs/ai_safety/metadata.yaml
|
|
99
|
-
- lib/vkit/policy/packs/ai_safety/policies/
|
|
100
|
-
- lib/vkit/policy/packs/ai_safety/policies/
|
|
101
|
-
- lib/vkit/policy/packs/ai_safety/policies/
|
|
100
|
+
- lib/vkit/policy/packs/ai_safety/policies/01_require_approval_agents_sensitive_prod.yaml
|
|
101
|
+
- lib/vkit/policy/packs/ai_safety/policies/02_require_approval_agents_high_sensitivity.yaml
|
|
102
|
+
- lib/vkit/policy/packs/ai_safety/policies/03_require_approval_agents_unknown_prod.yaml
|
|
102
103
|
- lib/vkit/policy/packs/financial_compliance/metadata.yaml
|
|
103
|
-
- lib/vkit/policy/packs/financial_compliance/policies/
|
|
104
|
-
- lib/vkit/policy/packs/financial_compliance/policies/
|
|
105
|
-
- lib/vkit/policy/packs/financial_compliance/policies/03_deny_non_admin_access_pci_in_prod.yaml
|
|
106
|
-
- lib/vkit/policy/packs/financial_compliance/policies/04_short_ttl_for_financial_grants.yaml
|
|
104
|
+
- lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_financial_prod.yaml
|
|
105
|
+
- lib/vkit/policy/packs/financial_compliance/policies/02_short_ttl_for_financial_grants.yaml
|
|
107
106
|
- lib/vkit/policy/packs/starter/metadata.yaml
|
|
108
|
-
- lib/vkit/policy/packs/starter/policies/
|
|
107
|
+
- lib/vkit/policy/packs/starter/policies/01_deny_credentials.yaml
|
|
109
108
|
- lib/vkit/policy/packs/starter/policies/02_mask_pii_by_default.yaml
|
|
110
|
-
- lib/vkit/policy/packs/starter/policies/
|
|
111
|
-
- lib/vkit/policy/packs/starter/policies/
|
|
112
|
-
- lib/vkit/policy/packs/starter/policies/
|
|
113
|
-
- lib/vkit/policy/packs/starter/policies/
|
|
109
|
+
- lib/vkit/policy/packs/starter/policies/03_mask_high_sensitivity.yaml
|
|
110
|
+
- lib/vkit/policy/packs/starter/policies/04_require_approval_high_sensitivity.yaml
|
|
111
|
+
- lib/vkit/policy/packs/starter/policies/05_default_ttl.yaml
|
|
112
|
+
- lib/vkit/policy/packs/starter/policies/06_flag_unknown.yaml
|
|
114
113
|
- lib/vkit/policy/policy_validator.rb
|
|
115
114
|
- lib/vkit/policy/schema/policy_bundle.schema.json
|
|
115
|
+
- lib/vkit/policy/tenancy_check.rb
|
|
116
116
|
- lib/vkit/policy/validate_bundle.rb
|
|
117
117
|
- lib/vkit/policy/validation_error.rb
|
|
118
118
|
- lib/vkit/utils/banner.rb
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
id: ai_deny_agent_prod_without_clearance
|
|
2
|
-
description: "Deny agent access in production unless requester_clearance is high/admin."
|
|
3
|
-
priority: 1100
|
|
4
|
-
|
|
5
|
-
match: {}
|
|
6
|
-
|
|
7
|
-
context:
|
|
8
|
-
environment: production
|
|
9
|
-
requester_role: agent
|
|
10
|
-
requester_clearance: low
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
deny: true
|
|
14
|
-
reason: "Automation/agents in production require elevated clearance."
|
|
15
|
-
ttl: "1h"
|
data/lib/vkit/policy/packs/ai_safety/policies/02_require_approval_for_sensitive_in_prod.yaml
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
id: ai_require_approval_sensitive_prod
|
|
2
|
-
description: "Require approval when accessing sensitive fields in production."
|
|
3
|
-
priority: 1200
|
|
4
|
-
|
|
5
|
-
match:
|
|
6
|
-
fields:
|
|
7
|
-
contains: ["pii", "financial", "phi", "secrets"]
|
|
8
|
-
|
|
9
|
-
context:
|
|
10
|
-
environment: production
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
require_approval: true
|
|
14
|
-
approver_role: security
|
|
15
|
-
reason: "Sensitive data access in production requires security review."
|
|
16
|
-
ttl: "1h"
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
id: ai_mask_sensitive_for_agents
|
|
2
|
-
description: "Mask sensitive fields by default for agents."
|
|
3
|
-
priority: 1300
|
|
4
|
-
|
|
5
|
-
match:
|
|
6
|
-
fields:
|
|
7
|
-
contains: ["pii", "financial", "phi", "secrets"]
|
|
8
|
-
|
|
9
|
-
context:
|
|
10
|
-
requester_role: agent
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
mask: true
|
|
14
|
-
reason: "Agents should not receive raw sensitive fields by default."
|
|
15
|
-
ttl: "4h"
|
|
16
|
-
|
|
17
|
-
masking:
|
|
18
|
-
default_method: redact
|
data/lib/vkit/policy/packs/financial_compliance/policies/01_require_approval_for_financial_prod.yaml
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
id: fin_require_approval_financial_prod
|
|
2
|
-
description: "Require approval for financial-category fields in production."
|
|
3
|
-
priority: 2100
|
|
4
|
-
|
|
5
|
-
match:
|
|
6
|
-
fields:
|
|
7
|
-
category: financial
|
|
8
|
-
|
|
9
|
-
context:
|
|
10
|
-
environment: production
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
require_approval: true
|
|
14
|
-
approver_role: finance_manager
|
|
15
|
-
reason: "Financial data access in production requires finance approval."
|
|
16
|
-
ttl: "1h"
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
id: fin_mask_payment_tokens
|
|
2
|
-
description: "Mask payment tokens / card-related fields everywhere."
|
|
3
|
-
priority: 2200
|
|
4
|
-
|
|
5
|
-
match:
|
|
6
|
-
fields:
|
|
7
|
-
contains: ["pci", "card", "payment_token"]
|
|
8
|
-
|
|
9
|
-
context: {}
|
|
10
|
-
|
|
11
|
-
action:
|
|
12
|
-
mask: true
|
|
13
|
-
reason: "Payment tokens/card-related data must never be exposed in raw form."
|
|
14
|
-
ttl: 24h
|
|
15
|
-
|
|
16
|
-
masking:
|
|
17
|
-
default_method: redact
|
data/lib/vkit/policy/packs/financial_compliance/policies/03_deny_non_admin_access_pci_in_prod.yaml
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
id: fin_deny_non_admin_pci_prod
|
|
2
|
-
description: "Deny PCI-tagged data access in production unless admin."
|
|
3
|
-
priority: 2300
|
|
4
|
-
|
|
5
|
-
match:
|
|
6
|
-
fields:
|
|
7
|
-
contains: ["pci"]
|
|
8
|
-
|
|
9
|
-
context:
|
|
10
|
-
environment: production
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
deny: true
|
|
14
|
-
reason: "PCI-tagged data in production is restricted to admins."
|
|
15
|
-
ttl: "1h"
|
data/lib/vkit/policy/packs/financial_compliance/policies/04_short_ttl_for_financial_grants.yaml
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
id: fin_short_ttl_financial
|
|
2
|
-
description: "Short TTL for financial access grants to reduce risk window."
|
|
3
|
-
priority: 2400
|
|
4
|
-
|
|
5
|
-
match:
|
|
6
|
-
fields:
|
|
7
|
-
category: financial
|
|
8
|
-
|
|
9
|
-
context: {}
|
|
10
|
-
|
|
11
|
-
action:
|
|
12
|
-
allow: true
|
|
13
|
-
reason: "Financial grants must expire quickly."
|
|
14
|
-
ttl: "30m"
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
id: deny_sensitive_without_clearance
|
|
2
|
-
description: "Deny access to highly sensitive data without sufficient clearance."
|
|
3
|
-
|
|
4
|
-
match:
|
|
5
|
-
fields:
|
|
6
|
-
sensitivity: high
|
|
7
|
-
|
|
8
|
-
context:
|
|
9
|
-
requester_clearance: low
|
|
10
|
-
|
|
11
|
-
priority: 90
|
|
12
|
-
|
|
13
|
-
action:
|
|
14
|
-
deny: true
|
|
15
|
-
reason: "High sensitivity data requires elevated clearance."
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
id: block_cross_region
|
|
2
|
-
description: "Block cross-region access to prevent data residency violations."
|
|
3
|
-
|
|
4
|
-
match: {}
|
|
5
|
-
|
|
6
|
-
context:
|
|
7
|
-
requester_region: US
|
|
8
|
-
dataset_region: EU
|
|
9
|
-
|
|
10
|
-
priority: 95
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
deny: true
|
|
14
|
-
reason: "Cross-region data access is not permitted."
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
id: limit_production_access
|
|
2
|
-
description: "Restrict production data access to approved roles."
|
|
3
|
-
|
|
4
|
-
match: {}
|
|
5
|
-
|
|
6
|
-
context:
|
|
7
|
-
environment: production
|
|
8
|
-
requester_role: analyst
|
|
9
|
-
|
|
10
|
-
priority: 70
|
|
11
|
-
|
|
12
|
-
action:
|
|
13
|
-
require_approval: true
|
|
14
|
-
approver_role: admin
|
|
15
|
-
reason: "Production access for analysts requires approval."
|
|
16
|
-
ttl: "2h"
|