A PCI DSS compliance system implementing requirements 6.4.3 (Script Management) and 11.6.1 (Detection and Alerting) to prevent page tampering and e-skimming attacks on payment pages.
Note: This repository is largely agent developed.
Run with minimal required parameters:
npm start -- --repo https://github.com/org/inventory --git-token <YOUR_TOKEN>This runs both inventory and detection workflows against all configured targets using default branches.
Run all workflows with Slack alerts:
npm start -- \
--repo https://github.com/org/inventory \
--git-token <YOUR_TOKEN> \
--slack-token <YOUR_SLACK_TOKEN>If you have your tokens in .env.secrets (see below for setup):
source .env.secrets
npm start -- \
--repo $INVENTORY_REPO_URL \
--git-token $INVENTORY_REPO_PAT \
--slack-token $SLACK_OAUTH_TOKEN \
--git-user-name $GIT_USER_NAME \
--git-user-email $GIT_USER_EMAILRun inventory only for a specific target:
npm start -- \
--mode inventory \
--target 1.0 \
--repo https://github.com/org/inventory \
--git-token <YOUR_TOKEN>Run detection only against production:
npm start -- \
--mode detection \
--repo https://github.com/org/inventory \
--git-token <YOUR_TOKEN> \
--slack-token <YOUR_SLACK_TOKEN>Use custom branches for inventory and detection:
npm start -- \
--repo https://github.com/org/inventory \
--git-token <YOUR_TOKEN> \
--inventory-branch inventory-updates \
--detection-branch mainLocal testing with file:// protocol:
npm start -- \
--repo file:///path/to/local/inventory \
--git-token dummyThe system runs one of four modes via --mode:
inventory— visits staging/inventory URLs, discovers scripts and headers, pushes updates to theinventory-updatesbranch of the inventory repo, and opens a PR for review. Alerts on resources that need manual authorization.detection— visits production/detection URLs, compares what's loaded against the approved inventory onmain, and alerts on anything unauthorized. Read-only against the inventory repo.all(default) — runsinventory, thendetection.validate— runs as a CI check inside the inventory repo. Fully deserializes everytargets/*.json(Zod schema,createMatcher(), workflow resolution) so malformed inventory cannot merge. No browser, no alerts, no push.
The intended day-to-day cycle:
- Inventory mode (against staging) discovers new scripts/headers, pushes them to
inventory-updates, and opens a PR. - A human reviews the PR, adds authorization metadata for legitimate resources, and merges to
main. - Detection mode (against production) reads from
mainand alerts on anything unauthorized.
See Branch Usage for the branch model and CI Validation for the Inventory Repo for the CI wiring.
| Parameter | Description | Example |
|---|---|---|
--repo <url> |
Inventory repository URL (HTTPS or file://) | https://github.com/org/inventory |
--git-token <token> |
Git authentication token (required for HTTPS; optional only for --mode validate with a file:// repo) |
${{ secrets.GITHUB_TOKEN }} |
| Parameter | Description | Default |
|---|---|---|
--mode <mode> |
Execution mode: inventory, detection, all, or validate |
all |
--target <name> |
Process specific target (e.g., "1.0") | all targets |
--slack-token <token> |
Slack token for alerts (logs to console if omitted) | - |
--inventory-branch <name> |
Branch for inventory operations | inventory-updates |
--detection-branch <name> |
Branch for detection operations | main |
--git-user-name <name> |
Git committer name for inventory updates | PCI DSS Page Tampering Bot |
--git-user-email <email> |
Git committer email for inventory updates | noreply@example.com |
--help |
Display help message and exit | - |
The system uses different branches for different purposes:
- Purpose: Updates baseline inventory with newly discovered scripts/headers
- Default:
inventory-updates - Behavior: Reads from and pushes changes to this branch
- Use case: Staging/development environment monitoring to update approved resource list
- Purpose: Read-only comparison against stable inventory
- Default:
main - Behavior: Reads from this branch, never pushes changes
- Use case: Production monitoring against approved baselines
-
Inventory workflow →
inventory-updatesbranch- Runs against staging/inventory URLs
- Adds new scripts/headers as they're discovered
- Creates alerts for resources needing manual authorization
-
Detection workflow →
mainbranch- Runs against production/detection URLs
- Compares against stable, reviewed inventory
- Alerts on any unauthorized changes
-
Review process:
- Review changes in
inventory-updatesbranch - Add authorization metadata for legitimate resources
- Merge to
mainafter approval - Detection workflow now recognizes these resources as authorized
- Review changes in
# Step 1: Run inventory to discover new resources
npm start -- \
--mode inventory \
--inventory-branch inventory-updates \
--repo https://github.com/org/inventory \
--git-token <TOKEN>
# Step 2: Review and approve changes in inventory-updates branch
# (Manual review via pull request or direct commits)
# Step 3: Run detection against approved baseline
npm start -- \
--mode detection \
--detection-branch main \
--repo https://github.com/org/inventory \
--git-token <TOKEN> \
--slack-token <SLACK_TOKEN>For GitHub Actions, pass secrets via CLI parameters:
- name: Run PCI DSS monitoring
run: |
npm start -- \
--repo https://github.com/${{ github.repository }}-inventory \
--git-token ${{ secrets.INVENTORY_REPO_PAT }} \
--slack-token ${{ secrets.SLACK_TOKEN }} \
--inventory-branch inventory-updates \
--detection-branch main \
--git-user-name 'PCI DSS Bot' \
--git-user-email 'pci-bot@example.com'Three GitHub Actions workflows ship with this repo under .github/workflows/:
ci.yml — Continuous Integration
Runs on every push to main, every pull request, and on manual dispatch. Installs dependencies, audits them at --audit-level=high, then runs linting, type checking, unit tests, and integration tests on Node 24. This is the gate that protects main.
inventory-and-detection.yml — Scheduled monitoring
The production runner. Triggers:
- Scheduled: daily at 12:00 UTC (overnight in AU). Runs
--mode allagainst every target. workflow_dispatch: manual run with optionalmode(all/inventory/detection) andtargetinputs — useful for ad-hoc inventory sweeps or re-running detection after a fix.- Push to
main: runs after merges so newly-approved inventory takes effect immediately.
Requires repo secrets INVENTORY_REPO_PAT and SLACK_OAUTH_TOKEN, and repo variables INVENTORY_REPO_URL, GIT_USER_NAME, GIT_USER_EMAIL. Installs Chrome system dependencies for Puppeteer before invoking npm start.
auto-merge-renovate.yml — Renovate auto-merge
Listens for completed CI runs (via workflow_run) and, when the run was triggered by a renovate[bot] PR and succeeded, approves and squash-merges the PR. Gating on workflow_run (rather than pull_request) ensures CI has actually passed before merging — the previous pull_request setup let broken lockfiles land on main.
For wiring --mode validate into the inventory repo's CI (a separate repo), see CI Validation for the Inventory Repo below.
The validate mode is designed to run as a pre-merge CI check in the script-inventory repository. It exercises the same code paths the runtime tool uses to load inventory files, so anything that passes CI will also load in production.
- Clones the inventory repo (supports
file://for the CI's local checkout) and switches to the requested branch. - Reads every
targets/*.jsonfile. - Parses each file with
RawInventorySchema(catches bad regex patterns, missing fields, malformed hashes, unsupported matcher shapes). - Runs
createMatcher()on everyidentifyWithandauthoriseWithtree (catches any matcher construction failures that slip past schema). - Resolves every
workflowreference viaWorkflowDefinitionSchema(catches dangling workflow files and malformed workflow definitions). - Exits 0 on success, or non-zero with a contextual error message on failure.
It does not launch Puppeteer, hit the monitored URLs, send alerts, or push any changes.
Against a local checkout of the inventory repo:
npm start -- --mode validate --repo file://$PWD--git-token is not required when --repo is a file:// URL in validate mode.
| Code | Meaning |
|---|---|
| 0 | All inventory files fully deserialize |
| 1 | CLI argument validation error (malformed --repo, missing --git-token for HTTPS, etc.) |
| 2 | Inventory or execution error (schema failure in an inventory file, invalid regex, malformed matcher, missing workflow file, clone failure) |
For inventory-file validation failures, exit-2 messages name the offending file — e.g. Validation failed for inventory file '1.0.json': Invalid regex in nameMatcher at "scripts.0.identifyWith.nameMatcher". Pre-read failures (clone failures, branch checkout errors) surface the underlying git error without a file qualifier.
Check out this tool alongside the inventory repo and run validate mode against the inventory's working tree. Pass GITHUB_HEAD_REF as --inventory-branch so the validation runs against the PR branch rather than the default branch.
jobs:
validate-inventory:
runs-on: ubuntu-latest
steps:
- name: Checkout inventory repo
uses: actions/checkout@v4
with:
path: inventory
fetch-depth: 0
- name: Checkout validation tool
uses: actions/checkout@v4
with:
repository: mr-yum/pci-dss-page-tampering
path: tool
- name: Install tool dependencies
working-directory: ./tool
run: npm ci
- name: Validate inventory
working-directory: ./tool
env:
INVENTORY_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
npm start -- \
--mode validate \
--repo file://$GITHUB_WORKSPACE/inventory \
--inventory-branch "$INVENTORY_BRANCH"Notes:
fetch-depth: 0on the inventory checkout ensures all branches are available so simple-git can clone fromfile://and switch to the PR branch.github.head_refis only set onpull_requestevents;github.ref_namecovers direct pushes. The example falls back between the two.- If the inventory repo's CI needs to validate
mainrather than the PR branch, omit--inventory-branch(defaults toinventory-updates) or passmainexplicitly.
Requires .env.secrets file:
# .env.secrets
INVENTORY_REPO_PAT=<PAT secret>
Run locally:
act push --container-architecture linux/amd64 --secret-file .env.secretsEach inventory file (targets/<name>.json) lists the scripts and headers approved for a target. Each entry uses two matchers:
identifyWith— picks out the script or header (e.g. by URL or header name)authoriseWith— describes what content/hash is acceptable, withauthorisationInfometadata
{
"identifyWith": { "nameMatcher": "^https://cdn\\.example\\.com/analytics\\.js$" },
"authoriseWith": {
"hashes": [{ "timestamp": "2025-10-21T12:00:00.000Z", "hash": { "value": "abc..." } }],
"authorisationInfo": {
"description": "Analytics script for conversion tracking",
"authorised": true,
"date": "2025-10-21T12:00:00.000Z"
}
}
}For complex authorization policies, authoriseWith supports composite matchers:
- AND Matcher: authorize only if ALL children succeed (e.g. CSP with multiple required directives)
- OR Matcher: authorize if ANY child succeeds (e.g. accept production OR staging policy)
- Array syntax: syntactic sugar for OR matcher (multiple acceptable versions)
AND Matcher (CSP with multiple required directives):
{
"identifyWith": { "headerNameMatcher": "^content-security-policy$" },
"authoriseWith": {
"andMatcher": [{ "contentMatcher": "default-src\\s+https:" }, { "contentMatcher": "script-src\\s+https:" }, { "contentMatcher": "object-src\\s+'none'" }],
"authorisationInfo": {
"description": "CSP requiring all three critical directives",
"authorised": true,
"date": "2025-10-24T12:00:00.000Z"
}
}
}OR Matcher (accept multiple acceptable policies):
{
"orMatcher": [{ "contentMatcher": "default-src\\s+https:.*script-src\\s+https:" }, { "contentMatcher": "default-src\\s+'self'.*script-src\\s+'self'" }, { "contentMatcher": "default-src\\s+'none'" }],
"authorisationInfo": {
"description": "Accept production, staging, or maintenance policies",
"authorised": true,
"date": "2025-10-24T12:00:00.000Z"
}
}Array syntax (multiple script versions):
{
"identifyWith": { "nameMatcher": "^https://cdn\\.example\\.com/analytics\\.js$" },
"authoriseWith": [
{
"hashes": [{ "timestamp": "2025-10-01T00:00:00.000Z", "hash": { "value": "abc..." } }],
"authorisationInfo": { "description": "Version 1.0.0", "authorised": true, "date": "2025-10-01T00:00:00.000Z" }
},
{
"hashes": [{ "timestamp": "2025-10-15T00:00:00.000Z", "hash": { "value": "def..." } }],
"authorisationInfo": { "description": "Version 1.1.0", "authorised": true, "date": "2025-10-15T00:00:00.000Z" }
}
]
}Every detected resource carries a single url field that captures where it
came from — for response headers it's the URL of the response that emitted
the header, for external scripts it's the script's own URL, and for inline
scripts it's the URL of the script that initiated the insertion (captured
at insertion time via a MutationObserver-style shim, falling back to the
page's own URL for parser-inserted inline scripts).
hostMatcher derives the host portion of that URL and matches a regex
against it — use when the inventory only cares about origin. urlMatcher
matches the full URL — use when path precision matters.
HostMatcher under AndMatcher — restrict a CSP entry to a single origin:
{
"identifyWith": {
"andMatcher": [{ "headerNameMatcher": "^content-security-policy$" }, { "hostMatcher": "^([^.]+\\.)*meandu\\.app$" }]
},
"authoriseWith": [
{
"contentMatcher": "^default-src 'self'$",
"authorisationInfo": { "description": "First-party CSP baseline", "authorised": true, "date": "2026-05-19T00:00:00.000Z" }
}
]
}This entry matches a content-security-policy header only when its
response came from a *.meandu.app host. The same default-src 'self'
emitted by a third-party domain (e.g. Stripe) will not match this entry —
operators can decide whether to add a separate entry for it or treat it
as a violation.
UrlMatcher — restrict an external (or inline-via-initiator) script to a specific URL pattern:
{
"identifyWith": { "urlMatcher": "^https://m\\.stripe\\.network/out-[0-9.]+\\.js$" },
"authoriseWith": {
"hashes": [{ "timestamp": "2026-05-19T00:00:00.000Z", "hash": { "value": "abc..." } }],
"authorisationInfo": { "description": "Stripe outer-window utility", "authorised": true, "date": "2026-05-19T00:00:00.000Z" }
}
}Because inline scripts are also tagged with their initiator's URL, the
same hostMatcher / urlMatcher semantics apply to inline entries —
useful when a third-party loader injects inline <script> elements and
you want the inventory to refuse anything that's not initiated by an
approved origin.
To validate every inventory file in a local checkout of the inventory repo, use --mode validate:
npm start -- --mode validate --repo file://$PWDValidate mode runs the full deserialization pipeline used at runtime — Zod schema parsing, createMatcher() construction for every identifyWith/authoriseWith tree, and workflow file resolution — so anything that parses here will also load at production execution time. See CI Validation for the Inventory Repo above for the GitHub Actions wiring.
| Error | Solution |
|---|---|
| Invalid regex pattern | Test regex: new RegExp("your-pattern") |
| Missing required field | Add both identifyWith and authoriseWith |
| Invalid SHA256 hash | Ensure 64 lowercase hex characters |