HubSpot Policy & Guardrails
Overview
Automated policy enforcement for HubSpot integrations: secret scanning, ESLint rules, CI checks for token leaks, and runtime guardrails.
Prerequisites
- ESLint configured in project
- CI/CD pipeline (GitHub Actions)
- TypeScript for compile-time enforcement
Instructions
Step 1: Secret Scanning (Prevent Token Leaks)
# .github/workflows/hubspot-security.yml
name: HubSpot Security Scan
on: [push, pull_request]
jobs:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan for HubSpot private app tokens
run: |
# Pattern: pat-{region}{number}-{uuid}
if grep -rE "pat-[a-z]{2}[0-9]-[a-f0-9-]{36}" \
--include="*.ts" --include="*.js" --include="*.json" --include="*.yaml" \
--exclude-dir=node_modules --exclude-dir=.git .; then
echo "::error::HubSpot private app token found in source code!"
echo "Rotate this token immediately in HubSpot Settings > Private Apps"
exit 1
fi
- name: Scan for deprecated API keys
run: |
# Pattern: hapikey=uuid
if grep -rE "hapikey=[a-f0-9-]{36}" \
--include="*.ts" --include="*.js" --include="*.env.example" \
--exclude-dir=node_modules .; then
echo "::error::Deprecated HubSpot API key found! Migrate to private app tokens."
exit 1
fi
- name: Verify .gitignore includes .env files
run: |
if ! grep -q "^\.env$" .gitignore; then
echo "::error::.gitignore missing .env entry"
exit 1
fi
Step 2: ESLint Rule -- No Deprecated API Key Auth
// eslint-rules/no-hubspot-api-key.js
module.exports = {
meta: {
type: 'problem',
docs: { description: 'Disallow deprecated HubSpot API key authentication' },
messages: {
noApiKey: 'HubSpot API keys are deprecated. Use accessToken from a private app instead.',
useAccessToken: 'Use { accessToken: process.env.HUBSPOT_ACCESS_TOKEN } instead of { apiKey }',
},
},
create(context) {
return {
Property(node) {
if (
node.key.type === 'Identifier' &&
node.key.name === 'apiKey' &&
node.parent?.parent?.callee?.name === 'Client'
) {
context.report({ node, messageId: 'noApiKey' });
}
},
Literal(node) {
if (typeof node.value === 'string') {
// Detect hardcoded private app tokens
if (node.value.match(/^pat-[a-z]{2}\d-[a-f0-9-]{36}$/)) {
context.report({
node,
message: 'Hardcoded HubSpot access token detected. Use environment variable.',
});
}
// Detect deprecated API keys
if (node.value.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/) &&
node.parent?.key?.name === 'apiKey') {
context.report({ node, messageId: 'useAccessToken' });
}
}
},
};
},
};
Step 3: TypeScript Compile-Time Guardrails
// src/hubspot/strict-types.ts
// Enforce that all HubSpot operations go through the service layer
// (not raw client calls scattered throughout the codebase)
// BAD: Using raw client anywhere
// const client = new hubspot.Client({ accessToken: '...' });
// client.crm.contacts.basicApi.create(...); // unguarded
// GOOD: All operations through typed service
interface IContactService {
findByEmail(email: string): Promise<Contact | null>;
create(data: CreateContactInput): Promise<Contact>;
update(id: string, data: UpdateContactInput): Promise<Contact>;
archive(id: string): Promise<void>;
}
// Enforce required properties at compile time
interface CreateContactInput {
email: string; // required
firstname?: string;
lastname?: string;
lifecyclestage?: 'subscriber' | 'lead' | 'marketingqualifiedlead' |
'salesqualifiedlead' | 'opportunity' | 'customer' | 'evangelist';
}
// Prevent passing unknown/dangerous properties
type UpdateContactInput = Partial<Omit<CreateContactInput, 'email'>>;
// Compile-time check: lifecycle stage must be valid
const validStage: CreateContactInput = {
email: 'test@example.com',
lifecyclestage: 'customer', // TypeScript enforces valid values
};
// This would fail at compile time:
// lifecyclestage: 'invalid_stage' // Type error
Step 4: Runtime Guardrails
// Prevent dangerous operations in production
const BLOCKED_IN_PROD: Record<string, string> = {
'batch/archive': 'Bulk archiving is blocked in production',
'gdpr-delete': 'GDPR delete requires manual approval in production',
};
function guardOperation(path: string): void {
if (process.env.NODE_ENV !== 'production') return;
for (const [pattern, message] of Object.entries(BLOCKED_IN_PROD)) {
if (path.includes(pattern)) {
throw new Error(`BLOCKED: ${message}. Environment: production.`);
}
}
}
// Rate limit self-protection (don't consume entire portal quota)
class SelfRateLimiter {
private callsThisSecond = 0;
private callsToday = 0;
private lastSecond = Math.floor(Date.now() / 1000);
private lastDay = new Date().toDateString();
private maxPerSecond: number;
private maxPerDay: number;
constructor(maxPerSecond = 8, maxPerDay = 400000) {
this.maxPerSecond = maxPerSecond; // leave 2/sec headroom for other apps
this.maxPerDay = maxPerDay; // leave 100K/day headroom
}
check(): void {
const now = Math.floor(Date.now() / 1000);
const today = new Date().toDateString();
if (now !== this.lastSecond) {
this.callsThisSecond = 0;
this.lastSecond = now;
}
if (today !== this.lastDay) {
this.callsToday = 0;
this.lastDay = today;
}
this.callsThisSecond++;
this.callsToday++;
if (this.callsThisSecond > this.maxPerSecond) {
throw new Error(
`Self rate limit: ${this.callsThisSecond}/${this.maxPerSecond} per second`
);
}
if (this.callsToday > this.maxPerDay) {
throw new Error(
`Self rate limit: ${this.callsToday}/${this.maxPerDay} per day`
);
}
}
}
Step 5: Pre-Commit Hook
#!/bin/bash
# .husky/pre-commit
# Scan for HubSpot tokens in staged files
PATTERN="pat-[a-z]{2}[0-9]-[a-f0-9-]{36}"
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E "\.(ts|js|json|yaml|yml)$")
if [ -n "$STAGED_FILES" ]; then
if echo "$STAGED_FILES" | xargs grep -lE "$PATTERN" 2>/dev/null; then
echo "ERROR: HubSpot access token found in staged files!"
echo "Remove the token and use environment variables instead."
exit 1
fi
fi
Output
- CI secret scanning for HubSpot tokens (private app and legacy API keys)
- ESLint rules preventing deprecated auth and hardcoded tokens
- TypeScript types enforcing valid property values at compile time
- Runtime guardrails blocking dangerous production operations
- Self-rate limiting to avoid consuming entire portal quota
- Pre-commit hook catching tokens before they hit git
Error Handling
| Issue | Cause | Solution |
|-------|-------|----------|
| False positive on UUID | Pattern too broad | Check context (parent node name) |
| Token leaked to git | Pre-commit hook skipped | Enforce in CI as backup |
| Self-limiter too strict | Conservative defaults | Adjust based on portal usage |
| ESLint rule not running | Plugin not registered | Add to .eslintrc plugins array |
Resources
Next Steps
For architecture blueprints, see hubspot-architecture-variants.