forked from github/docs
-
Notifications
You must be signed in to change notification settings - Fork 0
/
validate-asset-images.js
executable file
·156 lines (137 loc) · 4.67 KB
/
validate-asset-images.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env node
// [start-readme]
//
// Makes sure that all the image assets in `assets/` are safe.
//
// Generally writers don't check in bogus/corrupt images but mistakes
// can happen and it's ideally spotted in other processes such as
// reviewing PR preview environment.
// This script also makes sure that all images really are what they're
// called. For example, an image might be named `screenshot.png` but
// it might actually be something mischievous.
//
// [end-readme]
import fs from 'fs/promises'
import path from 'path'
import { program } from 'commander'
import chalk from 'chalk'
import cheerio from 'cheerio'
import { fileTypeFromFile } from 'file-type'
import walk from 'walk-sync'
import isSVG from 'is-svg'
const ASSETS_ROOT = path.resolve('assets')
const ROOT = path.dirname(ASSETS_ROOT)
// We put images that are used by the React components in with the assets
// directory. These aren't really content-contibuted.
const EXCLUDE_DIR = path.join(ASSETS_ROOT, 'images', 'site')
const IGNORE_EXTENSIONS = new Set([
// Currently has no known test for these
'.csv',
])
const EXPECT = {
'.png': 'image/png',
'.gif': 'image/gif',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.webp': 'image/webp',
}
const CRITICAL = 'critical'
const WARNING = 'warning'
program
.description('Make sure all asset images are valid')
.option('--dry-run', "Don't actually write changes to disk")
.option('-v, --verbose', 'Verbose outputs')
.parse(process.argv)
main(program.opts())
async function main(opts) {
let errors = 0
const files = walk(ASSETS_ROOT, { includeBasePath: true, directories: false }).filter(
(filePath) => {
const basename = path.basename(filePath)
const extension = path.extname(filePath)
return (
!IGNORE_EXTENSIONS.has(extension) &&
basename !== '.DS_Store' &&
basename !== 'README.md' &&
!filePath.startsWith(EXCLUDE_DIR)
)
}
)
const results = (await Promise.all(files.map(checkFile))).filter(Boolean)
for (const [level, filePath, error] of results) {
console.log(
level === CRITICAL ? chalk.red(level) : chalk.yellow(level),
chalk.bold(path.relative(ROOT, filePath)),
error
)
if (level === CRITICAL) {
errors++
}
}
if (opts.verbose) {
console.log(`Checked ${files.length.toLocaleString()} images`)
const countWarnings = results.filter(([level]) => level === WARNING).length
const countCritical = results.filter(([level]) => level === CRITICAL).length
const wrap = countCritical ? chalk.red : countWarnings ? chalk.yellow : chalk.green
console.log(wrap(`Found ${countCritical} critical errors and ${countWarnings} warnings`))
}
process.exitCode = errors
}
async function checkFile(filePath) {
const ext = path.extname(filePath)
const { size } = await fs.stat(filePath)
if (!size) {
return [CRITICAL, filePath, 'file is 0 bytes']
}
if (ext === '.svg') {
// Can't use `fileTypeFromFile` so have to check "manually"
const content = await fs.readFile(filePath, 'utf-8')
if (!content.trim()) {
return [CRITICAL, filePath, 'file is empty']
}
if (!isSVG(content)) {
return [CRITICAL, filePath, 'not a valid SVG file']
}
try {
checkSVGContent(content)
} catch (error) {
return [CRITICAL, filePath, error.message]
}
} else if (EXPECT[ext]) {
const fileType = await fileTypeFromFile(filePath)
if (!fileType) {
return [CRITICAL, filePath, "can't extract file type"]
}
const { mime } = fileType
const expect = EXPECT[ext]
if (expect !== mime) {
return [CRITICAL, filePath, `Expected mime type '${expect}' but was '${mime}'`]
}
} else if (!ext) {
return [WARNING, filePath, 'Has no file extension']
} else {
return [WARNING, filePath, `Don't know how to validate '${ext}'`]
}
// All is well. Nothing to complain about.
}
function checkSVGContent(content) {
const $ = cheerio.load(content)
const disallowedTagNames = new Set(['script', 'object', 'iframe', 'embed'])
$('*').each((i, element) => {
const { tagName } = element
if (disallowedTagNames.has(tagName)) {
throw new Error(`contains a <${tagName}> tag`)
}
for (const key in element.attribs) {
// Looks for suspicious event handlers on tags.
// For example `<path oNload="alert(1)"" d="M28 0l4.59 4.59-9.76`
// We don't need to do a case-sensitive regex here because cheerio
// will have normalized all the element attribute keys to lowercase.
if (/(\\x[a-f0-9]{2}|\b)on\w+/.test(key)) {
throw new Error(`<${tagName}> contains an unsafe attribute: '${key}'`)
}
}
})
}