abqari 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9d5e9a272de668ea9e75b33d47fd252c9a46413c6873c15d077a938f3817004
4
- data.tar.gz: 0d55679d1de17c940765b5a04c81e15980e1929a590fab878b36e65fdc877195
3
+ metadata.gz: ac44a7f76fcb30cb218104f9e002a0cd958518542912393d7681fceab92abdf0
4
+ data.tar.gz: 8a0cc5fe35425ca6571dbc76549df5105106bf8ff672f67ea9101c26e48af550
5
5
  SHA512:
6
- metadata.gz: 27ecd3e0f352ce9970cd85256badd8973bcd950092fa67cd56de2f9820862eef89740b3995d8c3e7f692259a9f50d49da1d41e3a76d7912c0873d650b46d101a
7
- data.tar.gz: 77b4d04a24911bf1705cb074ce4515bd130ee5db2340e1c988c81a5a707c54014bf5a62cc94ca1d83139747d7c87595a3c7c2607a79ae9b2f953c926b7f16d43
6
+ metadata.gz: 9050a56ac582b3cdb3f4590bf90f6543ee57fa0dd6e1c4b56afc7274a937dfbb805400869d157473019af1a9802da7e88af97e30961f436cbed8a2472f2bb6cb
7
+ data.tar.gz: db4d7cb9c339676e76cc794b8e205fb0000d50266d8b28ae6a53770b7d701a44fd3664d3f3d2076e3fc78cd2f08745ab442a4abd85a5bcbdbb0ef14586a8f26b
data/CHANGELOG.md CHANGED
@@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  _Nothing yet._
11
11
 
12
+ ## [1.3.0] — 2026-10-11
13
+
14
+ If CI runs `bin/audit --strict`, run `bin/audit` locally before
15
+ upgrading: a new Security check can fail an audit that passed on
16
+ 1.2.0. Every finding is inline code that is already blocked on any host
17
+ that enforces the site's CSP, so the fix is in the site, not the
18
+ policy.
19
+
20
+ ### Added
21
+
22
+ - **`bin/audit` now fails on inline code the site's own CSP would
23
+ block.** The generated policy has `style-src 'self'` and pins inline
24
+ scripts by hash, so a template that adds `style="…"`, a `<style>`
25
+ block, an `onclick=`, or an unhashed `<script>` breaks on any host
26
+ that enforces the CSP. It hid because nothing local enforces it: the
27
+ dev server doesn't apply `_headers`, the page renders, and the
28
+ browser only logs a console error. One site had 52 inline
29
+ `style="--ratio: …"` attributes that would have collapsed every photo
30
+ pane the moment its CSP went live.
31
+
32
+ The new **Security** category (`Audit::Csp`) checks every page
33
+ against the CSP in the built `_headers`, so a `headers:` override or
34
+ `csp:` extension is what's checked. It reports one error per page per
35
+ kind of inline code, with an example and how to fix it. It follows
36
+ the spec: hashes, `'unsafe-inline'` (ignored once a hash or nonce is
37
+ present), `'unsafe-hashes'` for attributes, the `-elem`/`-attr`
38
+ directives and the `default-src` fallback. `bin/audit --strict` fails
39
+ on these, so a site that passed yesterday can fail today; every
40
+ finding is code that is already broken wherever the policy is
41
+ applied.
42
+
12
43
  ## [1.2.0] — 2026-10-11
13
44
 
14
45
  Upgrade if you serve the generated CSP: Chrome has been blocking
@@ -1110,7 +1141,8 @@ run a real working author / publisher site.
1110
1141
  commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
1111
1142
  the `~> 2.4` floor bought.
1112
1143
 
