From 9b186a43b52dd57c835b2cbbeb06590875c4ea8f Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 11:14:25 +0200 Subject: [PATCH 01/11] fable security update --- .github/actions/safe-chain/action.yml | 16 ++++-- .github/workflows/ci.yml | 49 +++++++++++++---- .github/workflows/release.yml | 45 +++++++++++++--- .linelint.yml | 10 ---- index.js | 75 +++++++++++++++++++++------ test/index.js | 23 +++++++- 6 files changed, 169 insertions(+), 49 deletions(-) delete mode 100644 .linelint.yml diff --git a/.github/actions/safe-chain/action.yml b/.github/actions/safe-chain/action.yml index 4e7fe30..beedff6 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 497978a..b1fea37 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,32 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + # if a step starts failing with a blocked connection, the harden-runner + # step summary / annotations show the endpoint to add here + 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 @@ -39,6 +67,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 +100,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 +120,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 +130,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..293a6a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,17 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + 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 +43,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 +50,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 +80,21 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: - egress-policy: audit + 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 +112,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: | 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/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/test/index.js b/test/index.js index 488a575..de4baa1 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,23 @@ 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('control characters in file names are sanitized in output', async function() { + const { code, out } = await run(['test/evil .jpg']); + expect(code).to.equal(2); + expect(out).to.not.include(''); + expect(out).to.match(/Checking: test/?[2Kevil?.jpg/); + }); }); From f33866a8c9851fb4f67be3b37c40717add7451b0 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 11:17:03 +0200 Subject: [PATCH 02/11] fixes --- package.json | 12 +++++++++++- test/index.js | 6 +++--- 2 files changed, 14 insertions(+), 4 deletions(-) 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/test/index.js b/test/index.js index de4baa1..7af8a73 100644 --- a/test/index.js +++ b/test/index.js @@ -91,9 +91,9 @@ describe('index.js', function() { }); it('control characters in file names are sanitized in output', async function() { - const { code, out } = await run(['test/evil .jpg']); + const { code, out } = await run(['test/\u001b[2Kevil\r.jpg']); expect(code).to.equal(2); - expect(out).to.not.include(''); - expect(out).to.match(/Checking: test/?[2Kevil?.jpg/); + expect(out).to.not.include('\u001b'); + expect(out).to.match(/Checking: test\/\?\[2Kevil\?\.jpg/); }); }); From 4e14fae1387baa3e9b5ebdd4c03e190d5f17c965 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 11:17:22 +0200 Subject: [PATCH 03/11] update --- .npmignore | 10 ---------- package-lock.json | 50 +++++++++++------------------------------------ renovate.json | 6 ------ 3 files changed, 11 insertions(+), 55 deletions(-) delete mode 100644 .npmignore delete mode 100644 renovate.json 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/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/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" - ] -} From ee7c13c41447b76eb49a33e91bff404ce79f2cf4 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 11:56:12 +0200 Subject: [PATCH 04/11] more fixes --- .github/actions/safe-chain/action.yml | 8 +++--- .github/workflows/ci.yml | 8 +++++- .github/workflows/release.yml | 10 ++++--- .github/workflows/scorecard.yml | 24 ++++++++++++++++- README.md | 13 +++++++++ SECURITY.md | 39 +++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 SECURITY.md diff --git a/.github/actions/safe-chain/action.yml b/.github/actions/safe-chain/action.yml index beedff6..db762da 100644 --- a/.github/actions/safe-chain/action.yml +++ b/.github/actions/safe-chain/action.yml @@ -7,12 +7,13 @@ runs: - name: Install safe-chain shell: bash run: | - SCRIPT="$(mktemp -d)/install-safe-chain.sh" + WORKDIR="$(mktemp -d)" + SCRIPT="$WORKDIR/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" + rm --recursive --force "$WORKDIR" env: SAFECHAIN_VERSION: 1.5.12 SAFECHAIN_SHA256: e40995d546160bf18788dbfc6e9a4a765152abeca92014adf85102cf6b13d890 @@ -23,4 +24,5 @@ runs: # this must fail if safe-chain is working run: | # zizmor: ignore[adhoc-packages] npm safe-chain-verify - ! npm install safe-chain-test + # --no-save so a (failing) safe-chain miss cannot mutate package.json + ! npm install --no-save safe-chain-test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1fea37..d68dfaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: + disable-sudo: true # if a step starts failing with a blocked connection, the harden-runner # step summary / annotations show the endpoint to add here egress-policy: block @@ -51,7 +52,6 @@ jobs: 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 @@ -59,6 +59,12 @@ jobs: with: persist-credentials: false + - name: Dependency review + # blocks PRs that introduce dependencies with known vulnerabilities + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 293a6a1..8704293 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: + disable-sudo: true egress-policy: block allowed-endpoints: > api.github.com:443 @@ -80,6 +81,7 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 # https://github.com/step-security/harden-runner/releases with: + disable-sudo: true egress-policy: block allowed-endpoints: > api.github.com:443 @@ -132,12 +134,14 @@ jobs: VERSION=$(node -p "require('./package.json').version") echo "version=v${VERSION}" >> "$GITHUB_OUTPUT" - - name: Push version bump - run: git push --follow-tags - + # publish before pushing: a failed publish then leaves nothing on + # main/npm, so the run can simply be retried - 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..26905d5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -32,7 +32,29 @@ 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 + # if a step starts failing with a blocked connection, the harden-runner + # step summary / annotations show the endpoint to add here + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + *.actions.githubusercontent.com:443 + *.blob.core.windows.net:443 + uploads.github.com:443 + api.osv.dev:443 + api.deps.dev:443 + api.scorecard.dev:443 + api.securityscorecards.dev:443 + www.bestpractices.dev:443 + bestpractices.coreinfrastructure.org:443 + fulcio.sigstore.dev:443 + rekor.sigstore.dev:443 + oauth2.sigstore.dev:443 + tuf-repo-cdn.sigstore.dev:443 - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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..965c271 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security Policy + +## Supported Versions + +Only the latest published version of `has-exif-cli` is supported. +Releases are automated and frequent, so please update to the newest +version before reporting an issue. + +| Version | Supported | +| ------- | --------- | +| latest | yes | +| older | no | + +## Reporting a Vulnerability + +Please do not open a public issue for security problems. + +Report vulnerabilities privately via GitHub's private vulnerability +reporting: + +Alternatively, email [henrik@gemal.dk](mailto:henrik@gemal.dk). + +Please include: + +- a description of the issue and its impact +- steps to reproduce (a proof-of-concept image file is welcome) +- the version of `has-exif-cli` and Node.js you used + +You will normally receive a response within 7 days. Once the issue is +confirmed, a fix is developed privately and published as a new release, +after which the advisory is disclosed. + +## Scope + +`has-exif-cli` is a command line tool that parses image files. The most +security-relevant area is the handling of untrusted or malformed image +input passed to the CLI. Vulnerabilities in dependencies should be +reported upstream, but reports about how they affect this tool are +still appreciated. From 5c9417e620eb53910344b81ddc647a288645a496 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 12:04:30 +0200 Subject: [PATCH 05/11] fixes --- .editorconfig | 3 +-- .npmrc | 11 +++++++++++ SECURITY.md | 37 ++++++++----------------------------- 3 files changed, 20 insertions(+), 31 deletions(-) create mode 100644 .npmrc 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/.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/SECURITY.md b/SECURITY.md index 965c271..80656ff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,38 +2,17 @@ ## Supported Versions -Only the latest published version of `has-exif-cli` is supported. -Releases are automated and frequent, so please update to the newest -version before reporting an issue. - -| Version | Supported | -| ------- | --------- | -| latest | yes | -| older | no | +Only the latest published version is supported with security updates. ## Reporting a Vulnerability -Please do not open a public issue for security problems. - -Report vulnerabilities privately via GitHub's private vulnerability -reporting: - -Alternatively, email [henrik@gemal.dk](mailto:henrik@gemal.dk). - -Please include: - -- a description of the issue and its impact -- steps to reproduce (a proof-of-concept image file is welcome) -- the version of `has-exif-cli` and Node.js you used +Please do not report security vulnerabilities through public GitHub issues. -You will normally receive a response within 7 days. Once the issue is -confirmed, a fix is developed privately and published as a new release, -after which the advisory is disclosed. +Instead, use one of these channels: -## Scope +- **GitHub private vulnerability reporting** (preferred): + [Report a vulnerability](https://github.com/gemal/node-has-exif-cli/security/advisories/new) +- **Email**: henrik@gemal.dk -`has-exif-cli` is a command line tool that parses image files. The most -security-relevant area is the handling of untrusted or malformed image -input passed to the CLI. Vulnerabilities in dependencies should be -reported upstream, but reports about how they affect this tool are -still appreciated. +Please include a description of the issue, steps to reproduce, and the +affected version. You can expect an initial response within a week. From affb3cb45c08644cdc237a0209ae7e77d7df634f Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 12:33:02 +0200 Subject: [PATCH 06/11] fixes --- .github/actions/safe-chain/action.yml | 8 +++----- .github/workflows/ci.yml | 3 --- .github/workflows/release.yml | 2 -- .github/workflows/scorecard.yml | 17 ----------------- eslint.config.mjs => eslint.config.js | 10 ++++++---- 5 files changed, 9 insertions(+), 31 deletions(-) rename eslint.config.mjs => eslint.config.js (91%) diff --git a/.github/actions/safe-chain/action.yml b/.github/actions/safe-chain/action.yml index db762da..ef03b67 100644 --- a/.github/actions/safe-chain/action.yml +++ b/.github/actions/safe-chain/action.yml @@ -7,13 +7,12 @@ runs: - name: Install safe-chain shell: bash run: | - WORKDIR="$(mktemp -d)" - SCRIPT="$WORKDIR/install-safe-chain.sh" + 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 --recursive --force "$WORKDIR" + rm -f "$SCRIPT" env: SAFECHAIN_VERSION: 1.5.12 SAFECHAIN_SHA256: e40995d546160bf18788dbfc6e9a4a765152abeca92014adf85102cf6b13d890 @@ -22,7 +21,6 @@ runs: - name: Test safe-chain shell: bash # this must fail if safe-chain is working - run: | # zizmor: ignore[adhoc-packages] + run: | npm safe-chain-verify - # --no-save so a (failing) safe-chain miss cannot mutate package.json ! npm install --no-save safe-chain-test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d68dfaf..7132b67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,6 @@ jobs: # https://github.com/step-security/harden-runner/releases with: disable-sudo: true - # if a step starts failing with a blocked connection, the harden-runner - # step summary / annotations show the endpoint to add here egress-policy: block allowed-endpoints: > api.github.com:443 @@ -60,7 +58,6 @@ jobs: persist-credentials: false - name: Dependency review - # blocks PRs that introduce dependencies with known vulnerabilities if: github.event_name == 'pull_request' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 # https://github.com/actions/dependency-review-action/releases diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8704293..41feb6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,8 +134,6 @@ jobs: VERSION=$(node -p "require('./package.json').version") echo "version=v${VERSION}" >> "$GITHUB_OUTPUT" - # publish before pushing: a failed publish then leaves nothing on - # main/npm, so the run can simply be retried - name: Publish to npm run: npm publish --provenance --access public diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 26905d5..bdc4735 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,28 +33,11 @@ jobs: # https://github.com/step-security/harden-runner/releases with: disable-sudo: true - # if a step starts failing with a blocked connection, the harden-runner - # step summary / annotations show the endpoint to add here egress-policy: block allowed-endpoints: > api.github.com:443 github.com:443 codeload.github.com:443 - objects.githubusercontent.com:443 - release-assets.githubusercontent.com:443 - *.actions.githubusercontent.com:443 - *.blob.core.windows.net:443 - uploads.github.com:443 - api.osv.dev:443 - api.deps.dev:443 - api.scorecard.dev:443 - api.securityscorecards.dev:443 - www.bestpractices.dev:443 - bestpractices.coreinfrastructure.org:443 - fulcio.sigstore.dev:443 - rekor.sigstore.dev:443 - oauth2.sigstore.dev:443 - tuf-repo-cdn.sigstore.dev:443 - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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], }, }, -] +]; From 3075f993b0b94eed39131ed0e2d7c290761c49c4 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 12:39:07 +0200 Subject: [PATCH 07/11] Update scorecard.yml --- .github/workflows/scorecard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index bdc4735..9334d03 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -38,6 +38,7 @@ jobs: 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 From ba688b853f77f7a1dd66eb5e0de40aefcddf4fcc Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 12:43:33 +0200 Subject: [PATCH 08/11] Update ci.yml --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7132b67..127e649 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: 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 From caf39841b68db75afbc05e53ccfe5565849724ce Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 12:46:06 +0200 Subject: [PATCH 09/11] x --- .github/actions/safe-chain/action.yml | 2 +- eslint.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/safe-chain/action.yml b/.github/actions/safe-chain/action.yml index ef03b67..a9d8dd2 100644 --- a/.github/actions/safe-chain/action.yml +++ b/.github/actions/safe-chain/action.yml @@ -21,6 +21,6 @@ runs: - 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 --no-save safe-chain-test diff --git a/eslint.config.js b/eslint.config.js index 1ff16ad..3e88489 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,4 +38,4 @@ module.exports = [ 'indent': ['error', 4], }, }, -]; +]; \ No newline at end of file From 8f1f804ed2812d642bc5cdce483829cf774058f5 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 12:48:57 +0200 Subject: [PATCH 10/11] Update eslint.config.js --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 3e88489..1ff16ad 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,4 +38,4 @@ module.exports = [ 'indent': ['error', 4], }, }, -]; \ No newline at end of file +]; From 32c96552b01772651fb0679d7a1b444b7fac35a3 Mon Sep 17 00:00:00 2001 From: Henrik Gemal Date: Tue, 7 Jul 2026 13:40:29 +0200 Subject: [PATCH 11/11] add test --- test/index.js | 16 ++++++++++++++-- test/iptc.jpg | Bin 0 -> 7185 bytes 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 test/iptc.jpg diff --git a/test/index.js b/test/index.js index 7af8a73..0b82260 100644 --- a/test/index.js +++ b/test/index.js @@ -90,10 +90,22 @@ describe('index.js', function() { 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.jpg']); + 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/); + expect(out).to.match(/Checking: test\/\?\[2Kevil\?\?\.jpg/); }); }); diff --git a/test/iptc.jpg b/test/iptc.jpg new file mode 100644 index 0000000000000000000000000000000000000000..689f7d9e1e5d7d4bf3be8bd13aa0a3ad942e6812 GIT binary patch literal 7185 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 literal 0 HcmV?d00001