abqari 1.2.0 → 1.3.1
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/CHANGELOG.md +66 -1
- data/README.md +20 -12
- data/lib/abqari/audit/csp.rb +173 -0
- data/lib/abqari/audit.rb +4 -2
- data/lib/abqari/cli/headers.rb +1 -14
- data/lib/abqari/headers_file.rb +18 -0
- data/lib/abqari/helpers.rb +4 -3
- data/lib/abqari/html.rb +29 -0
- data/lib/abqari/image_pipeline.rb +3 -1
- data/lib/abqari/importers/base.rb +13 -8
- data/lib/abqari/minifier.rb +2 -1
- data/lib/abqari/renderer.rb +1 -1
- data/lib/abqari/version.rb +1 -1
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e61ca715dce737000acb0b0f08033cb7dc92d469c0553481d3bbaf6aec1276a
|
|
4
|
+
data.tar.gz: 3e5bf6cae00b055c9a6bd66fed47217b3a2a32f7f5ddc299e1e92200b23b82ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81e34727adb75ca47e012b73579158fd2a203bda6fe517a1d138b4cee563c4bb54d75926ed6597aa0df826861abe68cc7a36321c24701e3469b2cc4ec6854619
|
|
7
|
+
data.tar.gz: b9f6b8ecf943c8fa60048cf2e0bbe7a64b0b8e27592125d7b62bf47bb0eeee4d318b34583129220dd002b72e7bb6427808e79ec61d08b3316095a9553c7f0dbf
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,69 @@ 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.1] — 2026-10-20
|
|
13
|
+
|
|
14
|
+
Upgrade to correct CSP-audit parsing, HTML text extraction and importer
|
|
15
|
+
escaping. This patch also removes the regex patterns flagged by CodeQL.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- HTML5 parsing uses `nokolexbor`, one runtime dependency with no
|
|
20
|
+
transitive runtime gems. The clean-container CI matrix validates its
|
|
21
|
+
precompiled packages on Ruby 3.3, 3.4 and 4.0.
|
|
22
|
+
- Workshop rendering tests keep an upcoming fixture in memory instead
|
|
23
|
+
of expiring when the example workshop dates pass.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **CSP audits could miss browser-valid inline scripts and handlers.**
|
|
28
|
+
Regex parsing skipped whitespace in closing tags and unquoted
|
|
29
|
+
attributes, and could change script contents while removing comments.
|
|
30
|
+
HTML5 parsing now preserves browser hash input, decodes attributes
|
|
31
|
+
once and ignores inert template contents. Audit tests use the same
|
|
32
|
+
tag rules, including uppercase and comment edge cases.
|
|
33
|
+
- **Excerpts and reading statistics now read HTML as HTML.** Quoted
|
|
34
|
+
delimiters, entities, comments and non-visible script/style/template
|
|
35
|
+
content are handled by a parser rather than a stripping regex.
|
|
36
|
+
Extracted text still requires normal template escaping.
|
|
37
|
+
- **Imported titles retain quotes, backslashes and Unicode YAML line
|
|
38
|
+
breaks without changing the frontmatter structure.** Escaping is a
|
|
39
|
+
single literal-replacement pass, with round-trip regression coverage.
|
|
40
|
+
- **HTML rewrites and CSS minification avoid overlapping whitespace
|
|
41
|
+
matches.** Image and bundle URL rewrites retain their output while
|
|
42
|
+
regex matching no longer needs to reconsider delimiter whitespace.
|
|
43
|
+
|
|
44
|
+
## [1.3.0] — 2026-10-11
|
|
45
|
+
|
|
46
|
+
If CI runs `bin/audit --strict`, run `bin/audit` locally before
|
|
47
|
+
upgrading: a new Security check can fail an audit that passed on
|
|
48
|
+
1.2.0. Every finding is inline code that is already blocked on any host
|
|
49
|
+
that enforces the site's CSP, so the fix is in the site, not the
|
|
50
|
+
policy.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`bin/audit` now fails on inline code the site's own CSP would
|
|
55
|
+
block.** The generated policy has `style-src 'self'` and pins inline
|
|
56
|
+
scripts by hash, so a template that adds `style="…"`, a `<style>`
|
|
57
|
+
block, an `onclick=`, or an unhashed `<script>` breaks on any host
|
|
58
|
+
that enforces the CSP. It hid because nothing local enforces it: the
|
|
59
|
+
dev server doesn't apply `_headers`, the page renders, and the
|
|
60
|
+
browser only logs a console error. One site had 52 inline
|
|
61
|
+
`style="--ratio: …"` attributes that would have collapsed every photo
|
|
62
|
+
pane the moment its CSP went live.
|
|
63
|
+
|
|
64
|
+
The new **Security** category (`Audit::Csp`) checks every page
|
|
65
|
+
against the CSP in the built `_headers`, so a `headers:` override or
|
|
66
|
+
`csp:` extension is what's checked. It reports one error per page per
|
|
67
|
+
kind of inline code, with an example and how to fix it. It follows
|
|
68
|
+
the spec: hashes, `'unsafe-inline'` (ignored once a hash or nonce is
|
|
69
|
+
present), `'unsafe-hashes'` for attributes, the `-elem`/`-attr`
|
|
70
|
+
directives and the `default-src` fallback. `bin/audit --strict` fails
|
|
71
|
+
on these, so a site that passed yesterday can fail today; every
|
|
72
|
+
finding is code that is already broken wherever the policy is
|
|
73
|
+
applied.
|
|
74
|
+
|
|
12
75
|
## [1.2.0] — 2026-10-11
|
|
13
76
|
|
|
14
77
|
Upgrade if you serve the generated CSP: Chrome has been blocking
|
|
@@ -1110,7 +1173,9 @@ run a real working author / publisher site.
|
|
|
1110
1173
|
commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
|
|
1111
1174
|
the `~> 2.4` floor bought.
|
|
1112
1175
|
|
|
1113
|
-
[Unreleased]: https://github.com/grantrayner/abqari/compare/v1.
|
|
1176
|
+
[Unreleased]: https://github.com/grantrayner/abqari/compare/v1.3.1...HEAD
|
|
1177
|
+
[1.3.1]: https://github.com/grantrayner/abqari/compare/v1.3.0...v1.3.1
|
|
1178
|
+
[1.3.0]: https://github.com/grantrayner/abqari/compare/v1.2.0...v1.3.0
|
|
1114
1179
|
[1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
|
|
1115
1180
|
[1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
|
|
1116
1181
|
[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.
|
|
14
|
+
> **Status:** v1.3.1 — 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
|
|
@@ -38,10 +38,12 @@ Reporting a vulnerability:
|
|
|
38
38
|
|
|
39
39
|
## Design principles
|
|
40
40
|
|
|
41
|
-
1. **Minimal dependencies.**
|
|
42
|
-
|
|
43
|
-
the
|
|
44
|
-
|
|
41
|
+
1. **Minimal dependencies.** Eight runtime gems cover Markdown,
|
|
42
|
+
auto-escaped templates, images, HTML5 parsing, syntax highlighting,
|
|
43
|
+
the dev server, and CSV/Base64 handling. `nokolexbor` reads HTML for
|
|
44
|
+
CSP audits and text extraction, with no transitive runtime dependencies
|
|
45
|
+
and precompiled packages for the supported desktop/Linux platforms.
|
|
46
|
+
Content importing needs one more (`reverse_markdown`),
|
|
45
47
|
deliberately left out of the runtime set so a normal install doesn't
|
|
46
48
|
pull in nokogiri for a feature it never uses.
|
|
47
49
|
2. **Ruby-first.** ERB templates, real Ruby in views.
|
|
@@ -174,7 +176,7 @@ Test that it worked:
|
|
|
174
176
|
abqari version
|
|
175
177
|
```
|
|
176
178
|
|
|
177
|
-
You should see a version number, e.g. `abqari 1.
|
|
179
|
+
You should see a version number, e.g. `abqari 1.3.1`. If you get
|
|
178
180
|
`command not found`, see [Troubleshooting](#troubleshooting) below.
|
|
179
181
|
|
|
180
182
|
### Step 3 — Create your first site
|
|
@@ -460,7 +462,7 @@ Edit `Gemfile`:
|
|
|
460
462
|
```ruby
|
|
461
463
|
source 'https://rubygems.org'
|
|
462
464
|
|
|
463
|
-
gem 'abqari', '~> 1.
|
|
465
|
+
gem 'abqari', '~> 1.3'
|
|
464
466
|
|
|
465
467
|
group :development do
|
|
466
468
|
gem 'listen', '~> 3.8' # optional, for the dev-server file watcher
|
|
@@ -470,7 +472,7 @@ end
|
|
|
470
472
|
Or — pin to a git ref while tracking pre-release work:
|
|
471
473
|
|
|
472
474
|
```ruby
|
|
473
|
-
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.
|
|
475
|
+
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.1'
|
|
474
476
|
```
|
|
475
477
|
|
|
476
478
|
Install the libvips C library once (hard dep for the image pipeline):
|
|
@@ -947,6 +949,11 @@ and on `Abqari::RenderContext`, available everywhere ERB runs.
|
|
|
947
949
|
| `reading_time(text, wpm:)` | `reading_time(post.body)` → `"4 min read"` |
|
|
948
950
|
| `slugify(text)` | `slugify('Hello World!')` → `"hello-world"` |
|
|
949
951
|
|
|
952
|
+
`excerpt`, `word_count` and `reading_time` parse HTML and decode entities;
|
|
953
|
+
comments and script/style/template contents do not count as visible text.
|
|
954
|
+
An excerpt is plain text, not sanitized HTML. Render it with `<%= excerpt(...) %>`
|
|
955
|
+
so template auto-escaping remains in force.
|
|
956
|
+
|
|
950
957
|
**Time**
|
|
951
958
|
|
|
952
959
|
| Helper | Example |
|
|
@@ -2331,6 +2338,7 @@ Rebuilds the site, walks the output, and writes a markdown report to `audit/<YYY
|
|
|
2331
2338
|
| **Privacy** | External resource loads (URLs not on the site host) |
|
|
2332
2339
|
| **Dependencies** | Outdated gems via `bundle outdated`, CVEs via `bundle-audit` (if installed) |
|
|
2333
2340
|
| **Content** | Posts without descriptions, single-use tags, `series_position` problems that break prev/next silently (non-Integer, duplicate, or missing) |
|
|
2341
|
+
| **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
2342
|
|
|
2335
2343
|
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
2344
|
|
|
@@ -2365,7 +2373,7 @@ GitHub Dependabot does three things abqari's audit does **not**:
|
|
|
2365
2373
|
|
|
2366
2374
|
What `bin/audit` does that Dependabot doesn't:
|
|
2367
2375
|
|
|
2368
|
-
- **Same report covers links, SEO, accessibility, privacy, and
|
|
2376
|
+
- **Same report covers links, SEO, accessibility, privacy, content, and the CSP.** Dependabot only watches dependencies.
|
|
2369
2377
|
- **Uses the same `ruby-advisory-db`** Dependabot's Ruby ecosystem coverage uses, just at a single point in time.
|
|
2370
2378
|
- **Outputs to git-trackable markdown.** A diffable `audit/` history of every check you've run.
|
|
2371
2379
|
|
|
@@ -2558,14 +2566,14 @@ bin/audit # verify nothing regressed
|
|
|
2558
2566
|
```
|
|
2559
2567
|
|
|
2560
2568
|
`abqari new` scaffolds the Gemfile pinned to the current major.minor
|
|
2561
|
-
(`gem 'abqari', '~> 1.
|
|
2562
|
-
`>= 1.
|
|
2569
|
+
(`gem 'abqari', '~> 1.3'` as of this release). Bundler reads that as
|
|
2570
|
+
`>= 1.3, < 2.0`, so `bundle update abqari` takes minor and patch
|
|
2563
2571
|
releases but never a major one, which is the release that can need
|
|
2564
2572
|
changes to your site. To track a fork or an unreleased branch
|
|
2565
2573
|
instead, swap the line for a git source:
|
|
2566
2574
|
|
|
2567
2575
|
```ruby
|
|
2568
|
-
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.
|
|
2576
|
+
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.1'
|
|
2569
2577
|
```
|
|
2570
2578
|
|
|
2571
2579
|
The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require_relative '../html'
|
|
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
|
+
code = []
|
|
118
|
+
Html.document(html).css('*').each do |node|
|
|
119
|
+
next if node.ancestors.any? { |parent| parent.name == 'template' }
|
|
120
|
+
|
|
121
|
+
if node.name == 'script' && !node.key?('src')
|
|
122
|
+
type = node['type']&.strip&.downcase
|
|
123
|
+
if type.nil? || type.empty? || EXECUTABLE_TYPES.include?(type)
|
|
124
|
+
code << [:script, node.content, type]
|
|
125
|
+
end
|
|
126
|
+
elsif node.name == 'style'
|
|
127
|
+
code << [:style, node.content]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The parser already decodes attributes once, as the browser
|
|
131
|
+
# does. Decoding again changes the bytes used for CSP hashes.
|
|
132
|
+
node.attributes.each do |name, attribute|
|
|
133
|
+
next unless name == 'style' || name.match?(/\Aon[a-z]+\z/)
|
|
134
|
+
|
|
135
|
+
code << [name == 'style' ? :style_attr : :script_attr, attribute.value]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
code
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Mirrors the CSP spec's inline checks: a hash or nonce in the list
|
|
142
|
+
# switches `'unsafe-inline'` off; attribute hashes count only with
|
|
143
|
+
# `'unsafe-hashes'`. `'inline-speculation-rules'` is honoured only
|
|
144
|
+
# when the list has no hash or nonce: Chrome reports a block allowed
|
|
145
|
+
# by nothing but the keyword as a violation once hashes are present.
|
|
146
|
+
def allowed?(policy, kind, content, type = nil)
|
|
147
|
+
directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
|
|
148
|
+
return true unless directive
|
|
149
|
+
|
|
150
|
+
sources = policy[directive]
|
|
151
|
+
hashed = sources.any? { |s| s.match?(/\A'(sha256|sha384|sha512|nonce)-/i) }
|
|
152
|
+
hashed ||= kind == :script && sources.include?("'strict-dynamic'")
|
|
153
|
+
return true if sources.include?("'unsafe-inline'") && !hashed
|
|
154
|
+
return true if type == 'speculationrules' && sources.include?("'inline-speculation-rules'") && !hashed
|
|
155
|
+
return false if %i[style_attr script_attr].include?(kind) && !sources.include?("'unsafe-hashes'")
|
|
156
|
+
|
|
157
|
+
HASH_ALGORITHMS.any? do |alg, digest|
|
|
158
|
+
sources.include?("'#{alg}-#{Base64.strict_encode64(digest.digest(content))}'")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def example(kind, content)
|
|
163
|
+
snippet = content.strip.gsub(/\s+/, ' ')
|
|
164
|
+
snippet = "#{snippet[0, 60]}…" if snippet.length > 60
|
|
165
|
+
case kind
|
|
166
|
+
when :style_attr then %(style="#{snippet}")
|
|
167
|
+
when :script_attr then %(handler "#{snippet}")
|
|
168
|
+
else %(`#{snippet}`)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
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
|
|
data/lib/abqari/cli/headers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/abqari/headers_file.rb
CHANGED
|
@@ -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
|
data/lib/abqari/helpers.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'date'
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'time'
|
|
7
7
|
require 'uri'
|
|
8
|
+
require_relative 'html'
|
|
8
9
|
|
|
9
10
|
module Abqari
|
|
10
11
|
module Helpers
|
|
@@ -161,7 +162,7 @@ end
|
|
|
161
162
|
# `data-heading-content`, so each heading silently added several
|
|
162
163
|
# phantom "words" to the structured data search engines read.
|
|
163
164
|
def word_count(text)
|
|
164
|
-
text
|
|
165
|
+
Html.text(text, separator: ' ').scan(/\S+/).length
|
|
165
166
|
end
|
|
166
167
|
|
|
167
168
|
# Format a date or time. Default format is "1 January 2026"
|
|
@@ -224,7 +225,7 @@ end
|
|
|
224
225
|
end
|
|
225
226
|
|
|
226
227
|
def excerpt(text, words: 50, omission: '…')
|
|
227
|
-
stripped = text
|
|
228
|
+
stripped = Html.text(text).strip
|
|
228
229
|
arr = stripped.split(/\s+/)
|
|
229
230
|
return stripped if arr.length <= words
|
|
230
231
|
|
|
@@ -232,7 +233,7 @@ end
|
|
|
232
233
|
end
|
|
233
234
|
|
|
234
235
|
def reading_time(text, wpm: 200)
|
|
235
|
-
stripped = text
|
|
236
|
+
stripped = Html.text(text)
|
|
236
237
|
word_count = stripped.split(/\s+/).length
|
|
237
238
|
# Clamp wpm to ≥1 (a `wpm: 0` would divide to Infinity → FloatDomainError)
|
|
238
239
|
# and the result to ≥1 minute (empty text shouldn't read "0 min read").
|
data/lib/abqari/html.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokolexbor'
|
|
4
|
+
|
|
5
|
+
module Abqari
|
|
6
|
+
# HTML must be read with the same tag, comment and attribute rules as
|
|
7
|
+
# the browser. Regex stripping can change script hash input or hide an
|
|
8
|
+
# executable tag. Lexbor also keeps this work out of Ruby's regex engine.
|
|
9
|
+
module Html
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def document(html)
|
|
13
|
+
Nokolexbor::HTML(html.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fragment(html)
|
|
17
|
+
Nokolexbor::DocumentFragment.parse(html.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Plain text, never trusted HTML. Callers must still escape it when
|
|
21
|
+
# rendering (the normal <%= interpolation does this automatically).
|
|
22
|
+
# Non-visible code and comments must not inflate reading estimates.
|
|
23
|
+
def text(html, separator: '')
|
|
24
|
+
tree = fragment(html)
|
|
25
|
+
tree.css('script, style, template').each(&:remove)
|
|
26
|
+
tree.xpath('.//text()').map(&:content).join(separator)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -173,7 +173,9 @@ module Abqari
|
|
|
173
173
|
def post_process_html(html, page: nil)
|
|
174
174
|
return html unless enabled?
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
# Whitespace belongs to the delimiter, not to the attributes.
|
|
177
|
+
# Possessive quantifiers prevent overlapping backtracking paths.
|
|
178
|
+
html.gsub(/<img\s++([^>]*+)>/m) do |match|
|
|
177
179
|
attrs = ::Regexp.last_match(1)
|
|
178
180
|
src = attrs[/src="([^"]+)"/, 1]
|
|
179
181
|
next match unless src
|
|
@@ -285,7 +285,7 @@ module Abqari
|
|
|
285
285
|
# been written straight through, turning one mapping entry into
|
|
286
286
|
# two. Importer input is a third-party export, so this is
|
|
287
287
|
# attacker-shaped. Escape them into their YAML forms instead.
|
|
288
|
-
if str.match?(/[\
|
|
288
|
+
if str.match?(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/)
|
|
289
289
|
return %("#{escape_yaml_double_quoted(str)}")
|
|
290
290
|
end
|
|
291
291
|
|
|
@@ -301,13 +301,18 @@ module Abqari
|
|
|
301
301
|
# quotes, then the control characters that have named escapes,
|
|
302
302
|
# then anything else remaining as \uXXXX.
|
|
303
303
|
def escape_yaml_double_quoted(str)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
304
|
+
# A block returns literal replacement bytes: String#gsub's
|
|
305
|
+
# replacement-string backslash rules cannot reinterpret them.
|
|
306
|
+
str.gsub(/[\\"\u0000-\u001F\u007F-\u009F\u2028\u2029]/) do |char|
|
|
307
|
+
case char
|
|
308
|
+
when '\\' then '\\\\'
|
|
309
|
+
when '"' then '\\"'
|
|
310
|
+
when "\n" then '\\n'
|
|
311
|
+
when "\r" then '\\r'
|
|
312
|
+
when "\t" then '\\t'
|
|
313
|
+
else format('\\u%04X', char.ord)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
311
316
|
end
|
|
312
317
|
|
|
313
318
|
def block_array(key, items)
|
data/lib/abqari/minifier.rb
CHANGED
data/lib/abqari/renderer.rb
CHANGED
|
@@ -810,7 +810,7 @@ module Abqari
|
|
|
810
810
|
def rewrite_bundle_urls(html, page)
|
|
811
811
|
base = page.url
|
|
812
812
|
|
|
813
|
-
html.gsub(/(<(?:img|a)\s
|
|
813
|
+
html.gsub(/(<(?:img|a)\s++[^>]*?(?:src|href)=")([^"]+)/) do
|
|
814
814
|
prefix = ::Regexp.last_match(1)
|
|
815
815
|
url = ::Regexp.last_match(2)
|
|
816
816
|
|
data/lib/abqari/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Grant Rayner
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '1.13'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: nokolexbor
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 0.8.0
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 0.8.0
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: rouge
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -220,6 +234,7 @@ files:
|
|
|
220
234
|
- lib/abqari/audit.rb
|
|
221
235
|
- lib/abqari/audit/a11y.rb
|
|
222
236
|
- lib/abqari/audit/content.rb
|
|
237
|
+
- lib/abqari/audit/csp.rb
|
|
223
238
|
- lib/abqari/audit/dependencies.rb
|
|
224
239
|
- lib/abqari/audit/links.rb
|
|
225
240
|
- lib/abqari/audit/performance.rb
|
|
@@ -238,6 +253,7 @@ files:
|
|
|
238
253
|
- lib/abqari/headers_file.rb
|
|
239
254
|
- lib/abqari/helpers.rb
|
|
240
255
|
- lib/abqari/hooks.rb
|
|
256
|
+
- lib/abqari/html.rb
|
|
241
257
|
- lib/abqari/http_safe.rb
|
|
242
258
|
- lib/abqari/image_pipeline.rb
|
|
243
259
|
- lib/abqari/importers.rb
|