jekyll-agent-audit 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/CHANGELOG.md +8 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +17 -0
- data/README.md +180 -0
- data/Rakefile +38 -0
- data/doc/Jekyll/AgentAudit/BuildInventory.md +29 -0
- data/doc/Jekyll/AgentAudit/Configuration.md +42 -0
- data/doc/Jekyll/AgentAudit/ConfigurationError.md +6 -0
- data/doc/Jekyll/AgentAudit/Error.md +6 -0
- data/doc/Jekyll/AgentAudit/Extractor.md +20 -0
- data/doc/Jekyll/AgentAudit/Finding.md +30 -0
- data/doc/Jekyll/AgentAudit/InputError.md +6 -0
- data/doc/Jekyll/AgentAudit/LinkGraph.md +26 -0
- data/doc/Jekyll/AgentAudit/Registry.md +38 -0
- data/doc/Jekyll/AgentAudit/Report.md +27 -0
- data/doc/Jekyll/AgentAudit/ReportError.md +6 -0
- data/doc/Jekyll/AgentAudit/Reporters/Console.md +19 -0
- data/doc/Jekyll/AgentAudit/Reporters/JSON.md +9 -0
- data/doc/Jekyll/AgentAudit/Reporters.md +5 -0
- data/doc/Jekyll/AgentAudit/Rules/Provenance.md +127 -0
- data/doc/Jekyll/AgentAudit/Rules/Publication.md +73 -0
- data/doc/Jekyll/AgentAudit/Rules.md +5 -0
- data/doc/Jekyll/AgentAudit/Runner.md +13 -0
- data/doc/Jekyll/AgentAudit/UrlResolver.md +13 -0
- data/doc/Jekyll/AgentAudit.md +35 -0
- data/doc/Jekyll/Commands/AgentAudit/CommandParser.md +9 -0
- data/doc/Jekyll/Commands/AgentAudit/ParserErrors.md +9 -0
- data/doc/Jekyll/Commands/AgentAudit.md +13 -0
- data/doc/Jekyll/Commands.md +5 -0
- data/doc/Jekyll.md +5 -0
- data/doc/README.md +180 -0
- data/doc/index.csv +154 -0
- data/docs/example-report.json +281 -0
- data/docs/implementation-plan.md +26 -0
- data/docs/limitations.md +31 -0
- data/docs/verification.md +52 -0
- data/lib/jekyll/agent_audit/build_inventory.rb +196 -0
- data/lib/jekyll/agent_audit/command.rb +179 -0
- data/lib/jekyll/agent_audit/configuration.rb +196 -0
- data/lib/jekyll/agent_audit/errors.rb +10 -0
- data/lib/jekyll/agent_audit/extractor.rb +230 -0
- data/lib/jekyll/agent_audit/finding.rb +57 -0
- data/lib/jekyll/agent_audit/link_graph.rb +82 -0
- data/lib/jekyll/agent_audit/registry.rb +133 -0
- data/lib/jekyll/agent_audit/report.rb +79 -0
- data/lib/jekyll/agent_audit/reporters/console.rb +144 -0
- data/lib/jekyll/agent_audit/reporters/json.rb +16 -0
- data/lib/jekyll/agent_audit/rules/provenance.rb +412 -0
- data/lib/jekyll/agent_audit/rules/publication.rb +186 -0
- data/lib/jekyll/agent_audit/runner.rb +266 -0
- data/lib/jekyll/agent_audit/url_resolver.rb +98 -0
- data/lib/jekyll/agent_audit/version.rb +7 -0
- data/lib/jekyll-agent-audit.rb +24 -0
- data/llms.txt +35 -0
- data/schema/report-1.0.json +66 -0
- data/script/benchmark.rb +68 -0
- metadata +133 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module AgentAudit
|
|
7
|
+
module Rules
|
|
8
|
+
module Publication
|
|
9
|
+
IDS = %w[
|
|
10
|
+
architecture.output_collision links.target_missing links.fragment_missing architecture.orphan
|
|
11
|
+
identity.canonical_invalid identity.canonical_multiple identity.canonical_target_missing
|
|
12
|
+
structure.title_missing structure.h1_absent structure.heading_empty structure.heading_jump
|
|
13
|
+
structure.id_duplicate structure.link_unnamed
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
SEVERITIES = {
|
|
17
|
+
"architecture.output_collision" => :error, "links.target_missing" => :error,
|
|
18
|
+
"links.fragment_missing" => :error, "architecture.orphan" => :warning,
|
|
19
|
+
"identity.canonical_invalid" => :error, "identity.canonical_multiple" => :error,
|
|
20
|
+
"identity.canonical_target_missing" => :error, "structure.title_missing" => :warning,
|
|
21
|
+
"structure.h1_absent" => :info, "structure.heading_empty" => :warning,
|
|
22
|
+
"structure.heading_jump" => :info, "structure.id_duplicate" => :error,
|
|
23
|
+
"structure.link_unnamed" => :warning
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def evaluate(id, document, context)
|
|
29
|
+
return {status: :skipped, findings: [], reason: :unknown_rule} unless IDS.include?(id)
|
|
30
|
+
return collision(context) if id == "architecture.output_collision"
|
|
31
|
+
if document && document[:redirect] && %w[architecture.orphan structure.title_missing structure.h1_absent structure.heading_empty structure.heading_jump structure.id_duplicate structure.link_unnamed].include?(id)
|
|
32
|
+
return {status: :not_applicable, findings: [], reason: :redirect}
|
|
33
|
+
end
|
|
34
|
+
if document && !document[:content_confident] && %w[structure.h1_absent structure.heading_empty structure.heading_jump].include?(id)
|
|
35
|
+
return {status: :skipped, findings: [], reason: :content_extraction_uncertain}
|
|
36
|
+
end
|
|
37
|
+
return {status: :evaluated, findings: public_send(id.tr(".", "_"), document, context)}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def architecture_output_collision(_document, context)
|
|
41
|
+
Array(context[:inventory]&.collisions).map do |collision|
|
|
42
|
+
finding("architecture.output_collision", collision[:primary], "Multiple owners claim #{collision[:output_path]}.", {output_path: collision[:output_path], owners: collision[:owners].map { |o| o[:identity] }})
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def links_target_missing(document, context)
|
|
47
|
+
links(document, context).map do |edge|
|
|
48
|
+
next unless edge[:resolution][:status] == :missing
|
|
49
|
+
finding("links.target_missing", document, "Link points to a missing local target.", {href: edge[:occurrence][:href], path: edge[:resolution][:path]}, edge[:occurrence][:line])
|
|
50
|
+
end.compact
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def links_fragment_missing(document, context)
|
|
54
|
+
links(document, context).map do |edge|
|
|
55
|
+
resolution, occurrence = edge[:resolution], edge[:occurrence]
|
|
56
|
+
next unless resolution[:status] == :local && resolution[:fragment] && resolution[:target]
|
|
57
|
+
next if asset_target?(resolution[:target])
|
|
58
|
+
next if resolution[:target][:ids]&.include?(resolution[:fragment])
|
|
59
|
+
finding("links.fragment_missing", document, "Local target does not contain the requested fragment.", {href: occurrence[:href], fragment: resolution[:fragment], target: resolution[:target][:identity]}, occurrence[:line])
|
|
60
|
+
end.compact
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def architecture_orphan(document, context)
|
|
64
|
+
return [] if root?(document, context) || document[:redirect]
|
|
65
|
+
return [] unless document[:content_confident]
|
|
66
|
+
return [] unless graph(context)
|
|
67
|
+
return [] unless graph(context).incoming(document).empty?
|
|
68
|
+
[finding("architecture.orphan", document, "No incoming links in this build.", {incoming: 0})]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def identity_canonical_invalid(document, _context)
|
|
72
|
+
Array(document[:canonicals]).map do |canonical|
|
|
73
|
+
href = canonical[:href].to_s
|
|
74
|
+
next if canonical[:href] == ""
|
|
75
|
+
if canonical[:href].nil?
|
|
76
|
+
next finding("identity.canonical_invalid", document, "Canonical declaration has no href.", {href: nil}, canonical[:line])
|
|
77
|
+
end
|
|
78
|
+
next if href.match?(/[^\x00-\x7F]/)
|
|
79
|
+
begin
|
|
80
|
+
URI.parse(href)
|
|
81
|
+
nil
|
|
82
|
+
rescue URI::InvalidURIError
|
|
83
|
+
finding("identity.canonical_invalid", document, "Canonical declaration is not a valid URI reference.", {href: href}, canonical[:line])
|
|
84
|
+
end
|
|
85
|
+
end.compact
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def identity_canonical_multiple(document, context)
|
|
89
|
+
declarations = Array(document[:canonicals]).map do |canonical|
|
|
90
|
+
href = canonical[:href].to_s
|
|
91
|
+
next if href.empty?
|
|
92
|
+
[context[:resolver].resolve(href, document)[:url], canonical] rescue [href, canonical]
|
|
93
|
+
end.compact
|
|
94
|
+
values = declarations.map(&:first).uniq
|
|
95
|
+
return [] unless values.length > 1
|
|
96
|
+
locations = declarations.map { |value, canonical| {line: canonical[:line], href: canonical[:href]} }
|
|
97
|
+
[finding("identity.canonical_multiple", document, "Multiple distinct canonical URLs are declared.", {canonicals: values}, locations.first[:line], locations.drop(1))]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def identity_canonical_target_missing(document, context)
|
|
101
|
+
Array(document[:canonicals]).map do |canonical|
|
|
102
|
+
resolution = context[:resolver].resolve(canonical[:href], document)
|
|
103
|
+
next unless resolution[:status] == :missing
|
|
104
|
+
finding("identity.canonical_target_missing", document, "Canonical points to a missing local target.", {href: canonical[:href], path: resolution[:path]}, canonical[:line])
|
|
105
|
+
end.compact
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def structure_title_missing(document, _context)
|
|
109
|
+
document[:title].to_s.strip.empty? ? [finding("structure.title_missing", document, "Rendered HTML has no nonempty title.", {})] : []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def structure_h1_absent(document, _context)
|
|
113
|
+
return [] unless document[:content_confident]
|
|
114
|
+
document[:headings].none? { |h| h[:level] == 1 && h[:name_supported] != false && !h[:hidden] && !h[:name].to_s.empty? } ? [finding("structure.h1_absent", document, "No level-one heading detected within the extracted content.", {})] : []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def structure_heading_empty(document, _context)
|
|
118
|
+
return [] unless document[:content_confident]
|
|
119
|
+
document[:headings].map { |heading| finding("structure.heading_empty", document, "Heading has no supported accessible name.", {level: heading[:level]}, heading[:line]) if heading[:name_supported] != false && !heading[:hidden] && heading[:name].to_s.empty? }.compact
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def structure_heading_jump(document, _context)
|
|
123
|
+
return [] unless document[:content_confident]
|
|
124
|
+
previous = nil
|
|
125
|
+
document[:headings].map do |heading|
|
|
126
|
+
result = if previous && heading[:level] > previous[:level] + 1 && !heading[:hidden] && heading[:name_supported] && previous[:name_supported]
|
|
127
|
+
finding("structure.heading_jump", document, "Heading level jumps from h#{previous[:level]} to h#{heading[:level]}.", {from: previous[:level], to: heading[:level]}, heading[:line], [{line: previous[:line]}])
|
|
128
|
+
end
|
|
129
|
+
previous = heading unless heading[:hidden]
|
|
130
|
+
result
|
|
131
|
+
end.compact
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def structure_id_duplicate(document, _context)
|
|
135
|
+
locations = document[:duplicate_id_locations] || {}
|
|
136
|
+
Array(document[:duplicate_ids]).map do |id|
|
|
137
|
+
lines = Array(locations[id])
|
|
138
|
+
finding("structure.id_duplicate", document, "ID is declared more than once.", {id: id}, lines.first, lines.drop(1).map { |line| {line: line, id: id} })
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def structure_link_unnamed(document, _context)
|
|
143
|
+
document[:links].map { |link| finding("structure.link_unnamed", document, "Navigation link has no supported accessible name.", {href: link[:href]}, link[:line]) if link[:kind] == :navigation && !link[:hidden] && link[:name_supported] && link[:name].to_s.empty? }.compact
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def collision(context)
|
|
147
|
+
{status: :evaluated, findings: architecture_output_collision(nil, context)}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def links(document, context)
|
|
151
|
+
graph = context[:graph]
|
|
152
|
+
return graph.edges_for(document) if graph && graph.respond_to?(:edges_for)
|
|
153
|
+
Array(graph&.edges).select { |edge| edge[:source][:identity] == document[:identity] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def asset_target?(entry)
|
|
157
|
+
path = entry[:output_path].to_s
|
|
158
|
+
!path.empty? && !path.end_with?(".html", "/")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def graph(context)
|
|
162
|
+
context[:graph]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def root?(document, context)
|
|
166
|
+
config = context[:configuration].respond_to?(:to_h) ? context[:configuration].to_h : context[:configuration]
|
|
167
|
+
roots = config.dig(:graph, :roots) || config.dig("graph", "roots") || ["/"]
|
|
168
|
+
base = context.fetch(:site_config, {}).fetch("baseurl", "").to_s.sub(%r{/\z}, "")
|
|
169
|
+
roots.map(&:to_s).any? do |root|
|
|
170
|
+
route = root == "/" && !base.empty? ? "#{base}/" : root
|
|
171
|
+
document[:route].to_s == route || (root == "/" && document[:route].to_s == "/index.html") || begin
|
|
172
|
+
URI.parse(document[:url].to_s).path == route
|
|
173
|
+
rescue URI::InvalidURIError
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def finding(rule, document, message, observation, line = nil, related_locations = [])
|
|
180
|
+
{document: document, message: message, observation: observation, line: line,
|
|
181
|
+
related_locations: related_locations, severity: SEVERITIES[rule]}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "date"
|
|
7
|
+
require "yaml"
|
|
8
|
+
|
|
9
|
+
module Jekyll
|
|
10
|
+
module AgentAudit
|
|
11
|
+
class Runner
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
@options = options.transform_keys(&:to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
@extraction_diagnostics = []
|
|
18
|
+
temporary = Dir.mktmpdir("jekyll-agent-audit-")
|
|
19
|
+
site = nil
|
|
20
|
+
begin
|
|
21
|
+
jekyll_options = build_options(temporary)
|
|
22
|
+
configuration = Configuration.new(jekyll_options.fetch("agent_audit", {}), @options.slice("fail_on", "format"))
|
|
23
|
+
site = Jekyll::Site.new(jekyll_options)
|
|
24
|
+
register_context(site, configuration)
|
|
25
|
+
site.process
|
|
26
|
+
inventory = site.instance_variable_get(:@agent_audit_inventory)
|
|
27
|
+
inventory&.finalize!
|
|
28
|
+
report_from_site(site, inventory)
|
|
29
|
+
rescue Interrupt
|
|
30
|
+
operational_report("interrupted", "Audit interrupted", site)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
operational_report("build_failure", e.message, site)
|
|
33
|
+
ensure
|
|
34
|
+
FileUtils.remove_entry(temporary) if temporary && File.directory?(temporary)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_options(temporary)
|
|
41
|
+
options = @options.dup
|
|
42
|
+
source = File.expand_path(options["source"] || Dir.pwd)
|
|
43
|
+
destination = File.join(temporary, "site")
|
|
44
|
+
cache_dir = File.join(temporary, "cache")
|
|
45
|
+
options["source"] = source
|
|
46
|
+
options["destination"] = destination
|
|
47
|
+
options["cache_dir"] = cache_dir
|
|
48
|
+
options["incremental"] = false
|
|
49
|
+
options["disable_disk_cache"] = true
|
|
50
|
+
Jekyll.configuration(options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def register_context(site, configuration)
|
|
54
|
+
fail ReportError, "inventory_unavailable" unless defined?(BuildInventory)
|
|
55
|
+
inventory = BuildInventory.new(site, configuration)
|
|
56
|
+
site.instance_variable_set(:@agent_audit_configuration, configuration)
|
|
57
|
+
site.instance_variable_set(:@agent_audit_inventory, inventory)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def report_from_site(site, inventory)
|
|
61
|
+
configuration = site.instance_variable_get(:@agent_audit_configuration)
|
|
62
|
+
entries = inventory.entries
|
|
63
|
+
fail ReportError, "no_documents" if entries.empty?
|
|
64
|
+
documents = extract_documents(entries, configuration)
|
|
65
|
+
hydrate_explicit_data!(documents, site.source)
|
|
66
|
+
validate_page_suppressions!(documents, configuration)
|
|
67
|
+
findings, coverage, rule_diagnostics = evaluate_rules(documents, inventory, entries, configuration, site)
|
|
68
|
+
suppression_diagnostics = apply_suppressions(findings, configuration, documents)
|
|
69
|
+
diagnostics = Array(inventory&.diagnostics) + @extraction_diagnostics + rule_diagnostics + suppression_diagnostics
|
|
70
|
+
complete = inventory.nil? || inventory.complete?
|
|
71
|
+
complete = false if diagnostics.any? { |diagnostic| operational_diagnostic?(diagnostic) }
|
|
72
|
+
fail ReportError, "no_documents" if documents.empty? && diagnostics.empty?
|
|
73
|
+
reference_time = @options["reference_time"] ? Time.iso8601(@options["reference_time"].to_s).utc : Time.now.utc
|
|
74
|
+
Report.new(findings: findings, coverage: coverage, documents_inspected: documents.length,
|
|
75
|
+
options: configuration.to_h, site_config: site.config, complete: complete,
|
|
76
|
+
diagnostics: diagnostics, operational_error: !complete, reference_time: reference_time,
|
|
77
|
+
effective_configuration: configuration.to_h,
|
|
78
|
+
build: {"incremental" => false, "show_drafts" => !!@options["show_drafts"], "future" => !!@options["future"], "timezone" => site.config["timezone"].to_s})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_documents(entries, configuration)
|
|
82
|
+
fail ReportError, "extractor_unavailable" unless defined?(Extractor)
|
|
83
|
+
extractor = Extractor.new(configuration)
|
|
84
|
+
entries.filter_map do |entry|
|
|
85
|
+
output = entry[:output_path] || entry["output_path"]
|
|
86
|
+
route = entry[:route] || entry["route"]
|
|
87
|
+
next unless output.to_s.downcase.end_with?(".html") || route.to_s.end_with?("/")
|
|
88
|
+
extractor.extract(entry)
|
|
89
|
+
rescue InputError => e
|
|
90
|
+
@extraction_diagnostics << {"code" => "input_failure", "message" => sanitize_message(e.message)}
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def hydrate_explicit_data!(documents, source)
|
|
96
|
+
documents.each do |document|
|
|
97
|
+
path = document[:source_path] || document["source_path"]
|
|
98
|
+
next if path.to_s.empty?
|
|
99
|
+
absolute = File.expand_path(path.to_s, source.to_s)
|
|
100
|
+
next unless File.file?(absolute)
|
|
101
|
+
text = File.binread(absolute)
|
|
102
|
+
next unless text.start_with?("---")
|
|
103
|
+
front = text.split(/^---\s*$\n?/, 3)[1]
|
|
104
|
+
data = YAML.safe_load(front.to_s, permitted_classes: [Date, Time], aliases: true) || {}
|
|
105
|
+
document[:explicit_data] = data if document[:explicit_data].to_h.empty? && document.respond_to?(:[]=)
|
|
106
|
+
rescue Psych::Exception, Errno::ENOENT, Errno::EACCES
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def evaluate_rules(documents, inventory, entries, configuration, site)
|
|
112
|
+
extracted_by_output = documents.each_with_object({}) do |document, index|
|
|
113
|
+
index[document[:output_path] || document["output_path"]] = document
|
|
114
|
+
end
|
|
115
|
+
resolver_entries = entries.map do |entry|
|
|
116
|
+
output = entry[:output_path] || entry["output_path"]
|
|
117
|
+
extracted_by_output.fetch(output, entry)
|
|
118
|
+
end
|
|
119
|
+
resolver = defined?(UrlResolver) ? UrlResolver.new(resolver_entries, site.config, configuration) : nil
|
|
120
|
+
graph = if defined?(LinkGraph) && resolver
|
|
121
|
+
LinkGraph.new(documents, resolver, configuration, site.config)
|
|
122
|
+
end
|
|
123
|
+
context = {documents: documents, inventory: inventory || entries, resolver: resolver, graph: graph,
|
|
124
|
+
configuration: configuration, site_config: site.config}
|
|
125
|
+
findings = []
|
|
126
|
+
coverage = {}
|
|
127
|
+
diagnostics = []
|
|
128
|
+
diagnostics.concat(Array(graph&.diagnostics))
|
|
129
|
+
if graph && graph.respond_to?(:edges) && graph.edges.length > configuration["limits"]["max_edges"]
|
|
130
|
+
diagnostics << {"code" => "edge_limit", "limit" => configuration["limits"]["max_edges"], "value" => graph.edges.length}
|
|
131
|
+
end
|
|
132
|
+
selected = @options["only"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
133
|
+
rules = configuration.enabled_rules
|
|
134
|
+
rules = rules.select { |id, _| selected.include?(id) } unless selected.empty?
|
|
135
|
+
Registry.mvp_ids.each do |id|
|
|
136
|
+
metadata = Registry[id]
|
|
137
|
+
next if rules.key?(id)
|
|
138
|
+
coverage[id] = {candidate: 0, evaluated: 0, not_applicable: 0, skipped: 0, status: metadata[:release] == "D" ? "deferred" : "disabled"}
|
|
139
|
+
end
|
|
140
|
+
rules.each do |id, metadata|
|
|
141
|
+
candidates = id == "architecture.output_collision" ? [nil] : documents.select { |doc| doc[:selected] != false && doc["selected"] != false }
|
|
142
|
+
evaluated = 0
|
|
143
|
+
not_applicable = 0
|
|
144
|
+
skipped = 0
|
|
145
|
+
candidates.each do |document|
|
|
146
|
+
result = evaluate_one(id, document, context)
|
|
147
|
+
next unless result
|
|
148
|
+
status = (result[:status] || :evaluated).to_sym
|
|
149
|
+
status == :evaluated ? evaluated += 1 : status == :not_applicable ? not_applicable += 1 : skipped += 1
|
|
150
|
+
Array(result[:findings]).each { |draft| findings << enrich_finding(draft, id, document) }
|
|
151
|
+
diagnostics.concat(Array(result[:diagnostics]))
|
|
152
|
+
end
|
|
153
|
+
coverage[id] = {candidate: candidates.length, evaluated: evaluated, not_applicable: not_applicable, skipped: skipped}
|
|
154
|
+
end
|
|
155
|
+
[findings, coverage, diagnostics]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def evaluate_one(id, document, context)
|
|
159
|
+
if id.start_with?("provenance.")
|
|
160
|
+
defined?(Rules::Provenance) ? Rules::Provenance.evaluate(id, document, context) : {findings: []}
|
|
161
|
+
elsif defined?(Rules::Publication)
|
|
162
|
+
Rules::Publication.evaluate(id, document, context)
|
|
163
|
+
else
|
|
164
|
+
{findings: []}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def enrich_finding(draft, id, document)
|
|
169
|
+
draft = draft.transform_keys(&:to_sym)
|
|
170
|
+
source_document = draft[:document] || document
|
|
171
|
+
draft[:rule_id] = id
|
|
172
|
+
draft[:document_url] ||= source_document && (source_document[:url] || source_document["url"])
|
|
173
|
+
draft[:source_path] ||= source_document && (source_document[:source_path] || source_document["source_path"])
|
|
174
|
+
draft[:document_identity] ||= source_document && (source_document[:identity] || source_document["identity"])
|
|
175
|
+
draft[:extraction_basis] ||= source_document && (source_document[:content_basis] || source_document["content_basis"])
|
|
176
|
+
draft[:line] ||= draft[:rendered_line]
|
|
177
|
+
if draft[:rendered_location].nil? && source_document
|
|
178
|
+
draft[:rendered_location] = {path: source_document[:output_path] || source_document["output_path"], line: draft[:line]}
|
|
179
|
+
end
|
|
180
|
+
Finding.new(draft)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def apply_suppressions(findings, configuration, documents)
|
|
184
|
+
used = {}
|
|
185
|
+
today = (@options["reference_time"] ? Time.iso8601(@options["reference_time"].to_s).utc : Time.now.utc).to_date
|
|
186
|
+
findings.each do |finding|
|
|
187
|
+
page = documents.find { |doc| (doc[:source_path] || doc["source_path"]) == finding["source_path"] }
|
|
188
|
+
page_data = page ? (page[:explicit_data] || page["explicit_data"] || page[:data] || page["data"] || {}) : {}
|
|
189
|
+
audit_data = page_data["agent_audit"] || page_data[:agent_audit] || {}
|
|
190
|
+
page_entries = audit_data["suppress"] || audit_data[:suppress] || []
|
|
191
|
+
page_suppression = Array(page_entries).find do |entry|
|
|
192
|
+
rule = entry["rule"] || entry[:rule]
|
|
193
|
+
fingerprint = entry["fingerprint"] || entry[:fingerprint]
|
|
194
|
+
expiry = entry["until"] || entry[:until]
|
|
195
|
+
active = !expiry || Date.iso8601(expiry.to_s) >= today
|
|
196
|
+
fingerprint_match = fingerprint.nil? || fingerprint == finding["fingerprint"]
|
|
197
|
+
rule == finding["rule_id"] && fingerprint_match && (entry["reason"] || entry[:reason]).to_s.strip != "" && active
|
|
198
|
+
end
|
|
199
|
+
suppression = page_suppression || configuration.suppression_for(finding["rule_id"], source_path: finding["source_path"], fingerprint: finding["fingerprint"], now: today).first
|
|
200
|
+
if suppression
|
|
201
|
+
finding.data["suppression"] = suppression
|
|
202
|
+
used[suppression.object_id] = true
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
expired = configuration.expired_suppressions(now: today).map { |entry| {"code" => "expired_suppression", "rule" => entry["rule"], "message" => "Suppression has expired"} }
|
|
206
|
+
page_suppressions = documents.flat_map do |doc|
|
|
207
|
+
data = doc[:explicit_data] || doc["explicit_data"] || doc[:data] || doc["data"] || {}
|
|
208
|
+
audit = data["agent_audit"] || data[:agent_audit] || {}
|
|
209
|
+
Array(audit["suppress"] || audit[:suppress])
|
|
210
|
+
end
|
|
211
|
+
page_expired = page_suppressions.filter_map do |suppression|
|
|
212
|
+
expiry = suppression["until"] || suppression[:until]
|
|
213
|
+
next unless expiry && Date.iso8601(expiry.to_s) < today
|
|
214
|
+
{"code" => "expired_suppression", "rule" => suppression["rule"] || suppression[:rule], "message" => "Page suppression has expired"}
|
|
215
|
+
end
|
|
216
|
+
page_unused = page_suppressions.filter_map do |suppression|
|
|
217
|
+
next if used[suppression.object_id]
|
|
218
|
+
expiry = suppression["until"] || suppression[:until]
|
|
219
|
+
next if expiry && Date.iso8601(expiry.to_s) < today
|
|
220
|
+
{"code" => "unused_suppression", "rule" => suppression["rule"] || suppression[:rule], "message" => "Page suppression did not match a finding"}
|
|
221
|
+
end
|
|
222
|
+
unused = Array(configuration["suppressions"]).filter_map do |suppression|
|
|
223
|
+
next if used[suppression.object_id]
|
|
224
|
+
{"code" => "unused_suppression", "rule" => suppression["rule"], "message" => "Suppression did not match a finding"}
|
|
225
|
+
end
|
|
226
|
+
expired + page_expired + page_unused + unused
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_page_suppressions!(documents, configuration)
|
|
230
|
+
documents.each do |doc|
|
|
231
|
+
data = doc[:explicit_data] || doc["explicit_data"] || doc[:data] || doc["data"] || {}
|
|
232
|
+
audit = data["agent_audit"] || data[:agent_audit] || {}
|
|
233
|
+
entries = audit["suppress"] || audit[:suppress] || []
|
|
234
|
+
fail ConfigurationError, "page suppressions must be an array" unless entries.is_a?(Array)
|
|
235
|
+
entries.each do |entry|
|
|
236
|
+
fail ConfigurationError, "page suppression must be a mapping" unless entry.is_a?(Hash)
|
|
237
|
+
keys = entry.keys.map(&:to_s)
|
|
238
|
+
fail ConfigurationError, "unknown page suppression key" unless (keys - %w[rule reason until fingerprint]).empty?
|
|
239
|
+
rule = entry["rule"] || entry[:rule]
|
|
240
|
+
reason = entry["reason"] || entry[:reason]
|
|
241
|
+
fail ConfigurationError, "page suppression requires known MVP rule" unless Registry.mvp_ids.include?(rule.to_s)
|
|
242
|
+
fail ConfigurationError, "page suppression requires a nonempty reason" if reason.to_s.strip.empty?
|
|
243
|
+
Date.iso8601((entry["until"] || entry[:until]).to_s) if entry["until"] || entry[:until]
|
|
244
|
+
rescue Date::Error
|
|
245
|
+
fail ConfigurationError, "page suppression until must be an ISO date"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def operational_diagnostic?(diagnostic)
|
|
251
|
+
return false unless diagnostic.is_a?(Hash)
|
|
252
|
+
code = (diagnostic[:code] || diagnostic["code"]).to_s
|
|
253
|
+
code.match?(/resource|limit|incomplete|invalid_graph_root|input_failure|ownership_outside|capture_failed|no_documents/)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def operational_report(code, message, site)
|
|
257
|
+
Report.new(complete: false, operational_error: true, options: @options.merge("fail_on" => @options.fetch("fail_on", "error")),
|
|
258
|
+
site_config: site ? site.config : {}, diagnostics: [{"code" => code, "message" => sanitize_message(message)}])
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def sanitize_message(message)
|
|
262
|
+
message.to_s.gsub(%r{(?:/private/var|/var/folders|/tmp|/private/tmp)[^\s"']*}, "[temporary-path]")[0, 500]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module AgentAudit
|
|
7
|
+
class UrlResolver
|
|
8
|
+
def initialize(entries, site_config, configuration)
|
|
9
|
+
@entries, @site_config, @configuration = Array(entries), site_config || {}, configuration
|
|
10
|
+
@by_path = {}
|
|
11
|
+
@entries.each { |entry| index(entry) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(href, document, base: true)
|
|
15
|
+
raw = href.to_s.strip
|
|
16
|
+
if raw.empty?
|
|
17
|
+
target = @by_path[canonical_path(document[:route] || document[:url])]
|
|
18
|
+
return result(target ? :local : :missing, url: document[:url], path: document[:route], target: target, reason: target ? nil : :target_missing)
|
|
19
|
+
end
|
|
20
|
+
return result(:unsupported, reason: raw.split(":", 2).first) if raw.match?(/\A(?:mailto|tel|javascript|data):/i)
|
|
21
|
+
URI.parse(raw)
|
|
22
|
+
base_uri = URI.parse(document[:url].to_s)
|
|
23
|
+
if base && document[:base_href].to_s != ""
|
|
24
|
+
base_uri = URI.join(base_uri.to_s, document[:base_href].to_s)
|
|
25
|
+
end
|
|
26
|
+
resolved = URI.join(base_uri.to_s, raw)
|
|
27
|
+
configured = URI.parse(@site_config.fetch("url", @site_config[:url]).to_s) rescue nil
|
|
28
|
+
if resolved.host && configured&.host && !same_origin?(resolved, configured)
|
|
29
|
+
return result(:external, url: resolved.to_s, path: resolved.path, fragment: fragment(resolved.fragment))
|
|
30
|
+
end
|
|
31
|
+
baseurl = @site_config.fetch("baseurl", @site_config[:baseurl]).to_s.sub(%r{/\z}, "")
|
|
32
|
+
if baseurl != "" && raw.start_with?("/") && resolved.path != baseurl && !resolved.path.start_with?("#{baseurl}/")
|
|
33
|
+
return result(:unsupported, url: resolved.to_s, path: resolved.path, fragment: fragment(resolved.fragment), reason: :outside_baseurl)
|
|
34
|
+
end
|
|
35
|
+
baseurl = @site_config.fetch("baseurl", @site_config[:baseurl]).to_s.sub(%r{/\z}, "")
|
|
36
|
+
if !baseurl.empty? && resolved.path != baseurl && !resolved.path.start_with?("#{baseurl}/")
|
|
37
|
+
return result(:unsupported, url: resolved.to_s, path: resolved.path, fragment: fragment(resolved.fragment), reason: :outside_baseurl)
|
|
38
|
+
end
|
|
39
|
+
path = public_path(resolved)
|
|
40
|
+
target = @by_path[canonical_path(path)]
|
|
41
|
+
alias_path = alias_for(path)
|
|
42
|
+
target ||= @by_path[canonical_path(alias_path)] if alias_path
|
|
43
|
+
result(target ? :local : :missing, url: resolved.to_s, path: path, fragment: fragment(resolved.fragment), target: target, reason: target ? nil : :target_missing)
|
|
44
|
+
rescue URI::InvalidURIError, ArgumentError => e
|
|
45
|
+
result(:invalid, reason: e.message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def index(entry)
|
|
51
|
+
[entry[:route], entry[:output_path], entry[:url]].compact.each do |path|
|
|
52
|
+
key = canonical_path(path)
|
|
53
|
+
@by_path[key] = entry
|
|
54
|
+
base = @site_config.fetch("baseurl", @site_config[:baseurl]).to_s.sub(%r{/\z}, "")
|
|
55
|
+
@by_path[canonical_path("#{base}#{key}")] = entry if !base.empty? && !key.start_with?("#{base}/")
|
|
56
|
+
@by_path[key.sub(%r{/index\.html\z}, "/")] = entry if directory_index? && key.end_with?("/index.html")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def public_path(uri)
|
|
61
|
+
path = uri.path.to_s
|
|
62
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
63
|
+
base = @site_config.fetch("baseurl", @site_config[:baseurl]).to_s.sub(%r{/\z}, "")
|
|
64
|
+
path = "#{base}#{path}" if !base.empty? && !path.start_with?("#{base}/") && path != base
|
|
65
|
+
path
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def canonical_path(path)
|
|
69
|
+
value = path.to_s.split("?", 2).first.gsub(/%2F/i, "%252F")
|
|
70
|
+
value = URI::RFC2396_PARSER.unescape(value).gsub("%2F", "%252F")
|
|
71
|
+
value.gsub(%r{/+}, "/").then { |p| p.start_with?("/") ? p : "/#{p}" }
|
|
72
|
+
end
|
|
73
|
+
def fragment(value)
|
|
74
|
+
value.nil? || value.empty? || value == "top" || value.start_with?(":~:text=") ? nil : URI::RFC2396_PARSER.unescape(value)
|
|
75
|
+
end
|
|
76
|
+
def directory_index?
|
|
77
|
+
(route_config[:directory_index] || "index.html") == "index.html"
|
|
78
|
+
end
|
|
79
|
+
def route_config
|
|
80
|
+
h = @configuration.respond_to?(:to_h) ? @configuration.to_h : @configuration
|
|
81
|
+
h[:routes] || h["routes"] || {}
|
|
82
|
+
end
|
|
83
|
+
def alias_for(path)
|
|
84
|
+
(route_config[:aliases] || route_config["aliases"] || {})[path]
|
|
85
|
+
end
|
|
86
|
+
def same_origin?(left, right)
|
|
87
|
+
left.scheme.to_s.downcase == right.scheme.to_s.downcase && left.host.to_s.downcase == right.host.to_s.downcase && effective_port(left) == effective_port(right)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def effective_port(uri)
|
|
91
|
+
uri.port || (uri.scheme.to_s.downcase == "https" ? 443 : 80)
|
|
92
|
+
end
|
|
93
|
+
def result(status, **values)
|
|
94
|
+
{status: status, url: values[:url], path: values[:path], fragment: values[:fragment], target: values[:target], reason: values[:reason]}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jekyll"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "jekyll/agent_audit/version"
|
|
6
|
+
require_relative "jekyll/agent_audit/errors"
|
|
7
|
+
require_relative "jekyll/agent_audit/registry"
|
|
8
|
+
require_relative "jekyll/agent_audit/configuration"
|
|
9
|
+
require_relative "jekyll/agent_audit/finding"
|
|
10
|
+
require_relative "jekyll/agent_audit/report"
|
|
11
|
+
require_relative "jekyll/agent_audit/reporters/json"
|
|
12
|
+
require_relative "jekyll/agent_audit/reporters/console"
|
|
13
|
+
require_relative "jekyll/agent_audit/runner"
|
|
14
|
+
require_relative "jekyll/agent_audit/command"
|
|
15
|
+
%w[build_inventory extractor url_resolver link_graph rules/publication rules/provenance].each do |component|
|
|
16
|
+
require_relative "jekyll/agent_audit/#{component}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if defined?(Jekyll::Hooks)
|
|
20
|
+
Jekyll::Hooks.register(:site, :post_render, priority: 1_000) do |site|
|
|
21
|
+
inventory = site.instance_variable_get(:@agent_audit_inventory)
|
|
22
|
+
inventory.capture! if inventory
|
|
23
|
+
end
|
|
24
|
+
end
|
data/llms.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Module Jekyll::AgentAudit <a id="module-Jekyll-AgentAudit"></a>
|
|
2
|
+
|
|
3
|
+
| | |
|
|
4
|
+
| --- | --- |
|
|
5
|
+
| **Defined in** | lib/jekyll/agent_audit/errors.rb, lib/jekyll/agent_audit/report.rb, lib/jekyll/agent_audit/runner.rb, lib/jekyll/agent_audit/finding.rb, lib/jekyll/agent_audit/version.rb, lib/jekyll/agent_audit/registry.rb, lib/jekyll/agent_audit/extractor.rb, lib/jekyll/agent_audit/link_graph.rb, lib/jekyll/agent_audit/url_resolver.rb, lib/jekyll/agent_audit/configuration.rb, lib/jekyll/agent_audit/reporters/json.rb, lib/jekyll/agent_audit/build_inventory.rb, lib/jekyll/agent_audit/rules/provenance.rb, lib/jekyll/agent_audit/reporters/console.rb, lib/jekyll/agent_audit/rules/publication.rb |
|
|
6
|
+
|
|
7
|
+
## Constants
|
|
8
|
+
### `VERSION` <a id="constant-VERSION"></a> <a id="VERSION-constant"></a>
|
|
9
|
+
Not documented.
|
|
10
|
+
|
|
11
|
+
# Documentation
|
|
12
|
+
|
|
13
|
+
- [AgentAudit/BuildInventory.md](doc/Jekyll/AgentAudit/BuildInventory.md)
|
|
14
|
+
- [AgentAudit/Configuration.md](doc/Jekyll/AgentAudit/Configuration.md)
|
|
15
|
+
- [AgentAudit/ConfigurationError.md](doc/Jekyll/AgentAudit/ConfigurationError.md)
|
|
16
|
+
- [AgentAudit/Error.md](doc/Jekyll/AgentAudit/Error.md)
|
|
17
|
+
- [AgentAudit/Extractor.md](doc/Jekyll/AgentAudit/Extractor.md)
|
|
18
|
+
- [AgentAudit/Finding.md](doc/Jekyll/AgentAudit/Finding.md)
|
|
19
|
+
- [AgentAudit/InputError.md](doc/Jekyll/AgentAudit/InputError.md)
|
|
20
|
+
- [AgentAudit/LinkGraph.md](doc/Jekyll/AgentAudit/LinkGraph.md)
|
|
21
|
+
- [AgentAudit/Registry.md](doc/Jekyll/AgentAudit/Registry.md)
|
|
22
|
+
- [AgentAudit/Report.md](doc/Jekyll/AgentAudit/Report.md)
|
|
23
|
+
- [AgentAudit/ReportError.md](doc/Jekyll/AgentAudit/ReportError.md)
|
|
24
|
+
- [AgentAudit/Reporters/Console.md](doc/Jekyll/AgentAudit/Reporters/Console.md)
|
|
25
|
+
- [AgentAudit/Reporters/JSON.md](doc/Jekyll/AgentAudit/Reporters/JSON.md)
|
|
26
|
+
- [AgentAudit/Reporters.md](doc/Jekyll/AgentAudit/Reporters.md)
|
|
27
|
+
- [AgentAudit/Rules/Provenance.md](doc/Jekyll/AgentAudit/Rules/Provenance.md)
|
|
28
|
+
- [AgentAudit/Rules/Publication.md](doc/Jekyll/AgentAudit/Rules/Publication.md)
|
|
29
|
+
- [AgentAudit/Rules.md](doc/Jekyll/AgentAudit/Rules.md)
|
|
30
|
+
- [AgentAudit/Runner.md](doc/Jekyll/AgentAudit/Runner.md)
|
|
31
|
+
- [AgentAudit/UrlResolver.md](doc/Jekyll/AgentAudit/UrlResolver.md)
|
|
32
|
+
- [Commands/AgentAudit/CommandParser.md](doc/Jekyll/Commands/AgentAudit/CommandParser.md)
|
|
33
|
+
- [Commands/AgentAudit/ParserErrors.md](doc/Jekyll/Commands/AgentAudit/ParserErrors.md)
|
|
34
|
+
- [Commands/AgentAudit.md](doc/Jekyll/Commands/AgentAudit.md)
|
|
35
|
+
- [Commands.md](doc/Jekyll/Commands.md)
|