1113
- [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.2.0...HEAD
1144
+ [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.3.0...HEAD
1145
+ [1.3.0]: https://github.com/grantrayner/abqari/compare/v1.2.0...v1.3.0
1114
1146
  [1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
1115
1147
  [1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
1116
1148
  [1.1.1]: https://github.com/grantrayner/abqari/compare/v1.1.0...v1.1.1
data/README.md CHANGED
@@ -11,7 +11,7 @@ optional overrides), while the engine — layouts, themes, helpers, asset
11
11
  pipeline — lives in the dependency. Forking the engine is also
12
12
  supported for users who want to own all the code.
13
13
 
14
- > **Status:** v1.2.0 — see
14
+ > **Status:** v1.3.0 — see
15
15
  > [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md).
16
16
  > Distributed via RubyGems (`gem install abqari`) and as a Bundler
17
17
  > path/git source. The API follows [semver](https://semver.org/) from
@@ -174,7 +174,7 @@ Test that it worked:
174
174
  abqari version
175
175
  ```
176
176
 
177
- You should see a version number, e.g. `abqari 1.2.0`. If you get
177
+ You should see a version number, e.g. `abqari 1.3.0`. If you get
178
178
  `command not found`, see [Troubleshooting](#troubleshooting) below.
179
179
 
180
180
  ### Step 3 — Create your first site
@@ -460,7 +460,7 @@ Edit `Gemfile`:
460
460
  ```ruby
461
461
  source 'https://rubygems.org'
462
462
 
463
- gem 'abqari', '~> 1.2'
463
+ gem 'abqari', '~> 1.3'
464
464
 
465
465
  group :development do
466
466
  gem 'listen', '~> 3.8' # optional, for the dev-server file watcher
@@ -470,7 +470,7 @@ end
470
470
  Or — pin to a git ref while tracking pre-release work:
471
471
 
472
472
  ```ruby
473
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.2.0'
473
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
474
474
  ```
475
475
 
476
476
  Install the libvips C library once (hard dep for the image pipeline):
@@ -2331,6 +2331,7 @@ Rebuilds the site, walks the output, and writes a markdown report to `audit/<YYY
2331
2331
  | **Privacy** | External resource loads (URLs not on the site host) |
2332
2332
  | **Dependencies** | Outdated gems via `bundle outdated`, CVEs via `bundle-audit` (if installed) |
2333
2333
  | **Content** | Posts without descriptions, single-use tags, `series_position` problems that break prev/next silently (non-Integer, duplicate, or missing) |
2334
+ | **Security** | Inline code the built `_headers` CSP would block: `style="…"` attributes, `<style>` blocks, `on…=` handlers, and inline scripts whose hash isn't in `script-src`. Errors, because the page breaks on any host that enforces the policy |
2334
2335
 
2335
2336
  The report opens with a summary table, a **Suggested fixes** code block listing every `bundle update <gem>` command needed to clear the dependency findings, and then groups findings by category with severity (`error`, `warn`, `info`) and a suggestion where applicable. Reports accumulate in `audit/` — useful to commit so you have a diffable history of site quality over time.
2336
2337
 
@@ -2365,7 +2366,7 @@ GitHub Dependabot does three things abqari's audit does **not**:
2365
2366
 
2366
2367
  What `bin/audit` does that Dependabot doesn't:
2367
2368
 
2368
- - **Same report covers links, SEO, accessibility, privacy, and content.** Dependabot only watches dependencies.
2369
+ - **Same report covers links, SEO, accessibility, privacy, content, and the CSP.** Dependabot only watches dependencies.
2369
2370
  - **Uses the same `ruby-advisory-db`** Dependabot's Ruby ecosystem coverage uses, just at a single point in time.
2370
2371
  - **Outputs to git-trackable markdown.** A diffable `audit/` history of every check you've run.
2371
2372
 
@@ -2558,14 +2559,14 @@ bin/audit # verify nothing regressed
2558
2559
  ```
2559
2560
 
2560
2561
  `abqari new` scaffolds the Gemfile pinned to the current major.minor
2561
- (`gem 'abqari', '~> 1.2'` as of this release). Bundler reads that as
2562
- `>= 1.2, < 2.0`, so `bundle update abqari` takes minor and patch
2562
+ (`gem 'abqari', '~> 1.3'` as of this release). Bundler reads that as
2563
+ `>= 1.3, < 2.0`, so `bundle update abqari` takes minor and patch
2563
2564
  releases but never a major one, which is the release that can need
2564
2565
  changes to your site. To track a fork or an unreleased branch
2565
2566
  instead, swap the line for a git source:
2566
2567
 
2567
2568
  ```ruby
2568
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.2.0'
2569
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
2569
2570
  ```
2570
2571
 
2571
2572
  The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'cgi'
5
+ require 'digest'
6
+
7
+ module Abqari
8
+ class Audit
9
+ # CSP rule. Checks every page's inline code against the
10
+ # Content-Security-Policy the same build wrote to `_headers`: inline
11
+ # `<script>` and `<style>` blocks, `style="…"` attributes and `on…=`
12
+ # event handlers.
13
+ #
14
+ # The policy is strict on purpose (`style-src 'self'`, scripts pinned
15
+ # by hash), so anything a template adds inline is blocked wherever
16
+ # the CSP is enforced — and nothing else notices. The dev server
17
+ # doesn't apply `_headers`, the page still renders, and the browser
18
+ # only logs a console error. One site carried 52 inline
19
+ # `style="--ratio: …"` attributes that would have collapsed every
20
+ # photo pane, and the engine's own speculation-rules block shipped in
21
+ # 1.1.x allowed only by a keyword Chrome ignores.
22
+ #
23
+ # Reads the BUILT `_headers`, not the engine's defaults, so a
24
+ # `headers:` override or `csp:` extension in site.yml is what gets
25
+ # checked. Error-severity: on a host that enforces the policy the
26
+ # page is broken. A site on a host that ignores `_headers` (GitHub
27
+ # Pages) will see these too; the code is still blocked the day the
28
+ # policy is applied.
29
+ class Csp < Rule
30
+ # Script types the browser executes, so CSP governs them. Any other
31
+ # `type` is a data block (JSON-LD, templates) and CSP ignores it.
32
+ EXECUTABLE_TYPES = %w[
33
+ text/javascript application/javascript application/ecmascript text/ecmascript
34
+ module importmap speculationrules
35
+ ].freeze
36
+ HASH_ALGORITHMS = { 'sha256' => Digest::SHA256, 'sha384' => Digest::SHA384, 'sha512' => Digest::SHA512 }.freeze
37
+
38
+ # Most specific directive first, as browsers resolve them.
39
+ DIRECTIVES = {
40
+ script: %w[script-src-elem script-src default-src],
41
+ script_attr: %w[script-src-attr script-src default-src],
42
+ style: %w[style-src-elem style-src default-src],
43
+ style_attr: %w[style-src-attr style-src default-src]
44
+ }.freeze
45
+
46
+ LABELS = {
47
+ script: 'inline <script>',
48
+ script_attr: 'inline event handler',
49
+ style: '<style> block',
50
+ style_attr: 'style attribute'
51
+ }.freeze
52
+
53
+ ADVICE = {
54
+ script: 'Move it to a file under app/assets/js/, or add its sha256 to the CSP. ' \
55
+ 'For the engine\'s own scripts, upgrade abqari and re-copy the CSP.',
56
+ script_attr: 'Attach the handler from a script file with addEventListener instead.',
57
+ style: 'Move the rules into app/assets/css/site.css.',
58
+ style_attr: 'Move the styles into app/assets/css/site.css as a class, or a data ' \
59
+ 'attribute matched by an attribute selector for per-element values.'
60
+ }.freeze
61
+
62
+ def run
63
+ policies = csp_policies
64
+ return if policies.empty?
65
+
66
+ html_files.each do |file|
67
+ url = page_url(file)
68
+ applicable = policies.select { |prefix, _| url.start_with?(prefix) }.map(&:last)
69
+ next if applicable.empty?
70
+
71
+ check_page(url, File.read(file, encoding: 'UTF-8'), applicable)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # `[[path_prefix, { directive => [sources] }]]` for every CSP rule in
78
+ # the built `_headers`. More than one can apply to a page; the
79
+ # browser enforces all of them, so inline code has to pass each.
80
+ def csp_policies
81
+ path = File.join(@site.output_dir, '_headers')
82
+ return [] unless File.file?(path)
83
+
84
+ HeadersFile.parse(File.read(path, encoding: 'UTF-8'))
85
+ .select { |rule| rule['name'].casecmp?('Content-Security-Policy') }
86
+ .map { |rule| [rule['path'].delete_suffix('*'), parse_policy(rule['value'])] }
87
+ end
88
+
89
+ def parse_policy(value)
90
+ value.split(';').each_with_object({}) do |directive, policy|
91
+ name, *sources = directive.strip.split(/\s+/)
92
+ policy[name.downcase] = sources if name
93
+ end
94
+ end
95
+
96
+ def check_page(url, html, policies)
97
+ blocked = Hash.new { |h, k| h[k] = [] }
98
+ inline_code(html).each do |kind, content, type|
99
+ policy = policies.find { |pol| !allowed?(pol, kind, content, type) }
100
+ blocked[kind] << [content, policy] if policy
101
+ end
102
+
103
+ blocked.each do |kind, hits|
104
+ content, policy = hits.first
105
+ directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
106
+ count = hits.size
107
+ add(category: 'security', severity: 'error', location: url,
108
+ message: "#{count} #{LABELS[kind]}#{'s' unless count == 1} blocked by the CSP " \
109
+ "(#{directive} #{policy[directive].join(' ')}), e.g. #{example(kind, content)}",
110
+ suggestion: ADVICE[kind])
111
+ end
112
+ end
113
+
114
+ # Each piece of inline code as `[kind, content, script_type]`, where
115
+ # content is exactly what the browser hashes.
116
+ def inline_code(html)
117
+ html = html.gsub(/<!--.*?-->/m, '')
118
+ code = []
119
+
120
+ html.scan(%r{<script\b([^>]*)>(.*?)</script>}mi).each do |attrs, body|
121
+ next if attribute(attrs, 'src')
122
+
123
+ type = attribute(attrs, 'type')&.strip&.downcase
124
+ next unless type.nil? || type.empty? || EXECUTABLE_TYPES.include?(type)
125
+
126
+ code << [:script, body, type]
127
+ end
128
+ html.scan(%r{<style\b[^>]*>(.*?)</style>}mi).each { |(body)| code << [:style, body] }
129
+
130
+ # Attributes, with script and style bodies removed first so a
131
+ # string inside a script that looks like markup isn't counted.
132
+ tags_only = html.gsub(%r{<(script|style)\b[^>]*>.*?</\1>}mi, '')
133
+ tags_only.scan(/<[a-zA-Z][^>]*>/).each do |tag|
134
+ tag.scan(/\s(on[a-z]+|style)\s*=\s*(?:"([^"]*)"|'([^']*)')/i).each do |name, dq, sq|
135
+ code << [name.casecmp?('style') ? :style_attr : :script_attr, CGI.unescapeHTML(dq || sq)]
136
+ end
137
+ end
138
+ code
139
+ end
140
+
141
+ def attribute(attrs, name)
142
+ match = attrs.match(/\b#{name}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i)
143
+ match && (match[1] || match[2] || match[3])
144
+ end
145
+
146
+ # Mirrors the CSP spec's inline checks: a hash or nonce in the list
147
+ # switches `'unsafe-inline'` off; attribute hashes count only with
148
+ # `'unsafe-hashes'`. `'inline-speculation-rules'` is honoured only
149
+ # when the list has no hash or nonce: Chrome reports a block allowed
150
+ # by nothing but the keyword as a violation once hashes are present.
151
+ def allowed?(policy, kind, content, type = nil)
152
+ directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
153
+ return true unless directive
154
+
155
+ sources = policy[directive]
156
+ hashed = sources.any? { |s| s.match?(/\A'(sha256|sha384|sha512|nonce)-/i) }
157
+ hashed ||= kind == :script && sources.include?("'strict-dynamic'")
158
+ return true if sources.include?("'unsafe-inline'") && !hashed
159
+ return true if type == 'speculationrules' && sources.include?("'inline-speculation-rules'") && !hashed
160
+ return false if %i[style_attr script_attr].include?(kind) && !sources.include?("'unsafe-hashes'")
161
+
162
+ HASH_ALGORITHMS.any? do |alg, digest|
163
+ sources.include?("'#{alg}-#{Base64.strict_encode64(digest.digest(content))}'")
164
+ end
165
+ end
166
+
167
+ def example(kind, content)
168
+ snippet = content.strip.gsub(/\s+/, ' ')
169
+ snippet = "#{snippet[0, 60]}…" if snippet.length > 60
170
+ case kind
171
+ when :style_attr then %(style="#{snippet}")
172
+ when :script_attr then %(handler "#{snippet}")
173
+ else %(`#{snippet}`)
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
data/lib/abqari/audit.rb CHANGED
@@ -12,6 +12,7 @@ require_relative 'audit/privacy'
12
12
  require_relative 'audit/performance'
13
13
  require_relative 'audit/dependencies'
14
14
  require_relative 'audit/content'
15
+ require_relative 'audit/csp'
15
16
 
16
17
  module Abqari
17
18
  # Site audit orchestrator. Each check lives in its own rule class
@@ -36,14 +37,15 @@ module Abqari
36
37
  'privacy' => 'Privacy',
37
38
  'perf' => 'Performance',
38
39
  'deps' => 'Dependencies',
39
- 'content' => 'Content'
40
+ 'content' => 'Content',
41
+ 'security' => 'Security'
40
42
  }.freeze
41
43
 
42
44
  # Rule classes, in execution order. Order matters only for the
43
45
  # output report — findings are grouped by category, not insertion
44
46
  # order — but Links first keeps the report scan-friendly: errors
45
47
  # appear before warnings.
46
- RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content].freeze
48
+ RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content, Csp].freeze
47
49
 
48
50
  attr_reader :findings
49
51
 
@@ -47,21 +47,8 @@ module Abqari
47
47
  true
48
48
  end
49
49
 
50
- # `_headers` blocks: an unindented path line, then indented
51
- # `Name: value` lines. Same shape HeadersFile writes, for both the
52
- # generated policy and a `headers:` override in site.yml.
53
50
  def parse(text)
54
- path = nil
55
- text.each_line.with_object([]) do |line, rules|
56
- next if line.strip.empty? || line.lstrip.start_with?('#')
57
-
58
- if line.match?(/\A\S/)
59
- path = line.strip
60
- elsif path
61
- name, value = line.strip.split(/:\s*/, 2)
62
- rules << { 'path' => path, 'name' => name, 'value' => value.to_s } unless name.to_s.empty?
63
- end
64
- end
51
+ HeadersFile.parse(text)
65
52
  end
66
53
 
67
54
  private
@@ -31,6 +31,24 @@ module Abqari
31
31
  'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains'
32
32
  }.freeze
33
33
 
34
+ # A `_headers` file as `{ 'path', 'name', 'value' }` rules: an
35
+ # unindented path line, then indented `Name: value` lines. The shape
36
+ # `build` writes, for both the generated policy and a `headers:`
37
+ # override. Read back by `abqari headers` and the CSP audit.
38
+ def self.parse(text)
39
+ path = nil
40
+ text.each_line.with_object([]) do |line, rules|
41
+ next if line.strip.empty? || line.lstrip.start_with?('#')
42
+
43
+ if line.match?(/\A\S/)
44
+ path = line.strip
45
+ elsif path
46
+ name, value = line.strip.split(/:\s*/, 2)
47
+ rules << { 'path' => path, 'name' => name, 'value' => value.to_s } unless name.to_s.empty?
48
+ end
49
+ end
50
+ end
51
+
34
52
  def initialize(site)
35
53
  @site = site
36
54
  end
@@ -14,5 +14,5 @@
14
14
  #
15
15
  # Keep this file dependency-free.
16
16
  module Abqari
17
- VERSION = '1.2.0'
17
+ VERSION = '1.3.0'
18
18
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abqari
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Grant Rayner
@@ -220,6 +220,7 @@ files:
220
220
  - lib/abqari/audit.rb
221
221
  - lib/abqari/audit/a11y.rb
222
222
  - lib/abqari/audit/content.rb
223
+ - lib/abqari/audit/csp.rb
223
224
  - lib/abqari/audit/dependencies.rb
224
225
  - lib/abqari/audit/links.rb
225
226
  - lib/abqari/audit/performance.rb