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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +17 -0
  5. data/README.md +180 -0
  6. data/Rakefile +38 -0
  7. data/doc/Jekyll/AgentAudit/BuildInventory.md +29 -0
  8. data/doc/Jekyll/AgentAudit/Configuration.md +42 -0
  9. data/doc/Jekyll/AgentAudit/ConfigurationError.md +6 -0
  10. data/doc/Jekyll/AgentAudit/Error.md +6 -0
  11. data/doc/Jekyll/AgentAudit/Extractor.md +20 -0
  12. data/doc/Jekyll/AgentAudit/Finding.md +30 -0
  13. data/doc/Jekyll/AgentAudit/InputError.md +6 -0
  14. data/doc/Jekyll/AgentAudit/LinkGraph.md +26 -0
  15. data/doc/Jekyll/AgentAudit/Registry.md +38 -0
  16. data/doc/Jekyll/AgentAudit/Report.md +27 -0
  17. data/doc/Jekyll/AgentAudit/ReportError.md +6 -0
  18. data/doc/Jekyll/AgentAudit/Reporters/Console.md +19 -0
  19. data/doc/Jekyll/AgentAudit/Reporters/JSON.md +9 -0
  20. data/doc/Jekyll/AgentAudit/Reporters.md +5 -0
  21. data/doc/Jekyll/AgentAudit/Rules/Provenance.md +127 -0
  22. data/doc/Jekyll/AgentAudit/Rules/Publication.md +73 -0
  23. data/doc/Jekyll/AgentAudit/Rules.md +5 -0
  24. data/doc/Jekyll/AgentAudit/Runner.md +13 -0
  25. data/doc/Jekyll/AgentAudit/UrlResolver.md +13 -0
  26. data/doc/Jekyll/AgentAudit.md +35 -0
  27. data/doc/Jekyll/Commands/AgentAudit/CommandParser.md +9 -0
  28. data/doc/Jekyll/Commands/AgentAudit/ParserErrors.md +9 -0
  29. data/doc/Jekyll/Commands/AgentAudit.md +13 -0
  30. data/doc/Jekyll/Commands.md +5 -0
  31. data/doc/Jekyll.md +5 -0
  32. data/doc/README.md +180 -0
  33. data/doc/index.csv +154 -0
  34. data/docs/example-report.json +281 -0
  35. data/docs/implementation-plan.md +26 -0
  36. data/docs/limitations.md +31 -0
  37. data/docs/verification.md +52 -0
  38. data/lib/jekyll/agent_audit/build_inventory.rb +196 -0
  39. data/lib/jekyll/agent_audit/command.rb +179 -0
  40. data/lib/jekyll/agent_audit/configuration.rb +196 -0
  41. data/lib/jekyll/agent_audit/errors.rb +10 -0
  42. data/lib/jekyll/agent_audit/extractor.rb +230 -0
  43. data/lib/jekyll/agent_audit/finding.rb +57 -0
  44. data/lib/jekyll/agent_audit/link_graph.rb +82 -0
  45. data/lib/jekyll/agent_audit/registry.rb +133 -0
  46. data/lib/jekyll/agent_audit/report.rb +79 -0
  47. data/lib/jekyll/agent_audit/reporters/console.rb +144 -0
  48. data/lib/jekyll/agent_audit/reporters/json.rb +16 -0
  49. data/lib/jekyll/agent_audit/rules/provenance.rb +412 -0
  50. data/lib/jekyll/agent_audit/rules/publication.rb +186 -0
  51. data/lib/jekyll/agent_audit/runner.rb +266 -0
  52. data/lib/jekyll/agent_audit/url_resolver.rb +98 -0
  53. data/lib/jekyll/agent_audit/version.rb +7 -0
  54. data/lib/jekyll-agent-audit.rb +24 -0
  55. data/llms.txt +35 -0
  56. data/schema/report-1.0.json +66 -0
  57. data/script/benchmark.rb +68 -0
  58. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module AgentAudit
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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)