diff --git a/.editorconfig b/.editorconfig index ed87432..f00f5f3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,5 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[*.yml] -indent_style = space +[*.{yml,yaml}] indent_size = 2 diff --git a/.github/actions/safe-chain/action.yml b/.github/actions/safe-chain/action.yml index 4e7fe30..a9d8dd2 100644 --- a/.github/actions/safe-chain/action.yml +++ b/.github/actions/safe-chain/action.yml @@ -1,18 +1,26 @@ name: Install and verify safe-chain -description: Installs safe-chain and verifies it is working correctly +description: Installs safe-chain (checksum-verified) and verifies it is working correctly runs: using: composite steps: - name: Install safe-chain shell: bash - run: curl --fail --silent --show-error --retry 3 --location "https://github.com/AikidoSec/safe-chain/releases/download/${SAFECHAIN_VERSION}/install-safe-chain.sh" | sh -s -- --ci + run: | + SCRIPT="$(mktemp -d)/install-safe-chain.sh" + curl --fail --silent --show-error --retry 3 --location --output "$SCRIPT" \ + "https://github.com/AikidoSec/safe-chain/releases/download/${SAFECHAIN_VERSION}/install-safe-chain.sh" + echo "${SAFECHAIN_SHA256} ${SCRIPT}" | sha256sum --check --quiet + sh "$SCRIPT" --ci + rm -f "$SCRIPT" env: - SAFECHAIN_VERSION: 1.5.11 + SAFECHAIN_VERSION: 1.5.12 + SAFECHAIN_SHA256: e40995d546160bf18788dbfc6e9a4a765152abeca92014adf85102cf6b13d890 + # https://github.com/AikidoSec/safe-chain/releases - name: Test safe-chain shell: bash # this must fail if safe-chain is working - run: | + run: | # zizmor: ignore[adhoc-packages] npm safe-chain-verify - ! npm install safe-chain-test + ! npm install --no-save safe-chain-test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 497978a..127e649 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI on: push: + branches: + - main + pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -23,7 +26,31 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + codeload.github.com:443 + ghcr.io:443 + pkg-containers.githubusercontent.com:443 + objects.githubusercontent.com:443 + raw.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + *.actions.githubusercontent.com:443 + *.blob.core.windows.net:443 + nodejs.org:443 + registry.npmjs.org:443 + malware-list.aikido.dev:443 + api.snyk.io:443 + downloads.snyk.io:443 + api.osv.dev:443 + services.nvd.nist.gov:443 + cli.codecov.io:443 + api.codecov.io:443 + ingest.codecov.io:443 + storage.googleapis.com:443 + keybase.io:443 - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -31,6 +58,11 @@ jobs: with: persist-credentials: false + - name: Dependency review + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + # https://github.com/actions/dependency-review-action/releases + - name: Run zizmor uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 # https://github.com/zizmorcore/zizmor-action/releases @@ -39,6 +71,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set up editorconfig-checker + uses: editorconfig-checker/action-editorconfig-checker@840e866d93b8e032123c23bac69dece044d4d84c # v2.2.0 + # https://github.com/editorconfig-checker/action-editorconfig-checker/releases + + - name: Run editorconfig-checker + run: editorconfig-checker + - name: Run actionlint uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 # v1.7.12 # https://hub.docker.com/r/rhysd/actionlint/tags @@ -65,19 +104,13 @@ jobs: run: npm ci --ignore-scripts - name: Run Snyk to check for vulnerabilities + # fork PRs have no access to secrets, so skip the steps that need them + if: github.event_name == 'push' || !github.event.pull_request.head.repo.fork uses: snyk/actions/node@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 # https://github.com/snyk/actions/releases env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - - name: Check line endings - uses: fernandrone/linelint@7907a5dca0c28ea7dd05c6d8d8cacded713aca11 # 0.0.6 - # https://github.com/fernandrone/linelint/releases - - - name: Check EOL - uses: AODocs/check-eol@88fd9052e8ea211254a4de371011026603a329dc # v1.1 - # https://github.com/AODocs/check-eol/releases - - name: Lint run: npm run eslint @@ -91,6 +124,7 @@ jobs: run: ./node_modules/.bin/c8 report --reporter=lcov - name: Upload coverage to Codecov + if: github.event_name == 'push' || !github.event.pull_request.head.repo.fork uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 # https://github.com/codecov/codecov-action/releases with: @@ -100,6 +134,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Upload Test results to Codecov + if: github.event_name == 'push' || !github.event.pull_request.head.repo.fork uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 # https://github.com/codecov/codecov-action/releases with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33d1c6a..41feb6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,18 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + raw.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + malware-list.aikido.dev:443 + nodejs.org:443 + registry.npmjs.org:443 - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -33,9 +44,6 @@ jobs: with: persist-credentials: false - - name: Install and verify safe-chain - uses: ./.github/actions/safe-chain - - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # https://github.com/actions/setup-node/releases @@ -43,8 +51,13 @@ jobs: node-version: "lts/*" cache: npm + # safe-chain must be installed after setup-node so its shims wrap + # the npm that the following steps actually use + - name: Install and verify safe-chain + uses: ./.github/actions/safe-chain + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Lint run: npm run eslint @@ -68,7 +81,22 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + raw.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + uploads.github.com:443 + malware-list.aikido.dev:443 + nodejs.org:443 + registry.npmjs.org:443 + fulcio.sigstore.dev:443 + rekor.sigstore.dev:443 + tuf-repo-cdn.sigstore.dev:443 - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -86,8 +114,13 @@ jobs: cache: npm registry-url: "https://registry.npmjs.org" + # safe-chain must be installed after setup-node so its shims wrap + # the npm that the following steps actually use + - name: Install and verify safe-chain + uses: ./.github/actions/safe-chain + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Configure git run: | @@ -101,12 +134,12 @@ jobs: VERSION=$(node -p "require('./package.json').version") echo "version=v${VERSION}" >> "$GITHUB_OUTPUT" - - name: Push version bump - run: git push --follow-tags - - name: Publish to npm run: npm publish --provenance --access public + - name: Push version bump + run: git push --follow-tags + - name: Create GitHub release # zizmor: ignore[template-injection] VERSION is passed as env var, not interpolated into shell run: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 9035735..9334d03 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -32,7 +32,13 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + codeload.github.com:443 + cli.codecov.io:443 - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.linelint.yml b/.linelint.yml deleted file mode 100644 index 9b83ab4..0000000 --- a/.linelint.yml +++ /dev/null @@ -1,10 +0,0 @@ -# list of paths to ignore, uses gitignore syntaxes (executes before any rule) -ignore: - - node_modules/ -rules: - # checks if file ends in a newline character - end-of-file: - # set to true to enable this rule - enable: true - # if true also checks if file ends in a single newline character - single-new-line: true diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 9691d1f..0000000 --- a/.npmignore +++ /dev/null @@ -1,10 +0,0 @@ -.editorconfig -.gitattributes -.gitignore -.github/ -.linelint.yml -.typo-ci.yml -eslint.config.mjs -renovate.json -package-lock.json -test/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b05d407 --- /dev/null +++ b/.npmrc @@ -0,0 +1,11 @@ +# npm security best practices +# Source: https://github.com/lirantal/npm-security-best-practices + +# SECURITY: do not run any lifecycle scripts (postinstall) etc +ignore-scripts=true + +# SECURITY: reject git-source dependencies (git+ssh:// etc) +allow-git=none + +# SECURITY: block packages newer than X days +min-release-age=3 diff --git a/README.md b/README.md index ebe142e..0d8b58b 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,23 @@ Node command line interface to check if an image file has EXIF data. It can be u `npm install -g has-exif-cli` +Requires Node.js 22.12.0 or newer. + ## Execute `has-exif-cli /path/to/FILE.JPG` +### Options + +- `--xmp` also fail if XMP metadata is found +- `--iptc` also fail if IPTC metadata is found + +### Exit codes + +- `0` no metadata found +- `1` EXIF (or, with the flags above, XMP/IPTC) metadata found +- `2` a file could not be checked (unreadable, missing or unsupported format) + ## How to use in continuous integration automation like Scrutinizer, travis etc To make sure your checked in jpg, webp and other image files dont have EXIF data included in them: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..80656ff --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +Only the latest published version is supported with security updates. + +## Reporting a Vulnerability + +Please do not report security vulnerabilities through public GitHub issues. + +Instead, use one of these channels: + +- **GitHub private vulnerability reporting** (preferred): + [Report a vulnerability](https://github.com/gemal/node-has-exif-cli/security/advisories/new) +- **Email**: henrik@gemal.dk + +Please include a description of the issue, steps to reproduce, and the +affected version. You can expect an initial response within a week. diff --git a/eslint.config.mjs b/eslint.config.js similarity index 91% rename from eslint.config.mjs rename to eslint.config.js index 1328784..1ff16ad 100644 --- a/eslint.config.mjs +++ b/eslint.config.js @@ -1,7 +1,9 @@ -import js from '@eslint/js' -import globals from 'globals' +'use strict'; -export default [ +const js = require('@eslint/js'); +const globals = require('globals'); + +module.exports = [ js.configs.recommended, { languageOptions: { @@ -36,4 +38,4 @@ export default [ 'indent': ['error', 4], }, }, -] +]; diff --git a/index.js b/index.js index 46d44de..cd009ca 100644 --- a/index.js +++ b/index.js @@ -10,30 +10,71 @@ const { program } = require('commander'); program .version(require('./package.json').version) .argument('', 'image files to check') + .option('--xmp', 'also fail if XMP metadata is found') + .option('--iptc', 'also fail if IPTC metadata is found') .parse(process.argv); const files = program.args; +const options = program.opts(); + +// strip control characters (ANSI escapes, carriage returns) so crafted +// file names or error messages cannot spoof lines in (CI) logs +function sanitize(value) { + let out = ''; + for (const char of String(value)) { + const code = char.codePointAt(0); + out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? '?' : char; + } + return out; +} + +// exifreader includes the raw XMP document under _raw; only real tags count +function hasTags(group) { + if (!group) { + return false; + } + return Object.keys(group).some((key) => key !== '_raw'); +} console.log('Number of files to check: ' + files.length); -let hasexif = false; -let haserror = false; +async function main() { + let hasmetadata = false; + let haserror = false; -Promise.all(files.map(function(filePath) { - console.log('Checking: ' + filePath); - return ExifReader.load(filePath, { expanded: true }).then(function(tags) { - if (tags.exif && Object.keys(tags.exif).length !== 0) { - console.log('ERROR: Exif data found for: ' + filePath); - hasexif = true; - } - }).catch(function(error) { - if (error instanceof exifErrors.MetadataMissingError === false) { - console.log('ERROR: ' + error + ' for:' + filePath); - haserror = true; + for (const filePath of files) { + console.log('Checking: ' + sanitize(filePath)); + try { + const tags = await ExifReader.load(filePath, { expanded: true }); + const found = []; + if (hasTags(tags.exif)) { + found.push('Exif'); + } + if (options.xmp && hasTags(tags.xmp)) { + found.push('XMP'); + } + if (options.iptc && hasTags(tags.iptc)) { + found.push('IPTC'); + } + if (found.length !== 0) { + console.log('ERROR: ' + found.join(', ') + ' data found for: ' + sanitize(filePath)); + hasmetadata = true; + } + } catch (error) { + if (error instanceof exifErrors.MetadataMissingError === false) { + console.log('ERROR: ' + sanitize(error) + ' for: ' + sanitize(filePath)); + haserror = true; + } } - }); -})).then(function() { - if (hasexif || haserror) { + } + + // exit 1: metadata found, exit 2: file could not be checked + if (hasmetadata) { process.exit(1); } -}); + if (haserror) { + process.exit(2); + } +} + +main(); diff --git a/package-lock.json b/package-lock.json index b359c94..018ef3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "check-package-lock": "^1.5.0", "eslint": "^10.5.0", "mocha": "^11.7.6" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@bcoe/v8-coverage": { @@ -786,9 +789,9 @@ "license": "MIT" }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1819,16 +1822,6 @@ "node": ">=6" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1863,27 +1856,6 @@ "node": ">=4" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1898,13 +1870,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", + "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shebang-command": { diff --git a/package.json b/package.json index 3a672da..3955be8 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,15 @@ "version": "1.10.6", "description": "Checks if an image file has EXIF data. Available as CLI. Can be used in CI automation. Supports both JPG and WEBP image format", "main": "index.js", + "files": [ + "index.js" + ], + "engines": { + "node": ">=22.12.0" + }, "repository": { "type": "git", - "url": "git://github.com/gemal/node-has-exif-cli.git" + "url": "git+https://github.com/gemal/node-has-exif-cli.git" }, "scripts": { "test": "mocha", @@ -32,6 +38,10 @@ "commander": "^15.0.0", "exifreader": "^4.6.0" }, + "overrides": { + "diff": "^8.0.3", + "serialize-javascript": "^7.0.7" + }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^10.0.1", diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 93afd4a..0000000 --- a/renovate.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ] -} diff --git a/test/index.js b/test/index.js index 488a575..0b82260 100644 --- a/test/index.js +++ b/test/index.js @@ -49,13 +49,13 @@ describe('index.js', function() { it('single svg which cant be read', async function() { const { code, out } = await run(['test/wrong.svg']); - expect(code).to.equal(1); + expect(code).to.equal(2); expect(out).to.match(/Invalid image format/); }); it('single non existing file', async function() { const { code, out } = await run(['test/404.jpg']); - expect(code).to.equal(1); + expect(code).to.equal(2); expect(out).to.match(/ENOENT: no such file or directory/); }); @@ -77,4 +77,35 @@ describe('index.js', function() { expect(code).to.equal(0); expect(out).to.match(/Number of files to check: 1/); }); + + it('xmp only found when --xmp is given', async function() { + const { code, out } = await run(['--xmp', 'test/exifempty.jpg']); + expect(code).to.equal(1); + expect(out).to.match(/ERROR: XMP data found for: test\/exifempty\.jpg/); + }); + + it('iptc flag passes file without iptc', async function() { + const { code, out } = await run(['--iptc', 'test/none.jpg']); + expect(code).to.equal(0); + expect(out).to.match(/Number of files to check: 1/); + }); + + it('iptc only found when --iptc is given', async function() { + const { code, out } = await run(['--iptc', 'test/iptc.jpg']); + expect(code).to.equal(1); + expect(out).to.match(/ERROR: IPTC data found for: test\/iptc\.jpg/); + }); + + it('iptc ignored without the flag', async function() { + const { code, out } = await run(['test/iptc.jpg']); + expect(code).to.equal(0); + expect(out).to.match(/Number of files to check: 1/); + }); + + it('control characters in file names are sanitized in output', async function() { + const { code, out } = await run(['test/\u001b[2Kevil\r\u007f.jpg']); + expect(code).to.equal(2); + expect(out).to.not.include('\u001b'); + expect(out).to.match(/Checking: test\/\?\[2Kevil\?\?\.jpg/); + }); }); diff --git a/test/iptc.jpg b/test/iptc.jpg new file mode 100644 index 0000000..689f7d9 Binary files /dev/null and b/test/iptc.jpg differ